Real-time play advances in fixed ticks of 1/120 s, never in frame time. A real-time game runs a
typed-array sim on the UI thread inside a Reanimated frame callback; a turn-based game with a continuous
phase (a ricochet shot, a cascade) runs that phase inside its pure reducer and replays it as a timeline.
Either way a recorded run replays bit-exactly in Jest, on iOS and on Android. This skill ships the
loop kit (src/core/realtime/, src/core/geom/), the real-time host (src/canvas/realtime/), a
template game (a bouncing-orbs arena) and a checker that proves them.
Rules that must hold
- Simulate in fixed steps of
STEP_MS = 1000 / 120with a 250 ms catch-up clamp; the first frame after (re)activation simulates nothing. Why: results depend on ticks, never on 60, 90 or 120 Hz or frame timing, and a resumed sim never jumps. - Gameplay counts ticks, never milliseconds: speeds are per tick, timers are tick counts, the sim never sees a timestamp. Why: tick counts are exact integers on every device.
- Sim state is one plain object of typed arrays in ONE shared value, mutated in place on the UI
thread, then
sim.modify(). Why: no per-frame copies or allocations, and in-place mutation does not notify the picture by itself. - A tick allocates nothing: buffers (entities, events, RNG words, spatial-hash scratch) are created
once; a full event queue drops edge and hit events but keeps reserved slots so input, save-point and
run-end events never drop;
step…and every helper it reaches do scalar maths on the typed arrays (vec2/sweepreturn new objects and belong to the JS thread). One documented exception: an occasional sfc32 draw through anextU32wrapper; per-entity, per-tick randomness useshashU32. Why: the loop may run 30 ticks in one frame on the UI thread, garbage collection there is a visible hitch, and a lost input breaks the replay. - Sims, the UI-thread kit and real-time draw files are
'worklet'modules that import values only from other'worklet'modules. Why: a plain function called on the UI thread throws at runtime, not at build time. - Sims, the kit and simulate-then-replay rules obey the determinism policy: only
+ - * / %, bitwise,Math.sqrt/imul/floor/round/abs/min/max/PI; no**, otherMath.*,Math.random,Date,performance.now,Intl; randomness from the sim's own sfc32 words. Why: Hermes computes transcendental functions with each platform's libm and Jest uses V8, so replays would diverge between an iPhone, an Android phone and CI. - Input is an integer command recorded as
(tick, command), from the committed 16-direction table (1 right, 5 down, 9 left, 13 up; 0 idle), identical to the stick gesture's table. Why: only integer logs replay bit-exactly. - The sim never talks to JS or the app: it appends
[kind, value, tick]events;runLoopFramedrains them once per frame and sends one batch withscheduleOnRN; a sim imports nothing from React, React Native, Expo, Skia or the app's layers. Why: one crossing per frame, and the sim stays testable in plain Jest and reusable by headless bots. - The frame-callback body is one call to
runLoopFrame, which holds thetry/catch; errors stop the loop and pause the game. Why: an uncaught UI-thread exception kills the app, and React Compiler bails out of hooks with value blocks insidetry/catch. - The loop runs only while the surface may run (app active, screen focused, not covered, not
paused, not ended) through
useCanvasLifecycle; stopping takes a save point and shows the pause UI; real-time games never auto-resume, and resume resets the accumulator. Why: no simulation in the background or under an interstitial, and a killed app loses at most the current wave. - Every sim has a replay test and a frame-grouping test. Why: they are the proof of determinism; saves, bots and bug reports all depend on it.
- A real-time game exposes a
RealtimeSpec(createSim, step, drainEvents, snapshot, savePoints, isSavePoint) with a tested snapshot round trip, and is saved only at save points. Why: the host and the save path need one contract, a save must restore exactly, and a frame is too short for a write. - Simulate-then-replay games run the continuous phase inside the pure reducer with the same fixed
step, capped by
maxTicks, and return timed events (atMs). Why: the game stays turn-based for saves, undo, bots and goldens; only the picture moves in real time, and a volley always ends. - A real-time surface is proven like every canvas: a draw-call budget test with every entity slot
in play (≤ 1,000), bodies inside the canvas at 3 sizes, and pixel goldens through
paintSimPngat 3 sizes × moments 0, 0.5, 1 of a scripted run (9 baselines). Why: the loop's tests prove the numbers, not the picture. - No physics engine: swept circles,
reflectand the spatial hash;planckon the JS thread is the documented fallback only for stacking, joints or friction. Why: engines bring non-deterministic floats and a second runtime. - Every import of the kit, host, sims and draw files resolves; prerequisites come from their owner skills. Why: a half-copied kit fails at runtime on one screen; the checker names who creates the missing file.
Workflow
- Classify the game. Real-time (an arena chase): read references/fixed-step-loop.md and references/sim-design.md. A continuous phase inside a turn (a shot, a drop, a cascade): read references/simulate-then-replay.md. Geometry: references/geom-kit.md. Turn-based only: this skill does not apply (game-rules-engine, skia-canvas-rendering).
- Check prerequisites. The templates import
@/core/rng/sfc32(seeded-determinism-and-golden-vectors; a synced copy ships intemplates/src/core/rng/), the canvas kit'sdraw-kit,describe-error,use-canvas-lifecycleandtest/goldens/skia-golden.ts(skia-canvas-rendering, golden-and-snapshot-tests; canonicalcanvas-types,canvas-layoutandtimeline/copies ship intemplates/src/canvas/),@/hooks/use-is-app-active(react-components-and-hooks), and fast-check pinned exactly. - Copy the kit. Run
node ${CLAUDE_SKILL_DIR}/scripts/check-realtime-loop.mjs .from the repo root. For everyloop-file-missingcopy the file verbatim fromtemplates/to the path its first line names (templates/src/core/**,templates/src/canvas/realtime/**, tests included). Fix everyloop-import-unresolvedwith the skill it names. - Real-time game, test-first: copy
templates/src/games/__GAME_ID__/tosrc/games/<game-id>/, replacing__GAME_ID__(kebab),__GAME_PASCAL__and__GAME_CONST__(UPPER_SNAKE) in names and contents. Run the replay, frame-grouping and round-trip tests, then replace the gameplay slot (moveEntity) with the game's rules, keeping every test green after each change. Add event kinds as exported integer constants. Imitate examples/arena-chase/. - Its surface and proof: adapt
canvas/(palettes withcanvas-contrast.json,draw-arena.ts, the budget test; skia-canvas-rendering's palette and contrast rules apply) and copytemplates/test/goldens/canvas/__GAME_ID__-arena.golden.test.ts. Create the baselines deliberately:npx jest test/goldens/canvas/<game-id>-arena.golden.test.ts --selectProjects golden -u, open all 9 PNGs (bodies inside the canvas, nothing clipped atsmall), thenCI=1 npx jest --ci --selectProjects golden; commit the baselines with aGate-Change:trailer (golden-and-snapshot-tests). - Wire the board into the host (game-session-host owns the Game screen, the run store and the
writes): the game's
SurfacereceivesGameSurfacePropsand rendersRealtimeCanvasHostwithinitialSim={realtime.createSim(props.state)},game={realtime},draw,colorsandkit(makeCanvasColors, makeDrawKit),makeGesture={makeStickGesture},onEvents(sounds, HUD, statistics, input log),isSavePoint={realtime.isSavePoint},onSavePoint={(copy) => props.onSavePoint(realtime.snapshot(copy), inputLog)}(every save point and one final one at the run's end),isEnded={props.isEnded},isPaused={!props.isInputEnabled && !props.isEnded},onAutoPause(a no-op inside a hosted Surface: the Game screen already pauses on background, blur and full-screen ads),isFocused(useIsFocused()from expo-router; React Navigation 7:@react-navigation/native),isInterrupted,onFailure, a translated label, and in measurement buildsonFrameTime(performance-budgets'sampleFrameworklet). Keepgamea module constant; the host keeps the JS callbacks behind stable trampolines. Join the spec to the module:GameModule<…> & { realtime }withsavePolicy: { kind: 'save-points', points }. - Simulate-then-replay game: imitate examples/ricochet-volley/: a pure
simulate…function insrc/games/<game-id>/rules/called byapplyMove, events withatMs: tickToMs(tick), amaxTickscap, a tracks builder in the surface folder, and the property tests. - Lint, format, type-check and test:
npx eslint --fix src/games/<game-id> src/core/realtime src/core/geom src/canvas/realtime,npx prettier --writeon them,npm run typecheck, thennpx jest src/games/<game-id> src/core/realtime src/core/geom src/canvas/realtime --ci --selectProjects unit(paths first:--selectProjectstakes several values and would swallow them). - Run the check
node ${CLAUDE_SKILL_DIR}/scripts/check-realtime-loop.mjs .; fix everyFAILline (file, rule, fix) and rerun untilRESULT: PASS. Options, config keys and every rule: references/checker.md. - Play it for real. The host is unit-tested with Skia inert and the loop mocked; it has not run on
an Android device yet. On the first real-time game, run a Release build on a named iOS simulator
(ios-simulator-build) and a named Android emulator (android-emulator-build): the picture updates
every frame, pause, background and Android back stop the loop, resume starts clean. Then a human
plays at least 60 s on a 120 Hz phone of each platform with the frame-time recorder passed as the
host's
onFrameTime(hitch rate ≤ 10 ms/s, performance-budgets) and judges the feel.
Definition of done
- The kit exists with its tests:
src/core/realtime/{fixed-step,replay-commands,realtime-spec}.ts,src/core/geom/{vec2,sweep,spatial-hash}.ts; for a real-time game alsosrc/canvas/realtime/(run-loop-frame,use-fixed-step-loop,record-sim,paint-sim-png,realtime-canvas-host). - Each sim is a
'worklet'module of typed arrays that allocates nothing per tick, counts ticks, records(tick, command), drains[kind, value, tick]events, and its replay and frame-grouping tests pass; itsRealtimeSpecround-trip test passes. - Each real-time surface has light and dark palettes with
canvas-contrast.json, a'worklet'draw, a draw-call budget test (≤ 1,000) and 9 reviewed golden PNGs that pass underCI=1 npx jest --ci --selectProjects golden. - Each simulate-then-replay phase is pure, capped by
maxTicks, emitsatMsevents, and its properties (inside the arena, never faster, deterministic, tracks replay the path) pass. - The host stops the loop, saves and shows the pause UI on pause, background, blur and interruptions, and never auto-resumes.
-
npm run typecheck,npx eslint --max-warnings 0andnpx prettier --checkpass on the new files; no__GAME_…__placeholder is left. -
node ${CLAUDE_SKILL_DIR}/scripts/check-realtime-loop.mjs .printsRESULT: PASS
The checker prints no SKIP lines. It prints NOT APPLICABLE: no real-time code: … (a pass) in an app
whose games are all turn-based, and stops with exit 2 in a folder without package.json.
Anti-patterns
- Moving entities by
dtin milliseconds (x += speed * dt). Frame timing leaks into gameplay and replays diverge; move per tick insidestep. - Keeping the world in React state or copying it to JS every frame. Keep it in the shared value; JS gets batched events and a copy only at save points.
- Angles and
Math.atan2/Math.cosin the sim. Use committed unit-vector tables, dot products andreflect. - Calling
scheduleOnRNfrom inside the sim, or once per event. Queue events in the sim; the runner sends one batch per frame. - Growing arrays per tick (
events.pushon a JS array,new Float32Array,.map). Pre-allocate and write in place. - A module-level
letor array in a sim file (a counter, a hit log). The UI thread works on its own captured copy, and saves and replays never see it; keep every changing value in the typed arrays. - Passing a stride-4
bodyarray to the spatial hash (it expects stride 2). Wrong cells, no error. - Auto-resuming a real-time game when the app returns to the foreground or an interstitial closes. The player expects the pause UI; resume only on their tap.
- Running a volley live in the frame loop instead of inside
applyMove. It breaks saves, undo, bots and goldens; simulate first, then replay the events. - A second frame callback for measurements. The frame-time recorder goes in through the host's
onFrameTimeand is sampled as the first statement ofrunLoopFrame'stry; an extra callback keeps idle screens rendering. - Saving every frame or every tick. Save at wave ends, pause, background and blur only.
- Trusting the simulator or emulator for speed. The iOS simulator caps at 60 fps and emulators usually render at 60 Hz; judge feel and hitch rate on real 120 Hz phones.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/fixed-step-loop.md | The accumulator, runner and hook, the real-time host, lifecycle and save points on iOS and Android, sprites, refresh rates, what is verified, multi-package | Workflow step 1 for real-time games, and before touching the loop |
| references/sim-design.md | Typed-array state, one tick, events, sfc32 words, the determinism policy, integer commands, replay tests, save points, the RealtimeSpec | Steps 1 and 4 |
| references/simulate-then-replay.md | Continuous phases inside applyMove: fixed step, timed events, tracks, the collision recipe, discrete runs, tests |
Steps 1 and 7 |
| references/geom-kit.md | vec2, sweep, spatial-hash APIs and rules for new geometry |
Whenever a game needs vectors or collisions |
| references/checker.md | The checker's options, config keys, discovery, every rule with its fix, NOT APPLICABLE, troubleshooting | Step 9, when a line is unclear or folders differ |
templates/src/core/realtime/ |
fixed-step.ts, replay-commands.ts (each + test), realtime-spec.ts |
Step 3 (copy verbatim) |
templates/src/core/geom/ |
vec2.ts, sweep.ts, spatial-hash.ts, each with its test |
Step 3 (copy verbatim) |
templates/src/canvas/realtime/ |
run-loop-frame.ts (+ test), use-fixed-step-loop.ts, record-sim.ts (+ test), paint-sim-png.ts, realtime-canvas-host.tsx (+ lifecycle test) |
Step 3 (copy verbatim) for a real-time game |
templates/src/canvas/ (other files) |
canvas-layout.ts (+ test), canvas-types.ts, timeline/track.ts, timeline/sample.ts (+ test): synced canonical copies (skia-canvas-rendering owns them); keep the app's copy |
Step 2, only when the app lacks them |
templates/src/core/rng/ |
sfc32.ts and its golden test: synced canonical copy (seeded-determinism-and-golden-vectors owns it) |
Step 2, only when the app lacks it |
templates/src/games/__GAME_ID__/ |
The template game: sim/ (sim + replay tests), <id>-realtime.ts (RealtimeSpec + round-trip test), canvas/ (palettes, contrast pairs, draw, budget test) |
Steps 4 and 5 |
templates/test/goldens/canvas/ |
__GAME_ID__-arena.golden.test.ts and its nine reviewed baselines in __image_snapshots__/ |
Step 5 |
| examples/arena-chase/ | A chase sim (player, chasers, hits) with its replay tests: the shape of real gameplay in a sim | Step 4 |
| examples/ricochet-volley/ | Simulate-then-replay volley with sweep and reflect, its property tests, and the tracks builder with its test (one piece of the game's surface; skia-canvas-rendering builds the rest) | Step 7 |
scripts/check-realtime-loop.mjs |
The checker (21 rules; references/checker.md) | Steps 3 and 9 and at the end |
scripts/selftest.mjs |
Proves the checker passes the templates and examples and catches each planted bug | After changing the checker, a template or an example |
scripts/lib/source-scan.mjs |
Synced source helpers (worklet directive, imports, resolution); do not edit here | When changing the checker |
scripts/check-lib.mjs |
Shared script library, synced (do not edit here) | Never by hand |
assets/shared.json |
Declares the synced files | When adding a shared file |
assets/template-placeholders.json |
The values the self-test and template verification fill in (bounce-arena) |
When adding a placeholder |
tests/bases/app/ |
The fixture base under the templates: package.json, tsconfig.json and stand-ins for two canvas kit files |
When the fixture base changes |
tests/fixtures/ |
check-realtime-loop/: good/ (templates + ricochet example), one bad-* per planted bug, pass-* (turn-based app, config folder, installed skills, an RNG draw in the tick, a destructured frame callback with a renamed pause flag) and error-* |
When adding a rule to the checker |
Related skills
skia-canvas-rendering- drawing rules, palettes, the canvas kit and lifecycle, turn timelines that replay simulate-then-replay events.canvas-gestures-and-hit-testing- the stick gesture that writes integer commands, and aim intents.game-rules-engine-applyMove, events, the game module andSavePoint.seeded-determinism-and-golden-vectors- the determinism policy and the sfc32 PRNG.game-session-host- the game screen: pause UI, save points, HUD from events, results.game-balance-and-bots- thousands of seeded headless runs of the samestep.golden-and-snapshot-tests- the golden policy and accepting baselines.audio-and-haptics-feedback- drained events become sounds and haptics on JS.performance-budgets- the frame-time recorder and the on-device hitch-rate budget.ios-simulator-buildandandroid-emulator-build- the Release builds of step 10.