Thunderbird Labs
← Lemnos

Building Lemnos

The bugs, the dead ends, and the surprises from building the board and its games, and how each one got run down. Some of them were the hardware’s fault, some the software’s. All of them were mine.

October 2026 · Build notes

Where it started

MakerWorld’s Make for Good contest included a brief from PrintLab, PlayforAll, asking for a tabletop game people with cerebral palsy could play. Most of the entries were peg games: tic-tac-toe, but bigger. Which is fine, I guess, if you think that’s all people with CP can handle. The brief itself says not to assume that, and I don’t. Thirty days to design a game that anyone can play. What could possibly go wrong?

The first game was Salient, an island invasion game with supply lines, ships, a volcano, and everything. Then I built the board, put the game on it, and found out I couldn’t beat it on the easiest level. The game, after days of work, was a disaster.

I invented this game and can’t beat level 1

Salient started as a simple game with simple controls and simple rules that anyone could understand. Three modes (only two of which I was working on, the two player modes, Island Attacker and Island Defender), and ten difficulty levels. They were balanced perfectly through tens of thousands of simulations with the computer playing both sides...with perfect knowledge of the board layout, troop strengths, and hidden game state. After I couldn’t beat it even at the easiest difficulty, I reran the simulations with a more realistic player. One thousand simulated games played...zero player victories. Thirteen iterations of rules over days and I had turned my simple game that anyone could understand into John Nash’s D&D campaign.

The answer was right there the whole time...the undeveloped third mode, Island Rush. Rush was supposed to be the up-to-6-player version of Salient that I left behind on the road to ‘perfecting’ the Attacker and Defender modes. Still on the original, simple rules, it just worked. Possibly suffering from sleep deprivation, after passing on an ancient temple and a dozen other tired tropes, I decided that the goal of the game at the center tile of the jungle island was a taco truck, then I set up the simulations and turned them loose. Not bad, but it still needs...something. Tigers! Much better. Ship it.

The northern lights were too slow for the board

Aurora is one of the attract screens (or wall decorations, or night lights, you decide): curtains of northern lights that build, peak, and fade, drawn from each LED’s real position on the board. It looked great on the desktop. The RP2040 could not keep up with it.

The desktop is about a hundred times faster than the RP2040, so desktop timing tells you exactly nothing. Timing it on the board showed the moving parts of the sky took under 1 ms, and working out the colors of 155 LEDs took 60 ms. At the planned 25 updates a second, that’s half again more time than there is. For a baseline, the other attract screens came in at 46 ms (clock), 45 ms (forest fire) and 26 ms (fireflies), so the board’s real ceiling is about 20 frames a second, not the 40 in the config. That set the target.

What didn’t work, in order:

What worked was whole numbers and tables. On this chip integer math is cheap and small integers cost no memory, so the sky got rewritten so each LED is one subtraction, a range check, three table lookups, and three additions: positions in 64ths of a hex width, a 1,024-step sine table looked up once per column (29 of them) instead of once per LED, and each curtain’s fade precomputed by distance from its edge, already colored green to teal to violet. The result is about 37 ms on a quiet sky and 59 ms at full display, so 13 to 16 frames a second, which is plenty for something that moves that slowly.

A measurement mistake worth admitting. The integer version first timed at 23 ms, which looked like victory. But the timing scripts set the sky to full activity and then ran the game’s update, which promptly reset it to a quiet sky. So every number above until the final ones was a quiet sky. The comparisons between versions still hold, since they were all measured the same (wrong) way, but none of them showed a full display. Check that the thing you’re timing is the thing you think you’re timing.

Aurora also must never flash, so there’s a desktop test that runs whole displays and records the biggest change any LED makes between updates: nothing may go from dark to full in under about 0.2 s. That test caught three real jumps, including one that showed up after a rewrite, where an LED crossing a curtain’s edge went straight from green to pink because a short blend had gotten dropped.

