Spaces:
Paused
Paused
Claude
Claude Opus 4.8
Phase 5 (spectate): watch an AI-vs-AI match live, at 1x-8x
450f6c1 unverified | /* ============================================================ | |
| Fixed-timestep game loop. | |
| Simulation runs at a constant rate (default 20 Hz) regardless of | |
| display refresh rate, via an accumulator β the render callback runs | |
| once per animation frame and gets the leftover fraction for | |
| interpolation if it wants it. This is the seam that makes the sim | |
| deterministic-ish and decoupled from how fast the browser paints. | |
| ============================================================ */ | |
| ; | |
| const MAX_SUBSTEPS = 5; // most catch-up sim ticks to run in one animation frame | |
| // A speed MULTIPLIER on how much sim time one real second buys (docs/competitions-and-elo.md | |
| // Phase 5: 2x/4x/8x for a spectated match). Either a plain number or a getter, so a caller can | |
| // let the player change speed mid-match without rebuilding the loop. | |
| // | |
| // WHY IT SCALES THE ACCUMULATOR AND NOT `hz`. The obvious implementation β 4x means dtFixed/4 β | |
| // would change THE FIXED TIMESTEP, and the fixed timestep is the thing that makes this sim | |
| // replayable: same seed β same game only holds because every tick is the same dt in the same | |
| // order. Scaling the time that feeds the accumulator instead leaves `update(dtFixed)` byte-for- | |
| // byte what it always was and changes only how MANY of those identical steps a real second runs. | |
| // It also lands the overload in exactly the place the existing design already handles well: past | |
| // MAX_SUBSTEPS the loop degrades to slow motion (dropping sim time) rather than spiralling into | |
| // an ever-deepening backlog, so 8x on a machine that can't feed it just runs slower than the | |
| // label promises instead of freezing the tab. | |
| // Anything that isn't a finite positive number reads as 1x β an invalid speed must never be able | |
| // to stall the loop (0) or run it backwards. | |
| function speedMultiplier(speed) { | |
| const v = typeof speed === "function" ? speed() : speed; | |
| return (typeof v === "number" && Number.isFinite(v) && v > 0) ? v : 1; | |
| } | |
| export function createLoop({ update, render, hz = 20, speed = 1, now = () => performance.now() }) { // deterministic-exempt: wall clock drives the render loop, not the sim | |
| const dtFixed = 1 / hz; | |
| let acc = 0; | |
| let last = null; | |
| let running = false; | |
| let rafId = null; | |
| function frame(t) { | |
| if (!running) return; | |
| if (last === null) last = t; | |
| let delta = (t - last) / 1000; | |
| last = t; | |
| if (delta > 0.25) delta = 0.25; // clamp so a backgrounded tab doesn't spiral on return | |
| acc += delta * speedMultiplier(speed); // β¦then buy that much MORE sim time per real second (see above) | |
| // Cap catch-up substeps per frame. If a single update ever runs longer than | |
| // the fixed step (plausible on a Gigantic map once armies are huge), the | |
| // accumulator would otherwise grow every frame and the sim spirals into an | |
| // ever-deepening backlog. Capping lets it degrade to slow-motion β dropping | |
| // sim time β instead, which stays responsive and recovers. | |
| let steps = 0; | |
| while (acc >= dtFixed && steps < MAX_SUBSTEPS) { | |
| update(dtFixed); | |
| acc -= dtFixed; | |
| steps++; | |
| } | |
| if (acc > dtFixed) acc = 0; // over the cap: drop the backlog rather than carry it forward | |
| // A throwing render (e.g. a bad draw call on one odd entity) must not brick the whole | |
| // loop: without this, the throw unwinds past the requestAnimationFrame call below and | |
| // the session is permanently frozen. update() is deliberately left unguarded β a | |
| // throwing sim update is a correctness problem this catch isn't meant to paper over. | |
| try { | |
| render(acc / dtFixed); | |
| } catch (err) { | |
| console.error("render() threw; skipping this frame", err); | |
| } | |
| rafId = requestAnimationFrame(frame); // browser-exempt: the render loop IS the browser seam; drives no sim state | |
| } | |
| return { | |
| start() { | |
| if (running) return; | |
| running = true; | |
| last = null; | |
| rafId = requestAnimationFrame(frame); // browser-exempt: render-loop seam only | |
| }, | |
| stop() { | |
| running = false; | |
| if (rafId !== null) cancelAnimationFrame(rafId); // browser-exempt: render-loop seam only | |
| }, | |
| get running() { return running; }, | |
| }; | |
| } | |