Running an Imperative Game Loop Inside React: Word Rain
A KeyDo postmortem: words fall from the sky, requestAnimationFrame advances the physics each frame. Calling setState every frame in React grinds to a halt — state goes in a ref, rendering is triggered by an empty tick.
KeyDo’s fourth mode, “Word Rain,” is a little game: words fall from the top, you type the first letter to lock a target, finish the word to destroy it, and lose a life when one lands. It needs a game loop at 60 frames per second. And running a game loop in React, the first-instinct way freezes the page. This post is about making React and requestAnimationFrame coexist.
Don’t setState every frame
What the loop does each frame: advance every word’s y coordinate, check for landings, spawn new words, check lives. If all that is React state, that’s a setState per frame — 60 times a second triggering re-render, diff, reconcile. With enough words the frame rate collapses, input stutters, and the game is ruined.
React’s rendering is designed for “discrete updates driven by user interaction,” not “a continuous simulation stepping every 16 milliseconds.” Forcing high-frequency game state into React state is using a declarative framework for imperative work.
All state in refs, rendering via an empty tick
The fix moves the game world entirely out of React:
- All mutable state goes in refs:
wordsRef(the array of all on-screen words),lockedRef(the currently locked target id),loopRef(level, kills, lives, timing, therequestAnimationFramehandle); - Each frame,
frame(now)mutates those refs directly, never touching React state; - What about rendering? At the end of the loop, a
setTick(t => t + 1)— an empty piece of state whose only job is to “poke React into repainting.” On re-render, React just readswordsRef.currentand draws the current frame.
function frame(now) {
const dt = Math.min(now - L.lastTime, 100) / 1000 // seconds
L.lastTime = now
for (const w of wordsRef.current) w.y += w.speed * dt // imperative physics step
// …check landings, lose lives, spawn words, all on refs…
setTick(t => t + 1) // only to trigger one repaint
L.raf = requestAnimationFrame(frame)
}
The truth is in the refs, React is just a canvas. One setTick per frame still re-renders, but it does one light job — read the coordinates from the refs and place the DOM — with no complex state-diff chain.
Time step: dt is needed, and needs a cap
Physics can’t advance by “a fixed number of pixels per frame” — screens at different refresh rates (60Hz / 120Hz) would run the game at different speeds. You need a time step dt: how many seconds since the last frame, so displacement is speed * dt. A 120Hz screen moves less per frame but has more frames, a 60Hz screen moves more per frame with fewer frames, and the net speed matches.
But dt has a trap, and the Math.min(now - L.lastTime, 100) cap is crucial. If the user switches tabs and comes back, requestAnimationFrame pauses, and now - lastTime could be several seconds — without the cap, every word teleports to the bottom of the screen in one step, wiping out all lives at once. Capped at 100ms, the worst case is a small jump.
The more thorough handling is to just pause:
if (document.hidden) {
L.lastTime = 0 // clear the time baseline
L.raf = requestAnimationFrame(frame)
return // advance no state
}
When the tab is hidden, the loop idles but doesn’t advance the game, and it clears lastTime; on return it re-bases on the current moment and resumes seamlessly. The practice time playMs also only accumulates while visible — time spent in the background doesn’t count as practice.
Locking the “most dangerous” word
The input logic is the ztype style: type a letter, and if no target is locked, lock one among all words starting with that letter; if already locked, keep typing its next letter — right advances, wrong gives a “buzz.”
Which one to lock matters — pick the lowest one:
const candidates = words.filter(w => w.text[0] === key)
target = candidates.reduce((a, b) => (a.y > b.y ? a : b)) // largest y = lowest = most dangerous
The lowest word lands soonest and is the biggest threat, so killing it first is intuitive. Spawning also dodges ambiguity: no new word sharing a first letter with an existing one (or when you type that letter, the system doesn’t know which to lock), and no duplicate word on screen. The premise of first-letter locking is that no two words can share a first letter at any moment, and that constraint has to be held on the spawn side.
A small closure-reads-stale-value trap
endGame needs the final score, but it’s defined in the closure captured when the effect was set up, and reading score directly gets that frame’s stale value. Score is also React state (the HUD shows it, it can’t live only in a ref). The fix is a scoreRef mirror:
const scoreRef = useRef(0)
useEffect(() => { scoreRef.current = score }, [score])
// endGame reads scoreRef.current for the latest score
This is a patch on “truth in a ref” for the mixed case — score both participates in React rendering (keep the state) and needs its latest value read in an imperative callback (add the ref mirror). Need both ends, keep a copy at both ends.
Takeaways
Running a 60fps imperative game loop in React:
- Don’t setState every frame: a high-frequency continuous simulation shouldn’t go through React’s discrete updates — it tanks the frame rate;
- State in refs, render via an empty tick: keep the whole game world in refs, one
setTickper frame just to “poke a repaint”; - Use a capped time step:
speed * dtsmooths out refresh-rate differences, and cappingdt+ pausing ondocument.hiddenprevents teleporting after a tab switch; - Lock the most dangerous target: pick the lowest word, and dodge first-letter collisions on the spawn side to keep locking unambiguous;
- Add a ref mirror for mixed state: for a value that must both render and be read latest in an imperative callback, keep a copy in both state and a ref.
In one line: React isn’t good at running game loops, so don’t make it — let requestAnimationFrame run the simulation on refs, and let React fall back to what it does best: painting the current frame.
Comments