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>
This commit is contained in:
type-two 2026-08-02 20:17:18 +10:00
parent 4554bfcd2d
commit cb22239363
12 changed files with 215 additions and 31 deletions

View File

@ -92,6 +92,31 @@ every frequency is the real one: 350+440 dial tone, the DTMF matrix, R1 MF pairs
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`](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
```
@ -124,5 +149,4 @@ to zero in a hidden tab and that makes the game look broken when it's only aslee
- **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`](src/philes.js), all readable with `P`. Adding the next 126 is a writing job, not a code job: put the text in that file, call `ctx.phile('id')` wherever it should be found.
- **Era-generated music** — the design doc says each level's pad is built from that era's protocol. Right now they're chords.
- Levels run 38 minutes against a design doc that asks for 520. The genres, mechanics and set pieces are all in — the *duration* is still short. See [STAGES.md](STAGES.md) for a per-hop breakdown of what's in and what's next.

View File

@ -359,7 +359,7 @@ TIME_WAIT with FIN saying *told you*, then it loops to hop 1.
| | |
|---|---|
| **The philes** | 75 of 200 written. All in `src/philes.js`, all readable with `P`. Pure writing. |
| **Audio** | Every tone is real but the *music* isn't built — the design says each level's pad is generated from that era's protocol and right now they're just chords. |
| ~~**Audio**~~ | **DONE**`src/score.js`. Every bed derived from a real number: 1 kHz reference tone, 50 Hz mains, 0x90 as a pitch, 45.45 baud, the 1200 Hz carrier, 20 Hz ringing current, the six MF tones, 2600 folded six times. The handshake is scored silent on purpose. |
| ~~**The retrain**~~ | **DONE**`src/retrain.js`. Fires once per run after hop 51, drops you a notch, and the recovery is the 450 ms ANSam beat from hop 47. Terminal levels opt out. |
| **Difficulty** | Nothing scales. TTL is the only pressure and it's linear. |
| **The 64 hops** | 13 levels covering 64 hop numbers. Either fill in more levels or make the existing ones properly own their ranges. |

View File

@ -146,8 +146,6 @@ export default {
camera.position.set(0, 0, 0);
camera.far = 1400; camera.updateProjectionMatrix();
A.pad([55, 82.41, 110, 164.81, 220], { gain: 0.05, type: 'sawtooth' });
S.hiss = A.carrier(0.05);
ctx.hint('<k>A</k> <k>D</k>steer &nbsp; <k>SPACE</k>seize &nbsp; <k>E</k>use');
@ -275,7 +273,7 @@ export default {
if (S.z > END_AT && !S.done) {
S.done = true;
(async () => {
S.hiss.stop(); A.stopPad();
S.hiss.stop();
ctx.game.flags.climbClean = S.passed;
ctx.game.flags.climbGates = S.gates.length;
if (S.passed >= 6) ctx.phile('climb');
@ -483,7 +481,6 @@ export default {
ctx.link.unsplit(ctx.camera);
S.hiss?.stop();
S.gates?.forEach(g => g.seizing?.stop(0.05));
A.stopPad();
ctx.link.glitch = 0;
document.body.classList.remove('ink-dark');
ctx.hint('');

View File

@ -103,8 +103,6 @@ export default {
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();
A.pad([174.61, 261.63, 329.63], { gain: 0.03, type: 'sine' }); // sterile. major. nobody's home.
ctx.hint('<k>CLICK</k>look around');
S.line = 0;
@ -169,7 +167,6 @@ export default {
S.phase = 'dma'; S.dma = 0;
ctx.hint('');
ctx.hush?.();
A.stopPad();
A.thud(48, 1.2, 0.4);
streaks.visible = true;
S.camY = ctx.camera.position.y;
@ -215,7 +212,6 @@ export default {
async unmount(ctx) {
if (S.sc) { S.sc.stop(); S.sc = null; }
A.stopPad();
ctx.link.glitch = 0;
In.unlock();
},

View File

@ -526,7 +526,6 @@ export default {
async unmount(ctx) {
const cur = BOSS[ORDER[S.i]];
if (cur && cur.exit) cur.exit();
A.stopPad();
ctx.link.glitch = 0;
ctx.hint('');
},

View File

@ -6,6 +6,7 @@
import * as THREE from '../../vendor/three.module.js';
import * as A from '../audio.js';
import * as Score from '../score.js';
import * as In from '../input.js';
import { Body, Look, sprite, glow, damp, clamp } from '../kit.js';
import { TOTAL as PHILE_TOTAL } from '../philes.js';
@ -62,8 +63,6 @@ export default {
S.ackMesh.position.set(0, 1.2, -220);
S.ackMesh.visible = false;
scene.add(S.ackMesh);
A.pad([220, 329.63, 440], { gain: 0.028 });
ctx.hint('<k>WASD</k>walk<br><k>CLICK</k>capture mouse');
await ctx.card('ROOT', 'hop 64 · one TTL remaining', 3400);
ctx.say('you are here.', 3600);
@ -103,7 +102,6 @@ export default {
async theAck(ctx) {
S.phase = 'ack';
ctx.hush();
A.stopPad();
await ctx.sleep(3000);
S.ackMesh.visible = true;
@ -120,8 +118,7 @@ export default {
await ctx.sleep(4200);
ctx.say('somewhere in all of that — somebody answered.', 4600);
await ctx.sleep(4800);
A.pad([261.63, 329.63, 392, 523.25], { gain: 0.05 });
Score.established();
await ctx.flash('ESTABLISHED', 'est', 2600);
ctx.say('', 0); ctx.hush();
@ -134,7 +131,7 @@ export default {
ctx.hush();
// and then, because that is what TCP is and always was
A.stopPad();
Score.stop(2.2);
for (const [f, label] of [[440, 'FIN'], [523.25, 'FIN-ACK'], [392, 'ACK'], [0, 'CLOSED']]) {
if (f) A.burst(f, f * 1.5, 0.2, 0.12);
ctx.say(label, 1400);
@ -240,7 +237,6 @@ export default {
},
async unmount(ctx) {
A.stopPad();
In.unlock();
ctx.hint('');
},

View File

@ -187,8 +187,6 @@ export default {
camera.position.set(0, 4, 15);
camera.far = 400; camera.updateProjectionMatrix();
A.pad([98, 146.83, 196], { gain: 0.03 });
ctx.hint('<k>A</k> <k>D</k>move<br><k>SPACE</k>jump');
await ctx.card('SMASHING THE STACK', 'hop 17 · for fun and profit', 3000);
ctx.say('you are the input. walk right.', 4000, 'ALEPH ONE');
@ -399,7 +397,6 @@ export default {
},
async unmount(ctx) {
A.stopPad();
ctx.link.glitch = 0;
ctx.hint('');
},

View File

@ -221,8 +221,6 @@ export default {
S.look.yaw = 0.4;
S.spawn = S.body.pos.clone();
camera.far = 900; camera.updateProjectionMatrix();
A.pad([110, 164.81, 220, 329.63], { gain: 0.032 });
ctx.hint('<k>WASD</k>move<br><k>SPACE</k>jump<br><k>CLICK</k>capture mouse');
await ctx.card('7 APPLICATION', 'hop 2 · the descent begins', 2400);
ctx.say(LAYERS[0].note, 4200);
@ -478,5 +476,5 @@ export default {
setTimeout(() => { S.missing = false; }, 400);
},
async unmount(ctx) { A.stopPad(); In.unlock(); ctx.hint(''); },
async unmount(ctx) { In.unlock(); ctx.hint(''); },
};

View File

@ -120,8 +120,6 @@ export default {
// ── the rival crew. they show up after the first rejoin and they want the
// same empty room you do.
S.rivals = [];
A.pad([146.83, 220, 293.66], { gain: 0.03, type: 'sawtooth' });
ctx.hint('<k>WASD</k>move<br><k>E</k>take ops / possess<br><k>14</k>use it while it works');
await ctx.card('THE WAR', 'hop 10 · efnet, 1998', 2800);
ctx.say('nobody owns this room. that has never once stopped anybody.', 4600);
@ -436,7 +434,6 @@ export default {
},
async unmount(ctx) {
A.stopPad();
ctx.link.glitch = 0;
ctx.hint('');
},

View File

@ -100,8 +100,6 @@ export default {
camera.position.set(0, 42, 30);
camera.lookAt(0, 0, 0);
camera.far = 300; camera.updateProjectionMatrix();
A.pad([65.41, 98, 130.81], { gain: 0.035, type: 'triangle' });
ctx.hint('<k>1</k> <k>2</k> <k>3</k>choose vector');
await ctx.card('2 NOVEMBER 1988', 'hop 21 · approximately 60,000 machines', 3400);
ctx.say('you are the worm now. you get three ways in and one bug you did not write.', 5400);
@ -214,7 +212,6 @@ export default {
S.done = true;
ctx.link.glitch = 0;
(async () => {
A.stopPad();
await ctx.sleep(1400);
ctx.spendTTL(1);
ctx.game.flags.wormSaved = S.cured;
@ -288,7 +285,6 @@ export default {
},
async unmount(ctx) {
A.stopPad();
ctx.link.glitch = 0;
ctx.hint('');
},

View File

@ -8,6 +8,7 @@ import { Link } from './link.js';
import { World } from './kit.js';
import { lookup as phileLookup, TOTAL as PHILE_TOTAL } from './philes.js';
import * as Retrain from './retrain.js';
import * as Score from './score.js';
import coldboot from './levels/coldboot.js';
import stack from './levels/stack.js';
@ -156,6 +157,7 @@ export async function goto(id, { restart = false } = {}) {
HUD.hush();
await HUD.veil(1, 420);
A.stopPad();
Score.stop(0.6);
if (game.level && game.level.unmount) { try { await game.level.unmount(ctx); } catch (e) { console.error(e); } }
In.releaseText(); In.clearRemap(); In.unlock();
@ -195,6 +197,7 @@ 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;
Score.play(id);
Retrain.arm(ctx, nextLevel);
refreshHud();
saveGame();

181
src/score.js Normal file
View File

@ -0,0 +1,181 @@
// score.js — the soundtrack is built out of the signal.
//
// No level gets a chord that somebody picked because it sounded nice. Every bed in
// this game is derived from a real number belonging to the era it plays under: the
// mains frequency in the room, the ringing current on the pair, the carrier of the
// modem you are talking through, the byte value of a NOP, the baud rate of a
// teletype, the six frequencies of an MF keypad.
//
// Some of them come out beautiful and some of them come out ugly. That is the
// correct outcome. A 1980 telephone exchange was not tuned to A=440 and pretending
// otherwise would be the same lie as drawing the ANSI art in a proportional font.
import * as A from './audio.js';
let VOICES = [];
function reg(o) { VOICES.push(o); return o; }
// ── builders ──────────────────────────────────────────────────────────────
// A stack of detuned oscillators through a slowly-breathing lowpass. Give it real
// frequencies and it becomes a pad without ever choosing a note.
function drone(freqs, { gain = 0.04, type = 'sine', detune = 5, cut = 1800, breathe = 0.06 } = {}) {
const ac = A.AC(); if (!ac) return null;
const t = ac.currentTime;
const g = ac.createGain(); g.gain.value = 0; g.connect(A.music());
g.gain.linearRampToValueAtTime(gain, t + 2.6);
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cut; lp.connect(g);
const oscs = [];
freqs.forEach((f, i) => {
for (const d of [-detune, detune]) {
const o = ac.createOscillator(); o.type = type; o.frequency.value = f; o.detune.value = d;
const og = ac.createGain(); og.gain.value = 1 / (freqs.length * 2.2);
o.connect(og); og.connect(lp); o.start(t + i * 0.07);
oscs.push(o);
}
});
const lfo = ac.createOscillator(); lfo.frequency.value = breathe;
const lg = ac.createGain(); lg.gain.value = cut * 0.35;
lfo.connect(lg); lg.connect(lp.frequency); lfo.start(t);
oscs.push(lfo);
return reg({ g, oscs });
}
// A tone gated at a protocol's own rate. The rhythm is not a rhythm, it is a clock
// that already existed.
function pulse(rateHz, freq, { gain = 0.03, duty = 0.35, type = 'square', cut = 900 } = {}) {
const ac = A.AC(); if (!ac) return null;
const t = ac.currentTime;
const g = ac.createGain(); g.gain.value = 0; g.connect(A.music());
g.gain.linearRampToValueAtTime(gain, t + 1.6);
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = cut; lp.connect(g);
const o = ac.createOscillator(); o.type = type; o.frequency.value = freq;
// The gate is driven by a square oscillator, which swings -1..+1 — so it needs a
// DC offset equal to the swing, or the "off" half of the cycle is not silence, it
// is the same tone phase-inverted, and you get tremolo where you wanted a clock.
const gate = ac.createGain(); gate.gain.value = duty;
o.connect(gate); gate.connect(lp); o.start(t);
const clk = ac.createOscillator(); clk.type = 'square'; clk.frequency.value = rateHz;
const clkG = ac.createGain(); clkG.gain.value = duty;
clk.connect(clkG); clkG.connect(gate.gain); clk.start(t);
return reg({ g, oscs: [o, clk] });
}
// Filtered noise, for the levels where the era's signature sound is a surface.
function hiss(centre, { gain = 0.02, q = 0.9, wobble = 0 } = {}) {
const ac = A.AC(); if (!ac) return null;
const src = A.noise(3);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass';
bp.frequency.value = centre; bp.Q.value = q;
const g = ac.createGain(); g.gain.value = 0; g.connect(A.music());
src.connect(bp); bp.connect(g); src.start();
g.gain.linearRampToValueAtTime(gain, ac.currentTime + 2.2);
let iv = null;
if (wobble) iv = setInterval(() => bp.frequency.setTargetAtTime(
centre * (1 + (Math.random() - 0.5) * wobble), ac.currentTime, 0.4), 900);
return reg({ g, src, iv });
}
// f, f/2, f/4 ... — a carrier folded down until it is music. The doc's exact phrase
// for the board is "a modem carrier detuned into a pad", and this is that.
const sub = (f, n = 4) => Array.from({ length: n }, (_, i) => f / Math.pow(2, i));
// n partials of a fundamental
const parts = (f, n) => Array.from({ length: n }, (_, i) => f * (i + 1));
// ── the beds ──────────────────────────────────────────────────────────────
const BEDS = {
// 1000 Hz is the reference tone every piece of telecoms test gear in the world
// agrees on. The fortress at the top of the stack is tuned to a calibration tone.
coldboot: () => drone(sub(1000, 4), { gain: 0.030, cut: 2600, breathe: 0.04 }),
stack: () => drone(sub(1000, 5), { gain: 0.032, cut: 2200, breathe: 0.05 }),
// 50 Hz mains and its harmonics. The room's own hum is the entire score, and
// striplight() in the level supplies the top of it.
timewait: () => drone([50, 100, 150], { gain: 0.026, cut: 400, breathe: 0.03 }),
// V.32 ran a 1800 Hz carrier. 1998 is that, folded down, with the dial-up hiss
// still audible underneath because everybody on EFnet was on a modem.
war: () => { drone(sub(1800, 5), { gain: 0.028, type: 'sawtooth', cut: 1100, breathe: 0.09 });
return hiss(1800, { gain: 0.012, q: 0.8, wobble: 0.3 }); },
// 0x90 is the NOP. It is also, read as a number, 144 — so the sled has a pitch,
// and the whole level is tuned to the byte you slide down.
smash: () => drone(parts(144, 5), { gain: 0.028, type: 'triangle', cut: 1500, breathe: 0.07 }),
// one in seven. seven partials, and the seventh is the one that is wrong.
worm: () => { const f = parts(58, 7); f[6] *= 1.031; return drone(f, { gain: 0.030, cut: 700, breathe: 0.05 }); },
// 45.45 baud — the Baudot rate a Model 33 teletype ran at, which is 22 ms a bit.
// He had fifty of them in a basement and they are what he heard all night.
cuckoo: () => { pulse(45.45 / 8, 96, { gain: 0.022, duty: 0.3, cut: 500 });
return drone([58, 87], { gain: 0.020, cut: 420, breathe: 0.04 }); },
// V.22bis put 2400 bits a second down a 1200 Hz carrier. The board's theme is
// that carrier, folded down four times, which is the doc's own instruction.
board: () => drone(sub(1200, 5), { gain: 0.030, cut: 1400, breathe: 0.05 }),
// a Unix box has no signature frequency, so it gets the two it actually made:
// the terminal bell at 750 Hz, folded, over Helsinki's 50 Hz mains.
shell: () => drone([...sub(750, 4), 50], { gain: 0.026, cut: 1600, breathe: 0.045 }),
// the handshake is scored by the fight itself. anything under it would be noise
// competing with the only thing in the game that has to be listened to.
handshake: () => null,
// ringing current is 20 Hz at about ninety volts, and the exchange runs on 50 Hz
// mains, and a step-by-step switch pulses at ten a second. Three real clocks, and
// the cathedral is the beat they make against each other.
copper: () => { drone([20, 40, 50, 100], { gain: 0.030, cut: 320, breathe: 0.03 });
return pulse(10 / 8, 62, { gain: 0.018, duty: 0.22, cut: 260 }); },
// the six MF tones, all at once. This is literally what a blue box contains.
bluebox: () => drone(A.MF_TONES, { gain: 0.020, cut: 1900, breathe: 0.05, detune: 3 }),
// everything, layered: the reference tone, the carrier, the mains and 2600.
climb: () => { drone([...sub(1000, 3), ...sub(1200, 3), 50], { gain: 0.030, type: 'sawtooth', cut: 1300, breathe: 0.11 });
return hiss(2600, { gain: 0.010, q: 2.0, wobble: 0.15 }); },
// 2600 Hz, folded all the way down until it is a low room tone. The number the
// whole game turns on, finally at rest.
root: () => drone(sub(2600, 6), { gain: 0.026, cut: 2000, breathe: 0.03 }),
};
// ── cues ──────────────────────────────────────────────────────────────────
// ESTABLISHED. The handshake arena opened on 350 + 440 and called it "the sound a
// line makes when nothing is happening". This is the same two frequencies with
// something finally on them.
export function established() {
return drone([350, 440, 700, 880, 1320], { gain: 0.055, cut: 2400, breathe: 0.02, detune: 3 });
}
// ── control ───────────────────────────────────────────────────────────────
export function play(id) {
stop();
const build = BEDS[id];
if (!build) return;
try { build(); } catch (e) { console.error('score failed for', id, e); }
}
export function stop(rel = 1.8) {
const ac = A.AC();
const dying = VOICES; VOICES = [];
if (!ac) return;
const t = ac.currentTime;
dying.forEach(v => {
if (v.iv) clearInterval(v.iv);
try {
v.g.gain.cancelScheduledValues(t);
v.g.gain.setValueAtTime(v.g.gain.value, t);
v.g.gain.linearRampToValueAtTime(0, t + rel);
(v.oscs || []).forEach(o => o.stop(t + rel + 0.1));
if (v.src) setTimeout(() => { try { v.src.stop(); } catch (e) {} }, (rel + 0.2) * 1000);
} catch (e) { /* already stopped */ }
});
}
export const voices = () => VOICES.length;