diff --git a/STAGES.md b/STAGES.md
index f02aaab..ae1f86a 100644
--- a/STAGES.md
+++ b/STAGES.md
@@ -3,7 +3,7 @@
> One page per hop. What's built, what's thin, what I'd add, ranked.
> Write your direction under **DIRECTION** and I'll build to it.
-Current state: 13 playable hops, ~7,400 lines, all clean.
+Current state: 13 playable hops, ~8,000 lines, all clean.
**Since the first pass** (you gave me the wheel; these were my calls): the philes
are readable, the game saves, MTU costs you distance, ROOT reads your whole save and
@@ -56,10 +56,17 @@ make it send something outbound and arrive as the reply.
play the same. The hazards are one-note — you meet each idea once.
**What I'd add, ranked**
-1. **Give each layer its own verb, not just its own hazard.** PRESENTATION should be about *encoding* (platforms that only exist in one representation), SESSION about *state* (platforms that remember whether you've been on them). Right now they're all "jump accurately, but with a different colour of danger".
-2. **Make the firewall a three-stage fight.** It currently has one idea. Stage 2: it starts blocking the pad. Stage 3: it inspects the reply and you have to make the outbound packet look like the thing you want to be answering.
-3. **Vertical shortcuts.** Skipping a layer should be possible, hard, and cost you the header you'd have shed — so you arrive at the bottom still wearing TLS and the copper levels behave differently.
-4. **The canaries should be worth waking.** Right now they're pure punishment. Make one of them guard something.
+1. ~~Give each layer its own verb.~~ **DONE** — seven floors, seven jobs:
+ - **7 APPLICATION** — plain. The only polite floor.
+ - **6 PRESENTATION** — *transcode*. The same bytes in two encodings, RAW and BASE64, and only one set of platforms is real at a time. `Q` switches representation, in mid-air.
+ - **5 SESSION** — *keep it alive*. A platform you have stood on starts closing the moment you leave it. Standing still is the mistake.
+ - **4 TRANSPORT** — *reassemble*. They arrived out of order. Only the next platform **in sequence** is solid, and the sequence has nothing to do with where they are.
+ - **3 NETWORK** — *route*. Every platform is a router and every router costs a hop out of a budget of five. Overrun it and the floor ASLR-reshuffles under you.
+ - **2 DATA LINK** — *listen before transmit*. Shared media; each platform is busy on its own cycle. Land on a busy one and you both lose, with real exponential backoff — 0.35s, then 0.7, then 1.4, then 2.4.
+ - **1 PHYSICAL** — *be a signal*. No platforms at all: 46 segments of a travelling wave you ride, attenuating down the wire.
+2. **Make the firewall a three-stage fight.** It still has one idea.
+3. **Vertical shortcuts.** Skip a layer, keep the header, and have the copper levels behave differently because you're still wearing TLS.
+4. **The canaries should be worth waking.** (Note: canaries and NX floors were retired in the verb rebuild — the hazards are now the verbs. Re-add only if a floor needs a second idea.)
**DIRECTION:**
> _
@@ -194,7 +201,7 @@ is read-only.
2. ~~Let the player post and be replied to.~~ **DONE** — day 2's first message is FIDONET quoting **what you actually typed**, with an answer. Four days, three boards, a long distance call somebody else paid for.
3. ~~Leeching consequences.~~ **DONE** — over-take on day 1 and day 2 opens with your ratio adjusted to 1:1. He said he would.
4. ~~The board should close.~~ **DONE, and it's the level's payload now** — day 3 is **LAST CALL**. Not raided, not busted: the second line was never coming, the long distance is forty dollars a month, and a third of them have shell accounts and aren't calling any more and he doesn't blame them because it's *better*. Nine years, forty-four users, six real names, never met one of them. Ratio off, elite board open, NUP given away because it does not matter now. "do not say anything sad in the message base. i will only read it." They all ignore him.
-5. **Finish Trade Wars.** A sector map, a trade route, a Ferrengi.
+5. ~~Finish Trade Wars.~~ **DONE** — ten sectors, seven ports with the real class strings, 24 turns, and the Ferrengi in sector nine.
6. **More messages per day.** Five or six each is still thin for a board.
**DIRECTION:**
@@ -318,7 +325,7 @@ TIME_WAIT with FIN saying *told you*, then it loops to hop 1.
| | |
|---|---|
-| **The philes** | 65 of 200 written. All in `src/philes.js`, all readable with `P`. Pure writing. |
+| **The philes** | 66 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. |
| **The retrain** | A mid-game renegotiation that drops your baud without warning. One system, touches every level after it. |
| **Difficulty** | Nothing scales. TTL is the only pressure and it's linear. |
diff --git a/src/levels/stack.js b/src/levels/stack.js
index d183015..5e4b24c 100644
--- a/src/levels/stack.js
+++ b/src/levels/stack.js
@@ -1,7 +1,8 @@
// HOPS 2–8 — THE STACK
-// Seven floors, descending, one per layer. Everything that hurts you here is a real
-// mitigation played entirely straight. You arrive wearing seven headers. You will not
-// leave wearing any of them.
+// Seven floors, descending, one per layer. Each one asks you to do a different
+// thing, because each layer of the model is a different job — a floor that is just
+// "jump accurately, but the danger is a different colour" is not a layer, it is a
+// reskin. You arrive wearing seven headers. You will not leave wearing any of them.
import * as THREE from '../../vendor/three.module.js';
import * as A from '../audio.js';
@@ -10,113 +11,141 @@ import { Body, Look, rng, sprite, panel, makeSyn, glow, damp, clamp } from '../k
const LAYER_H = 30;
const LAYERS = [
- { n: 7, name: 'APPLICATION', hazard: 'none', tint: 0xffffff, note: 'the top. everything up here is polite.' },
- { n: 6, name: 'PRESENTATION', hazard: 'nx', tint: 0xe8f4ff, note: 'marked floors. you may look. you may not stand.' },
- { n: 5, name: 'SESSION', hazard: 'rate', tint: 0xd8ecff, note: 'one step every one point two seconds. it is coming anyway.' },
- { n: 4, name: 'TRANSPORT', hazard: 'canary', tint: 0xc8e4ff, note: 'do not wake the birds.' },
- { n: 3, name: 'NETWORK', hazard: 'aslr', tint: 0xb0d8ff, note: 'you cannot memorise this. you can only get good at reading it.' },
- { n: 2, name: 'DATA LINK', hazard: 'collide', tint: 0x98c8f8, note: 'two packets, one wire. somebody backs off.' },
- { n: 1, name: 'PHYSICAL', hazard: 'dark', tint: 0x7098c0, note: 'no protocol. just voltage and hope.' },
+ { n: 7, name: 'APPLICATION', verb: 'none', tint: 0xffffff,
+ note: 'the top. everything up here is polite. it is the only floor that is.' },
+ { n: 6, name: 'PRESENTATION', verb: 'transcode', tint: 0xe8f4ff,
+ note: 'the same bytes, two encodings. only one of them is real at a time.' },
+ { n: 5, name: 'SESSION', verb: 'session', tint: 0xd8ecff,
+ note: 'a session you stop feeding is a session that closes. do not stand still.' },
+ { n: 4, name: 'TRANSPORT', verb: 'reorder', tint: 0xc8e4ff,
+ note: 'they arrived out of order. only the next one in sequence is solid.' },
+ { n: 3, name: 'NETWORK', verb: 'route', tint: 0xb0d8ff,
+ note: 'every platform is a router and every router costs you a hop.' },
+ { n: 2, name: 'DATA LINK', verb: 'carrier', tint: 0x98c8f8,
+ note: 'shared media. listen before you transmit, or you both lose.' },
+ { n: 1, name: 'PHYSICAL', verb: 'wave', tint: 0x7098c0,
+ note: 'no protocol at all. you are a voltage on a wire and the wire is long.' },
];
+const HOPS_BUDGET = 5;
let S = {};
+function mat(kind, tint) {
+ if (kind === 'nx') return new THREE.MeshStandardMaterial({ color: 0x3a0d14, emissive: 0xff2a44, emissiveIntensity: 0.35, roughness: 0.6 });
+ if (kind === 'gate') return new THREE.MeshStandardMaterial({ color: 0x0d2a18, emissive: 0x35ff96, emissiveIntensity: 0.7, roughness: 0.5 });
+ if (kind === 'catch') return new THREE.MeshStandardMaterial({ color: 0x0a0e12, roughness: 1, transparent: true, opacity: 0.55 });
+ return new THREE.MeshStandardMaterial({ color: tint, roughness: 0.55, metalness: 0.05, transparent: true, opacity: 1 });
+}
+
+function slab(ctx, L, g, x, y, z, w, d, kind) {
+ const m = new THREE.Mesh(new THREE.BoxGeometry(w, 0.7, d), mat(kind, L.tint));
+ m.position.set(x, y + 0.35, z);
+ g.add(m);
+ const b = ctx.world.add(x, y, z, w, 0.7, d, kind === 'nx' ? 'nx' : kind);
+ b.mesh = m; b.kind = kind;
+ // the miss floor gets no lit edge — it is ninety metres across and a glowing
+ // strip that size is a bright blue disc filling half the screen
+ if (kind !== 'catch') {
+ const e = new THREE.Mesh(new THREE.BoxGeometry(w * 1.01, 0.06, d * 1.01),
+ glow(kind === 'gate' ? 0x63ffa2 : kind === 'nx' ? 0xff4a5e : 0x6fd8ff, kind === 'plat' ? 0.55 : 1));
+ e.position.set(x, y + 0.71, z);
+ g.add(e);
+ b.edge = e;
+ }
+ return b;
+}
+
+// ── one layer, built for whatever it is asking you to do
function buildLayer(ctx, i, seed) {
const L = LAYERS[i];
const top = -i * LAYER_H;
const g = new THREE.Group();
const rand = rng(seed);
- const boxes = [];
- const plats = [];
+ const boxes = [], plats = [];
+ const add = (x, y, z, w, d, kind) => { const b = slab(ctx, L, g, x, y, z, w, d, kind); boxes.push(b); if (kind !== 'catch') plats.push(b); return b; };
- const add = (x, y, z, w, h, d, kind) => {
- let mat;
- if (kind === 'nx') mat = new THREE.MeshStandardMaterial({ color: 0x3a0d14, emissive: 0xff2a44, emissiveIntensity: 0.35, roughness: 0.6 });
- else if (kind === 'gate') mat = new THREE.MeshStandardMaterial({ color: 0x0d2a18, emissive: 0x35ff96, emissiveIntensity: 0.7, roughness: 0.5 });
- else if (kind === 'catch') mat = new THREE.MeshStandardMaterial({ color: 0x0a0e12, roughness: 1, transparent: true, opacity: 0.55 });
- else mat = new THREE.MeshStandardMaterial({ color: L.tint, roughness: 0.55, metalness: 0.05 });
- const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
- m.position.set(x, y + h / 2, z);
- g.add(m);
- const b = ctx.world.add(x, y, z, w, h, d, kind === 'nx' ? 'nx' : kind);
- b.mesh = m; b.kind = kind; b.layer = i;
- boxes.push(b);
- if (kind !== 'catch') plats.push(b);
- // a hairline of light along the leading edge. reads at any resolution.
- if (kind !== 'catch') {
- const e = new THREE.Mesh(new THREE.BoxGeometry(w * 1.01, 0.06, d * 1.01),
- glow(kind === 'gate' ? 0x63ffa2 : kind === 'nx' ? 0xff4a5e : 0x6fd8ff, kind === 'plat' ? 0.55 : 1));
- e.position.set(x, y + h + 0.03, z);
- g.add(e);
- }
- return b;
- };
-
- // seven platforms spiralling down the band, then the gate at the bottom
+ const extra = { labels: [] };
const COUNT = 7;
let ang = rand() * 6.28;
+ const ring = [];
for (let k = 0; k < COUNT; k++) {
ang += 0.9 + rand() * 1.1;
const r = 7 + rand() * 9;
- const x = Math.cos(ang) * r, z = Math.sin(ang) * r;
- const y = top - 2.6 - k * 2.9;
- const w = 4.2 + rand() * 3.4, d = 4.2 + rand() * 3.4;
- let kind = 'plat';
- if (L.hazard === 'nx' && k > 0 && rand() < 0.42) kind = 'nx';
- add(x, y, z, w, 0.7, d, kind);
+ ring.push({ x: Math.cos(ang) * r, z: Math.sin(ang) * r, y: top - 2.6 - k * 2.9,
+ w: 4.2 + rand() * 3.4, d: 4.2 + rand() * 3.4 });
}
- // the way down
- const gx = Math.cos(ang + 1.2) * 6, gz = Math.sin(ang + 1.2) * 6;
- const gate = add(gx, top - 2.6 - COUNT * 2.9, gz, 5.4, 0.7, 5.4, 'gate');
- const label = sprite('▼ ' + LAYERS[Math.min(LAYERS.length - 1, i + 1)].name, { color: '#63ffa2', size: 1.1 });
- label.position.set(gx, gate.max.y + 2.4, gz);
- g.add(label);
- // the miss floor. wide, dark, and it costs you.
- const catchY = top - LAYER_H + 3;
- add(0, catchY, 0, 90, 0.6, 90, 'catch');
+ if (L.verb === 'transcode') {
+ // ── the same bytes in two encodings. only one is real at a time, and you
+ // switch representation in mid-air.
+ ring.forEach((p, k) => {
+ const enc = k % 2;
+ const b = add(p.x, p.y, p.z, p.w, p.d, 'plat');
+ b.enc = enc;
+ const lab = sprite(enc ? 'BASE64' : 'RAW', { color: enc ? '#ffb648' : '#6fd8ff', size: 0.8 });
+ lab.position.set(p.x, p.y + 1.5, p.z); g.add(lab); b.label = lab;
+ });
+ // a couple of platforms that exist in both, so there is somewhere to stand and think
+ [0, 4].forEach(k => { const p = ring[k]; const b = plats[k]; if (b) b.enc = -1; });
+ } else if (L.verb === 'reorder') {
+ // ── they arrived out of order. only the next in sequence is solid.
+ const order = [...ring.keys()];
+ for (let a = order.length - 1; a > 0; a--) { const b2 = Math.floor(rand() * (a + 1)); [order[a], order[b2]] = [order[b2], order[a]]; }
+ ring.forEach((p, k) => {
+ const b = add(p.x, p.y, p.z, p.w, p.d, 'plat');
+ b.seq = order.indexOf(k); // its sequence number, not its position
+ const lab = sprite(String(b.seq + 1), { color: '#c8e4ff', size: 1.1 });
+ lab.position.set(p.x, p.y + 1.6, p.z); g.add(lab); b.label = lab;
+ });
+ } else if (L.verb === 'route') {
+ // ── every platform is a router and every router costs you a hop
+ ring.forEach((p, k) => {
+ const b = add(p.x, p.y, p.z, p.w, p.d, 'plat');
+ b.router = true; b.used = false;
+ const lab = sprite('· · ·', { color: '#b0d8ff', size: 0.7 });
+ lab.position.set(p.x, p.y + 1.5, p.z); g.add(lab); b.label = lab;
+ });
+ } else if (L.verb === 'carrier') {
+ // ── shared media. each one is busy on its own cycle.
+ ring.forEach((p, k) => {
+ const b = add(p.x, p.y, p.z, p.w, p.d, 'plat');
+ b.phase = rand() * 6.28; b.period = 2.2 + rand() * 1.6;
+ });
+ } else if (L.verb === 'wave') {
+ // ── no platforms. a wire, and you are the signal on it, and it attenuates.
+ const SEG = 46;
+ extra.wave = [];
+ for (let k = 0; k < SEG; k++) {
+ const z = -6 + Math.sin(k * 0.34) * 2;
+ const x = -22 + k * 1.0;
+ const b = add(x, top - 12, z, 1.05, 7, 'plat');
+ b.wave = k; b.baseY = top - 12;
+ extra.wave.push(b);
+ }
+ } else {
+ ring.forEach(p => add(p.x, p.y, p.z, p.w, p.d, 'plat'));
+ }
+
+ // ── the way down
+ let gx, gz, gy;
+ if (L.verb === 'wave') { gx = 26; gz = 0; gy = top - 12; }
+ else { gx = Math.cos(ang + 1.2) * 6; gz = Math.sin(ang + 1.2) * 6; gy = top - 2.6 - COUNT * 2.9; }
+ const gate = add(gx, gy, gz, 5.4, 5.4, 'gate');
+ const glab = sprite('▼ ' + LAYERS[Math.min(LAYERS.length - 1, i + 1)].name, { color: '#63ffa2', size: 1.1 });
+ glab.position.set(gx, gate.max.y + 2.4, gz); g.add(glab);
+
+ // the miss floor
+ add(0, top - LAYER_H + 3, 0, 90, 90, 'catch');
- // layer nameplate, big, on the far wall of the shaft
const np = panel(String(L.n) + ' ' + L.name, { color: '#' + new THREE.Color(L.tint).getHexString(), size: 2.6 });
np.position.set(0, top - 5, -26); g.add(np);
const np2 = np.clone(); np2.position.set(0, top - 5, 26); np2.rotation.y = Math.PI; g.add(np2);
- // hazard furniture
- const extra = { canaries: [], gates: [], collide: [] };
- if (L.hazard === 'canary') {
- // stack canaries. yellow. they scream. everything comes.
- for (let k = 0; k < 4; k++) {
- const p = plats[1 + k * 2] || plats[plats.length - 1];
- const b = new THREE.Mesh(new THREE.IcosahedronGeometry(0.42, 0), glow(0xffe14a, 1.4));
- b.position.set((p.min.x + p.max.x) / 2, p.max.y + 0.5, (p.min.z + p.max.z) / 2);
- g.add(b);
- const beak = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.3, 5), glow(0xff9a2a, 1.2));
- beak.position.set(0, 0, 0.42); beak.rotation.x = Math.PI / 2; b.add(beak);
- extra.canaries.push({ mesh: b, plat: p, alive: true });
- }
- }
- if (L.hazard === 'rate') {
- // a corridor that lets you take one step every 1.2 seconds
- for (let k = 0; k < 3; k++) {
- const p = plats[1 + k * 2];
- if (!p) continue;
- const cx = (p.min.x + p.max.x) / 2, cz = (p.min.z + p.max.z) / 2;
- const bar = new THREE.Mesh(new THREE.BoxGeometry(6, 3.2, 0.35),
- new THREE.MeshBasicMaterial({ color: 0xff4a5e, transparent: true, opacity: 0.5 }));
- bar.position.set(cx, p.max.y + 1.6, cz + 2.6);
- bar.lookAt(0, p.max.y + 1.6, 0);
- g.add(bar);
- const wall = ctx.world.add(bar.position.x, p.max.y, bar.position.z, 6, 3.2, 0.5, 'rate');
- wall.mesh = bar; wall.phase = k * 0.4;
- extra.gates.push(wall);
- }
- }
- if (L.hazard === 'collide') {
- plats.forEach((p, k) => { if (k % 2 === 1) extra.collide.push({ box: p, phase: k * 0.7 }); });
- }
+ // the session layer wants to know when you leave a platform
+ if (L.verb === 'session') plats.forEach(b => { b.open = true; b.timer = 0; });
ctx.scene.add(g);
- return { group: g, boxes, plats, gate, top, extra, seed, layer: i };
+ return { group: g, boxes, plats, gate, top, extra, seed, layer: i, verb: L.verb };
}
function clearLayer(ctx, L) {
@@ -132,7 +161,9 @@ export default {
async mount(ctx) {
const { scene, camera } = ctx;
- S = { i: 0, layers: [], t: 0, firewall: null, refused: 0, aslrSeed: 90210, camShake: 0 };
+ S = { i: 0, layers: [], t: 0, firewall: null, refused: 0, aslrSeed: 90210, camShake: 0,
+ enc: 0, seq: 0, hops: HOPS_BUDGET, backoff: 0, collisions: 0 };
+ window.__S = S; // debug handle
scene.background = new THREE.Color(0x05080b);
scene.fog = new THREE.FogExp2(0x05080b, 0.012);
@@ -140,7 +171,6 @@ export default {
const key = new THREE.DirectionalLight(0xffffff, 1.6); key.position.set(6, 20, 8); scene.add(key);
const rim = new THREE.DirectionalLight(0x2ad8ff, 0.8); rim.position.set(-8, -10, -6); scene.add(rim);
- // the shaft itself — a tall hollow cylinder you fall down the middle of
const shaft = new THREE.Mesh(
new THREE.CylinderGeometry(30, 30, LAYER_H * (LAYERS.length + 2), 48, 1, true),
new THREE.MeshStandardMaterial({ color: 0x0b141c, roughness: 1, side: THREE.BackSide, metalness: 0.1 })
@@ -150,44 +180,39 @@ export default {
for (let i = 0; i < LAYERS.length; i++) S.layers.push(buildLayer(ctx, i, 1337 + i * 7919));
- // the top platform you arrive on
ctx.world.add(0, 1, 0, 9, 0.8, 9, 'plat');
const startMesh = new THREE.Mesh(new THREE.BoxGeometry(9, 0.8, 9), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.5 }));
startMesh.position.set(0, 1.4, 0); scene.add(startMesh);
- // ── the FIREWALL, at the bottom, stateful, patient
+ // a readout for whatever this floor is asking of you
+ S.hud = sprite('', { color: '#9ff5ff', size: 1.0 });
+ scene.add(S.hud);
+
+ // ── the FIREWALL
const fwY = -LAYERS.length * LAYER_H - 4;
const fwGroup = new THREE.Group(); scene.add(fwGroup);
const floor = new THREE.Mesh(new THREE.BoxGeometry(46, 1, 46), new THREE.MeshStandardMaterial({ color: 0x0e1a22, roughness: 0.9 }));
floor.position.set(0, fwY - 0.5, 0); fwGroup.add(floor);
ctx.world.add(0, fwY - 1, 0, 46, 1, 46, 'plat');
-
const fwMat = new THREE.MeshBasicMaterial({ color: 0xff4a5e, transparent: true, opacity: 0.42, side: THREE.DoubleSide });
const fwMesh = new THREE.Mesh(new THREE.PlaneGeometry(44, 16), fwMat);
fwMesh.position.set(0, fwY + 8, -14); fwGroup.add(fwMesh);
const fwBox = ctx.world.add(0, fwY, -14, 44, 16, 0.9, 'fw');
const fwLabel = sprite('DENY ALL INBOUND', { color: '#ff4a5e', size: 1.5 });
fwLabel.position.set(0, fwY + 13, -14); fwGroup.add(fwLabel);
-
- // the pad. it makes the firewall talk first.
const pad = new THREE.Mesh(new THREE.CylinderGeometry(2.6, 2.6, 0.3, 24), glow(0x2ad8ff, 0.9));
pad.position.set(0, fwY + 0.15, 8); fwGroup.add(pad);
const padLabel = sprite('OUTBOUND', { color: '#2ad8ff', size: 0.9 });
padLabel.position.set(0, fwY + 2.4, 8); fwGroup.add(padLabel);
-
const outbound = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 1), glow(0xffe14a, 1.6));
outbound.visible = false; fwGroup.add(outbound);
-
- // beyond the wall: the way out
const exitPad = new THREE.Mesh(new THREE.CylinderGeometry(3, 3, 0.3, 24), glow(0x63ffa2, 1.0));
exitPad.position.set(0, fwY + 0.15, -26); fwGroup.add(exitPad);
ctx.world.add(0, fwY - 1, -26, 60, 1, 30, 'plat');
const exitFloor = new THREE.Mesh(new THREE.BoxGeometry(60, 1, 30), new THREE.MeshStandardMaterial({ color: 0x0e1a22, roughness: 0.9 }));
exitFloor.position.set(0, fwY - 0.5, -26); fwGroup.add(exitFloor);
+ S.firewall = { group: fwGroup, mesh: fwMesh, mat: fwMat, box: fwBox, pad, outbound, exitPad, y: fwY, open: 0, label: fwLabel };
- S.firewall = { group: fwGroup, mesh: fwMesh, mat: fwMat, box: fwBox, pad, outbound, exitPad, y: fwY, open: 0, sent: false, label: fwLabel };
-
- // ── you
S.syn = makeSyn(); scene.add(S.syn);
S.body = new Body(ctx.world, { speed: 8.5, jump: 11.5, gravity: 30, r: 0.5, h: 1.1 });
S.body.hasFloor = false;
@@ -206,12 +231,13 @@ export default {
update(dt, t, ctx) {
S.t += dt;
const b = S.body, look = S.look, cam = ctx.camera;
-
if (In.mouse.clicked && !In.isLocked()) { In.lock(); A.resume(); }
+ S.backoff = Math.max(0, S.backoff - dt);
look.update();
const w = look.wish();
- b.move(dt, w.x, w.z, In.jumpHit());
+ const locked = S.backoff > 0;
+ b.move(dt, locked ? 0 : w.x, locked ? 0 : w.z, !locked && In.jumpHit());
S.syn.position.copy(b.pos);
S.syn.position.y += 0.55;
@@ -224,114 +250,186 @@ export default {
}
const L = S.layers[S.i];
+ if (L) this.layerUpdate(dt, ctx, L);
- // ── hazard behaviour, per layer
- if (L) {
- const hz = LAYERS[S.i].hazard;
-
- if (hz === 'rate') {
- // 1.2 s. exactly. you can hear it.
- L.extra.gates.forEach(gt => {
- const cyc = (S.t * (1 / 1.2) + gt.phase) % 1;
- const open = cyc > 0.62;
- gt.disabled = open;
- gt.mesh.material.opacity = open ? 0.06 : 0.5;
- if (!gt._was && open) A.relayClick(0.12, 1.6);
- gt._was = open;
- });
- }
-
- if (hz === 'canary') {
- L.extra.canaries.forEach(c => {
- if (!c.alive) return;
- c.mesh.position.y = c.plat.max.y + 0.5 + Math.sin(S.t * 3 + c.plat.min.x) * 0.08;
- c.mesh.rotation.y += dt * 0.6;
- const d = Math.hypot(b.pos.x - c.mesh.position.x, b.pos.z - c.mesh.position.z);
- if (d < 1.4 && Math.abs(b.pos.y - c.plat.max.y) < 2) {
- c.alive = false;
- c.mesh.material.color.set(0xff4a5e);
- A.burst(2400, 3100, 0.5, 0.2);
- S.camShake = 0.5;
- ctx.say('canary the bird screams. everything comes.', 2600);
- ctx.spendTTL(1, 'canary');
- }
- });
- }
-
- if (hz === 'collide') {
- // CSMA/CD. two packets, one wire, somebody backs off.
- L.extra.collide.forEach(c => {
- const on = Math.sin(S.t * 1.6 + c.phase) > -0.25;
- c.box.disabled = !on;
- if (c.box.mesh) { c.box.mesh.visible = on; c.box.mesh.material.opacity = on ? 1 : 0; }
- });
- }
-
- if (hz === 'dark') {
- ctx.scene.fog.density = 0.03 + Math.sin(S.t * 0.6) * 0.004;
- }
- }
-
- // ── did we land on something that matters
if (b.grounded && b.ground) {
const g = b.ground;
- if (g.kind === 'gate' && g.layer === S.i && !S.advancing) this.advance(ctx);
+ if (g.kind === 'gate' && !S.advancing) this.advance(ctx);
else if (g.kind === 'catch' && !S.advancing) this.miss(ctx);
}
- // ── the firewall
- const F = S.firewall;
- if (F) {
- F.mat.opacity = 0.30 + Math.sin(S.t * 2.1) * 0.06;
- if (F.open > 0) {
- F.open -= dt;
- F.box.disabled = true;
- F.mat.color.setHex(0x63ffa2);
- F.mat.opacity = 0.10;
- if (F.open <= 0) { F.box.disabled = false; F.mat.color.setHex(0xff4a5e); F.label.userData.setText('DENY ALL INBOUND', '#ff4a5e'); }
- }
- // step on the pad → it sends something outbound, and it will accept the reply
- const onPad = Math.hypot(b.pos.x - F.pad.position.x, b.pos.z - F.pad.position.z) < 2.8 && Math.abs(b.pos.y - F.y) < 3;
- if (onPad && F.open <= 0 && !F.flight) {
- F.flight = 0;
- F.outbound.visible = true;
- F.outbound.position.set(0, F.y + 1.4, 8);
- A.burst(880, 1320, 0.2, 0.16);
- ctx.say('something went out. the wall will accept what comes back.', 3000);
- }
- if (F.flight !== undefined && F.flight !== null) {
- F.flight += dt;
- F.outbound.position.z = 8 - F.flight * 34;
- if (F.outbound.position.z < -20) {
- F.outbound.visible = false; F.flight = null;
- F.open = 3.2;
- F.label.userData.setText('ESTABLISHED — RELATED', '#63ffa2');
- A.burst(1320, 1760, 0.3, 0.16);
- ctx.say('3 seconds. go now, and go as a reply.', 2600);
- }
- }
- // through the wall while it is shut
- if (!F.done && Math.abs(b.pos.z + 14) < 1.6 && Math.abs(b.pos.y - F.y) < 12 && F.open <= 0) {
- S.refused++;
- b.pos.z = -12.2; b.vel.z = 12; b.vel.y = 4;
- A.burst(300, 220, 0.35, 0.2);
- S.camShake = 0.6;
- ctx.say('connection refused', 2000);
- ctx.spendTTL(1, 'refused');
- if (S.refused === 2) ctx.say('it is stateful. it remembers everything it has ever seen.
it has never seen you.', 4200);
- if (S.refused === 4) ctx.say('it has, however, seen its own traffic. the pad.', 4200);
- }
- if (!F.done && b.pos.z < -22) {
- F.done = true;
- ctx.phile('firewall');
- (async () => {
- await ctx.card('FIREWALL', 'stateful firewalls trust the return leg', 2800);
- ctx.next();
- })();
+ this.firewall(dt, ctx);
+ if (b.pos.y < -LAYERS.length * LAYER_H - 40) { b.pos.copy(S.spawn); b.vel.set(0, 0, 0); }
+ },
+
+ // ── one verb per floor
+ layerUpdate(dt, ctx, L) {
+ const b = S.body;
+ const near = S.i * LAYER_H;
+ S.hud.position.set(b.pos.x, -near - 1, b.pos.z + 4);
+ let read = '';
+
+ if (L.verb === 'transcode') {
+ if (In.hit('KeyQ')) {
+ S.enc = 1 - S.enc;
+ A.burst(S.enc ? 880 : 660, S.enc ? 1320 : 990, 0.09, 0.13);
}
+ L.plats.forEach(p => {
+ if (p.enc === undefined || p.enc === -1) return;
+ const on = p.enc === S.enc;
+ p.disabled = !on;
+ p.mesh.material.opacity = on ? 1 : 0.14;
+ if (p.edge) p.edge.visible = on;
+ if (p.label) p.label.material.opacity = on ? 1 : 0.25;
+ });
+ read = `Q — ${S.enc ? 'BASE64' : 'RAW'}`;
}
- if (b.pos.y < -LAYERS.length * LAYER_H - 40) { b.pos.copy(S.spawn); b.vel.set(0, 0, 0); }
+ if (L.verb === 'session') {
+ // a session you stop feeding closes. standing still is the mistake.
+ L.plats.forEach(p => {
+ if (!p.open) return;
+ if (b.ground === p) { p.timer = 0; return; }
+ if (p.timer === undefined) p.timer = 0;
+ if (p.everStood) {
+ p.timer += dt;
+ const k = clamp(1 - p.timer / 3.2, 0, 1);
+ p.mesh.material.opacity = k;
+ if (p.edge) p.edge.visible = k > 0.15;
+ if (k <= 0) { p.open = false; p.disabled = true; A.blip(300, 0.05, 0.05); }
+ }
+ });
+ if (b.ground && b.ground.kind === 'plat') b.ground.everStood = true;
+ const live = L.plats.filter(p => p.open).length;
+ read = `sessions open ${live}/${L.plats.length}`;
+ }
+
+ if (L.verb === 'reorder') {
+ L.plats.forEach(p => {
+ if (p.seq === undefined) return;
+ const on = p.seq === S.seq;
+ const past = p.seq < S.seq;
+ p.disabled = !on && !past;
+ p.mesh.material.opacity = on ? 1 : past ? 0.55 : 0.12;
+ if (p.edge) p.edge.visible = on || past;
+ if (p.label) p.label.userData.setText(String(p.seq + 1), on ? '#63ffa2' : past ? '#4a6a7a' : '#2a3f48');
+ });
+ if (b.ground && b.ground.seq === S.seq) {
+ S.seq++;
+ A.burst(660 + S.seq * 110, 990 + S.seq * 110, 0.12, 0.15);
+ }
+ read = `next in sequence ${Math.min(S.seq + 1, L.plats.length)}`;
+ }
+
+ if (L.verb === 'route') {
+ if (b.ground && b.ground.router && !b.ground.used) {
+ b.ground.used = true;
+ S.hops--;
+ A.relayClick(0.14, 1.2);
+ if (b.ground.label) b.ground.label.userData.setText('used', '#ff8a4a');
+ if (S.hops <= 0) {
+ ctx.say('ttl exceeded in transit take fewer routers.', 3400);
+ this.reshuffle(ctx, L);
+ S.hops = HOPS_BUDGET;
+ ctx.spendTTL(1, 'hops');
+ }
+ }
+ read = `hops left ${S.hops}`;
+ }
+
+ if (L.verb === 'carrier') {
+ // listen before you transmit
+ L.plats.forEach(p => {
+ if (p.phase === undefined) return;
+ const busy = Math.sin(S.t * (6.28 / p.period) + p.phase) > 0.25;
+ p.busy = busy;
+ p.mesh.material.color.setHex(busy ? 0xff4a5e : 0x98c8f8);
+ if (p.edge) p.edge.material.color.setHex(busy ? 0xff4a5e : 0x63ffa2);
+ });
+ if (b.ground && b.ground.busy && S.backoff <= 0) {
+ S.collisions++;
+ // exponential backoff, which is the actual algorithm and it is merciless
+ S.backoff = Math.min(2.4, 0.35 * Math.pow(2, Math.min(3, S.collisions)));
+ A.burst(200, 260, 0.3, 0.2);
+ S.camShake = 0.5;
+ b.vel.y = 6;
+ ctx.say(`collision back off ${S.backoff.toFixed(1)}s`, 2200);
+ }
+ read = S.backoff > 0 ? `backing off ${S.backoff.toFixed(1)}` : 'listen before transmit';
+ }
+
+ if (L.verb === 'wave') {
+ // you are a voltage on a long wire, and it attenuates
+ L.extra.wave.forEach(p => {
+ const k = p.wave / L.extra.wave.length;
+ const amp = 3.4 * (1 - k * 0.72); // attenuation down the line
+ const y = p.baseY + Math.sin(S.t * 2.2 - p.wave * 0.42) * amp;
+ p.mesh.position.y = y + 0.35;
+ if (p.edge) p.edge.position.y = y + 0.71;
+ p.min.y = y; p.max.y = y + 0.7;
+ });
+ read = 'ride it';
+ }
+
+ S.hud.userData.setText(read, '#9ff5ff');
+ S.hud.visible = !!read;
+ },
+
+ reshuffle(ctx, L) {
+ clearLayer(ctx, L);
+ S.aslrSeed = (S.aslrSeed * 1103515245 + 12345) >>> 0;
+ S.layers[S.i] = buildLayer(ctx, S.i, S.aslrSeed);
+ S.body.pos.copy(S.spawn); S.body.pos.y += 1;
+ S.body.vel.set(0, 0, 0);
+ },
+
+ firewall(dt, ctx) {
+ const F = S.firewall, b = S.body;
+ if (!F) return;
+ F.mat.opacity = 0.30 + Math.sin(S.t * 2.1) * 0.06;
+ if (F.open > 0) {
+ F.open -= dt;
+ F.box.disabled = true;
+ F.mat.color.setHex(0x63ffa2); F.mat.opacity = 0.10;
+ if (F.open <= 0) { F.box.disabled = false; F.mat.color.setHex(0xff4a5e); F.label.userData.setText('DENY ALL INBOUND', '#ff4a5e'); }
+ }
+ const onPad = Math.hypot(b.pos.x - F.pad.position.x, b.pos.z - F.pad.position.z) < 2.8 && Math.abs(b.pos.y - F.y) < 3;
+ if (onPad && F.open <= 0 && !F.flight) {
+ F.flight = 0;
+ F.outbound.visible = true;
+ F.outbound.position.set(0, F.y + 1.4, 8);
+ A.burst(880, 1320, 0.2, 0.16);
+ ctx.say('something went out. the wall will accept what comes back.', 3000);
+ }
+ if (F.flight !== undefined && F.flight !== null) {
+ F.flight += dt;
+ F.outbound.position.z = 8 - F.flight * 34;
+ if (F.outbound.position.z < -20) {
+ F.outbound.visible = false; F.flight = null;
+ F.open = 3.2;
+ F.label.userData.setText('ESTABLISHED — RELATED', '#63ffa2');
+ A.burst(1320, 1760, 0.3, 0.16);
+ ctx.say('3 seconds. go now, and go as a reply.', 2600);
+ }
+ }
+ if (!F.done && Math.abs(b.pos.z + 14) < 1.6 && Math.abs(b.pos.y - F.y) < 12 && F.open <= 0) {
+ S.refused++;
+ b.pos.z = -12.2; b.vel.z = 12; b.vel.y = 4;
+ A.burst(300, 220, 0.35, 0.2);
+ S.camShake = 0.6;
+ ctx.say('connection refused', 2000);
+ ctx.spendTTL(1, 'refused');
+ if (S.refused === 2) ctx.say('it is stateful. it remembers everything it has ever seen.
it has never seen you.', 4200);
+ if (S.refused === 4) ctx.say('it has, however, seen its own traffic. the pad.', 4200);
+ }
+ if (!F.done && b.pos.z < -22 && Math.abs(b.pos.y - F.y) < 12) {
+ F.done = true;
+ ctx.phile('firewall');
+ (async () => {
+ await ctx.card('FIREWALL', 'stateful firewalls trust the return leg', 2800);
+ ctx.next();
+ })();
+ }
},
advance(ctx) {
@@ -345,10 +443,13 @@ export default {
await ctx.sleep(700);
S.i = nxt;
S.spawn = S.body.pos.clone();
- if (S.i === LAYERS.length - 1 && S.done7) { /* already bottom */ }
+ // reset whatever the next floor counts
+ S.enc = 0; S.seq = 0; S.hops = HOPS_BUDGET; S.collisions = 0; S.backoff = 0;
const L = LAYERS[S.i];
await ctx.card(`${L.n} ${L.name}`, `hop ${2 + S.i} · ${LAYERS.length - S.i} layers of you left`, 1900);
- ctx.say(L.note, 4000);
+ ctx.say(L.note, 4400);
+ if (L.verb === 'transcode') ctx.hint('Qswitch encoding
WASDmove SPACEjump');
+ else ctx.hint('WASDmove
SPACEjump');
ctx.spendTTL(1);
S.advancing = false;
if (S.i === LAYERS.length - 1) ctx.say('below this there is no protocol at all.', 3600);
@@ -361,16 +462,19 @@ export default {
A.thud(90, 0.4, 0.25);
S.camShake = 0.8;
ctx.spendTTL(1, 'missed');
- // NETWORK reshuffles on every death. that is the entire point of it.
- if (LAYERS[S.i].hazard === 'aslr') {
- const L = S.layers[S.i];
- clearLayer(ctx, L);
- S.aslrSeed = (S.aslrSeed * 1103515245 + 12345) >>> 0;
- S.layers[S.i] = buildLayer(ctx, S.i, S.aslrSeed);
+ const L = S.layers[S.i];
+ if (LAYERS[S.i].verb === 'route') {
+ // NETWORK reshuffles on every death. that is the entire point of it.
+ this.reshuffle(ctx, L);
+ S.hops = HOPS_BUDGET;
ctx.say('aslr it is not the same room. it was never going to be.', 3200);
+ } else if (LAYERS[S.i].verb === 'session') {
+ // reopen what you let close, or the floor is unfinishable
+ L.plats.forEach(p => { p.open = true; p.disabled = false; p.timer = 0; p.everStood = false; p.mesh.material.opacity = 1; if (p.edge) p.edge.visible = true; });
+ S.body.pos.copy(S.spawn); S.body.pos.y += 1; S.body.vel.set(0, 0, 0);
+ } else {
+ S.body.pos.copy(S.spawn); S.body.pos.y += 1; S.body.vel.set(0, 0, 0);
}
- S.body.pos.copy(S.spawn); S.body.pos.y += 1;
- S.body.vel.set(0, 0, 0);
setTimeout(() => { S.missing = false; }, 400);
},
diff --git a/src/philes.js b/src/philes.js
index ab6dd64..e4d76dc 100644
--- a/src/philes.js
+++ b/src/philes.js
@@ -35,6 +35,22 @@ everybody still teaches the seven. everybody still runs the four. the gap
between those two facts is where roughly half of this is hiding.`,
},
+ 'layers': {
+ title: 'what each layer is actually for',
+ from: 'the thing the model is trying to tell you',
+ body: `every layer is a different job, and the job is the interesting part.
+
+presentation is about representation — the same bytes mean two things and
+somebody has to agree which. session is about state that has to be kept
+alive or it stops existing. transport is about order, because the network
+does not promise you any. network is about which way to go and how many
+hops it costs. data link is about sharing one wire without shouting over
+each other. physical is a voltage on a piece of copper and nothing else.
+
+none of that is "the same job at a different altitude". that is why the
+model has seven of them.`,
+ },
+
'firewall': {
title: 'stateful inspection',
from: 'a whitepaper, 1994',