Video is my day job. Before this I'd never built a game, and I still can't write much TypeScript. On 22 October I started a small test called Rollenbank (Dutch for indoor trainer). It was one page: it read my Wahoo KICKR Core over Bluetooth and pushed a dot along a road. Three weeks on, that page is Tour du Pain, with over 140 releases behind it, two climbs, voice chat, clubs and riders on the road I've never met.
I'm not starting from zero, though. Years ago I studied CMD in the Netherlands (communication, multimedia and design), a mix of design, interaction and media. I never finished it. What stuck was a feel for how screens and systems fit together. And from my work as a filmmaker I know what a good shot looks like: where the light comes from, where the eye goes first, when a frame is too busy. That turned out to matter more than I expected, because most of my notes to Claude are about exactly that.

All of it came out of Claude Code, and along the way I got a little obsessed with using it well. So this is the long version. I'll go through how the work is split over parallel sessions and which model does what, then how nothing goes live without proof and how device logs from real riders become fixes. If you're planning something similar, take whatever helps.






Contents 13 sections
- I direct, Claude writes the code
- The setup at a glance
- Step one is always a brief
- One CLAUDE.md for every session
- Six sessions in parallel, one per work area
- The right model for each job
- Nothing is done without proof
- Real riders, real logs
- Fixing what you feel
- Visual work takes rounds
- Guardrails, because real riders are online
- Still a lot of work
- If you want to build something like this
I direct, Claude writes the code
My process looks a lot like making a film. There's a script, a shot list, a shoot and an edit, and what the film ends up being is my call. Claude writes the code, runs it, tests it and reports back on what it checked. I ride the result and point out what's wrong.
Code review, for me, happens on the bike. Does the climb feel steep? Does the voice cut out when someone joins? When something's off I send a screenshot with one sentence, like notes to an editor. Since I don't read diffs, the engineering discipline has to sit in the system around the sessions. Most of this post is about that system.

