haxtory/src/levels/coldboot.js
type-two cb22239363 The soundtrack is built out of the signal
The last structural promise in the design doc. No level gets a chord any
more. Every bed in the game is derived from a real number belonging to the
era it plays under, and src/score.js says which one and why in a comment
above each line.

  coldboot/stack  1000 Hz, the reference tone every piece of telecoms test
                  gear on earth agrees on. The fortress is tuned to a
                  calibration signal.
  timewait        50 Hz mains and its harmonics. The room's own hum was
                  always the entire score; now the bed agrees with it.
  war             the 1800 Hz V.32 carrier folded down, with dial-up hiss
                  under it, because everybody on EFnet was on a modem.
  smash           0x90 is the NOP. Read as a number it is 144, so the sled
                  has a pitch and the level is tuned to the byte you slide
                  down.
  worm            seven partials, and the seventh one is detuned. One in
                  seven, as a chord.
  cuckoo          45.45 baud — the Model 33 rate, 22 ms a bit. Fifty of
                  them in a basement is what he actually heard all night.
  board           the 1200 Hz V.22bis carrier folded four times, which is
                  the doc's exact phrase: a modem carrier detuned into a
                  pad.
  shell           the 750 Hz terminal bell over Helsinki's 50 Hz mains.
  handshake       silence. The fight is the score; anything underneath is
                  noise competing with the only thing in this game that
                  has to be listened to.
  copper          20 Hz ringing current, 50 Hz mains, and Strowger stepping
                  at ten pulses a second. Three real clocks, and the
                  cathedral is the beat they make against each other.
  bluebox         the six MF tones at once. That is literally what is
                  inside a blue box.
  climb           all of it, layered.
  root            2600 Hz folded six times until it is a low room tone.
                  The number the whole game turns on, finally at rest.

ESTABLISHED gets its own cue rather than a bed: 350 and 440, which is the
dial tone the handshake arena opened on and called the sound a line makes
when nothing is happening — with something finally on it.

Fixed while building: the pulse gate was driven by a bare square
oscillator swinging -1..+1, so its off half was not silence, it was the
same tone phase-inverted. Tremolo where a clock was wanted. DC offset now.

All 14 hops swept clean, and no voices leak between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:17:18 +10:00

219 lines
8.5 KiB
JavaScript

