Side project · embedded game dev · 2026
A tactics game that lives inside an e-reader.
A turn-based strategy game with fog of war, flanking, zones of control, and two story campaigns, built into the firmware of an e-ink reader. The host machine is not a gaming device: an ESP32-C3 with a single core at 160 MHz, about 380 KB of usable RAM that an EPUB engine already considers its own, and a screen that needs over a second for a clean refresh. The game copes by staying small and deterministic, and by keeping its logic testable on a normal desktop.
The Gates, the first battle of the dwarf campaign, played start to victory in the host simulator. The simulator draws the board with the same art code the firmware compiles, so these frames match the device.
Why this problem
E-ink is a bad fit for most game genres: refreshes are slow, there is no color, and anything that moves leaves ghosts behind. Turn-based tactics gets along with all of that. Nothing moves until you decide it should, a grid board is high contrast by nature, and a battle can sit on the screen for a week without costing any battery.
The tighter constraint is memory. CrossPoint exists to render EPUBs in 380 KB of RAM, and a game has no business endangering that. The game gets whatever is left over, and the reading side should never notice it has a neighbor.
The engine fits in 5.6 KB
The rules engine is a plain C++ library with no Arduino headers, no HAL, and no logging. The same source compiles into the firmware, into a GoogleTest suite, and into a desktop simulator. Most of the development happened against the second and third of those, with the actual device checked now and then.
A battle lives in one Game object made of fixed-size arrays, with no pointers inside and no heap allocation once it exists. The activity allocates exactly one, 5,640 bytes, and frees it on exit. A 48×32 map is a byte per tile, and the visibility, explored, and danger layers are bitfields of 192 bytes each. Keeping the object trivially copyable started as a size decision and quietly turned into the save format, which comes up again below.
- TacticsTypes
- TacticsLevel
- TacticsGame
- TacticsAi
- TacticsSprites
- TacticsActivity
- gtest suite
- TacticsSim
Deterministic rules that still bite
There are no dice. Damage is attack × (100 − defense)%, minimum 1, with defense stacked from unit, terrain, and race and capped at 60%. On a screen this slow you cannot hide randomness behind an animation, so every attack shows its exact outcome before you commit. The tension has to come from position instead.
Defenders counterattack if they survive and can reach you. A melee hit with an ally directly behind the target does half again more damage, and counterattacks get the same bonus, so a careless pincer cuts both ways. Stepping next to an enemy ends your movement, and walking out of contact takes a free hit on the way. Fog hides enemies outside sight range; terrain you have explored stays on the map, but the monsters in it do not.


The AI hunts your backline
Monsters start dormant, which is what makes the hidden-base maps explorable instead of a rush on turn one. One wakes when a player unit enters its aggro radius, when it gets attacked, or when a fight breaks out close enough for the pack to notice.
An awake unit scores every reachable tile against every attackable target and takes the best pair. Kills score high, pincer positions get the flank bonus priced in, and a lethal counterattack mostly rules a move out. Targets are weighted by threat, so a thunderer or a mage left uncovered is exactly what it will go for. When nothing is in reach, it walks down a cached distance field toward the nearest player unit. I assumed this would need something smarter eventually; so far, greedy with flank awareness has been unpleasant enough to play against.


constexpr in flash.Drawing a battlefield on e-ink
The panel is 800×480 at one bit per pixel, single-buffered to save RAM. Everything on the board is built from that one bit: terrain as line icons, unexplored fog as a dark dither, explored-but-unseen tiles as a lighter one, danger zones as hatching under the unit plates, and units as 16×16 sprites drawn at double size, with enemies inverted.
Moves and cursor steps use the panel's fast refresh. Transitions that pile up ghosting, like entering a battle or ending an enemy phase, force a full flash, and a pause-menu entry wipes the screen by hand for everything in between. Enemy turns play out one action per repaint, because an instant AI turn on e-ink just looks like the board rearranged itself while you blinked. The danger overlay gets recomputed on every draw instead of cached, which sounds wasteful and costs about a millisecond. Next to a refresh that takes hundreds of times longer, it is nothing, and it means a hidden enemy's threat can never linger on screen after vision shifts.
Every battle survives a power-off
An e-reader spends most of its life switched off, so the game assumes it will be powered down mid-battle and reopened days later. Because the state object is trivially copyable, serialisation is a raw byte copy of the Game, wrapped in a magic number, a format version, and a CRC of the level file.
Battles checkpoint after every completed enemy phase and on the way out of the activity. There is no save button. On re-entry the CRC gets checked against the level file, so if you edited the map since, the stale save is dropped instead of resuming a battle on terrain that no longer exists. The level list marks battles you are in the middle of and battles you have finished.

