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:
- Leaner Python. A color lookup table, flat lists, local variables, fewer lookups per LED. 60 ms down to about 41. Still way too slow.
- ulab, CircuitPython’s built-in numpy. Doing the math on whole arrays in compiled code sounded like the answer. It was slower (71 ms, and 53 after trimming). The RP2040 is a Cortex-M0+, which has no floating-point hardware, so every float operation runs in software, array or not, and every array operation made a new array. In real time the quiet sky ran at about 7 frames a second, with the memory clean-ups freezing the board for up to 420 ms at a time.
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:
- A stand-alone test that read the controller’s pins directly, skipping the game host, the lobby, and everything else, and printed every pin change. All the pins opened fine. Nothing ever changed.
- A raw scan of all 30 of the board’s pins at once, while the button was pressed. Nothing.
- A jumper wire from D9 straight to ground. Still nothing.
- The same scan against a switch known to work, the SCORPIO’s own BOOT button. It caught every press.
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
- Red never wins. Watching the computer play itself at Salient on the board, the attacker seemed to never win. The simulator said 49%. Both engines (the browser prototype and the board’s port) replay the same game from the same seed, so I logged the board’s games and replayed them on the desktop: every one matched move for move. Nothing was broken. But about 6.5% of games ended with the attacker wiped out by round 3, and those take a few minutes, while attacker wins take 20 or more. An hour of watching could easily show several quick losses and no wins. The fix was a rule: an attacker run off the island with ships still coming gets a free naval bombardment to land again. Quick losses went to zero, and the balance barely moved.
- The island ran backwards and upside down. After wiring up the first prototype, the game was upside down and backwards. I created testpattern.py and it ran...wait for it...upside down and backwards. I had wired it mirrored in both directions because the original design wired the board from underneath, face down and flipped (the current version solves that problem, too). While I was fixing, I added a setting that now names the corner hex where the data goes in, so however you wire your version is fine, any of the four corners works.
- LEDs jumping around mid-strip. The original default config was for “large” (60/m strip) and the first prototype board was the small one (144/m, with an extra LED at each hex edge), so every address after the first was off.
- Too bright to look at. The first time I fired up the original prototype it was like staring into a fusion reactor. I had to turn it face down on the bench to send it the commands to dim it. Still painful at 50%...no better at 25%, and finally comfortable at 5%. Convert the brightness control to log scaling and set default to 10%. A new problem: at the 10% default, each color has only about 25 levels left (10% of 255), so dimmed lights, like the cursor, can just vanish. The fix: double the brightness of the cursor under a threshold.
- Out of memory while the USB page watched. The USB page is a web page on the board’s own drive that shows its lights live, no install. Starting a game from it ran the board out of memory. Measuring part by part: the LED driver alone took 26 KB, and 10.5 KB of that was an assembler it needs exactly once, at startup. Unloading it, moving the dimming into my own code so the driver doesn’t keep a second copy of every pixel, and sending the page its frames in one piece instead of five took free memory during play from about 15 KB to 26.
- The crash when switching games was three bugs in a trench coat: memory too broken up after a few switches to load a big game again (the board now restarts cleanly with the new game when it won’t fit), a crash caused by the extra logging I’d added to find the crash, and the radio pushing its saved game back onto the board after a reboot, so it looked like the switch had failed. Now the board’s settings win and the radio follows.
- Tigers that picked on red and blue. In Island Rush, the tigers chased the first two seats most, because ties between equally close players went by turn order. Now a tie goes to whoever a tiger chased longest ago. Also closed: a loophole where you could ring the truck with your own spots and lock everyone out, and a bug where computer players would get bored and start a deforestation campaign.
- Toolchain gotchas on the radio firmware. ESP-IDF’s installer silently refuses to run under Git Bash, GCC chokes on network paths, and Git won’t fetch a submodule onto a network share. So the builds run from PowerShell, out of a local copy.
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
- Replay by seed. Two engines that play identical games from the same seed turn “the board is acting weird” into a yes-or-no question.
- A REPL over USB. Paste a small script into the board to time code, scan pins, or load something with the real error showing. Most of these bugs were found that way.
- Desktop renders. Draw the board’s LEDs to an image on the PC to see how something looks before it goes on the board.
- Test in layers. A stand-alone test, a raw pin scan, then a switch you know works. Each one rules out a layer.
- Measure, then change. Every speed-up was decided by timing the pieces on the board, and twice the timing proved the obvious fix wrong.
- Simulate before you rule. Rule changes get tried over thousands of computer games before they ever reach the board.
What’s next
- The large board, the 60/m version of the island, with its own CAD.
- Salient, back on the bench until it passes the same test Island Rush did: everything you need to know is on the board.
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