Self-Driven Car
Real neuroevolution in the browser, with the actual training code stepping live
- Timeline
- May 2024 – Sep 2026
- Reading Time
- 6 min read
20–150
Cars per generation
26
Backend tests
0
Accounts needed
How these numbers were measured: Server time on my own machine for a 30-car run over 25 generations.
Machine Learning Demos Hide the Interesting Part
Most neural network demos show a finished result: a car that already drives, or a chart that has already gone up. The part that teaches something, how a population of random networks becomes a set of drivers, happens inside a training loop nobody gets to see. I first built this project in May 2024 as a Pygame simulation trained with NEAT, and in early 2026 I added a Streamlit dashboard, but both needed a local Python install and neither explained what the code was doing.
I wanted a version anyone could open in a browser and learn from. The audience is students and curious engineers who have heard of neural networks and genetic algorithms but have never watched one evolve tick by tick. That meant the simulation had to be real rather than an animation, and the code behind it had to stay visible while it ran.
The Constraints I Set
Four constraints drove the design. First, honesty: every number on screen had to come from the running training loop, including the values in the code panel. Second, cost: this is a personal project, so the backend could not run around the clock and I ruled out a separately hosted database.
Third, zero friction: no sign-up and no login, yet I still had to stop one visitor from monopolising the CPU. Fourth, it had to work on phones and tablets as well as wide desktops, because a teaching tool that only works on a laptop loses half its readers.
How the System Fits Together
The browser asks the API for a one-time ticket that expires after 60 seconds, then opens a WebSocket with it. From then on the server pushes JSON frames, and the browser sends control messages to pause, step one tick, skip a generation or change speed. Each run lives in its own thread, so the NEAT loop never blocks the async API.
- Next.js 16 frontend: draws the track on a canvas, the leading car's network as SVG, and the code panel; owns the anonymous visitor ID in local storage and the controller that turns frames into UI state.
- FastAPI service: REST endpoints for runs, usage and data export, plus the ticket endpoint and the simulation socket; owns per-IP quotas and origin checks.
- Simulation session: one thread per run wrapping a NEAT-Python population; owns pause and stepping, the 45-minute run cap and the 5-minute idle disconnect.
- NumPy engine: runs sense, think, decide, move and score for every car each tick, at 30 ticks per second in real time; owns physics, ray casting and collision.
- SQLite on a persistent volume through async SQLAlchemy and aiosqlite: visitors, usage events, runs and per-generation statistics.
Keeping the Code Panel Honest
The code panel uses no hand-copied snippets. The backend reads the source of the functions that actually execute with Python's inspect module, from the training loop down to the ray caster, and sends it to the browser alongside the leading car's live values. If I change the engine, the panel changes with it, so the two can never drift apart.
Why a Headless NumPy Engine Over Pygame
The original version looped over cars in plain Python and let Pygame draw each one, so physics and rendering were tangled together. Moving to the browser left three options: stream rendered video from Pygame, port the simulation to JavaScript, or rewrite it as a headless Python engine that sends state and lets the browser draw. Video would have cost bandwidth and hidden the data I wanted to show, and a JavaScript port would have meant giving up NEAT-Python, the library doing the actual learning.
I chose the headless engine and vectorised it. The whole fleet lives in NumPy arrays, and one broadcast segment-intersection pass tests all 8 sensor rays of every car against every nearby wall segment, replacing nested loops. I build roads from Hermite splines in NumPy, which let me drop SciPy entirely. The trade-off is that I now own a rendering layer Pygame used to give me for free, and every visual has to be rebuilt from the frame data.
Why a Sleeping Server and SQLite Over Always-On Infrastructure
The conventional setup, a managed Postgres instance and an always-on API, costs money every hour whether anyone is training or not. Instead, the service is configured to sleep when idle and all data lives in one SQLite file on a 5 GB volume mounted at /data. Starting a run is what wakes the server.
Sleeping has a visible cost: the first request after an idle period can get a 502 while the container starts. The frontend treats 502, 503 and 504 as retryable, backs off at 1, 2, 3, 5 and 6 seconds, and shows a waking-up state instead of an error. Tickets and live sessions are held in memory, so I pinned the service to a single replica and capped it at 3 concurrent simulations. For a teaching tool that is the right trade; for heavy traffic it would not be.
Identity follows the same pay-nothing approach. The browser generates a UUID, and the server derives a workspace ID as a uuid5 of that ID plus the client IP, taken from the last X-Forwarded-For hop. Each IP gets 5 runs a day and 20 a week. Usage is consumed under an asyncio lock, and a run that fails before finishing its first generation is refunded.
The Hardest Problem: A CORS Error That Was Not a CORS Error
After I introduced anonymous visitors, a brand-new browser opening the dashboard would sometimes fail, and the console reported a CORS error. The CORS configuration was correct, and the clue was that it only happened for a visitor ID the server had never seen. The dashboard fires several requests at once, and each one checked whether the visitor row existed and inserted it if not.
Two concurrent requests could both see no row. The second insert then hit a primary-key violation, and the resulting 500 was produced outside the CORS middleware, so the response had no Access-Control-Allow-Origin header. The browser reported the missing header and hid the real server error behind it.
I fixed it in two places. The check-then-insert became a single SQLite insert with on-conflict-do-nothing, which is idempotent under concurrency. I also moved the middleware that turns unhandled exceptions into JSON responses inside the CORS layer, so even failures carry CORS headers and show up as what they are. Regression tests now fire concurrent requests from a fresh visitor.
Results
The simulator is live at self-driven-car.taaranjain.com with no sign-up. Visitors can train 20 to 150 cars per generation for up to 200 generations, and step through the real source of seven training phases. The backend has 26 tests covering the engine, the WebSocket protocol and the usage limits.
On my own machine, a 30-car run on the long track completed 25 generations at maximum speed in about 1.3 seconds of server time, with individual generations taking 25 to 105 milliseconds. In one run that kept the same track every generation, the best fitness climbed in steps from 2 to 62, and a car finished the 24,000-unit road in generation 24.
What I Would Do Differently
I would separate simulation from rendering from day one. The Pygame version mixed them, and it took a full rewrite to reach an engine I could unit test and run headless on a server. Much of the later speed-up came from that separation, not from the vectorisation itself.
I would also add visual regression tests much earlier. Two of the bugs that took longest were only visible on screen, and I caught both by screenshotting at several viewport sizes by hand; a Playwright visual check in CI would have caught them automatically.
- On high-density displays, the canvas kept resizing itself in a feedback loop.
- Sensor rays appeared to pass through walls, for two reasons: the frontend drew them from the wrong car, and the road edges had small notches where points stepped backwards.