Previews that showed a blinking dot

In the game picker, each game shows a little preview on the island. Aurora’s and the clock’s showed only a blinking white center hex, which is what the picker shows when a preview fails. Every preview drew fine on the desktop, and the picker was politely catching the error and hiding it, so the next step was to load each one on the board, with a big game running, and print the real error:

CARDS free with rush loaded: 36608
CARDS aurora FAILED: MemoryError('memory allocation failed, allocating 416 bytes')
CARDS clock ok
CARDS fire FAILED: MemoryError()
CARDS fireflies FAILED: MemoryError('memory allocation failed, allocating 376 bytes')

The picker loads a preview while the current game is still in memory (so you can go back to it). The big games were precompiled. The small ones were left as plain source so people could edit them without a toolchain, which meant loading one of their previews meant compiling the whole game on the board, and compiling takes a lot more memory than running. As the attract screens grew, “small” stopped being true. Which ones failed depended on how much memory happened to be free, so it changed from run to run, which is always fun.

The fix: everything gets compiled now, and the editable sources go in their own folder on the drive where the board doesn’t load them. While I was in there, it turned out Fireflies was keeping a list of every LED’s neighbors for all 155 LEDs: about 28 KB, on a board with about 70 KB free, to answer three questions an evening. It works that out only when it needs to now.

Then it came back. With Aurora or Rain running, every preview went back to the blinking dot. This time the picker logged why, right away. The worst case I’d tested hadn’t been the worst case: I’d checked the fix with Island Rush running, not with the two hungriest attract screens. A one-file game has no preview of its own, so the picker loaded the whole game just to draw its picture: 5.6 KB for Fire, 13 KB for the clock. Island Rush’s preview lives in its own small file and costs 1 KB. So now all the attract screens are folders like Island Rush, each with a small preview file, and every preview loads no matter what’s running.

Lessons: a fallback that hides errors also hides the cause, so log it. And on a microcontroller, how much memory you have depends on what’s already loaded, so test with the worst case loaded…the actual worst case.

The controller that did nothing

So the board works, the game works, and the web remote works. Awesome. Time to make the first wired controller.

Design it, print it, wire it, and...it (a rotary encoder and two buttons) does nothing, in any game. So, test in layers:

So the code was fine, and D9 wasn’t reaching the chip, even with a jumper on the header pin. Then I remembered: when I put the headers on, I soldered just the pins I was using at the time so I could swap the headers easily if (when) the design changed. I didn’t have a controller at the time, so the controller’s pins never got soldered. Centered perfectly in their holes, they weren’t touching anything, and a meter on the header pin still said everything was fine. Once soldered, everything worked first try...except the encoder and buttons that ended up wired backwards because I was cranky that I had just cut up all my wiring looking for the fault and forgot which colors I had wired to what, so I put them back together wrong.

Lesson: If something doesn’t work, blame Claude, then go find and fix the fault.

Shorter stories

The simulator did the heavy lifting

Salient’s rules went from v0.1 to v0.14 with thousands of computer-vs-computer games behind each change. Spread across all 16 cores of my PC, a 4,000-game run takes 10 to 18 seconds, and one sweep of 24 rule combinations still took half an hour (single-threaded, it would have run all night). Some ideas that sounded right fell flat on their face in the numbers: “build up the beachhead first” dropped the attacker from 21% wins to 3% or less, and none of nine other attacker strategies did reliably better than plain greed. The board’s engine replays 500 games identically to the browser’s: 255,876 moves, the same every time.

The toolkit that kept coming up

What’s next

Build one

Lemnos is free and open source: the board software and radio firmware are MIT, and the rules and docs are CC BY-SA. The CAD and print files are on MakerWorld, the parts list and build guide are here, and the code, the wiring, and the guide to making your own games are on GitHub. If you build one, break one, or write a game for it, I want to hear about it.

Build guide → Get the files on MakerWorld View on GitHub