~/projects $ cat projects/dino-arena.md
Dino Arena
activePersonal
A reproducible, framework-independent simulation environment for fair agent benchmarking, not just a game.
Deterministic, seeded two-lane browser runner built for human-vs-agent play and reinforcement-learning evaluation. One headless simulation drives the browser game, a controller roster, and a benchmark harness.
Technologies
- TypeScript
- Canvas
- Headless simulation
Skills
- Simulation design
- RL evaluation
- TypeScript
Links
Related projects
- 12/12 tests pass (~0.8s); npm run lint and npm run build both clean
- Benchmark, 9 fixed seeds: 3 model-predictive controllers clear 100% of seeds
- 3 reactive heuristics clear 0% of seeds, 1-29% median course progress
- 1,351 lines of simulation + test logic (core.ts 509, agents.ts 435, renderer.ts 240, tests 167)
Overview
Dino Arena is a dinosaur-runner browser game rebuilt from the ground up so its simulation is completely deterministic: same seed, same actions in, same outcome out, every time, independent of frame rate, wall-clock time, or Math.random. That property is what turns a toy game into a real evaluation harness. The repo ships eight opponent controllers, from a keyboard-passthrough practice mode up through an open-loop timer, three reactive heuristics, a closed-form analytical solver, and two model-predictive planners, plus a benchmark script that runs the exact same headless simulation the browser uses across nine fixed seeds.
Running that benchmark against this checkout produced a clean skill ladder: the three planning-based controllers, Arc Analyst, Horizon Scout, and Course Cartographer, clear the course on 100% of seeds, while the three reactive heuristics land between 1% and 29% median course progress and never clear a single seed. That split is not a tuning coincidence, it is the intended result: the controllers differ in how much of the world they can see and how far ahead they can look, and the benchmark makes that difference legible as a number instead of a vibe. The real engineering decision is not the dinosaur game itself, it is that the same 509-line simulation module drives a requestAnimationFrame canvas loop in the browser and a plain Node for loop in tests and the benchmark script, with zero code shared incidentally and zero divergence possible, because there is only one implementation of "advance one tick."
The problem
Browser runner games built for humans, the Chrome offline dinosaur game and its many clones, couple their physics to render timing and generate obstacles with unseeded randomness. That is fine for a person playing casually, but it makes the game useless as a benchmark: run the same controller against it twice and it faces two different obstacle courses, so there is no fair way to say one controller is actually better than another, only that it got luckier.
Dino Arena closes that gap: a small, self-contained runner where the simulation truth, physics, collisions, obstacle placement, and scoring, is fully separated from anything that varies run to run. It works as a game to play by hand, and without any changes, as a controlled environment for comparing hand-written agent policies head to head.
Approach and architecture
The design starts from one constraint and builds outward from it: a single pure function, stepMatch, advances the entire match state by exactly one 1/60-second tick and touches nothing outside its arguments, no DOM, no Canvas, no timers, no random number generator. A course is generated once from an integer seed via a small seeded PRNG and stored as an immutable array shared by both lanes, so a human runner dying never changes what obstacles the AI runner sees next. On top of that core sits a narrow AgentActionSource interface (reset(seed), nextAction(observation)), the entire contract a controller has to satisfy. That narrow seam is what let eight controllers get built as genuinely different algorithms rather than eight difficulty sliders on the same logic: an open-loop cadence timer that never looks at the world, a fixed-distance reflex threshold, a periodically sampled time-to-collision heuristic, a small approach/duck/recover state machine, a closed-form ballistic jump-window solver, and two model-predictive controllers that roll the ballistic solver forward over a bounded or full-course observation window.
Four TypeScript modules under app/game/ do the real work: core.ts (509 lines, the deterministic simulation, world constants, the seeded PRNG, course and match creation, collision, and the 23-value observation vector), agents.ts (435 lines, the eight controllers plus the registry that drives both the UI picker and the benchmark), renderer.ts (240 lines, one DPR-aware Canvas 2D draw function that only reads the match snapshot it is handed), and DinoArena.tsx (332 lines, the only file that touches requestAnimationFrame, keyboard events, and React state, running a fixed-timestep accumulator so the simulation always advances in true 60Hz increments regardless of the browser's actual frame rate). scripts/benchmark-agents.ts and the test files import core.ts and agents.ts directly and drive them in a plain loop with no browser APIs at all, which is the actual proof that the simulation is headless.
architecture · dino-arena
Implementation notes
Game speed depends only on tick count, not on agent behavior: speedAtTick(tick) ramps from 278px/s to a 472px/s cap purely as a function of elapsed ticks, so no controller can finish a shorter run by playing recklessly or a longer one by playing cautiously. The benchmark confirms this directly: Arc Analyst, Horizon Scout, and Course Cartographer all report a median of exactly 6,233 ticks, because they all reach the same finish line at the same simulated moment.
One ballistic solver is reused three times rather than written three times: flightAction(), the closed-form jump-window math, is used directly by Arc Analyst and as the leaf policy scored by a short forward rollout inside Horizon Scout (78-tick lookahead, 3 visible obstacles) and Course Cartographer (120-tick lookahead, full course). Course Cartographer's advantage is stated honestly as information, not intelligence: the README calls its "Expert" tier "intentionally transparent," since it rebuilds the known deterministic course from the match seed and plans over six hazards instead of three, rather than running a smarter algorithm.
No trained model exists anywhere in this repo, and the README says so directly. All eight controllers are hand-written deterministic policies. The DinoEnvironment class exposes a reset/step/serialize adapter with an RL-shaped reward signal, which reads as training-ready scaffolding, but nothing in the repo currently trains against it: no trainer, no replay buffer, no learned weights.
Game speed vs. tick: speedAtTick(tick)
speedAtTick(tick) = min(278 + tick × 0.095, 472)px/s, read directly from app/game/core.ts. Speed is a pure function of elapsed ticks, never of agent behavior, which is why every controller that clears the course finishes at the identical tick. Zero-based y-axis so the ramp isn't visually exaggerated.
figures · dino-arena
Results
Verified against a local checkout at commit c722e52 ("Add deterministic opponent roster"). npm test (12 tests across core and agent behavior) passes 12/12 in about 0.8 seconds. npm run lint is clean. npm run build (vinext, Vite 8.0.13) succeeds across all five build stages.
npm run bench:agents, median across 9 fixed qualification seeds: Clockwork Hopper (open-loop cadence) reaches 1% progress with a 0% clear rate, TTC Pilot (time-to-collision heuristic) 9% progress and 0% clear, Late Reflex (fixed-distance threshold) 13% progress and 0% clear, FSM Sentinel (approach/commit/recover state machine) 29% progress and 0% clear, and Arc Analyst, Horizon Scout, and Course Cartographer all reach 100% progress, 100% clear, at a median of 6,233 ticks.
Line counts verified with wc -l: core.ts 509, agents.ts 435, renderer.ts 240 (1,184 combined), plus 167 lines of tests, for 1,351 lines of simulation and test logic total. DinoArena.tsx (the React UI shell) is a further 332 lines, and the benchmark script is 61 lines, both separate from that figure.
Skill-ladder benchmark: median course progress by agent
Median across 9 fixed seeds (101, 307, 911, 1,337, 2,021, 4,067, 7,777, 12,345, 73,194), TICK_CAP 12,000, from npm run bench:agents. Zero-based bars, one chart, no smoothing. Clear rate is binary here (every agent is either 0% or 100%), so it's annotated per bar rather than plotted as its own axis. Horizon Scout and Course Cartographer both reuse the same closed-form ballistic solver as Arc Analyst, just applied over a wider forward-planning window (78 ticks / 3 visible obstacles vs. 120 ticks / the full course tape).
figures · dino-arena
Tradeoffs and lessons
Courses are finite, 96 obstacles, roughly 6,200 ticks to clear at the observed pace, not endless. That is the right choice for a benchmark tool, but it does mean Dino Arena is not a "play until you die" clone in the traditional sense. The benchmark's headline number, a 100% clear rate for the two model-predictive controllers against 1% to 29% median progress for the three reactive heuristics, says that planning beats short-horizon reactive heuristics on this specific course design. It does not generalize beyond that: none of the eight controllers are learned policies, so the result is a statement about hand-written control strategies, not a claim about reinforcement learning or any trained agent. The Next.js and Cloudflare Worker hosting shell is scaffolding from the vinext-starter template, not custom engineering. It exists to make the game playable at a URL.
The transferable idea is about evaluation design: comparing two policies fairly requires an environment that is provably identical between runs, and "provably" means removing every non-deterministic input (wall clock, frame rate, Math.random) from the state-transition function itself, rather than trying to control for it statistically after the fact. Building stepMatch as a pure, dependency-free function first, and only afterward wrapping it in a requestAnimationFrame loop for the browser, is what made the headless benchmark and the 12-test suite possible without a second implementation to keep in sync. It also let the difficulty ladder emerge honestly from information and planning depth rather than hand-tuned per-agent constants, and the repo names its own scope honestly: no hosted model, no training loop, and no hidden randomness behind any of the eight opponents, even though the RL-shaped API looks exactly like the seam a real pipeline would plug into.
Where it is heading
The tracked next step is to train a reinforcement-learning agent through the existing environment adapter and publish its learning curves next to the scripted roster's benchmark table. The deterministic core and the seeded qualification courses exist precisely so that comparison will be fair when it happens: a trained policy and a hand-written one will face byte-identical courses, and a difference in clear rate will mean a difference in policy, nothing else. Until an agent actually trains and beats the scripted baselines, the site reports the roster results above and nothing more.
notes
- No trained model exists in this repo. All eight opponent controllers are hand-written deterministic policies. The RL-shaped DinoEnvironment adapter (reset/step/serialize with a reward signal) is scaffolding, not a finished training pipeline.