// HOP 1 — COLD BOOT
// You wake in a NIC ring buffer. It is white and clean and enormous and there are
// thousands of you. You learn to walk. You learn that you cannot do anything.
import * as THREE from '../../vendor/three.module.js';
import * as A from '../audio.js';
import * as In from '../input.js';
import { Body, Look, World, rng, sprite, damp, clamp } from '../kit.js';
let body, look, ring, packets, head, tail, streaks, S = {};
export default {
id: 'coldboot', hop: 1, title: 'COLD BOOT', via: 'nic0.tx.ring', ms: '0.04 ms',
baud: 6, ink: 'dark',
async mount(ctx) {
const { scene, camera } = ctx;
S = { t: 0, phase: 'wake', dma: 0, spoke: 0, locked: false };
scene.background = new THREE.Color(0xfbfdff);
scene.fog = new THREE.Fog(0xfbfdff, 40, 300);
scene.add(new THREE.AmbientLight(0xffffff, 2.9));
const d = new THREE.DirectionalLight(0xffffff, 1.4); d.position.set(2, 30, 8); scene.add(d);
const d2 = new THREE.DirectionalLight(0xdfeaf2, 0.7); d2.position.set(-14, 6, -10); scene.add(d2);
// the floor of the buffer. featureless, seamless, cold.
const floor = new THREE.Mesh(
new THREE.CircleGeometry(200, 96),
new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.34, metalness: 0.0 })
);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
// a grid, so that "enormous" has something to be enormous against
const grid = new THREE.GridHelper(320, 160, 0xc8d6e0, 0xe4edf3);
grid.position.y = 0.01; grid.material.transparent = true; grid.material.opacity = 0.7;
scene.add(grid);
// the ring. 512 slots, because that is a normal number of descriptors and
// because 512 of anything looks like infinity from inside it.
ring = new THREE.Group(); scene.add(ring);
const SLOTS = 512, R = 62;
const wallGeo = new THREE.BoxGeometry(0.22, 3.2, 3.0);
const wallMat = new THREE.MeshStandardMaterial({ color: 0xdfe8ee, roughness: 0.7 });
const walls = new THREE.InstancedMesh(wallGeo, wallMat, SLOTS);
const m = new THREE.Matrix4(), q = new THREE.Quaternion(), sc = new THREE.Vector3(1, 1, 1), p = new THREE.Vector3();
for (let i = 0; i < SLOTS; i++) {
const a = (i / SLOTS) * Math.PI * 2;
p.set(Math.cos(a) * R, 0, Math.sin(a) * R);
q.setFromEuler(new THREE.Euler(0, -a, 0));
m.compose(p, q, sc);
walls.setMatrixAt(i, m);
}
ring.add(walls);
// and in most of the slots, another one of you.
const rand = rng(1971);
const pkGeo = new THREE.IcosahedronGeometry(0.42, 1);
const pkMat = new THREE.MeshStandardMaterial({ color: 0x9fd8ff, emissive: 0x2a6c96, emissiveIntensity: 0.5, roughness: 0.4 });
packets = new THREE.InstancedMesh(pkGeo, pkMat, SLOTS);
packets.instanceColor = null;
S.phases = new Float32Array(SLOTS);
for (let i = 0; i < SLOTS; i++) {
const a = (i / SLOTS) * Math.PI * 2;
S.phases[i] = rand() * 6.28;
p.set(Math.cos(a) * R, 0.9, Math.sin(a) * R);
m.compose(p, q.identity(), sc);
packets.setMatrixAt(i, m);
}
ring.add(packets);
S.SLOTS = SLOTS; S.R = R; S.pkMatrix = m;
// head and tail pointers, going round forever, which is the joke of a ring buffer
const mk = (col, txt) => {
const g = new THREE.Group();
const bar = new THREE.Mesh(new THREE.BoxGeometry(0.5, 9, 0.5), new THREE.MeshBasicMaterial({ color: col }));
bar.position.y = 4.5; g.add(bar);
const s = sprite(txt, { color: '#' + new THREE.Color(col).getHexString(), size: 1.5 });
s.position.y = 10.4; g.add(s);
scene.add(g); return g;
};
head = mk(0x2ad8ff, 'HEAD');
tail = mk(0xff4a5e, 'TAIL');
// the speed lines you get ripped through. hidden until the DMA fires.
const N = 2600, pos = new Float32Array(N * 6);
for (let i = 0; i < N; i++) {
const a = rand() * 6.28, rr = 2.5 + rand() * 26, z = -rand() * 900;
const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
const len = 6 + rand() * 60;
pos.set([x, y, z, x, y, z - len], i * 6);
}
const sg = new THREE.BufferGeometry();
sg.setAttribute('position', new THREE.BufferAttribute(pos, 3));
streaks = new THREE.LineSegments(sg, new THREE.LineBasicMaterial({ color: 0x7fd8ff, transparent: true, opacity: 0 }));
streaks.visible = false;
scene.add(streaks);
// you
ctx.world.add(0, -1, 0, 400, 1, 400); // the floor, for the body
body = new Body(ctx.world, { speed: 5.2, jump: 7 });
body.pos.set(0, 0, R - 4);
look = new Look();
look.yaw = Math.PI / 2; // stand in your slot and look along the curve
camera.near = 0.05; camera.far = 1200; camera.updateProjectionMatrix();
ctx.hint('<k>CLICK</k>look around');
S.line = 0;
// it starts by telling you what you are, because nothing else here will.
S.script = [
'you are a synchronise packet.',
'you have a sequence number, a window size, and one question.',
'<i>are you there</i>',
'nobody has ever answered it.',
'there are five hundred and twelve slots in this ring and you are in one of them.',
'the tail pointer is coming round.',
];
S.next = 3.0;
},
update(dt, t, ctx) {
S.t += dt;
// slots breathe. it is the only movement in the room.
if (packets && S.t < 90) {
const m = new THREE.Matrix4(), q = new THREE.Quaternion(), sc = new THREE.Vector3(1, 1, 1), p = new THREE.Vector3();
for (let i = 0; i < S.SLOTS; i += 1) {
const a = (i / S.SLOTS) * Math.PI * 2;
const b = 0.9 + Math.sin(S.t * 0.7 + S.phases[i]) * 0.08;
p.set(Math.cos(a) * S.R, b, Math.sin(a) * S.R);
q.setFromEuler(new THREE.Euler(0, S.t * 0.2 + S.phases[i], 0));
m.compose(p, q, sc);
packets.setMatrixAt(i, m);
}
packets.instanceMatrix.needsUpdate = true;
}
const ha = S.t * 0.42, ta = S.t * 0.42 - 1.15;
head.position.set(Math.cos(ha) * S.R, 0, Math.sin(ha) * S.R);
tail.position.set(Math.cos(ta) * S.R, 0, Math.sin(ta) * S.R);
if (In.mouse.clicked && !In.isLocked()) { In.lock(); A.resume(); }
if (S.phase === 'wake') {
look.update();
const w = look.wish();
body.move(dt, w.x, w.z, In.jumpHit());
look.applyFirstPerson(ctx.camera, body, Math.sin(S.t * 9) * 0.03 * Math.hypot(body.vel.x, body.vel.z) * 0.12);
if (In.isLocked() && !S.locked) { S.locked = true; ctx.hint('<k>WASD</k>move<br><k>SPACE</k>jump'); }
if (S.t > S.next && S.line < S.script.length) {
ctx.say(S.script[S.line], 3600);
S.line++;
S.next = S.t + 3.9;
if (S.line === S.script.length) S.dmaAt = S.t + 4.4;
}
// 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);
}
if (S.dmaAt && S.t > S.dmaAt) {
S.phase = 'dma'; S.dma = 0;
ctx.hint('');
ctx.hush?.();
A.thud(48, 1.2, 0.4);
streaks.visible = true;
S.camY = ctx.camera.position.y;
}
return;
}
// ── the DMA transfer. the floor leaves.
S.dma += dt;
const k = S.dma;
ring.position.y = -Math.pow(k * 1.9, 2.4);
ring.rotation.y += dt * k * 0.35;
head.position.y = tail.position.y = ring.position.y;
const cam = ctx.camera;
if (k < 0.9) {
cam.position.y = damp(cam.position.y, S.camY + 3, 3, dt);
cam.rotation.set(clamp(-0.3 + k, -0.4, 0), look.yaw, 0, 'YXZ');
} else {
const speed = Math.min(1, (k - 0.9) / 2.2);
S.z = (S.z || 0) - dt * (60 + speed * 1400);
cam.rotation.set(0, 0, 0, 'YXZ');
cam.position.set(Math.sin(k * 3.1) * 0.5 * speed, 2 + Math.cos(k * 2.3) * 0.4 * speed, 0);
streaks.position.z = S.z % 900;
streaks.material.opacity = speed;
if (ctx.scene.fog) { ctx.scene.fog.near = 26 + speed * 200; ctx.scene.fog.far = 180 + speed * 900; }
ctx.scene.background.setRGB(1 - speed, 1 - speed * 0.96, 1 - speed * 0.9);
ctx.link.glitch = Math.max(0, speed - 0.75) * 1.2;
if (!S.screamed && speed > 0.3) { S.screamed = true; S.sc = A.scream(0.05); }
if (S.sc && speed > 0.99) { S.sc.stop(); S.sc = null; }
if (speed >= 1 && !S.done) {
S.done = true;
ctx.link.glitch = 0;
(async () => {
await ctx.card('HANDSHAKE', 'a love story in 64 hops', 3400);
ctx.next();
})();
}
}
},
async unmount(ctx) {
if (S.sc) { S.sc.stop(); S.sc = null; }
ctx.link.glitch = 0;
In.unlock();
},
};