Working to the rule in STAGES.md — a longer level is not a better level, a level with a second idea in it is. Nothing here is padding. COLD BOOT was ninety seconds and it is the first ninety seconds of the game. Now: your own descriptor is written on the wall of your slot — seq, window, flags, ack, ttl — so the vocabulary the ending depends on is something you read standing up rather than something narrated at you. The packet in the next slot has been transmitted before and came back, which is not supposed to happen, and it explains that if you ever do get an answer you will not return here, and wants to be very clear that that is the good ending. And the tail pointer, which has been circling since the first frame, now actually has to reach you — the DMA is something that arrives rather than something the level schedules. THE CUCKOO'S EGG was a timer. Now it is a decision. ps shows him working and what it shows changes as he narrows: fifty printers on fifty lines, then a printer on ttyf3, which is yours, then nothing running except you. The process table is world-readable, so the hunt is visible to the hunted. And `clean` edits the accounting files — it costs you 34 seconds of clock and buys back eleven lines of cover, and the game tells you plainly that the fanfold in his basement has no editor. Filesystem roughly tripled, most of it dull on purpose, three files that matter — including sventek's .rhosts, which is two plus signs. THE HANDSHAKE bosses escalate now instead of sitting at one difficulty. Dial tone stops being patient after the first resonance and the rings come faster. DTMF's beams speed up past the fourth digit because the switch times out. ANSam's deaf window narrows from 190ms toward 90 every time you use it — the exploitable thing gets harder to exploit, which is the shape of the whole game. And failing the scream now makes the carrier channel wander, so a bad negotiation is a bad line and not only worse graphics. TIME_WAIT: somebody arrives. At 62 seconds the door opens and a new SYN comes in with the flag still set, asking whether this is it and whether somebody is coming to explain, having already worked out that nobody is. The room does not look up at that either. 81 philes. All 14 hops swept clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| .claude | ||
| src | ||
| vendor | ||
| .gitignore | ||
| card.html | ||
| DESIGN.md | ||
| index.html | ||
| README.md | ||
| serve.py | ||
| STAGES.md | ||
HANDSHAKE
a love story in 64 hops
You are SYN. You want ACK. Root is the only one who can give it to you.
Design doc: DESIGN.md.
Run it
python3 /Users/johnking/Documents/handshake/serve.py 8123
Then open http://localhost:8123/. Headphones — every frequency in this is the real
frequency and half the mechanics are inaudible without them.
serve.py is http.server plus Cache-Control: no-store, because ES modules cache
hard and it makes every edit look like it did nothing.
Controls
WASD |
move |
SPACE |
jump / hold 2600 Hz |
| mouse | look (click to capture; dragging works too) |
E |
use |
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
Fourteen playable hops, each a different genre, one renderer.
| id | hop | what it is |
|---|---|---|
coldboot |
1 | FP tutorial in a 512-slot NIC ring buffer, then the DMA rips you out |
stack |
2–8 | 7-floor descent, one verb per layer: transcode, keep the session, reassemble in sequence, route within a hop budget, listen-before-transmit with real exponential backoff, ride the waveform. Stateful FIREWALL boss |
timewait |
9 | walking sim. beige room, 50 Hz hum, half-open connections in plastic chairs, FIN by the door |
war |
10–16 | EFnet twin-stick. netsplit riding, ops crowns, and a trojan arms race — four weapons the level patches 70 s after you first use one |
smash |
17–20 | 2D side-scroll char buf[64]. canary, return address, NOP sled, shellcode — then NX lands and you build a ret2libc chain out of other people's platforms |
worm |
21–28 | Morris worm RTS, 1-in-7 reinfection locked on. An antidote you can release that travels only over live links, while you are killing links |
cuckoo |
29–34 | LBL 1986 terminal. connection time is visibility. you get caught |
board |
35–44 | the BBS, over three days. busy signal, ANSI painting at live baud, ratios, NUP gate, ANSI bomb, the sysop watching you type, FidoNet, playable LORD and Trade Wars — then LAST CALL, and it closes |
shell |
43–44 | kruuna.helsinki.fi, 1991. always on, 38 users at once, talk(1) character by character, the comp.os.minix post, and a kernel compiling in the background while you use the shell |
handshake |
45–50 | five-boss rhythm arena. dial tone → DTMF → ANSam → V.8bis → carrier |
copper |
51–58 | FP through duct, load coils, a Strowger cathedral, a lineman's handset |
bluebox |
59–63 | playable 13-button blue box. 2600, KP/MF/ST, tandem stacking, then SS7 |
climb |
63→1 | on-rails reverse ascent, one gate per era testing the verb it taught you. Spectre is a real split screen, plus Rowhammer and Log4Shell |
root |
64 | the room is empty. then the ACK comes back — and on Tuesday, so does your FidoNet message |
The three load-bearing ideas
TTL is the health bar and it only goes down. 64 hops, no healing, ever. Levels aren't hard, they're expensive.
Bandwidth is resolution — src/link.js. One render target and one
shader. Your baud rate sets the target height; at 1200 and below the scene renders as
ASCII glyphs. HS.link.setBaud(0..6) in the console to see it. The V.8bis fight sets
this for real.
Every header you drop is a piece of clothing — makeSyn() in
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.
Retrain
The line doesn't stay the way you negotiated it. Somewhere after hop 51, wherever you
happen to be, it degrades and takes a baud rate off you — and the way to claw it back
is the ANSam beat from hop 47: SPACE on the phase reversal, 450 ms, four clean ones.
Terminal levels opt out with noRetrain: true, because stealing SPACE from someone
mid-sentence isn't a mechanic, it's a bug with a story attached.
Audio
src/audio.js — every tone is oscillators, nothing is sampled, and
every frequency is the real one: 350+440 dial tone, the DTMF matrix, R1 MF pairs with
real 100 ms KP / 60 ms digit timing, 2600 Hz, ANSam at 2100 Hz reversing phase every
450 ms, Bell 103 FSK. About 300 lines and zero megabytes.
The score
src/score.js — no level gets a chord somebody picked because it
sounded nice. Every bed is derived from a real number belonging to its era:
| hop | built from |
|---|---|
| coldboot / stack | 1000 Hz, the reference tone every piece of telecoms test gear agrees on, folded down |
| timewait | 50 Hz mains and its harmonics — the room's own hum is the whole score |
| war | the 1800 Hz V.32 carrier, folded, with dial-up hiss under it |
| smash | 0x90 — the NOP byte read as 144 Hz, so the sled has a pitch |
| worm | seven partials, and the seventh one is wrong |
| cuckoo | 45.45 baud, the Model 33 teletype rate, as a clock |
| board | the 1200 Hz V.22bis carrier, folded four times — the doc's own instruction |
| shell | the 750 Hz terminal bell over Helsinki's 50 Hz mains |
| handshake | nothing. The fight is the score and anything under it is noise |
| copper | 20 Hz ringing current, 50 Hz mains, 10 pps Strowger stepping — three real clocks beating against each other |
| bluebox | the six MF tones. Literally what a blue box contains |
| climb | all of it at once |
| root | 2600 Hz, folded six times until it is a low room tone, finally at rest |
Some come out beautiful and some come out ugly. That's correct — a 1980 exchange wasn't tuned to A=440, and pretending otherwise would be the same lie as setting the ANSI art in a proportional font.
Layout
index.html shell, all CSS, importmap
serve.py dev server, no-store
src/main.js router, TTL, traceroute map, the loop
src/link.js bandwidth → resolution. the post shader.
src/audio.js every tone in the game
src/term.js 80x25 cell terminal, pipe colours, CP437 blocks as geometry
src/kit.js body/collision, camera rigs, SYN, labels, seeded rng
src/input.js keys, pointer lock, the ANSI bomb's remap table
src/hud.js status line, subtitles, title cards, NO CARRIER
src/levels/*.js one file per hop
vendor/ three.js r17x, vendored
Each level exports { id, hop, title, via, baud, mount(ctx), update(dt,t,ctx), unmount(ctx) }.
ctx hands it a fresh scene, camera and collision world, plus say/card/hint/phile/ spendTTL/setBaud/next. Genre switches are just different camera + controller modules
over one renderer — that's the whole trick.
Dev handles
HS on window: HS.goto(id), HS.link.setBaud(n), HS.game, HS.philes,
HS.run(n) — drives n frames by hand, because requestAnimationFrame is throttled
to zero in a hidden tab and that makes the game look broken when it's only asleep.
Skipped
- Multiplayer (
CHECKSUM) — the Journey-style anonymous pass-by. Needs a server; add when there's something to connect to. - 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 — 74 written in
src/philes.js, all readable withP. Adding the next 126 is a writing job, not a code job: put the text in that file, callctx.phile('id')wherever it should be found. - Levels run 3–8 minutes against a design doc that asks for 5–20. The genres, mechanics and set pieces are all in — the duration is still short. See STAGES.md for a per-hop breakdown of what's in and what's next.