Packing a 5×5 Lights Out board into six characters a person can read aloud, write on paper, and type back in — on a handheld with no network, no keyboard and no link cable.
Extracted from Closing Time, a Game Boy Advance puzzle game.
grid 0x000ABCDE -> "VBSEFD" -> grid 0x000ABCDE
The game has a stage editor. Building a board is only interesting if you can give it to someone else, and the target hardware is a GBA — so the transport is a human. Someone reads six characters across a room, or writes them in the margin of a notebook, and types them in a week later.
That rules out anything long, anything case-sensitive, and anything containing symbols that look like each other on a 240×160 screen.
| Payload | 25 bits — one per cell of a 5×5 grid |
| Length | 6 characters. Seven is noticeably worse to transcribe |
| Alphabet | uppercase + digits only, no lookalike pairs |
| Validation | must reject typos rather than load the wrong board |
| Budget | no allocation, no tables, runs on a 16.78 MHz ARM7 |
Six characters over a 32-symbol alphabet is 30 bits. The grid needs 25. That leaves exactly 5 bits for a checksum, which sets the ceiling on how good the error detection can possibly be: a 5-bit check can never reject more than 31 of every 32 bad inputs.
6 symbols x 5 bits = 30 bits
┌────────────────────────┬──────────┐
│ 25-bit grid payload │ checksum │
└────────────────────────┴──────────┘
packed >> 5 packed & 31
Encoding is three steps:
- XOR the grid with a constant (
0x13A7C5B). Without this, boards that differ by one cell produce codes that differ in one place, and a nearly-empty board produces a code that is nearly allA. The XOR scatters them. - Append 5 check bits mixed from the whole payload.
- Slice into six 5-bit symbols and index the alphabet.
Decoding reverses it, then rejects the empty grid — a board with no lights on is already solved, so it is never a valid stage.
ABCDEFGHJKLMNPQRSTUVWXYZ23456789
32 symbols with I, O, 0 and 1 removed. Those are the two pairs people actually confuse when copying a code by hand, and dropping them costs nothing — the remaining 32 is exactly the power of two needed for 5 bits per symbol.
This is the cheapest reliability win in the whole design. It eliminates a class of transcription error before the checksum ever has to catch it.
unsigned checksum(unsigned payload)
{
unsigned mixed = payload ^ (payload >> 7) ^ (payload >> 13) ^ 0x1Du;
mixed *= 0x15u;
mixed ^= mixed >> 5;
return mixed & 31u;
}Two right-shifted XORs fold high bits down into the low five, so a change anywhere in the 25-bit payload reaches the check bits. The multiply and final shift-XOR diffuse them further. Four operations, no lookup table.
Every figure below comes from verify/verify.py, which
mirrors the C++ exactly — including the 32-bit unsigned wraparound on the
multiply, which is what the hardware does. Seeded, so it reproduces.
ROUND TRIP 1,000,000 random grids 0 failures
SINGLE-CHARACTER TYPO 930,000 mutations
rejected 903,161 97.11%
silently wrong 26,839 2.89%
ADJACENT TRANSPOSITION 96,916 swaps
rejected 93,404 96.38%
RANDOM 6-CHAR STRINGS 50,000 tested
accepted 1,516 3.03%
theoretical floor 3.125%
CHECKSUM DISTRIBUTION 200,000 payloads
values seen 32/32
min 6,058 max 6,437 ideal 6,250
Two things worth drawing out.
Random strings are accepted 3.03% of the time, against a theoretical floor of 3.125%. With 5 check bits you cannot do better than 1-in-32. This checksum is at the limit — the remaining gap is sampling noise, not headroom.
Single-character typos are caught 97.11% of the time. The 2.89% that slip through load a different valid board rather than an error, which is the failure mode to be honest about: the player sees a puzzle, just not the one they were given. Eliminating that entirely would need more check bits, which would mean a seventh character.
That trade — one extra character for the last 3% — is the interesting design decision in the whole system, and it went the other way deliberately. Six characters is meaningfully easier to say out loud than seven.
Header-only, freestanding, C++17. No allocation, no dependencies.
#include "closing_time_codec.hpp"
char code[7] = {};
closing_time::encode(0x000ABCDE, code); // -> "VBSEFD"
auto result = closing_time::decode(code);
if(result.valid)
{
unsigned grid = result.grid; // 25-bit board
}encode writes exactly 6 characters and adds no terminator. decode returns
valid = false for unknown symbols, checksum failures, and the empty grid.
Both are constexpr, so codes can be baked at compile time.
python3 verify/verify.py
The game applies two further rules on top of the codec, which live in the game rather than here because they are about Lights Out specifically:
- the board must be solvable (checked with a brute-force chase solver)
- it must need at least 2 moves, so a one-press board is not shareable
The codec is deliberately unaware of both. It transports 25 bits; whether those bits are an interesting puzzle is not its problem.
MIT — see LICENSE.
The codec algorithm and constants are from Closing Time by Sarah C.