The setup at a glance
In one picture: six sessions, each with its own slice of the code on its own git branch, and all of them read the same project file before they start. One deploy lock and one check suite stand between them and the live site.
- Riding & trainersphysics, Bluetooth, workouts
src/simsrc/ble - Bots in the bunchpeloton, legends, companion
src/traffic - World & lookcourse, climbs, light
src/worldsrc/course - Multiplayer & voicerooms, chat, voice
src/networker - Accounts & ridessaving, Strava, Garmin
src/accountmigrations - Menus & languagescreens, English, Dutch
src/uisrc/i18n
Every release
- Type checks
- Unit tests
- Physics: simulated laps
- Game check
- Valid .fit for Strava
- Version and changelog
No freeze file, then live: tourdupain.app
Step one is always a brief
Nothing gets built without a brief first. That's a Markdown file describing the feature as though it already exists. PROMPT-TOUR-DU-PAIN.md was the first one, and it's the file that turned my trainer test into a game. It sets the tone ("funny, a bit absurd, French-flavoured, always easy to understand") and gives one rule per mechanic. It also lists what's out of scope.
That line is straight from the file. A single rule in a single sentence is easy to test.
Briefs take me more than one sitting. I start by talking the idea over with Claude and letting it ask questions until nothing vague is left. A separate pass then holds the brief up against a fixed checklist of mine: is every rule testable, what happens offline, what if two riders do it at once, what's the smallest version worth shipping. After that a session builds it, in one run if things go well. I keep thinking and building in different sessions, because a builder handed a half-finished idea will build the wrong thing and build it very well.
Next to the briefs sit contract files for the parts that talk to each other, such as CONTRACTS-ONLINE.md and CONTRACTS-CONNECT.md. Every message between game and server is pinned down there. A project rule says shared/protocol.ts and those contracts always change in the same go. That lets the multiplayer session and the server session each work their own end of the wire without breaking the other's.
One CLAUDE.md for every session
The first thing any session reads is CLAUDE.md at the root of the repo. Mine runs to about two pages: the stack, a table of which session owns which folders, the house rules and the release steps. Some lines from it:
I wrote it so I wouldn't have to say anything twice. A session starting tomorrow gets the same rules as the session that wrote them down. When I notice I'm correcting something for the second time, it goes into the file.
Six sessions in parallel, one per work area
Once the game got bigger, a single long conversation fell apart. Claude forgot decisions it had made an hour before, and each change rippled through everything else. So I divided the code roughly like a film crew divides its jobs:
| Area | Owns |
|---|---|
| Riding and trainers | Physics, Bluetooth, trainer support, workouts |
| Bunch and bots | Traffic and the bot riders, like Kristian |
| World and look | The course, the scenery, crowds, light, camera |
| Multiplayer, chat and voice | Netcode, voice, the lobby and the server rooms |
| Accounts and rides | Accounts, ride saving, Strava, Garmin |
| Menus, design and language | All screens, both languages |
All six share one folder on my Mac, so every session has its own git worktree and its own branch. Say the world session needs a small tweak in the menus: it keeps the tweak tiny, or hands it to the menus session. Sessions can message each other too. A note like "the game log shows a Zwift Hub problem" reaches the session that owns Bluetooth without me copying a thing.
My feedback goes to whoever owns the area. "Kristian rides next to me the whole time" is a job for the bunch session, and "the climb choice is too small on a tablet" lands with menus.
The right model for each job
Plenty of tasks run fine on a smaller model. I watched my usage for a few weeks and then settled on a split. Opus does the building, meaning anything that writes or changes code. Planning and checking, reviews included, go to a separate thinking pass in its own session, so the builder never judges its own work. Second opinions come from there as well. The routine stuff (browsing, checking a page, filling in a form, scanning logs for one pattern) goes to Sonnet and Haiku, which are cheaper and fast enough for it.
Subagents are the other piece. If answering a question means reading twenty files, a subagent does the reading and returns only its conclusion, and the main session's context stays clean. Context is a session's working memory. Once it's stuffed with file dumps, the session gets worse at its real job, so keeping it clean pays off more than I expected.
Nothing is done without proof
I'm strictest about this one. When a model says "it works", I want to know what it checked. Before any release goes out, the full suite has to pass:
- Strict-mode type checks, for the game and for the server.
- Unit tests on four workers with a 60 second timeout. With six sessions on one machine, the default settings time out.
sim-check: the whole simulation run headless, covering physics, workouts, heart rate, the bots and the circuit.game-check, which does the same for the game layer: the baguette economy, the café stop, your profile and your placement on the course.verify-fit, which records rides headless and checks the .fit files byte by byte, so what reaches Strava and Garmin is valid.
$ npm run deploy
deploy lock✓claimed by this sessiontsc✓game and server, strict, no errorsvitest✓238 files, 2895 tests passedsim-check✓passed (250 W → 36.6 km/h)game-check✓passed (5 baguettes at 26 km)verify-fit✓passed (Decoder.isFIT) 633 checks across sim, game and .fitvite build✓built in 706 msd1 migrations✓nothing to applywrangler deploy✓tourdupain-appindexnow✓1 URL sent, answer 200
$ curl tourdupain.app/api/version
{"version":"1.39.4"}Once a deploy is done, the session opens the live site and reads the browser console. It also checks that /api/version reports the new number, and only then tells me "live as 1.39.4". Without that evidence I don't count it as live.
Performance is held to the same standard. A benchmark script can build any commit and run it in headless Chrome on the real GPU while the camera moves through the scene. A graphics change counts as an improvement only if the numbers agree. On an older laptop, simulated by slowing the CPU down four times, Medium graphics with 50 riders on screen holds 39 to 54 fps. The script measured that, and I trust it far more than anyone saying it felt smooth.
For multiplayer there's a load test where 50 simulated riders hit the lobby at the same moment. Each behaves like a real game, joining in small batches and sending its position many times a second.
- Solo lap, median
- 50 riders, best run
- 50 riders, CPU 4× slower
- 60 fps
Real riders, real logs
A ride can carry a small device log. It records which trainer connected and which slots it filled, how old its data was and what errors Bluetooth threw. A session reads these logs and writes up an audit that gives every problem a root cause plus a file and line.
The audit from this morning turned up a nice example. One rider used a Zwift Hub as trainer and heart-rate source at once, and after a page reload neither came back. The log had two errors in the same millisecond. The reconnect code had asked that one Bluetooth device to start watching for its signal twice, once for each slot, and Chrome allows a single pending watch per device. When the second call failed, its cleanup cancelled the first one too, and both slots gave up.
Device log · one rider · 10 Nov · after a reload
16:42:02heartRateZwift Hub from last time: Failed to execute 'watchAdvertisements': pending watch operation.
16:42:02trainerZwift Hub from last time: The Bluetooth operation was cancelled.
The same second: the second watch fails, and its cleanup cancels the first.
Cause
Reconnect asked the same Bluetooth device to start watching twice, once per slot. Chrome allows one pending watch per device.
Fix, live in 1.40.3
Watch once per device, then connect every slot that wants it. A fake Hub that behaves like Chrome now runs in every release.
Now the game watches once per device and then connects every slot that wants it. The audit wrote the test as well: a fake Zwift Hub that acts like Chrome and turns down a second watch while the first is still pending. It runs with every release now. I'd never have caught this by riding, because I don't own a Zwift Hub.
Fixing what you feel
A few bugs only show up as a feeling. When we rode together over home wifi, the other rider would now and then shoot ahead and drop back. Their position updates came in clumps, and the game drew each one the moment it arrived.
Version 1.37.6 stopped trusting arrival times and started working out each rider's timing from their own speed. After the fix the session simulated it too. On a congested router, 110 jumps in three minutes dropped to 5, and the other rider now shows up within 2.4 metres of their real position, down from 12.8.
110 → 5jumps in three minutes on a congested router
12.8 → 2.4 mhow far the other rider is drawn from where they really are
Visual work takes rounds
My first Col du Baguette went up quickly, and you could tell. Held against my own sketch it got a 4 out of 10, so it was rebuilt in six phases: the light first, then the mountains, the snow, the hamlet, the crowd and the top. At the end of each phase the session took screenshots from fixed camera points. I set those beside the sketch and decided whether it was ready to move on.
My favourite detail is the light. Lanterns and torches light up the road, the snow, the houses and the fans' faces, and all of it is baked into the colours of the 3D models. That means it costs nothing per frame and an old laptop still runs it, and it's a big part of why the climb went from a 4 to an 8.