Campaigns are just folders full of text files
Levels are plain text files dropped into /tacticson the SD card, written in any editor or uploaded over the reader's web file transfer. The parser handles exactly the YAML subset the format needs and rejects everything else at load time with an error message rather than a surprise later. Story interludes are .story text files that sort alongside the battles, and filename order is the campaign: win a battle and Continue opens the next file, whether that is prose or a fight. Each subfolder of /tacticsis its own campaign.
Two ship with the firmware. The Deep Road follows three dwarves against a goblin flood, and The Broken Crown is a human civil war, each five battles with story pages between them. Making a third takes a text editor and nothing else.
name: Deep Road - The Gates
win: defeat_all
aggro: 30
map: |
^^..........
^...........
.....f......
............
......f.....
............
^^.....f....
^^^.........
units:
- {type: dwarf_warrior, at: [1, 2], side: player}
- {type: dwarf_thunderer, at: [0, 1], side: player}
- {type: dwarf_guard, at: [1, 5], side: player}
- {type: goblin, at: [9, 2], side: enemy}
- {type: goblin, at: [10, 5], side: enemy}
- {type: goblin_slinger, at: [10, 3], side: enemy}A complete, shippable level: the first battle of The Deep Road. Terrain is ASCII art, with ^ for mountains, f for forest, and. for open ground.
A bot playtests every level in CI
I did not trust myself to re-balance five battles by hand after every rules change, so the test suite plays them. A greedy bot that knows about flanking, focus fire, and parting blows runs every shipped battle on every push, and each battle asserts a band of surviving units rather than a bare win: the floor catches unwinnable, the ceiling catches a walkover. Because the bot loads the real files that ship on the SD card, it also catches plain mistakes, like a base nobody can reach or a unit standing inside a mountain.
The bot has earned its keep. The knight originally moved seven tiles and flattened everything in testing, and the goblin slinger exists because the bot showed a melee-only monster roster could be kited to death from range without taking a single hit back. The same engine also links into a desktop simulator that takes commands on stdin, draws the board with the same art code the firmware compiles, and dumps a bitmap after every action. Every frame on this page came out of it.
loaded 'Deep Road - The Gates' 12x8 win=0 units=6
...
--- turn 3, your move ---
012345678901
0 ^^..g...????
1 ^...1....???
2 ....0f...???
...
units:
[0] dwarf_warrior hp 20/36 at (4,2)
[1] dwarf_thunderer hp 28/28 at (4,1)
[2] dwarf_guard hp 34/42 at (4,5)
(5) goblin_slinger hp 16/16 at (4,0) ENEMY
[1] hits (5) for 12; counter 5
moved [0] to (3,0)
can attack (5) goblin_slinger: 10 dmg, counter 0
[0] hits (5) for 10 KILL; counter 0
=== VICTORY! (turn 3) ===Simulator transcript of The Gates, trimmed. The indented line is the combat forecast: exact damage and counter, known before committing, because nothing is random.
The whole game touches six upstream files
The branch adds roughly 7,800 lines, but only six files that existed before it: two to register the activity, two for the home-menu entry, the English string table, and the host-test build list. Everything else, from the engine to the campaigns to the simulator, is new files.
CrossPoint is someone else's active project, and this game is a guest in it. The less it touches, the easier it rides along when upstream moves. In practice, rebasing the branch onto a newer upstream has so far been a non-event, which is the property I care about most.
Under the hood
Tech stack
- C++20 on ESP32-C3 (single-core RISC-V, ~380 KB usable RAM), no exceptions, no RTTI
- Hardware-free rules library compiled three ways: firmware, GoogleTest, desktop simulator
- Host tests wired into GitHub Actions via CMake and ctest
- Plain-text level and story formats with a purpose-built minimal parser
- 16×16 1-bit sprites and all stat tables stored
constexprin flash
Design decisions
- Deterministic combat with a full forecast, since dice hide badly behind a slow screen
- One trivially copyable state object, so save/resume is a byte copy
- No heap allocation inside the engine; the activity allocates one Game and nothing else
- Enemy turns paced one action per e-ink repaint so the player can follow them
- Balance enforced by survivor bands in CI instead of by hand-testing
What I'd do next
- A deployment phase, picking your squad from a roster instead of playing fixed units
- Units that persist across a campaign, so losses in battle two matter in battle five
- Multi-turn AI coordination; the greedy scorer never sets up next turn's pincer
- Offering it upstream, split into engine and UI halves for reviewable pieces
Built on CrossPoint Reader, community firmware for the Xteink X4 e-reader. The game lives on thetactics branch as a self-contained series of commits on top of upstream develop.