How a race game fits in 508KB
Three animations were planned. There was room for two.
- Approximately 508KB app heap on the FR255 tier
- 49 supported Garmin devices
- 240 race events
- 36 training scenarios
We wanted three animations. There was room for two.
That is most of what we learned building this, and it took about eight months to learn it properly. The rest of this is how we got there.
Two watches out of forty-seven
The game runs on forty-seven Garmin watches. Forty-five of them have 786,432 bytes of application heap, which is plenty. The Forerunner 255 and the 255s have 524,288, which is not.
There is no watch in between. We looked, more than once, hoping for some middle tier to design toward. It does not exist. So the choice was to build for the 255 or to drop it.
We should be honest about how that decision got made. The 255 is the watch that runners who are serious but not rich actually own, it is still sold, and it will be on wrists for years. All true, and some of it is reasoning we did afterwards. The 255 was where Preracer started. We were never going to drop it.
That turned out to be an expensive bit of sentiment, and not in the way we expected. Keeping the 255 meant keeping MIP, and once you support one MIP panel you may as well support all fourteen of them, which is how a game that could have had one rendering path ended up with two. More on that further down.
There are two numbers to keep straight here. The SDK profile ceiling is 524,288 bytes, or 512 KiB. The simulator exposes 519,880 bytes of usable application heap, just under 508 KiB. The title uses the public shorthand; the measurements below use the bytes the simulator actually reported.
So: roughly 508 KiB in practice, and a decision made in about ten minutes that then governed the next eight months.
(If you are wondering why a watch at all, the short answer is that a long race means hours in which you cannot look at your phone, and the watch is already on your arm telling you things you would rather not know. The long answer is a different piece.)
The title screen used to be a picture
The first thing you see is a runner. Coloured bars, a horizon, a small figure moving across it. It is on the front page of this site too, so you have seen it already if you scrolled.
It began as a 416-by-416 bitmap, because that is the obvious way to put a picture on a watch. You draw it in a tool you like, ship the file, and let the device render it. At that size it was also an expensive way to spend memory we did not have, so the full-screen image went away.
The current title is not procedural. It is a narrow 454-by-160 Monkey Motion resource: twelve encoded frames shared across screen sizes and clipped by the round display. Static watch text sits above and below it. MIP and AMOLED get separate palette encodings, 35,409 and 42,408 bytes respectively, and the layer exists only while the title is visible.
That distinction matters. The lesson was not that pictures are forbidden. It was that the dimensions, palette, lifetime and number of unique frames all have to justify themselves.
What has to fit
Two courses: a city marathon in Amsterdam and DXT35 from the Dolomiti Extreme Trail. 240 race events. 36 training scenarios. Each event is a situation, a set of choices, and an outcome per choice, all of it written prose, because this is a text game and the writing is the actual product. We did not want to solve the limit by deleting the writing. You can make a novel smaller by deleting the adjectives; you may no longer have the same novel.
None of it can be in memory at once, obviously.
Event metadata loads scoped to the course and the segment you are in, and gets released once the race has been built. Only the scenario in front of you stays fully resident. Run forward and the previous kilometres stop existing, in the literal sense that their objects are gone.
That sounds harsher than it feels, because a race only ever goes one direction and a player only ever needs the next thing. The limit and the shape of the content agreed with each other by accident. It does not always work out like that and we would not count on it a second time.
Where the peak actually was
We set the gate before doing any of the memory work, which we recommend. The rule was that less than 40,960 bytes free on an FR255 blocks release. No exceptions, no good reasons, no discussion at the end when everyone is tired and the date is close.
Then, measurement. Twelve routes on real device profiles at the end of August: both courses, a clean finish and a late did-not-finish, on FR255, FR255s, and an FR570 42mm as the 768KB reference.
Worst case came out at 136,904 bytes free, on an FR255, against a Store gate of 40,960. So 95,944 bytes of margin, a number we had been guessing at for months and were relieved to finally have in front of us.
Measured headroom above the release gate: 95,944 bytes.
What we did not expect is where the twelve peaks landed. Not in the race. All of them landed during Training Impact, the screen that shows what your preparation did to your starting reserves, before the race has begun at all. Our working hypothesis is that this state combines the active training scenario, resource state, animation state and layout caches at their least convenient moment. The measurement proves the phase, not that explanation. The later race phases never approached the same peak.
We had spent weeks worrying very carefully about the wrong part of the program. The only reason it turned up is that the measurement covered routes we had no suspicion about. If we had measured only what we thought was expensive, we would have certified the wrong part of the program.
Where all this actually happens
Our physical test set contains two of the forty-seven watches: a Forerunner 255 and a 570. Everything we know about the other forty-five comes from Garmin's SDK, and it is worth saying how that works, because the numbers above are only as good as the setup that produced them.
The current manifest contains forty-seven device profiles. Those profiles are where the ceilings in this piece come from: 524,288 bytes for the non-Music 255 and 255s, 786,432 for the other forty-five. We did not measure those ceilings. Garmin published them, and the whole project is downstream of one table in an SDK install.
The simulator runs one device at a time, which is inconvenient when the entire question is how two panel families differ. So each device gets its own Docker container with the simulator inside it, reachable over VNC. That means two watches in two browser windows, same state, same frame, side by side on one screen. The MIP-versus-AMOLED arc decision further down was made exactly like that: FR255 in one window, FR570 in the other, the same resource at the same value, switching between them until one of the two stopped looking wrong.
Measurements run in fixtures rather than in the game. A fixture is a small build, with its own manifest so it can never end up in a release, that proves one thing: a geometry, a state, a runtime path. The memory probe costs about 3,640 bytes of heap on the FR255 title state, which means every free-memory number in this piece is slightly pessimistic. We prefer it that way round.
The release gate compiles all forty-seven products. Not only the few we were working on. The full matrix had thirty-eight products when the memory and finish work in this piece was measured; nine were added afterwards. The distinction is worth making because historical measurements do not grow when a manifest does.
Here is what none of that gives you.
The simulator does not reproduce device framerate or sunlight. It cannot tell you whether a thin arc on a MIP panel disappears when you are eleven kilometres in and your wrist is at the wrong angle. So the project runs two separate approvals that never get merged: technically proven through compiles, fixtures and the simulator, and separately checked on physical hardware where a decision needs it. Specific title and interaction paths have been checked on a physical 255 or 570; that is not a blanket approval of every screen or every display condition. Most supported models have profile and compile evidence only, and we would rather say that plainly than pretend otherwise.
One loose end remains, in the same spirit. There is a 280-pixel profile we still cannot capture at runtime, because a Garmin font file that tier needs is not installed in the test environment and we have not managed to get hold of it. The build compiles. We have never watched it run. That bothers us more than the margin ever did.
The finish, and the half point
Cross the line and you get an animation. Runner past the arch, arms up, confetti, then the results.
On most watches that is a one-shot: it plays through, resolves, ends. Building both finish resources for the FR255s produced a 524,220-byte PRG, against 455,644 for the loop-only build. On the FR255 the loop-only PRG measures 486,252. Those are compiled program sizes, not appheap readings, and the SDK heap ceiling is not a direct PRG-file limit. They were still enough evidence not to make the tight devices carry both resources.
So the 255 family ships the loop instead. It has twelve unique frames, repeated six times for roughly 7.2 seconds of cheering, then results. Same final beat, less unique animation.
Back in July we had written down a rating for both: 8.5 out of 10 for the one-shot, 8 for the loop. That turned out to be the most useful thing we wrote that month. Not because the numbers mean anything absolute, but because it turned "we like this one more" into "half a point, on two devices." You can decide to pay half a point. A vague preference you can only lose arguments with.
Creating the animation layer costs around 1,104 bytes of appheap in the measured finish state. The much larger cost is the encoded asset in the PRG and its decoded frames in a separate graphics pool. Free appheap alone cannot prove that either of those fits.
01 · What is shipped
PRG / package
Compiled code and bundled resources. The FR255s build with both finish clips measured 524,220 B; loop-only measured 455,644 B.
02 · What is live
Application heap
Monkey C objects and active game state. The tight FR255 route exposed 519,880 B total and peaked at 382,976 B used.
03 · What is decoded for display
Graphics pool
Animation and bitmap graphics live in a separate device-managed pool. Free app heap does not prove that a large animation can be allocated.
The one that is not there
There is no DNF animation.
You can fail this game. Sugar, water, legs or morale hits zero and the race ends where you are standing. Miss the cutoff, same thing. DNF is an expected outcome, and that is deliberate: the choices only have weight because they can cost you the finish.
It is also the moment with nothing moving on it.
By the time the title screen and finish reward were both in, we did not want to spend a third animation budget on a separate failure clip. It could have been built for roomier watches and skipped on the tight ones, and we did think about that for a while. But then the same outcome would have had a visibly different reward by device family, and that felt worse than it being plain everywhere.
So the DNF screen is a still frame and some text. It is fine. It is not what we wanted. This is the one decision in the project we would still argue among ourselves about.
The same design, twice
Fourteen of the forty-seven watches have MIP panels. The other thirty-three are AMOLED, and the difference is not decorative. This is the bill for the sentimental decision at the top.
Four resources sit as arcs around the bezel with a marker at the start of each. On the bigger screens that marker is a small icon. At 218, 240 and 260 pixels it is a letter instead, because a 20-pixel source icon scaled down to 12 becomes a smudge and the letter S does not.
The icons that do get drawn scale through the same cached layout factor as the node they sit in: 12, 15, 17, 18 and 20 pixels depending on tier. One of them was wrong and we could not say why. The legs icon just sat badly. So rather than nudge it around by eye until it looked acceptable, we went and got the actual numbers: the alpha centroid of that icon sits at 9.15, 8.65 in a 20×20 canvas, which is up and to the left of its geometric centre. It is nudged one pixel right and one pixel down and now it looks centred.
Nobody will ever notice this. We notice it.
Arc ends were worse. MIP panels have a restricted palette and round their pixels differently, so the same curve terminates differently on the two families. MIP drops the terminal cap entirely and extends the arc by its own cap radius. AMOLED keeps the round cap and moves only its centre inward by that same amount. Two fixes that sound like opposites, one visual intent, arrived at by putting both on real panels next to each other and staring until one of them stopped being wrong.
Now a confession, because this is the part we would want to read.
For a while the palette was chosen from screen size. 260 pixels or under meant low colour. That is a proxy, and proxies hold right up until they do not: four 280-pixel MIP watches were quietly getting AMOLED treatment. Wrong warning colours, wrong critical fills, on real hardware, for weeks, and nobody told us because nobody had one.
The thing is, that line was not wrong as code. It compiled, it ran, it did exactly what it said. It was wrong as an assumption about the world, and no amount of staring at the source would have caught it, because the source was fine. What caught it was eventually asking what the SDK actually publishes, which turned out to be the capability itself, sitting there the whole time. At the time, that meant thirteen low-colour products and twenty-five of the other kind. The current forty-seven-device manifest contains fourteen and thirty-three, and the capability split is verified at the release gate.
The part that is not on the watch
All of the above is about 508 kilobytes. There is a second problem that has nothing to do with the watch, and solving it is the reason the first one was survivable.
The game is text, so the tone is the product. 240 events have to be funny in the same way and cruel in the same way, written across a great many evenings, without drifting. That is an attention problem, not a memory problem, and it got solved by putting all the writing in a dashboard where it can be read in bulk next to what the engine does with it. Prose scattered through source files goes stale in corners you stop opening.
Balance got the same treatment, harder. Every course gate runs eight thousand simulated races. If the engine changes underneath the content, the snapshots are marked stale by hash and the gate fails, loudly, before anything ships. So a race plays the way it played last month. If it stops doing that, something says so before a player finds out.
Neither of these ships to the watch. Both are why the thing that ships is consistent, and eight months in we think they matter more than any of the memory work above. Memory problems announce themselves. Content drift does not. It just gets slightly worse every week until the race is no longer the race you calibrated.
Anyway
The game is free and runs on the watch with no phone and no signal.
Three animations, two built. We still think about the third one.