Guardrails, because real riders are online
The most frightening point in the whole project was the first evening I saw riders on the road who weren't my friends. From then on a release could knock someone off mid-ride. I tightened the rules, and by now most of them live in scripts rather than in my head.
Deploys happen one session at a time. Before releasing, a session runs node scripts/deploy-lock.mjs take, and any other session that wants to deploy waits its turn. A lock that shows no sign of life for 20 minutes is freed, so one crashed session can't hold up the others. There's also a freeze switch. If I drop a file named DEPLOY_FREEZE into the repo, the deploy script refuses to run until I take it out.
It's dull stuff, and it's the reason six sessions can run side by side while I still get to sleep. The other house rules are plainer. The protocol stays backwards compatible, so a new server has to accept whichever game version is live, because riders don't reload mid-ride. Each deploy restarts the rooms and every rider drops for a few seconds. Their game reconnects with a resume message and the ride carries on. Database changes only add new tables or new columns, and a rename or a drop never ships in the same release. Each release also gets a version number with two notes: a technical line in the changelog and a plain one for riders on the updates page.

Still a lot of work
Tour du Pain cost me a very large number of hours in these weeks. Evenings and weekends, and rides where I stopped halfway to write down what felt wrong. Claude writes the code, but someone still has to decide what the game is, try every version, throw away what doesn't work and say no to a lot of good ideas. That someone is me, and it takes time.
What has changed is who can do it. A game like this would normally take a big team and the better part of twelve months. I built it on my own in a few weeks. That only works if you have a clear idea of what you want to make, and just as clear an idea of what you're not going to make. The vision still has to come from you. With Claude you can carry it on your own.
If you want to build something like this
You can do this without being a developer. What you can't skip is knowing exactly what you want and being strict about how it gets checked.
What helped me most started with picking something I'd use myself. I ride this game with friends, so I usually hit the problems first. The brief comes before the code, and it gets reviewed by a different session than the one that builds it. Anything I've had to correct twice goes into the one CLAUDE.md.
Splitting by ownership kept the context small and the changes clean, with one area per session and each in its own worktree. I pick the model per job as well: the strongest one for anything that builds, and cheaper ones (or a subagent, when it's a wide search) for the rest.
Proof matters most to me. The only "done" I accept looks like "live as 1.39.4, the version endpoint shows it and the console is clean". Put your rules in scripts, since a script still remembers at eleven at night what you've forgotten. And log what real users run into, because the bugs that matter most happen on hardware you don't own.
Things break. Plenty of my evenings go to testing a climb on the trainer for the fifth time, and I enjoy that part as well. I've made films for years, and this is the first time I get to direct something that people then ride, live and together.
To see what came out of it, try the game without a trainer. If you're building something with Claude yourself and get stuck, mail me at info@tourdupain.com. Talking about this stuff is fun for me.


