Skip to content

Debug runtimes freeze values handed to the world - #178

Merged
SandroMaglione merged 2 commits into
mainfrom
core/freeze-world-values-in-debug
Nov 2, 2026
Merged

SandroMaglione merged 2 commits into
mainfrom
core/freeze-world-values-in-debug

Conversation

@SandroMaglione

Copy link
Copy Markdown
Member

Problem

Found while reviewing Collision Chamber, a pinball game on Ritmo 0.10. Its adapter runs structuredClone(...) as Ball on every read and write at 240 Hz, because nothing else stops this:

const first = { x: 1 }
// a system: commands.spawn(Game.Command.spawn([Body, first]))
const cp = runtime.checkpoint(simulate)
runtime.tick(simulate)   // a system: set({ x: 2 })
first.x = 777            // host mutates the object it spawned with
runtime.rewind(cp)       // ok: true
// world reads x: 777, not 1

The world stored values by reference, so a later mutation changed the world with no write. changed readers missed it, and rewind brought back the mutated value, breaking the "exactly as it was" promise in Checkpoint.

Change

  • A runtime made with debug: { hostClock } deep-freezes every value it is handed, as it is queued or written:

    • Game.Command.spawn / entry / commands.insert
    • a cell's set / update / setRaw, including entity states
    • resource writes, emit, runtime.send, next-state set, machine update
    • the resources and machines given to Game.Runtime.make

    A later mutation throws a TypeError at the line that does it. The walk stops at frozen objects, so { ...value, x } freezes one object. Typed arrays are skipped; Map/Set contents stay writable.

  • Release runtimes are unchanged. They freeze nothing, and the write hot path is untouched: debug runtimes get separate cell classes, chosen once when the cell is made.

  • Docs:

    • WriteCell.set/update, Command.entry/spawn, ReadonlyValue, and Debug say the world owns what it is given.
    • Runtime.tick points to FixedLoop.start; the game hand-wrote a fixed-step accumulator.
    • The ritmo skill now treats a variable a system closes over like a module variable, which a rewind can't restore.

Breaking for debug runtimes only. The changeset carries the fix: build a new value instead of mutating the one handed over.

Checks

  • pnpm run check: passes (1286 tests). test/Runtime.ownership.test.ts covers each entry point, the rewind case, and that release runtimes freeze nothing; 5 of its 8 tests fail without the fix.
  • pnpm --dir website build: passes.
  • pnpm bench:check fails locally on render/sync-1k-changed-of-10k (critical). main fails it the same way on this machine (+25% over the committed baseline), and main against its own earlier run moved +38–57%, so it is machine noise. Allocations are unchanged.

🤖 Generated with Claude Code

SandroMaglione and others added 2 commits November 2, 2026 18:50
Values passed to spawn, insert, set/update, resource writes, emit, send,
next-state set, and Runtime.make were stored by reference. Mutating one
afterwards changed the world with no write, and a rewind restored the
mutated value. A runtime made with `debug` now deep-freezes each value as
it is queued or written, so the mutation throws where it happens.
Release runtimes are unchanged.

Also: Runtime.tick points to FixedLoop.start, the JSDoc of set/update,
Command.entry/spawn, ReadonlyValue, and Debug says the world owns what it
is given, and the ritmo skill treats variables a system closes over like
module variables.

Found while reviewing Collision Chamber (collision-chamber.gavri.xyz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@SandroMaglione
SandroMaglione merged commit f6502b7 into main Nov 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant