diff --git a/README.md b/README.md
index 0c187a6..2e55d2b 100644
--- a/README.md
+++ b/README.md
@@ -26,10 +26,14 @@ hard and it makes every edit look like it did nothing.
| `SPACE` | jump / hold 2600 Hz |
| mouse | look (click to capture; dragging works too) |
| `E` | use |
-| `M` | traceroute — the level select, click any hop you've reached |
+| `M` | traceroute — the level select, and where you drop cargo |
+| `P` | philes — read what you've picked up |
| `` ` `` | hop select (dev) |
| `ESC` | release the mouse |
+It saves. Every hop completes a handshake with the world and writes to
+localStorage; the boot screen offers `R — RESUME` when there's something to resume.
+
## What's here
Thirteen playable hops, each a different genre, one renderer.
@@ -64,6 +68,14 @@ this for real.
[`src/kit.js`](src/kit.js). Seven wireframe shells, one per OSI layer, shed as you
descend.
+## MTU
+
+Files taken off the BBS are lore, and lore is paid for in distance. Two is free;
+after that you don't fit down the next hop in one piece and every hop costs a TTL
+per extra fragment. You can put things down again from the traceroute map. That's
+the whole economy: everything worth reading costs you some of the distance you have
+left.
+
## Audio
[`src/audio.js`](src/audio.js) — every tone is oscillators, nothing is sampled, and
@@ -101,7 +113,7 @@ to zero in a hidden tab and that makes the game look broken when it's only aslee
## Skipped
- **Multiplayer (`CHECKSUM`)** — the Journey-style anonymous pass-by. Needs a server; add when there's something to connect to.
-- **MTU fragmentation** — BBS cargo lowers your MTU and it's on the HUD, but nothing splits you into two packets yet. Add when a level has a corridor narrow enough to need it.
-- **The 200 philes** — the collectible framework is live (`ctx.phile`, `HS.philes`) with ~20 written. The rest is a writing job, not a code job.
+- **Playing both fragments** — fragmenting costs TTL, it doesn't split you into two packets you alternate between. Add when a level has a corridor narrow enough to need it.
+- **The 200 philes** — 40 written in [`src/philes.js`](src/philes.js), all readable with `P`. Adding the next 160 is a writing job, not a code job: put the text in that file, call `ctx.phile('id')` wherever it should be found.
- **Trade Wars** — the second door game. LORD is real and playable; TW2002 is "closed for maintenance since 1994", which is both a joke and true.
- Levels are one hop's worth of content each, not the full 5–20 minutes the design doc describes. The genres, mechanics and set pieces are all in — the *duration* isn't.
diff --git a/index.html b/index.html
index a4c2efa..0bf38fe 100644
--- a/index.html
+++ b/index.html
@@ -95,6 +95,36 @@
#boot .keys k{color:#5b8494}
#cd{color:#ff4a5e;font-size:10px;letter-spacing:.3em;transition:opacity .2s}
+ .row .ms{color:#2a3f48;font-size:11px}
+ .cargo{margin-top:14px;font-size:11px;color:#5b8494;letter-spacing:.08em;line-height:2}
+ .cargo .bad{color:#ffb648}
+ .cargo .drop{color:#7fa8b8;border:1px solid #1e3a45;border-radius:3px;
+ padding:2px 7px;cursor:pointer;display:inline-block;margin:2px 3px 0 0}
+ .cargo .drop:hover{color:#ff4a5e;border-color:#5c1a22;background:#1a0a0d}
+
+ /* ── the philes ────────────────────────────────────────────── */
+ #reader{position:fixed;inset:0;z-index:12;display:none;align-items:center;justify-content:center;
+ background:#000000f2;backdrop-filter:blur(3px);}
+ .rd{width:min(1000px,94vw);height:min(76vh,720px);display:flex;flex-direction:column;font-size:13px}
+ .rd .hdr{color:#4d6b78;letter-spacing:.2em;margin-bottom:12px;text-transform:uppercase;font-size:11px}
+ .rd .ftr{color:#3d5560;margin-top:12px;font-size:11px;letter-spacing:.1em}
+ .rd .dim{color:#2a3f48}
+ .rd .empty{color:#4d6b78;line-height:2;padding:40px 0}
+ .rd .cols{display:flex;gap:26px;flex:1;min-height:0}
+ .rd .list{width:28ch;overflow-y:auto;border-right:1px solid #14252d;padding-right:14px;flex:none}
+ .rd .it{color:#5b8494;padding:4px 6px;cursor:pointer;border-radius:3px;line-height:1.4;
+ font-size:12px;letter-spacing:.02em}
+ .rd .it:hover{background:#0c1a20;color:#c9f4ff}
+ .rd .it.on{background:#12252d;color:#eafcff}
+ .rd .body{flex:1;overflow-y:auto;padding-right:8px}
+ .rd .body h2{margin:0 0 2px;font-size:17px;color:#eafcff;letter-spacing:.08em;font-weight:600}
+ .rd .body .from{color:#3d5560;font-size:11px;letter-spacing:.14em;margin-bottom:16px}
+ .rd .body pre{margin:0;white-space:pre-wrap;color:#a8ccd8;line-height:1.75;
+ font-family:var(--mono);font-size:13px}
+
+ #resume{color:#ffb648;letter-spacing:.14em;font-size:11px;margin-top:6px}
+ #resume b{color:#ffe14a}
+
/* ── dark ink, for the levels that are white ───────────────── */
body.ink-dark #hud{color:#8a9aa6;text-shadow:0 1px 0 #fff}
body.ink-dark #ttl{color:#14232c}
@@ -125,6 +155,7 @@
+
@@ -135,6 +166,7 @@
M traceroute ESC release mouse ` hop select
headphones. every frequency in this is the real frequency.
+
CLICK OR PRESS ANY KEY TO DIAL
▮ NO CARRIER
diff --git a/src/levels/bluebox.js b/src/levels/bluebox.js
index 30591cd..f926246 100644
--- a/src/levels/bluebox.js
+++ b/src/levels/bluebox.js
@@ -292,6 +292,7 @@ export default {
S.hum.stop(0.3);
A.relayClick(0.35, 0.5);
ctx.link.glitch = 0;
+ ctx.phile('inband');
ctx.say('trunk seized the far end let go. the near end still thinks you are talking.', 5000);
if (STAGES[S.stage] && STAGES[S.stage].id === 'seize') setTimeout(() => this.advance(ctx), 2400);
}
diff --git a/src/levels/coldboot.js b/src/levels/coldboot.js
index 06f1528..37d1db1 100644
--- a/src/levels/coldboot.js
+++ b/src/levels/coldboot.js
@@ -161,6 +161,7 @@ export default {
// walk far enough out and the ring notices you are missing
if (!S.strayed && Math.hypot(body.pos.x, body.pos.z) < 30) {
S.strayed = true;
+ ctx.phile('ring-buffer');
ctx.say('you have left your slot. this has never mattered before.', 3800);
}
diff --git a/src/levels/copper.js b/src/levels/copper.js
index bd6ba1b..01b3d06 100644
--- a/src/levels/copper.js
+++ b/src/levels/copper.js
@@ -76,6 +76,7 @@ export default {
w.rotation.x = Math.PI / 2; w.position.set(x, 0.4 + r * 0.45, z); scene.add(w);
}
}
+ S.coilAt = new THREE.Vector3(-4, 0, -52);
const coilSign = panel('LOAD COILS · 88 mH · every 6000 ft', { color: '#c08040', size: 0.3 });
coilSign.position.set(-6.6, 3.4, -46); coilSign.rotation.y = Math.PI / 2; scene.add(coilSign);
@@ -233,6 +234,10 @@ export default {
S.banks.instanceMatrix.needsUpdate = true;
}
+ if (!S.readCoil && Math.hypot(b.pos.x - S.coilAt.x, b.pos.z - S.coilAt.z) < 8) {
+ S.readCoil = true; ctx.phile('loadcoil');
+ }
+
// the plaque
if (Math.hypot(b.pos.x + 19.4, b.pos.z + 150) < 6 && !S.readPlaque) {
S.readPlaque = true;
diff --git a/src/levels/cuckoo.js b/src/levels/cuckoo.js
index bd5f23a..a0fb45c 100644
--- a/src/levels/cuckoo.js
+++ b/src/levels/cuckoo.js
@@ -125,7 +125,7 @@ export default {
term.writeln('');
await term.paint('|07 ' + d.body.replace(/\n/g, '\n ') + '\n', 900);
S.read++;
- ctx.phile('cuckoo-' + arg, d.title, d.body);
+ ctx.phile(d.bait ? 'sdinet' : 'cuckoo-' + arg, d.title, d.body);
if (d.bait && !S.baited) {
S.baited = true;
S.lines = Math.max(1, Math.round(S.lines * 0.35));
diff --git a/src/levels/handshake.js b/src/levels/handshake.js
index a97d0bd..fe2bdf0 100644
--- a/src/levels/handshake.js
+++ b/src/levels/handshake.js
@@ -211,7 +211,7 @@ const BOSS = {
}
}
},
- win(ctx) { nextPhase(ctx, 'it is ringing.'); },
+ win(ctx) { ctx.phile('dtmf'); nextPhase(ctx, 'it is ringing.'); },
exit() {},
},
@@ -272,7 +272,7 @@ const BOSS = {
if (b.hits >= b.need) this.win(ctx);
}
},
- win(ctx) { S.b.tone.stop(); nextPhase(ctx, 'it has stopped asking politely.'); },
+ win(ctx) { S.b.tone.stop(); ctx.phile('ansam'); nextPhase(ctx, 'it has stopped asking politely.'); },
exit() { S.b.tone?.stop(); },
},
@@ -289,6 +289,7 @@ const BOSS = {
core.position.y = 2.6; S.arena.add(core); S.b.core = core;
S.b.tag = sprite('PROBE', { color: '#ffd9a8', size: 1.6 });
S.b.tag.position.set(0, 8, -19); S.arena.add(S.b.tag);
+ ctx.phile('v8bis');
ctx.say('it is asking what you can do. it will only ask once.', 4200);
},
update(dt, t, ctx) {
@@ -360,6 +361,7 @@ const BOSS = {
const proto = caps >= 5 ? '/V34' : caps >= 4 ? '/V32B' : caps >= 3 ? '/V32' : caps >= 2 ? '/V22B' : '';
const comp = caps >= 4 ? '/LAPM/V42BIS' : '';
await ctx.card(`CONNECT ${CAPS[caps].rate}`, `${CAPS[caps].tag}${arq}${proto}${comp}`, 3400);
+ ctx.phile('connect');
if (caps <= 1) ctx.say('you are going to see the rest of this in letters. honestly, it suits it.', 4600);
else if (caps >= 6) ctx.say('everything. it agreed to everything. do not get used to it.', 4200);
nextPhase(ctx, null);
diff --git a/src/levels/smash.js b/src/levels/smash.js
index d7aad73..4f1f531 100644
--- a/src/levels/smash.js
+++ b/src/levels/smash.js
@@ -227,7 +227,7 @@ export default {
ctx.say('there is nothing down there for you yet. write the address first.', 4000);
}
if (b.grounded && b.ground && b.ground.kind === 'nop') {
- if (!S.sliding) { S.sliding = true; ctx.say('0x90 0x90 0x90 0x90 0x90 0x90', 4200); }
+ if (!S.sliding) { S.sliding = true; ctx.phile('nopsled'); ctx.say('0x90 0x90 0x90 0x90 0x90 0x90', 4200); }
b.vel.x = Math.max(b.vel.x, 9); // you do not walk a sled. you arrive on it.
if (Math.random() < 0.25) A.blip(1400 + Math.random() * 200, 0.008, 0.02);
}
diff --git a/src/levels/stack.js b/src/levels/stack.js
index 69f30a0..d183015 100644
--- a/src/levels/stack.js
+++ b/src/levels/stack.js
@@ -323,6 +323,7 @@ export default {
}
if (!F.done && b.pos.z < -22) {
F.done = true;
+ ctx.phile('firewall');
(async () => {
await ctx.card('FIREWALL', 'stateful firewalls trust the return leg', 2800);
ctx.next();
@@ -340,6 +341,7 @@ export default {
A.burst(660 + S.i * 90, 990 + S.i * 90, 0.28, 0.16);
(async () => {
if (shed) ctx.say(`shed ${shed}`, 2200);
+ if (S.i === 0) ctx.phile('osi');
await ctx.sleep(700);
S.i = nxt;
S.spawn = S.body.pos.clone();
diff --git a/src/levels/timewait.js b/src/levels/timewait.js
index de3da4b..936b510 100644
--- a/src/levels/timewait.js
+++ b/src/levels/timewait.js
@@ -228,11 +228,13 @@ export default {
ctx.say(line, 3600, d.name);
near.spoken++;
S.cool = 0.5;
+ if (near !== S.fin) ctx.phile('timewait');
A.blip(420 + Math.random() * 120, 0.02, 0.02);
if (near === S.fin && near.spoken >= FIN_LINES.length && !S.finDone) {
S.finDone = true;
S.fin.walking = true;
+ ctx.phile('fin');
ctx.hint('');
setTimeout(() => {
ctx.say('the door does open. it opens for people who are leaving.', 4200, 'FIN');
diff --git a/src/levels/war.js b/src/levels/war.js
index 10f3daf..f25be23 100644
--- a/src/levels/war.js
+++ b/src/levels/war.js
@@ -225,6 +225,7 @@ export default {
}
S.ghost.visible = true;
S.ghost.position.set(S.splitDir * (R * 0.55), 1.5, (Math.random() - 0.5) * R);
+ ctx.phile('irc');
ctx.say('*** netsplit. that half of the network is gone. go and stand in it.', 5000);
},
diff --git a/src/levels/worm.js b/src/levels/worm.js
index b69c182..153051c 100644
--- a/src/levels/worm.js
+++ b/src/levels/worm.js
@@ -92,6 +92,7 @@ export default {
S.vec = VECTORS.indexOf(v);
A.blip(900, 0.04, 0.05);
ctx.say(`${v.name} ${v.desc}`, 3000);
+ if (v.name === 'fingerd') ctx.phile('fingerd');
}
const V = VECTORS[S.vec];
S.vecTag.userData.setText(`${V.name} — 1 fingerd 2 sendmail 3 rsh`, '#63ffa2');
diff --git a/src/main.js b/src/main.js
index 79237a7..7818928 100644
--- a/src/main.js
+++ b/src/main.js
@@ -6,6 +6,7 @@ import * as In from './input.js';
import * as HUD from './hud.js';
import { Link } from './link.js';
import { World } from './kit.js';
+import { lookup as phileLookup, TOTAL as PHILE_TOTAL } from './philes.js';
import coldboot from './levels/coldboot.js';
import stack from './levels/stack.js';
@@ -66,6 +67,15 @@ function refreshHud() {
});
}
+// How many pieces you arrive in. Two files is free; after that the network starts
+// charging you for the privilege of being interesting.
+export function fragments() {
+ game.mtu = Math.max(400, 1500 - game.cargo.length * 180);
+ if (game.cargo.length <= 2) return 1;
+ return 1 + Math.ceil((game.cargo.length - 2) / 3);
+}
+let fragNote = null;
+
// TTL only ever goes down. There is no item for this.
export function spendTTL(n = 1, why = '') {
game.ttl -= n;
@@ -110,9 +120,10 @@ const ctx = {
phile(id, title, body) {
if (game.philes.has(id)) return false;
game.philes.add(id);
- HUD.say(`phile ${title} [${game.philes.size}/200]`, 2600);
+ const p = phileLookup(id, title, body);
+ HUD.say(`phile ${p.title} [${game.philes.size}/${PHILE_TOTAL}] P to read`, 3000);
A.blip(1800, 0.05, 0.05); setTimeout(() => A.blip(2400, 0.06, 0.05), 70);
- phileLog.push({ id, title, body });
+ phileLog.push(p);
return true;
},
};
@@ -157,6 +168,17 @@ export async function goto(id, { restart = false } = {}) {
game.reached.add(id);
if (!restart) game.hop = nextLevel.hop;
game.ttl = Math.max(1, Math.min(game.ttl, 65 - game.hop));
+
+ // ── MTU. Carry too much and you do not fit down the next hop in one piece.
+ // Everything you took off the board is lore, and lore is paid for in distance.
+ if (!restart) {
+ const frags = fragments();
+ if (frags > 1) {
+ game.ttl -= (frags - 1);
+ fragNote = `fragmented ${game.mtu} byte MTU · ${frags} pieces · −${frags - 1} TTL`;
+ if (game.ttl <= 0) { expire(); switching = false; return; }
+ } else fragNote = null;
+ }
if (nextLevel.baud !== undefined && nextLevel.baud !== null) link.setBaud(nextLevel.baud);
link.noTint();
if (nextLevel.tint) link.tint(nextLevel.tint[0], nextLevel.tint[1]);
@@ -170,6 +192,9 @@ export async function goto(id, { restart = false } = {}) {
const mounting = (async () => { try { await nextLevel.mount(ctx); } catch (e) { console.error('mount failed:', id, e); } })();
HUD.veil(0, 520);
await mounting;
+ refreshHud();
+ saveGame();
+ if (fragNote) { HUD.say(fragNote, 3600); fragNote = null; }
switching = false;
}
@@ -186,8 +211,9 @@ function frame(now) {
export function step(dt) {
game.time += dt;
- if (In.hit('Escape')) { In.unlock(); }
+ if (In.hit('Escape')) { In.unlock(); if (readerOpen) toggleReader(); }
if (In.hit('KeyM') && !In.hasTextCapture()) toggleMap();
+ if (In.hit('KeyP') && !In.hasTextCapture()) toggleReader();
if (In.hit('Backquote')) toggleDev();
if (!game.paused && game.level && game.level.update) {
@@ -226,19 +252,98 @@ function toggleMap() {
${via || ''}
${seen ? (l.ms || (12 + i * 17) + ' ms') : ''}`;
}).join('');
+ const frags = fragments();
+ const cargo = game.cargo.length
+ ? `carrying ${game.cargo.length} · MTU ${game.mtu} · ${frags > 1
+ ? `fragmenting into ${frags}, −${frags - 1} TTL every hop`
+ : 'fits in one piece'}
` +
+ game.cargo.map((f, i) => `${f} ✕`).join(' ') + '
'
+ : '';
mapEl.innerHTML = `
-
traceroute to root (0.0.0.0), 64 hops max, 1500 byte packets
+
traceroute to root (0.0.0.0), 64 hops max, ${game.mtu} byte packets
${rows}
+ ${cargo}
TTL ${game.ttl} remaining · ${game.philes.size} philes · ${game.deaths} expiries
-
click a reached hop to return · M to close
`;
+
click a reached hop to return · P for philes · M to close`;
mapEl.querySelectorAll('.row.seen').forEach(r => r.addEventListener('click', () => {
const id = r.dataset.id;
toggleMap();
goto(id);
}));
+ // you can put something down. that is the whole trade.
+ mapEl.querySelectorAll('.drop').forEach(el => el.addEventListener('click', () => {
+ game.cargo.splice(+el.dataset.c, 1);
+ fragments(); refreshHud(); saveGame();
+ toggleMap(); toggleMap();
+ }));
}
}
+// ────────────────────────────────────────────────────── the phile reader
+// You spend the game collecting text files. There has to be somewhere to read them.
+
+const readerEl = document.getElementById('reader');
+let readerOpen = false, readerAt = 0;
+function toggleReader() {
+ readerOpen = !readerOpen;
+ game.paused = readerOpen;
+ readerEl.style.display = readerOpen ? 'flex' : 'none';
+ if (readerOpen) { In.unlock(); drawReader(); }
+}
+function drawReader() {
+ if (!phileLog.length) {
+ readerEl.innerHTML = `philes 0/${PHILE_TOTAL}
+
you have not picked anything up yet.
+ they are lying around. nothing tells you where.
+
P to close
`;
+ return;
+ }
+ readerAt = Math.max(0, Math.min(phileLog.length - 1, readerAt));
+ const p = phileLog[readerAt];
+ const list = phileLog.map((q, i) =>
+ `${q.title}
`).join('');
+ readerEl.innerHTML = `
+
philes ${phileLog.length}/${PHILE_TOTAL}
+
+
${list}
+
${p.title}
${p.from ? `
${p.from}
` : ''}
${p.body || '(no text)'}
+
+
click a title · P to close
`;
+ readerEl.querySelectorAll('.it').forEach(el =>
+ el.addEventListener('click', () => { readerAt = +el.dataset.i; drawReader(); }));
+}
+
+// ─────────────────────────────────────────────── ESTABLISHED — the save
+// You do not save. You connect. Sitting still long enough to complete a handshake
+// with the world is your checkpoint — which in practice means every hop, because
+// arriving somewhere is the only moment the state is worth anything.
+
+const SAVE_KEY = 'handshake.save.v1';
+function saveGame() {
+ try {
+ localStorage.setItem(SAVE_KEY, JSON.stringify({
+ level: game.level ? game.level.id : 'coldboot',
+ ttl: game.ttl, hop: game.hop, baud: link.index,
+ philes: [...game.philes], phileLog,
+ cargo: game.cargo, mtu: game.mtu, flags: game.flags, deaths: game.deaths,
+ }));
+ } catch (e) { /* private browsing, a full disk, somebody's kiosk. not worth dying over. */ }
+}
+export function loadSave() {
+ try { return JSON.parse(localStorage.getItem(SAVE_KEY) || 'null'); } catch (e) { return null; }
+}
+function applySave(s) {
+ game.ttl = s.ttl; game.hop = s.hop;
+ game.philes = new Set(s.philes || []);
+ phileLog.length = 0; (s.phileLog || []).forEach(p => phileLog.push(p));
+ game.cargo = s.cargo || []; game.mtu = s.mtu ?? 1500;
+ game.flags = s.flags || Object.create(null);
+ game.deaths = s.deaths || 0;
+ link.setBaud(s.baud ?? 6);
+ ORDER.forEach(l => { if (l.hop <= s.hop) game.reached.add(l.id); });
+}
+export function wipeSave() { try { localStorage.removeItem(SAVE_KEY); } catch (e) {} }
+
// ─────────────────────────────────────────────────────────────── dev
const devEl = document.getElementById('dev');
@@ -258,18 +363,30 @@ function toggleDev() {
// ─────────────────────────────────────────────────────────────── boot
const bootEl = document.getElementById('boot');
-async function start() {
+const save = loadSave();
+if (save) {
+ const l = ORDER.find(x => x.id === save.level);
+ document.getElementById('resume').innerHTML =
+ `R — RESUME hop ${String(save.hop).padStart(2, '0')} · ${l ? l.title : save.level} · TTL ${save.ttl} · ${(save.philes || []).length} philes`;
+ document.getElementById('resume').style.display = 'block';
+}
+
+let started = false;
+async function start(resume) {
+ if (started) return;
+ started = true;
bootEl.style.display = 'none';
A.boot(); A.resume();
requestAnimationFrame(frame);
- await goto('coldboot');
+ if (resume && save) { applySave(save); await goto(save.level, { restart: true }); }
+ else { wipeSave(); await goto('coldboot'); }
}
-bootEl.addEventListener('click', start);
+bootEl.addEventListener('click', () => start(false));
addEventListener('keydown', function once(e) {
- if (bootEl.style.display === 'none') { removeEventListener('keydown', once); return; }
+ if (started) { removeEventListener('keydown', once); return; }
if (e.key === 'Tab') return;
removeEventListener('keydown', once);
- start();
+ start(save && e.key.toLowerCase() === 'r');
});
// carrier detect blinking on the boot screen, because of course
diff --git a/src/philes.js b/src/philes.js
new file mode 100644
index 0000000..6680b32
--- /dev/null
+++ b/src/philes.js
@@ -0,0 +1,583 @@
+// philes.js — the lore, and all of it in one place.
+//
+// Text files. The collectible. None of this is delivered by a cutscene and none of
+// it is required. The design says the writing budget goes here, so it goes here:
+// levels call ctx.phile('id') and the words live in this file, which means adding
+// the next hundred is a writing job and not a code job.
+//
+// Voice rule: each one is written by somebody, in the year it happened, who did not
+// know they were in a game. Nobody explains anything for the player's benefit.
+
+export const PHILES = {
+
+ // ── hop 1–9 ────────────────────────────────────────────────────────────
+
+ 'ring-buffer': {
+ title: 'nic0: tx ring, 512 descriptors',
+ from: 'a comment in a driver, 1994',
+ body: `/* The ring is 512 entries because 512 is a nice number and because the
+ card's DMA engine wraps at a power of two. Head chases tail. Tail chases
+ head. Neither ever arrives.
+
+ If you are reading this because something is stuck, check that you
+ actually wrote the tail pointer back. It is always the tail pointer. */`,
+ },
+
+ 'osi': {
+ title: 'the seven layers, as built',
+ from: 'ISO/IEC 7498-1, and what actually happened',
+ body: `the model has seven layers.
+
+the thing that won has four, does not match the model, was designed by
+people who were not in the room, and shipped first.
+
+everybody still teaches the seven. everybody still runs the four. the gap
+between those two facts is where roughly half of this is hiding.`,
+ },
+
+ 'firewall': {
+ title: 'stateful inspection',
+ from: 'a whitepaper, 1994',
+ body: `the innovation is that the firewall remembers.
+
+it does not just look at a packet and decide. it keeps a table of every
+conversation it has allowed, and when something arrives it asks: is this
+part of something we already started?
+
+which means the way in is not through the wall. the way in is to be
+mistaken for the second half of a sentence the wall began.`,
+ },
+
+ 'timewait': {
+ title: 'why 2MSL',
+ from: 'RFC 793, September 1981',
+ body: `the connection stays in TIME-WAIT for twice the maximum segment lifetime.
+
+this is so that any duplicate segments still wandering the network have
+expired before the same pair of addresses and ports could be reused, and
+be delivered to a connection that has nothing to do with them.
+
+it is a waiting room for things that are already over, kept open in case
+something arrives late. it is not for the connection. it is for the
+segments that have not stopped travelling yet.`,
+ },
+
+ 'fin': {
+ title: 'the four-way close',
+ from: 'FIN, FIN-ACK, ACK, and the one before it',
+ body: `it takes three packets to open a connection and four to close one.
+
+opening is quicker than closing. everybody who has ever run a network has
+noticed this and nobody has ever found it strange enough to write down.`,
+ },
+
+ // ── hop 10–20 ──────────────────────────────────────────────────────────
+
+ 'netsplit': {
+ title: 'riding the split',
+ from: '#2600, EFnet, some time in 1998',
+ body: `when the servers lose each other the channel tears in half. your half
+still exists. it just has almost nobody in it.
+
+so you take ops in a room with nobody in it, which nobody can stop you
+doing, and you wait. when the servers find each other again they merge the
+two channels, and the merge does not adjudicate. it just unions.
+
+you were opped. now you are opped in the real one.
+
+this was not a bug anybody could point at. it was two reasonable rules
+having a child.`,
+ },
+
+ 'sub7': {
+ title: 'Back Orifice',
+ from: 'cDc, DEF CON 6, 1 August 1998',
+ body: `they shipped it as a remote administration tool, which is exactly what it
+was, and named it after a Microsoft product, which is exactly what the
+joke was.
+
+the argument was: this is not new, this is what the operating system
+already lets anyone do, and we have simply made it easy enough that you
+will finally have to care.
+
+they were right and it did not help anybody.`,
+ },
+
+ 'irc': {
+ title: 'how a channel is owned',
+ from: 'a text file with no author on it',
+ body: `nobody owns #anything. the server hands the first person in the room a
+crown and then declines to have any further opinion.
+
+everything after that — the takeovers, the split riding, the bots, the
+three years of war — is people improvising a system of government inside a
+program that does not have one and was never going to get one.`,
+ },
+
+ 'canary': {
+ title: 'the stack canary',
+ from: 'StackGuard, 1997',
+ body: `you put a random word on the stack between the buffer and the return
+address, and before returning you check it is still the word you put there.
+
+a linear overflow has to write through the canary to reach the return
+address, so it dies loudly instead of quietly.
+
+it stops the overflow that walks. it does nothing about the one that knows
+where it is going, and it does nothing at all if you can read the value
+first — at which point it is not a guard, it is a password you have.`,
+ },
+
+ 'ret': {
+ title: 'the return address',
+ from: 'Smashing the Stack for Fun and Profit, Phrack 49, November 1996',
+ body: `you did not break in.
+
+the function was always going to return. it was always going to jump to
+whatever address was sitting in that slot. you did not subvert anything —
+you filled in a field, correctly, with a value of your choosing, and then
+the machine did exactly what it was built to do, on time, without error.
+
+that is the part nobody expects: none of this is the computer going wrong.
+all of it is the computer going right about the wrong thing.`,
+ },
+
+ 'nopsled': {
+ title: '0x90',
+ from: 'why the ramp',
+ body: `you do not know exactly where your code landed. stack addresses move.
+
+so you put a long run of do-nothing instructions in front of it and aim
+somewhere in the middle. land anywhere on the ramp and you slide to the
+bottom, and the bottom is where the work is.
+
+it is the single most forgiving thing in the whole field. everything else
+demands you be exactly right. the sled says: near enough.`,
+ },
+
+ // ── hop 21–34 ──────────────────────────────────────────────────────────
+
+ 'morris': {
+ title: 'the one in seven',
+ from: '2 November 1988',
+ body: `it asks a host whether it is already infected, so as not to waste it.
+
+but a host could lie. a host could say yes to make the worm go away. so —
+to defeat that — it reinfects anyway, one time in seven, regardless of the
+answer.
+
+that is the whole disaster. not the three exploits. not the copies. one
+defensive line, written by somebody being careful, that turned a
+self-limiting program into a machine for grinding a network into a stop.
+
+about six thousand hosts. roughly a tenth of everything there was.`,
+ },
+
+ 'fingerd': {
+ title: 'gets()',
+ from: 'the actual line',
+ body: ` gets(line);
+
+there is no length. there was never a length. the function reads until it
+finds a newline and writes wherever you told it to start, and it has no
+way at all of knowing how much room it has, because nobody passed it that.
+
+it was removed from the C standard in 2011. it had been the answer to
+"how did they get in" for twenty-three years by then.`,
+ },
+
+ 'stoll': {
+ title: 'seventy-five cents',
+ from: 'Lawrence Berkeley Lab, August 1986',
+ body: `two accounting systems disagreed by seventy-five cents of connect time.
+
+everybody who had ever seen that rounded it off. one astronomer, who had
+been moved onto computing because the telescope time ran out, decided to
+find the nine seconds.
+
+it took ten months. he wired fifty printers to fifty lines in a basement
+and slept next to a teletype with a pager taped to it. the answer was at
+the other end of a satellite link and it was being paid in cocaine and
+cash by people who wanted MILNET.
+
+nobody believed him for most of it. the FBI asked how much money had been
+stolen and he said seventy-five cents and they hung up.`,
+ },
+
+ 'sdinet': {
+ title: 'the file that was not real',
+ from: 'the bait',
+ body: `he could not trace the line because the intruder never stayed on long
+enough.
+
+so he wrote a fake project — a whole distribution list, memos, a
+bureaucracy — full of the word the intruder kept searching for, and left
+it where it would be found, and made it long.
+
+it was very boring on purpose. reading it took thirty minutes. thirty
+minutes was enough.`,
+ },
+
+ // ── hop 35–44, the board ───────────────────────────────────────────────
+
+ 'board-login': {
+ title: 'THE LAST TRUNK — login screen',
+ from: 'ANSI, 1987',
+ body: `somebody sat and drew this with the eight block characters and sixteen
+colours, one cell at a time, so that it would take exactly the right
+amount of time to paint at 2400 baud and land its last line on the beat.
+
+they were not paid. about forty people ever saw it.`,
+ },
+
+ 'joy-ears': {
+ title: 'the sound of a tired switch',
+ from: 'JOYBUBBLES',
+ body: `you can hear when a crossbar is worn. it comes back a half tone flat and
+it takes an extra beat to answer.
+
+i know six offices by ear and i have never seen any of them and i never
+will and it does not matter one bit.
+
+people keep telling me i am missing something. i am not missing anything.
+i am the only one actually listening.`,
+ },
+
+ 'nup-leak': {
+ title: 'NEW USER POLICY — READ',
+ from: 'TANDEM',
+ body: `we do not take new users off the nodelist any more. too much heat.
+
+if somebody vouched for you they gave you the word. type it at the ELITE
+prompt.
+
+(and no, i will not tell you the word. i am not going to type it into a
+public message base, am i.)
+
+— the entire security model of the scene, in four lines, defeated by the
+person who designed it, in the same four lines.`,
+ },
+
+ 'ansi-bomb': {
+ title: 'the ANSI bomb',
+ from: 'ANSI.SYS, and what it would do for you',
+ body: `the terminal driver everybody loaded had a feature: an escape sequence
+that redefines what a key produces.
+
+it was meant for macros. it is in the manual.
+
+so you put the sequence in a message, and the board sends the message to
+the reader, and the reader's terminal driver reads it, and now their F1
+key types FORMAT C: and presses return.
+
+nobody exploited anything. it was a documented feature, working, on a
+machine that had no concept of the message being from someone else.`,
+ },
+
+ 'crunch': {
+ title: 're: whistle',
+ from: 'CAP N CRUNCH',
+ body: `it is a toy. it came free in a box of cereal. it makes one note.
+
+the note happens to be two thousand six hundred cycles, which happens to
+be the note the long distance network uses to tell itself a trunk is idle.
+
+nobody designed that coincidence. somebody just did not think anybody
+would be listening. that is the whole of it. that is every single one of
+these.`,
+ },
+
+ 'conscience': {
+ title: 'the conscience of a hacker',
+ from: 'Phrack 7, 8 January 1986',
+ body: `we explore, and you call us criminals.
+we seek after knowledge, and you call us criminals.
+we exist without skin colour, without nationality, without religious bias,
+and you call us criminals.
+
+you build atomic bombs, you wage wars, you murder, cheat and lie to us and
+try to make us believe it is for our own good, yet we are the criminals.
+
+yes, i am a criminal. my crime is that of curiosity.
+
+— written the day after he was arrested. he was seventeen.`,
+ },
+
+ 'sysop-chat': {
+ title: 'TANDEM broke into chat',
+ from: 'the sysop, watching',
+ body: `he can see what you type before you send it.
+
+not the message — the keystrokes. the board echoes your line to his screen
+as you build it, because the board is a program running on his computer in
+his house and there was never any layer in between.
+
+every sysop could do this. most of them watched. nobody thought to mention
+it because there was nothing to mention: where else would it have gone.`,
+ },
+
+ 'lord': {
+ title: 'LORD — you killed a Small Thief',
+ from: 'Legend of the Red Dragon, v3.29',
+ body: `you get three forest fights a day, and then you have to hang up and come
+back tomorrow, because there is one phone line and forty people and the
+only fair way to share a world is to ration time in it.
+
+nobody has built a better multiplayer economy since. they have all built
+faster ones.`,
+ },
+
+ 'fido': {
+ title: 'you posted to fidonet',
+ from: 'zone 1, net 129, node 22',
+ body: `at four in the morning your board will dial the next board up the tree and
+hand over everything written today, and that board will dial the one above
+it, and so on, all night, across the country, one long distance call at a
+time, mostly to save money on the rates.
+
+your message will reach the far side of the world in about four days,
+having been physically carried there by a chain of hobbyists' modems while
+everyone involved was asleep.
+
+it worked for fifteen years. at its peak it had about forty thousand
+nodes. nobody was in charge of it.`,
+ },
+
+ 'the-trunk': {
+ title: 'TANDEM — the trunk',
+ from: 'the elite sub-board',
+ body: `there is a way down out of the board and into the plant itself.
+
+the switch will hold the line open for a few seconds after you drop. that
+is the hole. it has been there since 1971 and nobody closed it because
+nobody wrote it down.
+
+hang up without typing goodbye.`,
+ },
+
+ // ── hop 45–50, the handshake ───────────────────────────────────────────
+
+ 'dtmf': {
+ title: 'the matrix',
+ from: 'Bell Labs, 1963',
+ body: ` 1209 1336 1477 1633
+ 697 1 2 3 A
+ 770 4 5 6 B
+ 852 7 8 9 C
+ 941 * 0 # D
+
+a digit is not a tone. a digit is two tones, one from each set, chosen so
+that no pair can be produced by the harmonics of any other pair, and so
+that a human voice cannot accidentally make one.
+
+the fourth column exists. your phone does not have it. it was for the
+military, it is called A B C D, and it is still there.`,
+ },
+
+ 'ansam': {
+ title: 'why it goes bong',
+ from: 'ITU-T V.8 / V.25',
+ body: `the answering modem sends 2100 Hz, and every 450 milliseconds it reverses
+the phase.
+
+this is not decoration. the telephone network is full of echo cancellers,
+which are extremely helpful for speech and completely ruinous for a modem
+trying to run full duplex. the phase reversals are a specific, agreed
+signal that means: turn yourselves off, we know what we are doing.
+
+the noise you remember is not the modem talking to the other modem. most
+of it is the two of them talking to the network about the network.`,
+ },
+
+ 'v8bis': {
+ title: 'the scream',
+ from: 'capability negotiation',
+ body: `the sound in the middle — the one that goes up — is both ends listing what
+they can do, at each other, at once.
+
+speeds, error correction, compression, whether the line will even carry it.
+they try, they measure, they climb down until it works.
+
+which is why two identical modems on two different lines connect at two
+different speeds, and why the number it lands on is not a specification.
+it is a measurement of the copper between you.`,
+ },
+
+ 'connect': {
+ title: 'CONNECT 14400/ARQ/V32B/LAPM/V42BIS',
+ from: 'the line that told you what you got',
+ body: `it is the only benchmark result anybody ever cared about that arrived
+unprompted, in plain english, at the exact moment it was decided, about a
+negotiation you had no part in.
+
+nothing since has been this honest about what it actually managed.`,
+ },
+
+ // ── hop 51–58, the copper ──────────────────────────────────────────────
+
+ 'strowger': {
+ title: 'Almon B. Strowger, undertaker',
+ from: 'Kansas City, 1891',
+ body: `he was losing business. people ringing for an undertaker were being put
+through to his competitor.
+
+his competitor's wife was the local telephone operator.
+
+so he built a switch that connects calls without a person in the middle,
+patented it, and removed her from the process entirely. it is called
+step-by-step. it ran large parts of the world's telephone network for
+about ninety years.
+
+the entire automatic telephone exchange exists because one man in Missouri
+was furious about a specific woman.`,
+ },
+
+ 'loadcoil': {
+ title: '88 millihenries, every six thousand feet',
+ from: 'Pupin, 1900',
+ body: `a long copper pair eats the high frequencies and the voice arrives dull
+and quiet. you fix it by adding inductance at intervals — lumps of iron
+and wire, in a case, in the ground, every mile and change.
+
+it makes speech travel much further. it also puts a brick wall at about
+3400 Hz, which is fine for a voice and fatal for a modem.
+
+the network was tuned, physically, in steel, for the human throat. every
+single thing after that had to be smuggled through a hole shaped like a
+person talking.`,
+ },
+
+ 'beige': {
+ title: 'the beige box',
+ from: 'a handset and a pair of jaws',
+ body: `it is a lineman's telephone. you clip it across the two wires at the box
+on the side of somebody's house and you are on their line.
+
+there is no exploit here. there is no cleverness. the pair is right there,
+it is not encrypted because it is not anything, it is two bits of copper
+with a voice on them, and the only thing that was ever protecting it was
+that it is on the side of a house and people can see you.
+
+it took no skill whatsoever. that is what makes it the worst one.`,
+ },
+
+ // ── hop 59–63, the blue box ────────────────────────────────────────────
+
+ 'inband': {
+ title: 'in-band signalling',
+ from: 'the original sin',
+ body: `the control channel and the voice channel are the same channel.
+
+the switches tell each other what to do by making noises down the line —
+the same line your voice is on, because building a second network just for
+the switches to gossip on would have cost a fortune, and in 1950 nobody
+was going to be on the line who could make those noises on purpose.
+
+that assumption held for about twenty years.`,
+ },
+
+ 'tandem-loop': {
+ title: 'you rang the payphone next to you',
+ from: 'the set piece',
+ body: `you stack trunks. each one takes the last one's far end and hands you a
+fresh one, and you keep going: this coast, that coast, London, Sydney,
+back.
+
+and then you dial the payphone six feet behind you, and you walk over, and
+you pick it up, and you hear yourself a quarter of a second late, having
+gone all the way around the earth to travel two metres.
+
+every switch on that path believed you. not one of them asked.`,
+ },
+
+ 'ss7': {
+ title: 'the day 2600 stopped working',
+ from: 'Common Channel Signalling System No. 7',
+ body: `they moved the signalling off the voice path and onto its own network.
+
+that is all. no arrests, no patch, no announcement. the control channel
+and the voice channel became two different things and one day the tone
+just did not do anything any more, in one exchange, and then another, and
+then everywhere.
+
+you can still make the sound. the sound is just a sound now.
+
+it is the only fix in this entire history that actually worked, and it
+worked because it stopped being possible to say the thing, rather than
+trying to stop people saying it.`,
+ },
+
+ // ── the climb ──────────────────────────────────────────────────────────
+
+ 'spectre': {
+ title: 'the branch that did not happen',
+ from: '2018',
+ body: `the processor guesses which way a branch will go and starts work early. if
+it guessed wrong it throws the work away and nobody is any the wiser.
+
+except the work touched memory, and touching memory leaves a line in the
+cache warm, and you can time how fast something loads.
+
+so: get it to guess wrong, on purpose, in a direction it was never allowed
+to go, and read the answer out of the temperature of the cache afterwards.
+
+the timeline was discarded. the timeline still left a mark. it is in every
+fast processor made in twenty years, because it is not a bug in any of
+them — it is what "fast" meant.`,
+ },
+
+ 'rowhammer': {
+ title: 'rowhammer',
+ from: '2014',
+ body: `DRAM cells are so close together now that repeatedly reading one row leaks
+charge out of its neighbours. do it enough times, fast enough, before the
+refresh comes round, and a bit in a row you were never given access to
+changes value.
+
+you do not read it. you do not write it. you shout next to it.
+
+it is the point at which software stopped being able to pretend that
+memory is an abstraction and had to admit it is a warehouse of tiny
+buckets of electricity that are leaking into each other.`,
+ },
+
+ 'log4shell': {
+ title: '${jndi:ldap://}',
+ from: 'December 2021',
+ body: `the logging library, when asked to write a line to a file, would notice a
+particular pattern in the text and go and fetch what it pointed at, over
+the network, and run it.
+
+this was a feature. it had been a feature since 2013.
+
+so you put it in your username. or your user agent. or the name of your
+iPhone. anywhere that anything, anywhere, might eventually write down what
+you said.
+
+you did nothing. you held up a sign. it went and got it. it was very
+helpful. that was the bug.`,
+ },
+
+ // ── hop 64 ─────────────────────────────────────────────────────────────
+
+ 'root': {
+ title: 'root',
+ from: 'uid 0',
+ body: `the room was empty.
+
+root is not a person and it is not a place. it is a number, and the number
+is zero, and everything else in the system agrees to defer to whatever is
+wearing it.
+
+the reply was not empty.`,
+ },
+};
+
+// levels can still pass their own title/body inline; this is the shared store
+export function lookup(id, title, body) {
+ const p = PHILES[id];
+ if (p) return { id, title: p.title, from: p.from || '', body: p.body };
+ return { id, title: title || id, from: '', body: body || '' };
+}
+
+export const TOTAL = 200; // what the counter counts up to. we are not there yet.