diff --git a/README.md b/README.md index deeeb65..f73cf56 100644 --- a/README.md +++ b/README.md @@ -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 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](STAGES.md) for a per-hop breakdown of what's in and what's next. diff --git a/STAGES.md b/STAGES.md index 8871947..70963e3 100644 --- a/STAGES.md +++ b/STAGES.md @@ -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. | diff --git a/src/levels/climb.js b/src/levels/climb.js index de28bc4..2c378f5 100644 --- a/src/levels/climb.js +++ b/src/levels/climb.js @@ -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('A Dsteer   SPACEseize   Euse'); @@ -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(''); diff --git a/src/levels/coldboot.js b/src/levels/coldboot.js index 37d1db1..aefeff8 100644 --- a/src/levels/coldboot.js +++ b/src/levels/coldboot.js @@ -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('CLICKlook 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(); }, diff --git a/src/levels/handshake.js b/src/levels/handshake.js index fe2bdf0..2b5af9e 100644 --- a/src/levels/handshake.js +++ b/src/levels/handshake.js @@ -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(''); }, diff --git a/src/levels/root.js b/src/levels/root.js index a5c7bd6..32a10c8 100644 --- a/src/levels/root.js +++ b/src/levels/root.js @@ -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('WASDwalk
CLICKcapture 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(''); }, diff --git a/src/levels/smash.js b/src/levels/smash.js index 19baa0e..6e60240 100644 --- a/src/levels/smash.js +++ b/src/levels/smash.js @@ -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('A Dmove
SPACEjump'); 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(''); }, diff --git a/src/levels/stack.js b/src/levels/stack.js index 5e4b24c..77bfb40 100644 --- a/src/levels/stack.js +++ b/src/levels/stack.js @@ -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('WASDmove
SPACEjump
CLICKcapture 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(''); }, }; diff --git a/src/levels/war.js b/src/levels/war.js index 740de64..f149fc5 100644 --- a/src/levels/war.js +++ b/src/levels/war.js @@ -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('WASDmove
Etake ops / possess
1–4use 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(''); }, diff --git a/src/levels/worm.js b/src/levels/worm.js index 5312f13..d910889 100644 --- a/src/levels/worm.js +++ b/src/levels/worm.js @@ -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('1 2 3choose 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(''); }, diff --git a/src/main.js b/src/main.js index e8a67e2..0268b9d 100644 --- a/src/main.js +++ b/src/main.js @@ -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(); diff --git a/src/score.js b/src/score.js new file mode 100644 index 0000000..eb569cf --- /dev/null +++ b/src/score.js @@ -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;