The Engine
src/engine/loop.ts — a framework-free, deterministic tick loop that drives the
whole race. It owns cadence and state; React only subscribes.
The GameEngine class
class GameEngine {
constructor(opts: { mode, lanes, smoothing? }, cb: EngineCallbacks)
start() // begin the race
pause() // halt arrivals + countdown, keep state
resume() // restart intervals without re-seeding
stop() // full halt (used by reset / end)
}
The callbacks are the only way the engine talks to the outside world:
interface EngineCallbacks {
onLaneUpdate: (laneId, runtime: LaneRuntime) => void;
onTick: (timeLeft: number) => void;
onEnd: (results: Record<string, LaneRuntime>) => void;
}
The Zustand store wires these to set() calls, so every lane update flows into
React. The engine never imports React.
What runs on each tick
start() sets up two intervals (extracted into startIntervals() so resume()
can re-use them):
- Arrival pump (every
arrival.intervalMs): callsnextArrival(laneIds), which draws one scenario and pushes an identical clone to every lane's queue. When a lane's queue is atmaxQueue, the arrival spills intobackloginstead — the belt jams. - Master tick (every 1000ms): decrements
timeLeft(or counts elapsed for endless), firesonTick, runs the sudden-death ramp, and decays each lane's smoothed tokens/sec so the speedometer drifts down when idle. Ends the race whentimeLeft <= 0(never for endless).
The per-lane processing loop
Each lane has its own async processLane(lane, runtime) loop:
pull next scenario → runPipeline(...) → scoreItem(...) → update stats → repeat
- If the queue is empty, it re-arms a short
setTimeoutand waits. - After the pipeline
awaits (real model calls can take seconds), it re-checksthis.runningbefore mutating — so an ended/reset race doesn't get stale writes. - Each lane paces its next item slightly (Cerebras faster than others) so the belt reads naturally.
State: LaneRuntime
interface LaneRuntime {
queue: TaskScenario[]; // pending parcels
backlog: number; // overflow (drives the handler's stress)
cleared: number; // resolved items
score: number;
smoothedTps: number; // EMA of tokens/sec for the speedometer
busy: boolean; // is this lane mid-item?
focus: LaneFocus | null; // the current focus-card contents
items: ScoredItem[]; // full history (for the summary)
// ...caught, escalated, tokensPerSec, itemNo, lastItems
}
backlog is the emotional engine: it drives the Handler
character's stress state and the belt jam — the strongest readability signal.
Ending a run
There is deliberately no pause — the lanes are live agents, so freezing the clock can't freeze in-flight model calls (it would just decouple the timer from reality). A run is ended (scored) or reset (back to lobby), never paused.
end()is idempotent (anendedflag guards double-fire: a final tick at 0 + a manual "End" could otherwise fireonEndtwice → double confetti).
Supporting modules
src/engine/arrivalPump.ts—drawScenario,nextArrival,cloneScenario,MODE_CONFIG. The fairness contract lives here.src/engine/scoring.ts—scoreItem,totalScore,resolvedCount,clearedCount,verdictAccuracy,meanTokensPerSec. Pure functions overScoredItem[].
Testing the engine
The engine integration test (src/engine/loop.test.ts, run under Vitest — not
the Playwright e2e suite) runs a full mock race with fake timers
and a near-zero-latency mock profile, fast-forwarding past the window. It
asserts: the loop ends, both lanes score, items carry the answer-key fields,
pause freezes the countdown, endless never ends, and both lanes process an equal
count (the fairness convergence check).