~/projects $ cat projects/neat-playground.md
NEAT Playground
activePersonal
NEAT neuroevolution implemented from scratch in C, evolving neural nets that play Flappy Bird, Snake, 2048, and Pac-Man, with interactive replay visualizers you can run in the browser.
A from-scratch C implementation of NEAT (NeuroEvolution of Augmenting Topologies) that evolves small neural networks to play four arcade games with no datasets and no gradient descent. The engine runs tens of millions of agent-steps per second per core. Committed evolution histories replay in interactive browser visualizers showing the network, its sensors, and its decisions live.
try it
Interactive replay visualizers, hosted right here. Pick a game and scrub through generations.
Technologies
- C99
- NEAT
- SDL2
- HTML/JS visualizers
Skills
- Neuroevolution
- C
- Simulation design
- Visualization
Related projects
- Throughput: ~60M agent-steps/second/core (C99, struct-of-arrays)
- Flappy Bird solved (zero deaths, gen 16); Snake 51 apples; 2048 peak 65; Pac-Man 57 pellets
- 2048 groks at generation ~170 to 200: long plateau, then a sharp jump
- Full 4-game study reproduces in under a minute on 24 cores, bit-reproducible from one seed
Overview
NEAT Playground grows neural networks instead of training them: a population of small networks plays Flappy Bird, Snake, 2048, or Pac-Man, the best performers reproduce with small mutations, and over enough generations the random flailing turns into real skill, no dataset and no gradient descent anywhere in the loop. The method is NEAT (NeuroEvolution of Augmenting Topologies), built from scratch in C99, driving all four games through one shared interface at about 60 million agent-steps per second per core, enough throughput to make several hundred training runs and a full ablation study affordable on a laptop instead of a cluster.
The project has been running since April 2019, seven years from an early single-game prototype (the repo was originally named AiBirb, a nod to Flappy Bird) to today's four-game study with browser replay visualizers for every recorded generation. Its centerpiece result is not a leaderboard score. It is a memorization ablation: train the same algorithm on one fixed world instead of a fresh random one every generation, and every game except Flappy loses most of its score the moment it meets a world it never memorized.
The problem and motivation
Most neural networks are trained: a fixed architecture, a labeled dataset, and an optimizer nudging every weight a little less wrong each pass. That needs two things these four games do not hand over for free, a differentiable objective and a teacher that knows the right answer at every step. A Flappy Bird agent that has not flapped yet has no labeled "correct" action, only one number at the end of its short life: whether it survived and how far it got.
Neuroevolution sidesteps both requirements. It treats a network's weights, and in NEAT's case its structure too, as a genome, scores it once per life with a single fitness number, and searches with a genetic algorithm instead of a gradient. Nothing needs to be differentiable, and sparse or delayed reward is not a problem, since nothing is ever differentiated in the first place.
The four games form a difficulty ladder, not four unrelated demos: Flappy is a pure reflex baseline, Snake adds path planning, 2048 adds reading a whole board over many moves, and Pac-Man adds pursuing one goal while evading another. Holding the algorithm fixed and varying only the game turns each result into a question about what NEAT does under different demands.
System architecture
One NEAT engine drives all four games through a single shared interface, the Env vtable in include/env.h: observe, step, reset, alive, score. The engine is 2,086 lines across 7 files (genome operations, population and speciation, network compilation, HyperNEAT, config parsing, serialization, and the RNG), game-agnostic by design. Three driver binaries, a trainer, a viewer, and a server, all reach every game through that vtable, and each of the four game modules, 1,238 lines total, implements it independently: adding a fifth game means one new file behind the vtable, not touching the engine or the drivers. The whole tree is 4,698 lines across 21 files.
The evolutionary core is canonical NEAT: every new connection gets a global innovation number so differently-shaped networks can still be recombined correctly, and new structure is protected from immediate competition by speciation, grouping networks by a compatibility distance (excess genes, disjoint genes, average weight difference) and breeding mostly within each species using fitness sharing, with stagnant species removed while the top species and the all-time champion stay protected. Reproducibility is enforced at the RNG level: a single master seed drives two separate streams, one for the game and one for evolution, no wall-clock entropy anywhere, and the build skips -march=native and -ffast-math so results are bit-identical across machines. Even each generation's held-out test batch is derived from that same master seed plus the generation number, fresh yet fully reproducible.
The browser visualizers run on a JSON export of that same state, not recorded video. Each run exports every species' champion network at every generation plus the exact evaluation seeds used to test it, compressed into a gzipped history sidecar 50 to 300 KiB per game. A JavaScript port of the NEAT evaluator and all four environments loads that file and plays the game live in the browser, network activations and sensor readings included, checked against the C reference frame by frame rather than assumed to match.
One game-agnostic NEAT engine (2,086 lines across 7 files) is driven by three binaries (trainer, viewer, server). All three reach every game through the same Env vtable defined in include/env.h, four calls (observe, step, reset, score) that each of the four game modules (1,238 lines total) implements independently. Adding a fifth game means writing one new file behind that vtable, not touching the engine or the drivers.
architecture · neat-playground
The games and evaluation design
Each game hands the network a fixed-size sensor vector and gets back one action, chosen by argmax over the output layer. Flappy Bird gives 5 sensors (height, vertical velocity, distance to the next pipe, its gap's top and bottom) and 2 actions, glide or flap. Snake gives 7 heading-relative sensors (danger ahead/left/right, the apple's offset and distance, the snake's length) and 3 relative-turn actions. 2048 gives all 16 board cells as sensors and 4 slide actions. Pac-Man gives 16 sensors (open neighbors, the nearest pellet's and two nearest ghosts' directions and distances, Pac-Man's own position, pellets remaining) and 4 move actions.
Every world is procedurally random and never reused. Flappy's pipe gaps, Snake's start cell and apple positions, 2048's tile spawns, and Pac-Man's maze (a lattice of pillars, each staying a wall about 70% of the time, always generated from a fully-connected base so no maze is unsolvable) are freshly drawn from the run's RNG stream. That removes the usual reason for a train/test split: with no fixed level to memorize, doing well on training worlds is already close to doing well in general, which the memorization ablation below measures directly.
Champion selection follows the same logic. Instead of crowning whichever genome had the highest fitness ever recorded, rewarding whoever's generation served up an easy world, the trainer periodically re-tests the top genomes on a brand-new random batch and crowns whoever scores best on it. On runs pushed further, a separate verifier re-tests the finished champion against a large batch of unseen seeds, reporting mean, median, and worst-case score rather than one number.
Experiments and results
The memorization ablation is the centerpiece: the one experiment that could have falsified the random-world design. Setup: train the same population two ways, once on a single world reused for the entire run ("fixed world") and once on a fresh random world every generation ("random world," the default), then score both on a world neither ever saw. A 2048 population trained on a fixed world reaches tile 128 on that world but collapses to 6 on an unseen one. Trained on random worlds instead, it holds at 64 on training and 42 unseen. Snake shows the same shape (31 fixed-world training, 10 unseen, versus 12 and 25 trained random), as does Pac-Man (40 and 15, versus 66 and 41). Flappy is the instructive exception: its pipe course scrolls forever, so even "fixed seed" training never hands the network a finite thing to memorize, and both regimes stay low (6 and 2). Snake's random-world unseen score (25) even edges past its own random-world training score (12), a reminder of how noisy one run is at this scale.
Learning dynamics differ sharply by game, averaged over 5 seeds from the held-out test batch each generation. Flappy solves almost immediately, reaching its ceiling of 54 pipes by generation 16. Snake climbs steadily to a 27.6-apple mean by generation 75. Pac-Man rises fast to a 32.8-pellet mean by generation 22 and then stays noisy, since one unlucky ghost encounter swings a game a lot. 2048 is the clearest case of grokking: roughly 170 generations near a plateau, then a sharp jump around generation 200 to a 102.6 mean, with by far the widest spread of any game (standard deviation 118.3), one seed out of five finding a substantially better strategy than the rest.
Two further 3-seed ablations sharpen the picture. Selecting the champion on a fresh test batch instead of trusting training fitness gives a small, consistent lift on three of four games (Flappy 54 versus 51, Snake 27 versus 26, 2048 38 versus 35, Pac-Man an exact wash at 30). Recurrence looked like a much bigger win in an early sweep, nearly doubling 2048's score, which turned out to be a confound, covered under lessons learned below. Pushed further with a larger population over as many as 800 generations, later runs reached a solved Flappy, Snake up to 51 apples typically and 59 at best, a 2048 run peaking at 65, and Pac-Man up to 57 typically and 62 at best, roughly double the 5-seed baseline on the two hardest games, with the curves still climbing at generation 800.
Learning curves
Held-out test score by generation, 5 seeds averaged per generation. The violet marker is the grokking generation: the first generation at 90% of that game's final plateau score. 2048's curve is the clearest case, a long flat stretch followed by a sharp jump around generation 200; 2048 also carries by far the widest seed variance (std 118.3 on a mean of 102.6), one seed found a much better strategy than the other four.
final 54.0 ± 0.0 pipes (5-seed mean, held-out test batch)
final 27.6 ± 1.7 apples (5-seed mean, held-out test batch)
final 102.6 ± 118.3 max tile (5-seed mean, held-out test batch)
final 32.8 ± 7.0 pellets (5-seed mean, held-out test batch)
Memorization ablation
Same population, two training regimes: one world reused for the whole run ("fixed world") vs. a new random world every generation ("random world"), both then scored on a world neither ever saw. Hollow bars are the score on the world the population actually trained on; solid cyan bars are the score on an unseen world. Flappy and 2048 show the memorization gap most sharply: fixed-world 2048 scores 128 on its own training world but collapses to 6 on an unseen one, while random-world training holds at 64 and 42. Snake's random-world unseen score (25) even edges past its own random-world training score (12), reflecting how noisy a single-run comparison at this scale is.
fixed: 6→2 unseen · random: 54→54 unseen
fixed: 31→10 unseen · random: 12→25 unseen
fixed: 128→6 unseen · random: 64→42 unseen
fixed: 40→15 unseen · random: 66→41 unseen
Decisions and tradeoffs
Writing NEAT from scratch in C, instead of reaching for an existing library, was a throughput decision as much as a control one. At about 60 million agent-steps per second per core, several hundred training runs and a full ablation study finish in a lunch break instead of overnight. The cost is what a mature library gives away for free: ready-made visualizers, community mutation operators, prior art for when something behaves strangely.
Random, never-reused worlds are a decision, not an afterthought: a fixed train/test split was rejected because a fixed test set can itself be overfit to given enough generations, so regenerating the world removes the target instead of protecting it. A related choice was what the network should see. A raw board grid failed outright on the harder games (Snake scored 0 apples every time, Pac-Man about 7), since NEAT adding one connection at a time cannot learn to use 148 to 251 raw inputs, so a hand-built feature vector is what every number in this case study is built on.
The replay visualizers run a deterministic JavaScript port of the network evaluator and every game's rules, not a video recording or live training in the browser. Training stays on the fast C engine, and the browser only replays a compact, already-decided history: a champion's genome plus the seeds it was tested on. The tradeoff is a second implementation of every game's logic, kept in sync with the C original by hand and checked frame by frame, in exchange for replays that scrub instantly and never truncate.
The newest work in the same tree raises the engineering bar again: C++20 and CMake instead of hand-rolled Makefiles, sanitizer builds, a ctest suite, and a held-out verifier as a first-class command rather than a line in a training log, aimed at a fifth game, a Frogger-style lane crossing that adds moving-obstacle timing to the planning and board-reading the first four games already demanded.
Lessons learned
The clearest lesson is also the most generalizable: if the thing a system is graded on never changes, it memorizes the thing instead of learning the skill, and the fix is to stop the target from ever being fixed rather than detect memorization after the fact. That is the entire justification for training on a fresh random world every generation, and the memorization ablation above is the number behind it.
The second lesson is a methodology warning that cost a real result to learn. An early one-factor-at-a-time sweep showed recurrent connections nearly doubling 2048's score, a headline-looking finding. Re-running the comparison from an already-tuned baseline instead of a default one made the effect mostly disappear: feedforward and recurrent networks landed within a couple of points of each other (45 versus 44). The recurrence had been compensating for a poorly-tuned baseline, not adding real capability, a textbook case of a one-factor sweep crediting one knob for what was really an interaction between knobs.
A third lesson: an easy benchmark tells you almost nothing about an algorithm, since it never exercises the parts that make the algorithm interesting. Flappy ignored nearly every hyperparameter that mattered for the other three games, so judged on Flappy alone, the honest conclusion would have been that none of NEAT's knobs matter, exactly wrong for the rest.
Current limitations
The repository is private, so this page has no code link. Every number and figure above is still drawn directly from that repository, there is just no way for a reader to check it firsthand yet. The games and networks are both small by design, which is what makes several hundred training runs affordable, but it also means the conclusions are about small-network neuroevolution specifically, not neuroevolution at scale. The hyperparameter sweep is one-factor-at-a-time: it identifies which single knobs matter but, as the recurrence result shows, not how they interact, and the full interaction surface was not mapped.
A few modeling choices simplify the games themselves. Pac-Man's three ghosts use straight-line Manhattan targeting at half of Pac-Man's own speed, which keeps the maze winnable, rather than the arcade original's timing-based, personality-driven chase logic. 2048's illegal-move handling, a small penalty plus a cap on consecutive illegal moves before ending the episode, is one reasonable policy among several. The checkpoint serializer is built to round-trip the project's own files, not hardened against malformed input, so it is safe for personal runs and not a trusted import format for files from elsewhere.
Where it is heading
The repository is private while some early experimental history gets cleaned up before anyone else reads it. Opening the source is the plan once that pass is done, and a code link replaces this page's current no-repo state at that point.
The newer C++20 line of work, still in the same working tree, keeps moving on its own fifth game. Its stated next steps are a raw or combined pixel-observation mode (currently hand-built feature vectors only) and river and log lanes to complete the hazard set (currently road hazards only), both open work rather than finished.
Experiments
2 experiment write-ups · continued development log
Setup
Same population and hyperparameters, two training regimes per game. "Fixed world" reuses one world seed for the entire run, the setup a train/test split would normally guard against. "Random world" generates a fresh world every generation, the project's default everywhere else in this case study. Both regimes were then scored on a world neither had ever trained on, a genuinely unseen batch built from seeds the trainer never touched while selecting a champion.
Result
The gap lands exactly where the design predicts. 2048 shows it sharpest: fixed-world training reaches tile 128 on its own training world but collapses to 6 on an unseen one, while random-world training holds at 64 on training and 42 unseen, a much smaller drop. Snake and Pac-Man show the same shape. Snake reaches 31 apples on its own fixed training world but 10 on an unseen one, against 12 and 25 when trained random. Pac-Man reaches 40 and 15, against 66 and 41. Flappy is the instructive exception: its pipe course scrolls forever, so even "fixed seed" training never hands the network a finite thing to memorize, and both regimes stay low (6 and 2). Snake's random-world unseen score (25) even edges past its own random-world training score (12), a reminder of how noisy a single-run comparison is at this scale.
What it changed
Random, never-reused worlds were already the trainer's default going into this ablation, not a change this result triggered. What the ablation did was turn that default from a design assumption into a measured property: every game except Flappy loses most of its unseen-world score when trained on a fixed world instead, which is the concrete number behind "never reuse a world" as a rule rather than a hunch, and the clearest evidence that a fixed train/test split would have been the weaker design.
Watch it evolve
same-origin static demo · runs entirely in your browser
See it run

Flappy Bird replay dashboard with live network visualization

Evolved agent navigating Flappy Bird pipes

Evolved Snake agent with sensor rays displayed

2048 board with network output panel

Pac-Man agent balancing pellet collection and ghost evasion

Animated capture of the live evolution observatory: the Flappy Bird viewer's species timeline, generation slider, and per-species strategy cards