Running a game loop outside React
July 23, 2026
Poker Defense renders through Canvas 2D. The simulation runs outside React's render cycle: each frame builds a new world state object, publishes it to a useRef, and increments a counter in React state. An effect keyed on that counter performs the draw.
The split is between payload and scheduling. Data flows through the ref. The signal to draw flows through React.
requestAnimationFrame
│
▼
advance simulation
│
▼
stateRef.current = nextState (publish snapshot)
│
▼
setRenderTick(n + 1) (schedule draw)
│
▼
React effect
│
▼
canvas.draw(stateRef.current)
The constraint
A simulation needs a single deterministic view of the world for each frame. Movement updates positions, combat resolution consumes those updated positions, and rendering draws the resolved state. Each stage depends on the writes from the one before it.
React deliberately decouples state updates from rendering. That is the right tradeoff for a UI, where the goal is to batch work and avoid redundant DOM operations. It is the wrong one for a simulation, which cannot assume a write performed earlier in the frame is observable everywhere else by the time it is read.
The performance cost compounds it. Storing the simulation in React state means a state update per frame, each one scheduling a render and reconciling a tree over data that has changed completely every time. React's reconciler exists to avoid unnecessary DOM work, and in a canvas game there is no DOM work to avoid.
Publishing snapshots
Using a ref does not require mutable world objects. The ref provides stable ownership outside React. Rebuilding the object each frame is a separate decision, and it is the one that makes reads safe.
The loop runs from requestAnimationFrame with a delta-time step: real elapsed milliseconds, capped at 50 to avoid a large catch-up step after a stalled tab, then scaled by a player-selectable speed multiplier. Everything in the simulation multiplies through the scaled value. The countdown that auto-starts the next wave uses the unscaled one, because it is a UI timer and should not run five times faster when the player speeds up combat.
Within a tick, the stages run in order: timer decrement, spawning, damage-over-time, slow effects, enemy movement along the lane, tower attacks, projectile movement, particle decay, wave-completion check. Then a single assignment publishes the results.
stateRef.current = {
...stateRef.current,
enemies,
towers,
projectiles: waveJustEnded ? [] : liveProjectiles,
particles: waveJustEnded ? [] : liveParticles,
lives, gold, score,
enemiesRemaining, enemySpawnTimer, roundTimer,
phase, wave, message, dealPhase,
hasDealtThisRound,
autoWaveTimer: phase === 'wave' ? null : stateRef.current.autoWaveTimer,
};
setRenderTick(t => t + 1);
animRef.current = requestAnimationFrame(tick);
That assignment spreads from stateRef.current rather than from the snapshot read at the top of the frame. Boss kills publish a wildcard drop mid-tick, and spreading the frame-start snapshot at commit time would silently discard it. It is the one place the publish-once discipline is broken, and the commit is written to tolerate the exception rather than to prevent it.
Because assigning an object reference is atomic, each frame publishes a complete snapshot. A reader observes either the previous frame or the next one. There is no intermediate state to observe and nothing to defend against, which is the property that mutating a long-lived object in place would give up.
React receives one counter increment per frame from the loop, in one branch or the other, never both. Player actions publish and increment the same way, outside the frame schedule. Incrementing a ref triggers nothing on its own, so that counter is the entire mechanism by which any state change becomes visible.
Damage resolution and projectile rendering are intentionally decoupled. Tower damage is applied immediately based on a distance check against the furthest-along enemy in range. The projectile rendered afterward is purely visual: it lerps toward the target's captured position and expires on a timer, and nothing ever checks whether it arrives. Only one of the two needs to be correct.
Eliminating synchronization entirely
The application has two update models. The simulation advances every frame. Poker advances on discrete events: cards dealt, community cards revealed, hand evaluated, units awarded.
They are never concurrent. phase is a single field on the same state object, and the entire simulation branch is gated behind it. In the deal phase, the tick does nothing but count down an auto-start timer. In the wave phase, the dealing functions early-return. A hand resolves completely, through the function that evaluates it and appends the resulting units to inventory, before a wave can begin.
That collapses what would otherwise be a synchronization problem into a state machine. There is no boundary to keep consistent, because the two systems never run at the same time. It is also why poker state and simulation state share one object: hole cards, community cards, enemies, towers, projectiles, and the UI message string are all fields on the same interface, with no translation layer between them.
The tradeoff is that nothing enforces the separation. The canvas component takes the entire state as a prop and reads whatever it wants. That works at this size and would not survive the codebase getting much larger.
What the architecture costs
The simulation is invisible to React DevTools. No component tree shows enemy positions, no props to inspect, no time travel. React's execution model ends at the boundary, so nothing warns about stale reads either.
Testing requires restructuring. The per-frame orchestration is roughly 265 lines inside a useCallback, closed over the ref and recursively scheduling itself, so it cannot be exercised without mocking requestAnimationFrame. The pure helpers it calls are a different story: hand evaluation, wave modifier calculation, enemy construction, and aura buff resolution are all importable and independently testable. Making the orchestration itself testable would mean extracting a step(state, dt) function that the scheduler calls, which is a refactor rather than a limitation of the approach.
Separately, and not a consequence of any of this: there is no test suite and no debug tooling in the project. That is a choice about a personal game, not something the architecture imposed.
Where this fits
This architecture is a poor fit for most React applications. It works here because the simulation is a continuously advancing state machine rendered into a bitmap. React never owns the world. It schedules draws and renders the interface around the board while the simulation advances independently.
Source: src/hooks/useGameLoop.ts, src/components/GameCanvas.tsx