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>
219 lines
8.5 KiB
JavaScript
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();
|
|
},
|
|
};
|