Ship to monsterrobot.games, and a model card

Deployed to monsterrobot.games/games/haxtory/ — the botchat box, bind
mounted into forum-nginx, same pattern as the other games.

card.html is a model card in the AI sense rather than the arcade sense:
what the game is, the three ideas it is built on, an itemised list of
which parts are historically true (the frequencies, Strowger, the 1-in-7,
the 75 cents, SS7, the Linux dates) and which parts are invented (all the
people, the board itself, the romance), and then an honest list of what it
does not do — it is short for what it is, 75 of 200 philes, no
multiplayer, not accessible, unprofiled, and never playtested by a
stranger. Linked from the boot screen.

Also fixed: the blue box has been sitting in the far corner of the frustum
since it was built, where perspective squashed it into a sliver and half
of it fell outside the visible cone. I twice assumed occlusion and twice
wrote it off as a capture artifact. It was framing — the camera was
pointed past the only object in the level that matters. Camera now faces
it, and the routing card and Joybubbles moved to balance the shot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-02 21:46:48 +10:00
parent cb22239363
commit 682b7d88fa
3 changed files with 181 additions and 8 deletions

167
card.html Normal file
View File

@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>HANDSHAKE — model card</title>
<meta name="description" content="What HANDSHAKE is, how it was made, which parts are historically true, and what it does not do.">
<style>
:root{--bg:#07080d;--panel:#0d1018;--line:#1b2233;--ink:#dbe6da;--dim:#7b8894;--faint:#47535f;
--phos:#3dff9a;--pink:#ff3ea5;--cyan:#38d6ff;
--mono:ui-monospace,Menlo,"DejaVu Sans Mono",Consolas,monospace;}
*{box-sizing:border-box}
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:var(--mono);
-webkit-font-smoothing:antialiased;line-height:1.7;font-size:15px}
a{color:var(--cyan);text-decoration:none;border-bottom:1px solid #1d4152}
a:hover{color:var(--phos);border-color:var(--phos)}
.wrap{max-width:900px;margin:0 auto;padding:48px 22px 90px}
header{border-bottom:1px solid var(--line);padding-bottom:26px;margin-bottom:34px}
h1{font-size:clamp(28px,7vw,52px);letter-spacing:.30em;margin:0 0 6px;color:#eafcff;
font-weight:700;text-shadow:0 0 60px #17627f}
.sub{color:var(--dim);letter-spacing:.26em;font-size:11px;text-transform:uppercase}
.kicker{color:var(--pink);letter-spacing:.22em;font-size:11px;text-transform:uppercase;margin-bottom:14px}
h2{font-size:13px;letter-spacing:.26em;text-transform:uppercase;color:var(--phos);
margin:44px 0 4px;font-weight:600}
h2+p,h2+ul,h2+table,h2+div{margin-top:10px}
h3{font-size:14px;color:#eafcff;margin:26px 0 2px;letter-spacing:.06em}
p{margin:12px 0;color:#c3d0cf}
ul{margin:12px 0;padding-left:20px}li{margin:7px 0;color:#c3d0cf}
.dim{color:var(--dim)}
.box{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:18px 20px;margin:18px 0}
.box.warn{border-color:#4a2430;background:#140d12}
table{border-collapse:collapse;width:100%;margin:14px 0;font-size:13px}
th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{color:var(--dim);font-weight:600;letter-spacing:.12em;font-size:10px;text-transform:uppercase}
td.k{color:var(--cyan);white-space:nowrap;width:1%}
.play{display:inline-block;margin:26px 0 0;padding:11px 22px;border:1px solid var(--phos);
color:var(--phos);border-radius:4px;letter-spacing:.2em;font-size:12px}
.play:hover{background:var(--phos);color:#04140b}
footer{margin-top:60px;padding-top:22px;border-top:1px solid var(--line);color:var(--faint);font-size:12px}
code{background:#0b0f16;border:1px solid var(--line);border-radius:3px;padding:1px 5px;color:var(--cyan);font-size:13px}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="kicker">model card</div>
<h1>HANDSHAKE</h1>
<div class="sub">a love story in 64 hops · monsterrobot</div>
</header>
<p>You are <b>SYN</b>, a TCP synchronise packet. Your entire nature is arriving somewhere
and asking <i>are you there</i>, and nobody has ever replied. You have heard there is
someone at the top of the machine who can receive anything. Their name is
<b>root</b>.</p>
<p>The modern network is a fortress, so you go <i>down</i> to go up — back through
fifty years of telecommunications, because the deeper you go the older it gets, and
the older it gets the more it trusts you.</p>
<a class="play" href="./">▶ play it</a>
<h2>At a glance</h2>
<table>
<tr><td class="k">Type</td><td>Single-player browser game. Fourteen hops, each a different genre, over one renderer.</td></tr>
<tr><td class="k">Length</td><td>Roughly 5070 minutes for one pass. It saves, and it is designed to be put down.</td></tr>
<tr><td class="k">Input</td><td>Keyboard and mouse. No gamepad. No touch — it will load on a phone and will not play well on one.</td></tr>
<tr><td class="k">Audio</td><td><b>Required.</b> Several mechanics are inaudible without it, and one boss is unbeatable if you cannot hear the beat.</td></tr>
<tr><td class="k">Runs on</td><td>Any browser with WebGL2 and Web Audio. No install, no account, no server. Progress is in <code>localStorage</code>.</td></tr>
<tr><td class="k">Data</td><td>None leaves your machine. There is no analytics, no telemetry, no network call after the page loads.</td></tr>
<tr><td class="k">Built with</td><td>~9,000 lines of hand-written JavaScript and one vendored copy of three.js. No engine, no build step, no dependencies.</td></tr>
</table>
<h2>The three ideas it is built on</h2>
<h3>TTL is the health bar, and it only goes down</h3>
<p>You start with 64 and every hop costs one. There is no healing item, no shrine, no
pickup. Levels are not <i>hard</i> — they are <i>expensive</i>. Every optional thing
you look at costs some of the distance you have left.</p>
<h3>Bandwidth is resolution</h3>
<p>Your connection speed is wired directly to the renderer. At v.90 the game is full 3D;
at 1200 baud and below it renders as ASCII glyphs, drawn one character at a time. You
earn your rate by performing well in a dial-up handshake fight, and there is no retry
prompt. The game gets warmer and more human as it gets technically worse, which is the
argument the whole thing is making.</p>
<h3>Every header you drop is a piece of clothing</h3>
<p>You descend the OSI model wearing seven layers. Each floor takes one off. At layer
seven you are wrapped in TLS and nothing can see you; at layer one you are bare voltage
on a wire.</p>
<h2>What in this is true</h2>
<p>Most of it. Specifically:</p>
<ul>
<li><b>Every frequency is the real frequency.</b> Nothing is sampled — it is all oscillators. 350+440 Hz dial tone. The full DTMF matrix. R1 multi-frequency pairs at their real 100 ms KP / 60 ms digit timing. 2600 Hz. ANSam at 2100 Hz reversing its phase every 450 ms, which is the actual interval and the actual reason (it disables echo cancellers). Bell 103 FSK.</li>
<li><b>Almon Strowger</b> was an undertaker in Kansas City who invented automatic telephone switching in 1891 because the local operator was his competitor's wife and kept redirecting his calls.</li>
<li><b>The Morris worm's 1-in-7.</b> It asked a host whether it was already infected, then — worried about being lied to — reinfected anyway one time in seven. That single defensive line is what turned it into a disaster. In the game it is on the HUD and you cannot switch it off.</li>
<li><b>The 75-cent discrepancy</b> at Lawrence Berkeley Lab in 1986, and the astronomer who wired fifty printers to fifty lines in a basement rather than round it off.</li>
<li><b>2600 Hz and in-band signalling.</b> The control channel and the voice channel were the same channel, so you could sing to the switch. SS7 moved signalling out of band and the hole closed permanently. The game takes the ability away from you and does not give it back.</li>
<li><b>Linux.</b> 25 August 1991, comp.os.minix. He wanted to call it Freax; the FTP administrator named the directory <code>linux</code> and never mentioned it. The GPL relicence at 0.12 in January 1992 is the date that actually mattered.</li>
<li><b>The port classes in Trade Wars</b>, the Baudot rate of a Model 33 teletype, exponential backoff on shared media, little-endian byte order, ret2libc, W^X, Spectre, Rowhammer, and the Log4Shell JNDI lookup — all as described.</li>
</ul>
<div class="box">
<p style="margin:0"><b>The soundtrack is derived, not composed.</b> No level has a chord
somebody picked because it sounded nice. Each bed is built from a real number belonging
to its era: the 1 kHz telecoms reference tone, 50 Hz mains, the 1200 Hz V.22bis carrier
folded down, <code>0x90</code> read as 144 Hz so the NOP sled has a pitch, 20 Hz ringing
current, the six MF tones of a blue box, and 2600 Hz folded six times until it is a low
room tone. Some of it is beautiful and some of it is ugly. A 1980 telephone exchange was
not tuned to A=440.</p>
</div>
<h2>What in this is invented</h2>
<ul>
<li><b>The people.</b> The sysop, the regulars, the rival crews, the person on the other end of a <code>talk</code> session — all fiction. Where a real person appears, what they say is written for the game, not quoted, except where explicitly marked as a quotation.</li>
<li><b>The board.</b> THE LAST TRUNK never existed. Its nine years, forty-four users and closing bulletin are composed — but the <i>shape</i> of how it ends is how most of them ended: not raided, just quietly out-competed by something with no busy signal.</li>
<li><b>The framing.</b> Packets do not want anything. The romance is a device.</li>
<li>Dates and mechanisms are accurate; dialogue and dramatisation around them are not.</li>
</ul>
<h2>What it does not do</h2>
<div class="box warn">
<ul style="margin:0">
<li><b>It is short for what it is.</b> Each hop runs 38 minutes against a design document that asks for 520. The genres, mechanics and set pieces are all in; the duration is not.</li>
<li><b>The philes are incomplete.</b> 75 of a planned 200 text collectibles are written. The rest is a writing job, not a code job.</li>
<li><b>There is no multiplayer.</b> The design calls for anonymous pass-by contact between players. It needs a server and there isn't one.</li>
<li><b>It is not accessible.</b> There is no colourblind mode, no remappable input, no subtitles for the non-speech audio cues, and at least one mechanic is gated behind hearing a 450 ms rhythm. This is a real limitation and not a stylistic choice.</li>
<li><b>Performance is untuned.</b> It runs on a modern laptop. It has not been profiled on anything else, and one level puts 4,200 instanced meshes on screen.</li>
<li><b>It has not been playtested by strangers.</b> Difficulty, pacing and clarity are one person's guesses.</li>
</ul>
</div>
<h2>Content</h2>
<p class="dim">No graphic content. Mild profanity, none of it aimed at anyone. It is about
computer crime in the sense that a documentary is: it explains how things worked and does
not teach anything that has been usable for thirty years. Two levels are quietly about
loss, and one of them is about a room where nothing happens on purpose.</p>
<h2>How it was made</h2>
<p>Written by <b>Claude Opus 5</b> (Anthropic) in an extended session with John King,
from a one-paragraph idea to the finished thing — design document, engine, fourteen
levels, audio synthesis and this page. Roughly 9,000 lines of JavaScript, no engine and
no build step, on top of one vendored copy of three.js (MIT).</p>
<p class="dim">Every level was verified in a real browser as it was built, and several
genuine bugs are documented in the commit history rather than quietly fixed — the
renderer was 45% too dark for an hour because a raw shader does not get three.js's
colour-space conversion, and the Morris worm level could not reach its own milestones
until the spread rate was made to scale with the infection.</p>
<h2>Source</h2>
<p>The design document and the per-stage build notes ship alongside the game:
<a href="./DESIGN.md">DESIGN.md</a> ·
<a href="./STAGES.md">STAGES.md</a> ·
<a href="./README.md">README.md</a></p>
<a class="play" href="./">▶ play it</a>
<footer>
HANDSHAKE · monsterrobot.games · three.js is MIT.<br>
Headphones. Every frequency in this is the real frequency.
</footer>
</div>
</body>
</html>

View File

@ -94,6 +94,9 @@
#boot .keys{color:#2a3f48;font-size:11px;line-height:2;text-align:center;letter-spacing:.1em} #boot .keys{color:#2a3f48;font-size:11px;line-height:2;text-align:center;letter-spacing:.1em}
#boot .keys k{color:#5b8494} #boot .keys k{color:#5b8494}
#cd{color:#ff4a5e;font-size:10px;letter-spacing:.3em;transition:opacity .2s} #cd{color:#ff4a5e;font-size:10px;letter-spacing:.3em;transition:opacity .2s}
#boot .cardlink{color:#3d5560;font-size:11px;letter-spacing:.12em;text-decoration:none;
border-bottom:1px solid #1a2a32;margin-top:4px}
#boot .cardlink:hover{color:#9fe6ff;border-color:#2a5566}
.row .ms{color:#2a3f48;font-size:11px} .row .ms{color:#2a3f48;font-size:11px}
.cargo{margin-top:14px;font-size:11px;color:#5b8494;letter-spacing:.08em;line-height:2} .cargo{margin-top:14px;font-size:11px;color:#5b8494;letter-spacing:.08em;line-height:2}
@ -168,6 +171,7 @@
</div> </div>
<div id="resume" style="display:none"></div> <div id="resume" style="display:none"></div>
<div class="go">CLICK OR PRESS ANY KEY TO DIAL</div> <div class="go">CLICK OR PRESS ANY KEY TO DIAL</div>
<a class="cardlink" href="./card.html">what this is &amp; what it doesn't do →</a>
<div id="cd">▮ NO CARRIER</div> <div id="cd">▮ NO CARRIER</div>
</div> </div>

View File

@ -179,30 +179,32 @@ export default {
const jh = new THREE.Mesh(new THREE.SphereGeometry(0.9, 16, 12), const jh = new THREE.Mesh(new THREE.SphereGeometry(0.9, 16, 12),
new THREE.MeshBasicMaterial({ color: 0xffb648, wireframe: true, transparent: true, opacity: 0.3 })); new THREE.MeshBasicMaterial({ color: 0xffb648, wireframe: true, transparent: true, opacity: 0.3 }));
S.joy.add(jh); S.jhalo = jh; S.joy.add(jh); S.jhalo = jh;
S.joy.position.set(-4.4, -3.6, -6.4); S.joy.position.set(-3.2, -3.2, -5.6);
scene.add(S.joy); scene.add(S.joy);
const jl = new THREE.PointLight(0xffc98a, 8, 14, 2); jl.position.copy(S.joy.position); scene.add(jl); const jl = new THREE.PointLight(0xffc98a, 8, 14, 2); jl.position.copy(S.joy.position); scene.add(jl);
// the card. worked out by hand, traded, and kept in a pocket. // the card. worked out by hand, traded, and kept in a pocket.
const cardTxt = 'ROUTING\n\n' + ROUTING.map(([n, c]) => n.padEnd(17, ' ') + c).join('\n'); const cardTxt = 'ROUTING\n\n' + ROUTING.map(([n, c]) => n.padEnd(17, ' ') + c).join('\n');
S.card = panel(cardTxt, { color: '#cfe4f6', size: 4.0, align: 'left' }); S.card = panel(cardTxt, { color: '#cfe4f6', size: 3.4, align: 'left' });
S.card.position.set(-5.6, 0.4, -9.0); S.card.position.set(-3.0, 0.7, -7.6);
S.card.rotation.y = 0.44; S.card.rotation.y = 0.34;
scene.add(S.card); scene.add(S.card);
const cb = new THREE.Box3().setFromObject(S.card); const cb = new THREE.Box3().setFromObject(S.card);
const cardBack = new THREE.Mesh( const cardBack = new THREE.Mesh(
new THREE.PlaneGeometry((cb.max.x - cb.min.x) * 1.12 + 0.4, (cb.max.y - cb.min.y) * 1.08 + 0.4), new THREE.PlaneGeometry((cb.max.x - cb.min.x) * 1.12 + 0.4, (cb.max.y - cb.min.y) * 1.08 + 0.4),
new THREE.MeshBasicMaterial({ color: 0x0a1626 })); new THREE.MeshBasicMaterial({ color: 0x0a1626 }));
cardBack.position.set(-5.6, 0.4, -9.06); cardBack.rotation.y = 0.44; scene.add(cardBack); cardBack.position.set(-3.0, 0.7, -7.68); cardBack.rotation.y = 0.34; scene.add(cardBack);
const cardLight = new THREE.PointLight(0x9fc8ff, 9, 12, 2); const cardLight = new THREE.PointLight(0x9fc8ff, 9, 12, 2);
cardLight.position.set(-4.6, 1.0, -7.2); scene.add(cardLight); cardLight.position.set(-2.4, 1.2, -6.2); scene.add(cardLight);
S.cardSeen = false; S.cardSeen = false;
S.readout = sprite('', { color: '#9fd8ff', size: 0.6 }); S.readout = sprite('', { color: '#9fd8ff', size: 0.6 });
S.readout.position.set(-3.6, 2.4, -7.5); scene.add(S.readout); S.readout.position.set(2.0, 3.4, -7.6); scene.add(S.readout);
camera.position.set(0, 0, 0); camera.position.set(0, 0, 0);
camera.rotation.set(0, 0, 0); camera.rotation.set(0, 0, 0, 'YXZ');
camera.rotation.y = -0.20; // face the box, which sits to the right at x=1.9
camera.rotation.x = -0.05;
// the trunk hum you are standing in // the trunk hum you are standing in
S.hum = A.pair(120, 180, { gain: 0.035 }); S.hum = A.pair(120, 180, { gain: 0.035 });