diff --git a/docs/rollback-netcode.md b/docs/rollback-netcode.md new file mode 100644 index 000000000..18413c07e --- /dev/null +++ b/docs/rollback-netcode.md @@ -0,0 +1,223 @@ +# Rollback Netcode + +**Status:** phase 0 shipped — `fight` plays hotseat and the sim is proven +rollback-safe. No networking yet. + +The goal is a versus fighting game on AC with GGPO-style rollback: both peers +run the same deterministic simulation, send only inputs, predict what they +haven't received, and silently rewind-and-resimulate when a prediction was +wrong. + +The thing worth internalizing up front: **this is not a networking project, it +is a determinism project.** The transport is the easy part. Making two browsers +on two machines produce bit-identical state from identical inputs, for +thousands of frames, is the hard part, and everything below is organized around +it. + +## What AC already gives us + +`lib/loop.mjs:104` drains a classic fixed-timestep accumulator, calling a +piece's `sim()` at a constant **120Hz** (`updateFps`, `lib/loop.mjs:7`). Render +is gated separately. Most engines make you build this; AC hands it over. A +fighting game ticks its own logic every *other* `sim()` call, which yields a +steady 60Hz game clock on both 60Hz and 120Hz displays. + +Rollback needs no change to `loop.mjs` or `disk.mjs`. A piece owns its own +state, so AC's `sim()` call is just a clock tick: when a late input arrives, the +piece restores a snapshot and resimulates N ticks inside that single call. + +### Two things AC costs us + +**Input is one frame stale.** The frame handler runs the sim loop +(`lib/disk.mjs:13067`) *before* dispatching input to `act()` +(`lib/disk.mjs:13088`). A key pressed during frame N is not visible to `sim()` +until frame N+1. Rollback fighters budget 3–4 frames of input delay total +(Killer Instinct uses 3), so spending one on dispatch order is expensive. +Swapping that order is a real candidate change. + +**Keyboard has no pollable held-state.** Only `keyboard:down:*` / +`keyboard:up:*` events. A rollback sim must *sample* a held-button bitmask once +per tick, so the piece maintains its own held set. Pen state (`$api.pen`) is +pollable; keyboard is not. + +## What AC does not give us: peers + +`session-server` uses [geckos.io](https://github.com/geckosio/geckos.io), which +reads peer-to-peer because it is WebRTC underneath — but **the server is the +only peer**. Every "peer" message is relayed: `channel.broadcast.emit(...)` in +`session-server/session.mjs:3777`. Production is a single DigitalOcean droplet +in NYC, so two players in Berlin currently round-trip through New York, twice. + +Production also has **STUN only, no TURN** (`session.mjs:1101-1104`, with a literal +`// TODO: Add production TURN server`). + +Direct browser↔browser data channels are net-new work, but small: + +- `RTCPeerConnection` is main-thread-only, so it lives in `bios.mjs` — exactly + where `lib/udp.mjs` already sits. +- Configure the channel `{ ordered: false, maxRetransmits: 0 }` for true + fire-and-forget. That is a legitimate UDP substitute: measured at ~1–3ms over + raw UDP ([arXiv 2112.02163](https://arxiv.org/pdf/2112.02163)), and with + sub-MTU packets (an input is ~2 bytes) there is no head-of-line blocking. +- Signaling is ~30 lines of offer/answer/ICE relay in `session.mjs`, which + already has the room and broadcast plumbing. +- Budget **~15–25% of sessions needing TURN relay** (symmetric NAT, cellular + CGNAT). The often-repeated "8% relay" figure has no traceable source; the best + real measurement is [callstats.io's 22%](https://webrtchacks.com/usage-stats/). + Cloudflare Realtime TURN is $0.05/GB with the first 1TB/month free — free at + input-packet volumes. + +Do **not** extend `session-server/duel-manager.mjs`. It is a server-authoritative +Quake-3 model (60Hz server sim, 20Hz snapshots). That is a genuinely different +architecture, not a stepping stone. + +## Determinism rules + +JavaScript is friendlier here than C++: the spec pins every intermediate to +double precision, so `+ - * /` and `Math.sqrt` cannot leak 80-bit x87 +intermediates or contract into an FMA. They are bit-identical across V8, +JavaScriptCore, and SpiderMonkey. + +What is poison is the **implementation-approximated** set — `sin, cos, tan, pow, +atan2, hypot, exp, log` and the `**` operator. The spec explicitly allows "some +latitude in the choice of approximation algorithms" and only *recommends* +fdlibm. Chrome uses fdlibm; Firefox historically used the platform libm, and +measured results differ in the last bit between x86-64 and ARM. One `Math.sin` +in the sim desyncs Chrome against Safari. + +So `lib/fight/sim.mjs` obeys: + +1. **Integers only.** Positions in subpixels (1/256px), velocities in + subpixels/frame. A 2D fighter is frame data and hitbox rectangles — it needs + no trig and no `sqrt`. This dissolves the problem rather than managing it, + and avoids fixed-point Q16.16 entirely. +2. **All state in one `Int32Array`.** A snapshot is `.slice()`, a checksum is a + walk. `Int32Array` also *forces* int32 on write, so state can never silently + become a float. +3. **Seeded PRNG in the state array.** mulberry32, integer-only (`Math.imul`, + `>>> 0`). Its cursor rolls back with everything else. +4. **Fixed iteration order.** Both fighters are probed before either hit is + applied, so a trade never depends on index order. +5. **No wall clock.** No `Date.now`, no `performance.now`, no `Math.random`. + +A spec greps the sim source and fails the build if any banned identifier appears. + +### Sound, and why it lives in state + +The classic rollback bug (Street Fighter x Tekken) is audio replaying on every +resimulated frame — sounds popping and cutting out. The fix is that **the sim +never plays a sound.** It records intent into `G.SFX`, a bitmask cleared at the +top of every `step()`, and only the *leading* frame reads it: + +```js +function sim({ sound }) { + if ((half ^= 1)) return; // ac ticks at 120hz; the game at 60 + game.step(s, held[0], held[1]); + hear(sound); // never called during a rollback resim +} +``` + +Because the flag is derived state, a rewound frame that no longer lands a hit +un-schedules its own sound. There is a spec for exactly the GGPO case: predict +the defender idle and it sounds like a hit; the real input arrives showing they +held back, and after the rollback that same frame sounds like a block. + +The same rule governs particles and screen shake. Anything gameplay-relevant +(hitboxes, animation frames that gate hurtboxes) must be *in* the snapshot. +Anything cosmetic must be derivable from it, never accumulated outside it. + +### The stage never learns the screen size + +`STAGE_W` is a fixed 256 in the sim. If it tracked `screen.width`, two players +on differently sized windows would simulate different fights. `paint()` scales; +the sim does not. This is easy to get wrong — it was a real bug caught by +running the piece, since AC handed it a 174×128 canvas. + +## The safety net: SyncTest + +`lib/fight/rollback.mjs` implements GGPO's `ggpo_start_synctest`: every frame, +rewind `distance` frames, replay them from the snapshot with the recorded +inputs, and compare checksums. Identical inputs must land on an identical +checksum. If they don't, some state lives outside the snapshot — a module-level +variable, an unrestored RNG cursor, a cached derived value. + +This is the same idea as Factorio's ["heavy mode"](https://wiki.factorio.com/Desynchronization), +and it catches on one machine, in one process, what would otherwise surface as +a miserable 60Hz desync across two continents. + +```bash +npx jasmine --config=spec/support/jasmine.json --filter=fight +``` + +Green at distances 1, 2, 4 and 8 (GGPO's `MAX_PREDICTION_FRAMES` is 8). + +**A SyncTest can pass vacuously.** The first version of the input script tapped +a button nearly every frame, so both fighters sat in attack recovery for all 900 +frames: they never walked, the gap stayed at exactly 96px, no hit landed, and +the RNG never advanced. The harness faithfully verified that *nothing happening* +rolls back correctly. There is now a spec asserting the RNG advances, so that +failure mode cannot come back silently. If you write a new input script, check +that it produces hits before you trust a green run. + +## Files + +| path | role | +| --- | --- | +| `system/public/aesthetic.computer/lib/fight/sim.mjs` | the deterministic integer simulation | +| `system/public/aesthetic.computer/lib/fight/rollback.mjs` | SyncTest, snapshot ring, desync diff | +| `system/public/aesthetic.computer/disks/fight.mjs` | the piece: input, render, audio | +| `spec/fight-sim-spec.mjs` | determinism, sound, and rules specs | + +Run it: `fight` for a match, `fight:boxes` for the hit/hurt overlay plus a live +tick and checksum readout, `fight:synctest` to run the harness in the browser. + +Two players share one QWERTY, hands left-to-right across the board: + +| | move | light / medium / heavy | +| --- | --- | --- | +| P1 | `W A S D` | `F G H` | +| P2 | arrows | `J K L` | + +Most keyboards ghost past ~6 simultaneous keys, so a two-player scramble can +drop inputs. That is hardware, and it is part of why phase 3 exists. + +## Roadmap + +- **Phase 0 — determinism.** *Done.* Integer sim, SyncTest, hotseat play. +- **Phase 1 — the rollback session.** Input queues, repeat-last-input + prediction, the rewind loop, and the prediction-window stall. Driven by two + local input streams with latency and packet loss injected. Still no network. + SyncTest proves the *sim* survives rewinding; phase 1 proves the *session* + does. +- **Phase 2 — rollback over the existing relay.** geckos.io works today and + needs zero infrastructure. Rollback over a relay is still rollback; it just + has worse RTT. This validates the machinery against real jitter and loss. +- **Phase 3 — P2P.** `RTCPeerConnection` in `bios.mjs`, signaling over the + existing WebSocket, Cloudflare TURN as fallback. A latency optimization + layered on once the hard part is proven. + +### Numbers to design against + +| parameter | value | +| --- | --- | +| GGPO max prediction window | 8 frames | +| input delay used by real fighters | 3 frames (Killer Instinct); 3–4 acceptable | +| prediction usability ceiling | ~100–150ms before it feels bad | +| per-frame resim budget (8-frame window) | ~1.5–1.8ms | +| input encoding | 1–2 byte bitmask, XOR-diffed against the previous frame | +| latency identity | total ≈ input_delay + avg rollback frames | + +Inputs are re-sent redundantly: GGPO packs every un-ACKed frame into each +packet, so a dropped packet is covered by the next one. Do not build +retransmission on top of the data channel — resend the input window instead. + +### Known hazards + +- **A backgrounded tab stops receiving `requestAnimationFrame` entirely**, + which stalls the peer and hangs the match. Handle hidden-tab as an explicit + pause-or-forfeit state rather than discovering it in playtesting. +- **Do not enable cross-origin isolation** (COOP/COEP) chasing 5µs timers. It + would break AC's cross-origin CDN assets and embeds, and rollback counts ticks + rather than measuring time, so `performance.now()` clamping is irrelevant. +- Hitstop must be part of deterministic state, not a render pause, or the two + peers disagree about frame numbering. diff --git a/spec/fight-sim-spec.mjs b/spec/fight-sim-spec.mjs index 53e7f7ac5..2220f9e9e 100644 --- a/spec/fight-sim-spec.mjs +++ b/spec/fight-sim-spec.mjs @@ -92,6 +92,79 @@ describe("fight sim determinism", () => { } }); +describe("fight sim sound", () => { + it("records a swing as state rather than playing it", () => { + const s = sim.create(1); + sim.step(s, sim.LIGHT, 0); + expect(s[sim.G.SFX] & sim.SFX.SWING).toBeTruthy(); + expect(s[sim.G.SFXMV]).toBe(0); + }); + + it("clears the event every tick, so a silent frame stays silent", () => { + const s = sim.create(1); + sim.step(s, sim.LIGHT, 0); + expect(s[sim.G.SFX]).not.toBe(0); + sim.step(s, sim.LIGHT, 0); // still held — no fresh press + expect(s[sim.G.SFX]).toBe(0); + }); + + it("flags a hit, and a block instead when the defender holds away", () => { + const hit = sim.create(1); + for (let f = 0; f < 40; f++) sim.step(hit, sim.RIGHT, 0); + let flags = 0; + for (let f = 0; f < 20; f++) { + sim.step(hit, f < 2 ? sim.HEAVY : 0, 0); + flags |= hit[sim.G.SFX]; + } + expect(flags & sim.SFX.HIT).toBeTruthy(); + expect(flags & sim.SFX.BLOCK).toBeFalsy(); + expect(hit[sim.G.SFXMV]).toBe(2); // heavy + + const blk = sim.create(1); + for (let f = 0; f < 40; f++) sim.step(blk, sim.RIGHT, 0); + let bflags = 0; + for (let f = 0; f < 20; f++) { + sim.step(blk, f < 2 ? sim.HEAVY : 0, sim.RIGHT); + bflags |= blk[sim.G.SFX]; + } + expect(bflags & sim.SFX.BLOCK).toBeTruthy(); + expect(bflags & sim.SFX.HIT).toBeFalsy(); + }); + + // the ggpo case. we predicted the defender was holding nothing and played a + // hit; the real input arrives and they were holding back. after the rollback + // that frame must sound like a block, and the hit must never have happened. + it("re-sounds a frame when a rollback corrects the defender's input", () => { + const s = sim.create(1); + for (let f = 0; f < 40; f++) sim.step(s, sim.RIGHT, 0); + for (let f = 0; f < 10; f++) sim.step(s, f < 2 ? sim.HEAVY : 0, 0); + + const before = sim.snapshot(s); // the tick the heavy connects on + + sim.step(s, 0, 0); // predicted: defender idle + expect(s[sim.G.SFX] & sim.SFX.HIT).toBeTruthy(); + expect(s[sim.G.SFX] & sim.SFX.BLOCK).toBeFalsy(); + + sim.restore(s, before); // confirmed: they were holding back + sim.step(s, 0, sim.RIGHT); + expect(s[sim.G.SFX] & sim.SFX.BLOCK).toBeTruthy(); + expect(s[sim.G.SFX] & sim.SFX.HIT).toBeFalsy(); + }); + + it("stays mute while frozen in hitstop", () => { + const s = sim.create(1); + for (let f = 0; f < 40; f++) sim.step(s, sim.RIGHT, 0); + let hitAt = -1; + for (let f = 0; f < 20 && hitAt < 0; f++) { + sim.step(s, f < 2 ? sim.HEAVY : 0, 0); + if (s[sim.G.SFX] & sim.SFX.HIT) hitAt = f; + } + expect(hitAt).toBeGreaterThan(-1); + sim.step(s, 0, 0); // first hitstop frame + expect(s[sim.G.SFX]).toBe(0); + }); +}); + describe("fight sim rules", () => { const idle = () => [0, 0]; @@ -128,12 +201,17 @@ describe("fight sim rules", () => { ); }); - it("ends the round when health runs out", () => { + it("ends the round when health runs out, and says so", () => { const s = sim.create(1); s[sim.PN + sim.P.HP] = 10; for (let f = 0; f < 40; f++) sim.step(s, sim.RIGHT, 0); - for (let f = 0; f < 40; f++) sim.step(s, f % 20 < 2 ? sim.HEAVY : 0, 0); + let ko = 0; + for (let f = 0; f < 40; f++) { + sim.step(s, f % 20 < 2 ? sim.HEAVY : 0, 0); + ko |= s[sim.G.SFX] & sim.SFX.KO; + } expect(s[sim.G.OVER]).toBe(1); + expect(ko).toBeTruthy(); }); it("idles to a timeout decision", () => { diff --git a/system/public/aesthetic.computer/disks/CLAUDE.md b/system/public/aesthetic.computer/disks/CLAUDE.md index 6c7be838a..e0a2d5cd7 100644 --- a/system/public/aesthetic.computer/disks/CLAUDE.md +++ b/system/public/aesthetic.computer/disks/CLAUDE.md @@ -107,6 +107,10 @@ The shape: in `boot({ net: { socket, udp }, handle })`, receives `(id, type, content)`; watch for `connected*`, `left`, and your own `game:*` types. +Note both channels relay through the server — geckos.io is not peer-to-peer. +For frame-critical 1v1 (rollback netcode), see `docs/rollback-netcode.md` and +the `fight` piece; its simulation lives in `../lib/fight/`. + Session-server routing (`session-server/session.mjs`): - UDP handlers: add `channel.on("game:move", ...)` in the geckos section - WebSocket: position messages use `others()` (relay to all except sender), diff --git a/system/public/aesthetic.computer/disks/fight.mjs b/system/public/aesthetic.computer/disks/fight.mjs index 82ebc6409..17cdc1c1a 100644 --- a/system/public/aesthetic.computer/disks/fight.mjs +++ b/system/public/aesthetic.computer/disks/fight.mjs @@ -11,7 +11,7 @@ import * as game from "../lib/fight/sim.mjs"; import { syncTest, report } from "../lib/fight/rollback.mjs"; -const { P, PN, G, ST, SUB, BODY_W, BODY_H, CROUCH_H, MOVES } = game; +const { P, PN, G, ST, SFX, SUB, BODY_W, BODY_H, CROUCH_H, MOVES } = game; const KEYS = { w: [0, game.UP], @@ -56,10 +56,37 @@ function boot({ colon }) { } } -function sim() { +function sim({ sound }) { if ((half ^= 1)) return; // every other 120hz step if (s[G.OVER]) return; game.step(s, held[0], held[1]); + hear(sound); // leading frame only — a rollback's resimulated frames stay mute +} + +// heavier hits land lower. the sim already decided what happened; this only +// gives it a voice. +const TONE = [220, 165, 110]; + +function hear(sound) { + const f = s[G.SFX]; + if (!f) return; + const mv = s[G.SFXMV]; + const play = (o) => sound?.synth?.({ attack: 0.001, ...o }); + + if (f & SFX.SWING) + play({ type: "noise-white", duration: 0.03, decay: 0.6, volume: 0.1 }); + if (f & SFX.JUMP) + play({ type: "triangle", tone: 330, duration: 0.05, decay: 0.7, volume: 0.14 }); + if (f & SFX.BLOCK) + play({ type: "noise-white", duration: 0.07, decay: 0.5, volume: 0.24 }); + if (f & SFX.HIT) { + play({ type: "square", tone: TONE[mv], duration: 0.06 + mv * 0.02, decay: 0.55, volume: 0.3 }); + play({ type: "noise-white", duration: 0.05, decay: 0.4, volume: 0.18 }); + } + if (f & SFX.KO) { + play({ type: "sawtooth", tone: 110, duration: 0.5, decay: 0.9, volume: 0.3 }); + play({ type: "square", tone: 55, duration: 0.6, attack: 0.02, decay: 0.95, volume: 0.18 }); + } } function act({ event: e }) { diff --git a/system/public/aesthetic.computer/lib/fight/sim.mjs b/system/public/aesthetic.computer/lib/fight/sim.mjs index b845c5f2d..c502cbbbb 100644 --- a/system/public/aesthetic.computer/lib/fight/sim.mjs +++ b/system/public/aesthetic.computer/lib/fight/sim.mjs @@ -76,8 +76,16 @@ export const P = { }; export const PN = 15; -export const G = { TICK: 30, RNG: 31, TIMER: 32, OVER: 33 }; -export const SIZE = 34; +// what the sim wants heard this tick. the sim never plays a sound itself — it +// records the intent and lets the caller decide. a rollback resimulates frames +// that already happened, and playing audio from those is the street fighter x +// tekken bug: sounds popping and cutting out. so only the leading frame reads +// this. cleared at the top of every step, which means a rewound frame that no +// longer lands a hit silently un-schedules its own sound. +export const SFX = { SWING: 1, HIT: 2, BLOCK: 4, JUMP: 8, KO: 16 }; + +export const G = { TICK: 30, RNG: 31, TIMER: 32, OVER: 33, SFX: 34, SFXMV: 35 }; +export const SIZE = 36; export const START_HP = 1000; export const ROUND_TICKS = 99 * 60; @@ -125,6 +133,7 @@ const grounded = (s, b) => s[b + P.Y] === 0; export function step(s, i0, i1) { s[G.TICK]++; + s[G.SFX] = 0; // before every early return, so silent frames stay silent if (s[G.OVER]) return; // hitstop freezes both fighters. inputs still latch so a button held @@ -159,6 +168,7 @@ export function step(s, i0, i1) { s[b + P.HP] = 0; s[b + P.ST] = ST.KO; s[G.OVER] = 2 - p; // 1 → p0 wins, 2 → p1 wins + s[G.SFX] |= SFX.KO; } } if (!s[G.OVER] && s[G.TIMER] === 0) { @@ -207,6 +217,8 @@ function control(s, p, inp) { s[b + P.MV] = btn & LIGHT ? 0 : btn & MEDIUM ? 1 : 2; s[b + P.STF] = 0; s[b + P.HIT] = 0; + s[G.SFX] |= SFX.SWING; + s[G.SFXMV] = s[b + P.MV]; return; } @@ -219,6 +231,7 @@ function control(s, p, inp) { s[b + P.ST] = ST.JUMP; s[b + P.VY] = JUMP_V; s[b + P.VX] = inp & RIGHT ? AIR : inp & LEFT ? -AIR : 0; + s[G.SFX] |= SFX.JUMP; return; } @@ -300,7 +313,10 @@ function connect(s, p) { s[ob + P.STF] = m.block; s[b + P.STOP] = BLOCK_STOP; s[ob + P.STOP] = BLOCK_STOP; + s[G.SFX] |= SFX.BLOCK; } else { + s[G.SFX] |= SFX.HIT; + s[G.SFXMV] = s[b + P.MV]; s[ob + P.HP] -= m.dmg; s[ob + P.ST] = ST.HITSTUN; s[ob + P.STF] = m.stun;