@typeonce/ritmo-devtools
v0.17.0
Published
Headless debug sessions, trace history, text reports, replays, and fuzzing for Ritmo runtimes
Maintainers
Readme
@typeonce/ritmo-devtools
Debug sessions for ritmo runtimes: run schedules headless, keep a trace
history, and ask what happened as text. Built for coding agents: every
answer is text and data, and nothing draws in the page.
pnpm add -D @typeonce/ritmo-devtoolsThe main import (Session, Fuzz, Invariant, Replay, Lint, ...) and
ritmo-devtools lint need no peer dependency. Each optional peer is for one
feature: playwright and vite for runs in browser engines
(PlaywrightEngine, ritmo-devtools verify), vite for ReplayVitePlugin,
and rolldown and oxlint for ritmo-devtools scan.
The package reads the core Debug handle, which exists only on runtimes made
with debug: { hostClock }. Production runtimes carry no handle and pay
nothing. The modules read no platform globals: give the runtime and the
session the same HostClock (HostClock.test() headless, so every timing is
0 and text output is identical across runs; BrowserHostClock.make(window)
in the browser).
A dev-only debug handle in a browser game:
const runtime = Game.Runtime.make({
services,
debug: import.meta.env.DEV ? { hostClock: BrowserHostClock.make(window) } : undefined
}).valueIn the browser, record a replay (see Replays) and debug it
headless with a Session.
The debugging loop
- Get a headless runtime. Keep the game's simulation (schema, systems,
and the schedules ticked at the fixed step) in modules that import no
renderer or page API. Build those schedules on a runtime made with
debug: { hostClock: HostClock.test() }, and feed input fromKeyboard.scripted(bindings, timeline)(from@typeonce/ritmo-browser) instead of the page. Put that in a script that makes the runtime, theSession, and the run (see the cheat sheet below), and run it with Node. - Read the lints, then orient.
session.describe()starts with lints (events nobody reads, next states no marker applies, relations whose failures nobody reads, ...); a warning there is often the whole answer. Then it lists every schedule step, each system's declared reads and writes, and who reads and writes each component, resource, and event.run()also prints the lint warning count. Info-levelread-before-writelints list systems that run before the schedule's first writer of what they read, so they see the previous run's value. That is often intended (input before movement), but it is a one-frame lag worth checking when a reaction looks late. - Reproduce. Script the input with
Keyboard.scripted(bindings, timeline)(or replay a recorded browser session), add an invariant that encodes the bug (Invariant.fromInspector(Game.Inspector(...)), returning a message when violated), andsession.run("update", { frames: 600 }). Give the sessionlabelsfor the entities the bug is about, so every line saysplayer e12instead ofe12. The run stops at the first frame the invariant fails, a system fails, or a system throws. - Explain.
session.why(entity, Component)andsession.whyResource(Resource)show the latest changes and which system made them;whyalso names the systems whose declaredspawnscould have spawned the entity. When the writer only passed on a wrong input, raisedepthto follow what it declared it reads, one system further back per step.session.journal({ frames: [from, to], entity })shows everything that touched an entity around the failure.session.report()lists warnings the trace collected and the current entity counts per component.run()prints the warnings it raised. When the wrong value appears within one frame, between two systems, step that frame: run up to the frame before it, thenconst frame = session.step("update"),frame.until(ApplyDamage)stops before that system,session.dump(...)/why/journalshow the world and the partial frame,frame.next()runs one step (a system or a marker), andframe.finish()runs the rest and reports the frame asrundoes, invariants included.frame.check()asks the invariants mid-frame, where one may legitimately fail until a later marker applies. - Fix and keep it. Rerun the same script, then turn it into a test next to the game so the bug stays fixed.
node --import tsx debug.tsCheat sheet
import { Keyboard } from "@typeonce/ritmo-browser"
import { Invariant, Session } from "@typeonce/ritmo-devtools"
const keyboard = Keyboard.scripted(bindings, [
{ frame: 0, press: ["left"] }, // frame = index of the input snapshot
{ frame: 30, release: ["left"] },
{ frame: 32, press: ["jump"], release: ["jump"] } // a tap within one frame
])
const hostClock = HostClock.test() // every timing 0: identical text on every run
const runtime = Game.Runtime.make({ services, resources, machines, debug: { hostClock } }).value
// An invariant is a typed, read-only Inspector that returns a violation
// message, or undefined while it holds. It declares its reads like a system:
// Session.make rejects at compile time one that reads a resource, service,
// or machine the runtime does not provide.
const HealthNeverNegative = Invariant.fromInspector(Game.Inspector("health >= 0", {
queries: { player: Game.Query({ selection: { health: Game.Query.read(Health) }, with: [Player] }) }
}, ({ queries }) => {
const health = queries.player.each().map((match) => match.data.health.get())
return health.some((value) => value < 0) ? `health ${health.join(", ")}` : undefined
}))
// Labels name entities in every line: `player e12` instead of `e12`. An
// inspector returning entity id -> label; any other return does not compile.
const Labels = Game.Inspector("labels", {
queries: { players: Game.Query({ selection: { player: Game.Query.read(Player) } }) }
}, ({ queries }) => new Map(queries.players.each().map((match) => [match.entity.id, "player"] as const)))
const session = Session.make(runtime, {
hostClock, // times run(); the runtime's clock
schedules: { setup, update }, // names used by run() and in traces
describe: { render }, // described and linted, never run (needs a renderer)
invariants: [HealthNeverNegative], // checked after every frame of every run
labels: Labels, // evaluated after every frame; despawned entities keep theirs
history: 600 // frames kept for journal/why
})
session.run("setup")
const run = session.run("update", { frames: 300, until: (frame) => frame === 120 })
run.data.ok // true when completed or `until` returned true
run.data.stop // { reason: "completed" | "until" | "failure" | "defect"
// | "invariant" | "missingRequirements" | "tickOrder", frame, ... }
const frame = session.step("update") // one frame, a step at a time; refuses run/tick until finished
frame.until(ApplyDamage) // runs the steps before the system value; Result: reached, or
// { reason: "failure" | "defect" | "frameEnded" | "notAhead" | ... }
session.dump({ entities: [12] }) // the world between two systems (journal and why show the partial frame)
frame.next() // exactly one step: a system or a marker
frame.check() // invariants now (may fail mid-frame); Result
frame.finish() // the rest of the frame, reported like run(name): labels, invariants
session.describe() // schema, schedules, access, lints
session.dump({ with: [Player], limit: 5 })// entities, resources, machines, pending commands
session.why(12, Position) // latest changes to e12 Position
session.why(12, Health, { depth: 3 }) // and, under each, the declared reads behind it, two levels back
session.whyResource(Score) // latest changes to a resource
session.journal({ frames: [180, 185], entity: 12, kinds: ["write", "effect"] })
session.journal({ system: "Game/Move", last: 10, verbose: true })
session.system("Game/Move") // declared access, stats, recent lines
session.streams() // event/transition/relation-failure/dropped-relation retention
session.report() // per-system timing and warningsEvery call returns a Rendered value: console.log prints its text,
.data holds the structured result, JSON.stringify serializes the data.
Reading the output
f185 update Game/ApplyVelocity write e12 Game/Position {x:130,y:418} -> {x:130,y:421.2}
f185 update Game/LandPlayer queued insert
f185 update Game/LandPlayer applyDeferred: insert e12 Game/Grounded={}
f186 update - transition Game/Flow Playing -> Paused appliedf185: frame number, counting everytick/tryTickcall from 1, setup included. Keyboard timeline frames count input snapshots from 0 instead, so with one setup frame, timeline framenis read in session framen + 2.e12: entity 12;player e12when the session'slabelsname it (the label it had at the end of that line's frame).&e12: a stored handle to entity 12. Write entities ase${id}in invariant messages and the run summary labels them too.- The schedule path shows nesting, for example
update > onEnter(Game/Flow=Paused). write: a component a system's write query changed to a different value (writing back an equal value is not recorded).resource,emit,next state: resource writes, event emits, and queued machine values. Failed systems show(rolled back).queued ...thenapplyDeferred: ...: a command queued by a system, then its structural effect when a marker applied it. The system named on the effect line is the one that queued it. A despawn also lists each edge it dropped:Game/Targeting from e7 to e12 dropped: e12 despawned(whatGame.System.readDroppedRelationsdelivers).skipped: <condition> is false: a run condition held the system back;discarded N Eventmeans messages published meanwhile are lost to it.missed ...: the system could not see entries dropped before it ran.- Lines that change nothing are hidden by default; pass
verbose: trueto see them, marked(unchanged). - Large objects print only their changed paths:
~ up.held: false -> true, left.held: false -> true.
why and whyResource with depth above 1 indent, under each change,
what the writing system declared it reads, with the latest traced fact that
could have produced it; each depth step goes one system further back:
e12 Game/Health is now 40; last 1 changes (oldest first):
could have spawned it: Game/SpawnEnemy
indented: what each writing system declared it reads, and the latest fact that could have produced it (declared: possible causes, not observed reads)
f181 update Game/ApplyDamage write e12 Game/Health 70 -> 40
read Game/Damage [{amount:30}] (declared), emitted by Game/Hitscan at f181
read Game/Position of 14 matching entities now (declared; the trace does not say which it read)
read resource Game/Aim {target:12} (declared), written by Game/AimAtNewest at f180
read e12 Game/Armor 5 (declared), spawned by Game/SpawnEnemy at f3- The trace records writes, not reads, so every indented line is a
possible cause, marked
(declared), not a read that was observed. - An event read lists what was published to it since the system's previous
run, and by whom (
runtime.sendfor the host). A component in the query that wrote the value is read on the same entity: its latest change before the run. A resource or machine: its latest change or transition. - A query over other entities says only how many match now: the trace does
not say which one the system used. Use
journalto narrow it down. - A system already in the chain prints
(cycle: not followed);limitcaps the reads under each line (…+N more declared reads). In.data, these lines havekind: "read"anddepth1 or more.
Report warnings
| Code | Meaning |
|---|---|
| pending-commands | A system queued commands and the frame ended before a marker applied them. Usually a missing applyDeferred(). |
| pending-next-state | A system queued a next state and the frame ended before an applyStateTransitions() marker applied it. |
| missed-read | A system lost stream, removed, or despawned entries at capacity. |
| discarded-messages | A skipped system discarded messages published while it was skipped. |
| transition-failed | An exit or transition schedule failed (queued again), or an enter schedule failed. |
| system-failed | A system returned an expected failure or threw. |
| ignored-failure | A tick / tryTick result failed and nobody read its ok before the next tick (or the session's report()): the failure went unnoticed and later systems did not run. run stops on it with reason: "ignored-failure". |
| non-finite-value | A system wrote NaN or an infinity into a component, resource, event, or spawned entity. The message names the entity and path; why() shows the history. |
| out-of-range | A system wrote a number outside the range its descriptor declares for that field (meta: { hp: { range: [0, 100] } }), in a write, a resource, or a spawned or inserted component. One count per write; the message names the entity, field, value, range, system, and first frame. The range is advisory: the schema decides what is valid. |
| population-growing | A component's count rose through every quarter of a run of 60+ frames: its lowest count in each quarter was higher than in the quarter before. Bursts that are cleaned up do not trigger it; entities that are never despawned do. run() returns the counts in growing. |
The report also starts with the static lints from describe().
Lint from the command line
ritmo-devtools lint <entry> is the pass/fail check for a game's check
script. <entry> exports lintTarget (see the Lint module): it prints
every lint of the schedules with its fix and exits with 1 on a warning.
ritmo-devtools lint src/lint.tsKeep the entry beside the simulation, not inside it, when describe needs
host schedules: it is the one module that imports both, so the simulation
never imports render or audio code. Load the host schedules inside
lintTarget:
// src/lint.ts
import { makeRuntime, setup, update } from "./simulation/index.ts"
export const lintTarget = async () => {
const { render } = await import("./render/index.ts")
return { runtime: makeRuntime({ debug: { hostClock: HostClock.test() } }), schedules: { setup, update }, describe: { render } }
}A debug script that needs the same runtime and host schedules (a
scripts/debug.ts that builds a Session) imports lintTarget and calls
it, instead of repeating the wiring: const { runtime, schedules } = await
lintTarget().
When nothing needs describing, the simulation's own entry module can export
lintTarget instead.
It also checks that the oxlint simulation rules from
@typeonce/oxlint-plugin-ritmo cover the simulation. Every module that
importing <entry> loads, inside the directory of the .oxlintrc.json in the
working directory and outside node_modules, must have
ritmo/keep-simulation-headless on there (overrides apply in order, the last
match wins; ignorePatterns matches are uncovered). The entry itself, and
modules imported inside lintTarget or later (render schedules passed to
describe), are host code and are not checked, nor are type-only imports. A missing config, one with
extends, or one that never turns the rule on prints why the check was
skipped and does not fail. The check needs Node 22.15 or later.
| Code | Meaning |
|---|---|
| uncovered-simulation-module | The simulation loads this file, but no simulation override in .oxlintrc.json covers it, so oxlint never checks it for platform, time, randomness, or math calls. Add it to the override's files, or stop importing values from it. Exits with 1. |
| skill-not-linked | For each skill @typeonce/ritmo ships (ritmo, ritmo-prototype): neither .agents/skills/<skill> nor .claude/skills/<skill> links it from node_modules/@typeonce/ritmo/skills/<skill>, so the game's agent does not read it for this Ritmo version. Link it (SETUP.md step 2). A note: the exit code is unchanged. |
| skill-copied | The folder holds a copy of the skill (or links somewhere else), which goes stale on the next upgrade. Replace it with the link. A note: the exit code is unchanged. |
The trace shows what systems wrote, not what they read. When a system writes a wrong value from correct inputs, the journal narrows the bug to that system (it runs, in order, and writes this value) and the rest is its code.
Determinism
Determinism.check compares the same scripted simulation twice, including
transient components that snapshots omit:
const result = Determinism.check(({ hostClock }) => makeSimulation(hostClock), {
schedule: "update", frames: 1800, hostClock: HostClock.test()
})
if (!result.ok) throw new Error(JSON.stringify(result))The factory returns { runtime, schedules }, with a fresh debug runtime on
every call. A difference reports its frame, value path, and the first system
or schedule marker found by a diagnostic rerun; inside an entity, it names
the entity (entity) and, given labels (the session's labels inspector),
its label. This checks the supplied inputs on the current JavaScript
engine; Verify across engines plays a replay in
Node and real browsers.
Desyncs
Peers of a networked game (@typeonce/ritmo-netcode) compare checksums of
confirmed ticks. When they differ, a session made with keepSnapshots: true
fails with Desynced carrying its save of that tick, and the other peer's
carries its own. Desync.compare lists what differs, entities by id and
values by path, in a stable order:
console.log(Desync.compare(mine.snapshot, theirs.snapshot, { labels: runtime.inspect(Labels) }))
// 2 differences (mine -> theirs):
// player e12 Game/Position.x: 40 -> 41
// resource Game/Rng: 1730 -> 2210The first difference is where to look: rerun the match headless and ask a
Session why that value changed.
A Netcode.syncTest made with keepSnapshots: true fails with
RewindDiffers carrying the world before and after a rewind:
Desync.compare(error.snapshots.expected, error.snapshots.actual) names
what the rewind did not put back.
Replays
A replay is the last seconds of a play session as a file: a debug artifact to open headless, explain, and turn into a test, then delete.
// In the game's dev build: record the simulation schedule.
const recorder = Replay.recorder(runtime, {
schedule: simulate, name: "simulate",
keep: 30, step: STEP, // seconds kept, the fixed step (Clock.hz)
sink: ReplayViteSink.make({ fetch: (url, init) => fetch(url, init) }),
hostClock: BrowserHostClock.make(window), // for savedAt
saveOn: { failure: true, invariants: [DeadAreGone] } // saves by itself
})
recorder.save("player stuck") // on demand, e.g. from a dev-only key
// vite.config.ts: writes posted replays to .replays/<time>.json and logs them.
plugins: [ReplayVitePlugin.plugin()]- The recorded schedule's services must be
snapshot()sources of plain data (input) or plain data (rules); anything else is a compile error that names the service. Time and randomness live in the world, so the file holds everything the simulation read: periodic snapshots, each tick's source values (as changes), and everyruntime.send. - Sinks deliver files:
Replay.memorySink()(tests),ReplayNodeSink(files, for headless runs),ReplayViteSink+ReplayVitePlugin(dev server). The generic modules call no platform API; wall time comes fromhostClock.
// In Node: make(playback) builds the game with the recorded input.
const make = (playback: Replay.Playback) => {
const hostClock = HostClock.test() // replay text is identical on every run
const runtime = makeRuntime({ input: playback.source(Input), debug: { hostClock } })
return { runtime, schedules: { simulate }, hostClock }
}
const file = Replay.parse(JSON.parse(readFileSync(".replays/....json", "utf8")))
const replay = file.ok ? Replay.session(make, file.value) : file
replay.value.play() // run the ticks; reports checkpoint divergence
replay.value.session.why(12, Position) // a normal Session over the replayed frames
Replay.session(make, file.value, { labels: Labels }) // names entities, as in Session.make
await Verify.acrossEngines(make, file.value) // replays twice here and against the recording
Replay.extract(make, file.value, { from: 1400, to: 1412 }) // a small replay for a testReplay.session(make, file, { invariants }) checks invariants after every
replayed tick, so play() stops where one breaks, as a Session run does.
playback.source(descriptor) takes any snapshot()-only source service:
Schedule.Source<T>, Netcode.Source<T>, or browser input declared as
InputCapture.SourceOf<...> (a device, a Controls merge, or a pool; see
the browser README).
A service declared as the whole device, with rebind and dispose, is a
compile error naming that fix: a replay can only stand in for its
snapshot(), so the page keeps the device to rebind it.
A replay recorded with another schema fails with "replay recorded with an older schema".
Verify across engines
Verify.acrossEngines is the one replay check. With no engines it plays
the replay twice in this process and against the recording, the check a
test runs; ritmo-devtools verify plays the same replay in Node and in real
browser engines and reports the first tick whose world differs, with every
value that differs there. Run it before
trusting a lockstep or rollback match across browsers, and in CI on a
committed replay of the game:
ritmo-devtools verify src/replay-target.ts replays/boss-fight.json --engines node,chromium,webkit,firefox
# NOT verified: 1800 ticks of simulate
# node: agrees with a second node run and the recording over 1800 ticks
# chromium: agrees with node over 1800 ticks
# tick 1412: webkit differs from node in e41 Game/Position.x (40.000000000000014 -> 40)
# 1 difference (node -> webkit):
# e41 Game/Position.x: 40.000000000000014 -> 40
# firefox: not installed (the firefox browser is not downloaded): npx playwright install firefox<entry> exports replayTarget, the make(playback) factory
Replay.session takes (above). It must load in a browser too: keep Node
built-ins out of it.
// src/replay-target.ts
export const replayTarget = (playback: Replay.Playback) => {
const hostClock = HostClock.test()
return { runtime: makeRuntime({ input: playback.source(Input), debug: { hostClock } }), schedules: { simulate }, hostClock }
}- Node always runs, as the reference: twice (a difference is
nondeterminism here) and against the recording's checkpoints (a
difference is a code change since recording, or the recording engine).
--engines node, the default, needs no other dependency. - Browser engines run headless through Playwright, with the entry served by
a bare Vite dev server (the game's
vite.configis not read). Install them where the check runs:npm install --save-dev playwright viteandnpx playwright install chromium webkit firefox. An engine that is not installed is printed with its install command and fails the check. - Exit codes: 0 every engine agrees, 1 one differs or did not run, 2 usage
error (arguments, a file that is not a replay, an entry without
replayTarget).--jsonprints the result as JSON.
The same check from code, for a game's own test (leave out engines to
check this process alone):
import { Verify } from "@typeonce/ritmo-devtools"
import * as PlaywrightEngine from "@typeonce/ritmo-devtools/PlaywrightEngine"
const browsers = PlaywrightEngine.make({ entry: "src/replay-target.ts", browsers: ["chromium", "webkit", "firefox"] })
const verified = await Verify.acrossEngines(replayTarget, file, { engines: browsers.engines })
await browsers.close()
if (!verified.ok && verified.error._tag === "NotVerified") {
for (const outcome of verified.error.outcomes) if (outcome._tag === "Diverged") console.log(outcome.tick, outcome.differences)
}The error side is ReplayInvalid (the replay does not open here) or
NotVerified, one outcome per engine: Agreed, Diverged (tick,
against, differences), EngineMissing (install), or EngineFailed.
For another host (a webview, a console), run Verify.trace and
Verify.snapshotAt there and wrap them as a Verify.Engine.
Scan dependencies
oxlint skips node_modules, so a dependency the simulation calls can still
use Math.sin, Date.now, or Math.random. ritmo-devtools scan <entry>
resolves every file the simulation entry imports, node_modules included,
and runs ritmo/use-ops-for-math, ritmo/use-clock-for-time, and
ritmo/use-random-for-randomness over them, grouped by package:
ritmo-devtools scan src/simulation/index.ts
# fake-physics (2 findings)
# node_modules/fake-physics/index.js:4:17 ritmo/use-ops-for-math Math.sin
# node_modules/fake-physics/index.js:6:32 ritmo/use-random-for-randomness Math.random
#
# scanned 4 files in 2 packages imported by src/simulation/index.ts: 2 findings in 1 package- A finding is a hint, not a verdict: code the simulation never calls is
fine. For a dependency it calls, vendor it into the game's source and run
oxlint --fix, or replace it. A clean scan does not prove the run is deterministic: confirm withritmo-devtools verifyorNetcode.syncTest. - It uses the game's
oxlintand@typeonce/oxlint-plugin-ritmo, androlldownto resolve imports the way a build does (Vite 8 includes it; otherwisenpm install --save-dev rolldown). A missing tool is printed with its install command. - Exit codes: 0 whenever the scan ran, with or without findings, 2 usage
error (arguments, an unreadable entry, an import that does not resolve, a
missing tool).
--jsonprints{ entry, files, packages: [{ name, files, findings: [{ file, line, column, rule, snippet }] }] }.
Fuzzing
Fuzz.run finds a bug before anyone plays into it: seeded random input,
run headless against the game's invariants, until an invariant breaks, a
system fails, or something throws. It then shrinks the failing input to the
few frames that still fail the same way, and returns them as a replay file.
import { Fuzz, Replay } from "@typeonce/ritmo-devtools"
import * as ReplayNodeSink from "@typeonce/ritmo-devtools/ReplayNodeSink"
// One fuzzed snapshot() source: a value of its type for each frame, and the idle value.
const Pads = Fuzz.input(PadInput, {
generate: (random, frame) => ({ key: random.integer(0, 5), aim: random.range(-1, 1) }),
neutral: { key: 0, aim: 0 }
})
// `make` is the replay factory (above): it builds the game on playback.source(PadInput).
const found = Fuzz.run(make, {
schedule: "update", setup: "setup", // setup is ticked once first, every input neutral
inputs: [Pads], invariants: [HealthNeverNegative],
frames: 3000, runs: 10, seed: 7 // run i draws from seed 7 + i
})
if (!found.ok && found.error._tag === "Found") {
ReplayNodeSink.make({ directory: ".replays" }).save(found.error.replay)
console.log(found.error.violation) // InvariantViolated | SystemFailed | Defect
console.log(found.error.shrink) // { attempts, steps, fromFrames: 1412, toFrames: 4 }
}- A browser game fuzzes through the same descriptor its page and replays
read:
Fuzz.input(ControlsInput, { generate: (random) => Actions.random(random, devices), neutral: Actions.idle(devices) })draws merged input a real merge could report (button states, vectors of length at most 1);Players.random(random, frame, { devices, seats })does it for a local multiplayer pool. randomis a seededRandomfrom@typeonce/ritmo; the same seed draws the same input, so a found bug'sseedwithruns: 1finds it again. Every sourcemakeasksplayback.sourcefor must be a fuzzed input; give plain-data services (rules, config) directly.- Shrinking drops ranges of frames, then moves each remaining value to
neutral(whole, then one field at a time), re-running the simulation for every candidate.shrinkAttempts(500) bounds it. - The error side is
Found(with the shrunkreplay,seed,frame, andreproduces: whetherReplay.sessionreplays it to the same violation),Flaky(the same input did not fail again: runDeterminism.check),InvalidOptions, orUnreplayable(the world after setup cannot be saved).
The shrunk replay is the regression test:
const file = Replay.parse(JSON.parse(readFileSync(".replays/....json", "utf8")))
const opened = file.ok ? Replay.session(make, file.value, { invariants: [HealthNeverNegative] }) : file
expect(opened.ok && opened.value.play().data.run.stop.reason).toBe("completed") // fails until the fixOver a network
The same Fuzz.run fuzzes a rollback game over a faulty network. Give it one
Fuzz.seats input (every seat's input per frame) and network: each seat
plays on its own peer, a fresh make(playback), joined by a seeded
MemoryNetworkLink and driven by a Netcode.rollback session from
@typeonce/ritmo-netcode.
// The seats service the simulation captures; make provides it with playback.source(Seats).
const Players = Fuzz.seats(Seats, {
codec: PlayerInput, count: 2, idle: { dx: 0, fire: false }, // as given to Netcode.seats
generate: (random, frame, seat) => ({ dx: random.integer(-1, 2), fire: random.next() < 0.2 })
})
const found = Fuzz.run(make, {
schedule: "simulate", setup: "setup", inputs: [Players], invariants: [HealthNeverNegative],
frames: 600, seed: 7,
network: { seed: 3, latencyMS: 60, jitterMS: 40, loss: 0.1, step } // faults repeat for the same seed
})
if (!found.ok && found.error._tag === "Found" && found.error.violation._tag === "SessionFailed") {
console.log(found.error.violation.seat, found.error.violation.error) // Desynced, with this peer's save of the tick
}- A session failure (
Desynced,TickFailed,CheckpointLost, or the session ending) is aSessionFailedviolation; invariants are checked on every peer after each frame.settleidle frames (120) follow the fuzzed ones so late input and checksums arrive; checksums are compared every 10 ticks unlesschecksumEverysays otherwise. - Shrinking keeps the network seed and shrinks the input timeline, one seat
at a time. The
replayplays the shrunk timeline on one runtime (step it to see each tick);reproducesisfalsefor a desync, which needs two peers: find it again with the sameseed,runs: 1, andnetwork. - A
simulatethat cannot be rewound, or a runtime that cannot save, does not compile, as forNetcode.rollback. RunNetcode.syncTestfirst: it finds state a rewind cannot put back on one peer, faster.
Recording a browser session
const recorder = Keyboard.recording(Keyboard.actions(window, bindings))
// ...provide `recorder` as the keyboard service, play, reproduce the bug...
copy(JSON.stringify(recorder.timeline()))
// In Node:
const timeline = Keyboard.parseTimeline(bindings, JSON.parse(saved))
if (timeline.ok) {
const keyboard = Keyboard.scripted(bindings, timeline.value)
}A recorded timeline replays to the same input snapshots. A browser session reproduces headless when everything else nondeterministic, such as time steps, random numbers, and viewport size, also comes in through services or resources that the simulation sets to the same values.
