The LED Highlight Subsystem¶
Under each of the 64 squares sits a ring of 8 SK6805 addressable RGB LEDs — 512 in one continuous data chain. The board uses them to talk to the player: the legal destinations of a lifted piece, the engine’s suggested move, the last move played, the checked king, and error states.
The subsystem is two components with a deliberately plain seam between them. LedController (in kirin_hardware) owns the framebuffer, the physical mapping and the power invariant, and knows nothing about chess. LedAssist (in the app layer) translates game concepts into coloured squares and knows nothing about wires. Everything hard about timing lives below the driver interface; everything hard about chess lives above it.
Driving the chain¶
D-29 — LEDs are driven directly by the Pi over SPI MOSI with DMA, on a second SPI bus
Context SK6805 (WS2812-family) LEDs need a tightly-timed single-wire waveform. Getting that waveform out of a Linux userspace process, on a Pi 5, was the subsystem’s single biggest software risk.
Decision Generate the waveform with the Pi’s SPI peripheral in DMA mode, on a second SPI bus (SPI1 MOSI, BCM GPIO20, header pin 38), through a 74AHCT125 level shifter (3.3 V to a clean 5 V) with a series damping resistor into the chain’s DIN. Each LED bit becomes three output bits at roughly 2.4 MHz — 1$\rightarrow$110, 0$\rightarrow$100.
Rejected Classic rpi_ws281x PWM on GPIO18, which was the original plan, does not support the Pi 5’s RP1 I/O controller — the mainstream Pi 5 stacks moved to SPI for precisely this reason. Sharing SPI0 with the board’s MCP3208 hall ADC is impossible in principle rather than merely inadvisable: a WS2812-class chain has no chip-select line, so every byte that appears on MOSI is interpreted as pixel data, and ADC traffic would be rendered as colours. An RP2040 co-processor owning the waveform is retained as a documented fallback, but it requires a board respin, so it is a real cost rather than a free swap.
Consequences One PCB change was accepted: rerouting LED_DOUT from GPIO18 to the second bus’s MOSI pin. Only MOSI reaches the shifter — SCLK and CS are not wired to the chain. Nothing above the ILedDriver interface changed, which is what allowed the policy layer to keep being developed against a simulated driver throughout. The two buses have independent DMA, so the standing rule that sensors are scanned with the LED frame quiescent is a firmware-ordering concern rather than bus contention.
D-30 — LEDs driven from GPIO18 via the PWM peripheral
Context The original transport plan, following the widely-documented rpi_ws281x approach used on earlier Pi generations. Decision Drive the chain from GPIO18 using PWM with DMA. Rejected — (this is the entry that was itself rejected; see D-29). Consequences Recorded because it explains an artefact that outlived it: the reference PCB was laid out with LED_DOUT on GPIO18/header pin 12, and the reroute to MOSI is a consequence of this decision being wrong, not of the board being wrong.
At 512 LEDs a full frame is about 4.6 KB and takes roughly 15 ms to shift out, followed by a reset latch of at least 80 μs. Highlights are static and refresh only on change — the subsystem does not animate — so throughput is a non-issue.
The snake mapping¶
The chain snakes across the board rather than rastering, so that no long return traces are needed. Ranks alternate direction: rank 8 runs a8$\rightarrow$h8, rank 7 runs h7$\rightarrow$a7, and so on.
positionInRow = (row is even) ? col : (7 - col); // boustrophedon
squareSlot = row * 8 + positionInRow; // 0..63
ledBase = squareSlot * 8; // first of 8 ring LEDs
This function is the LED analogue of BoardScanner::toSquareIndex, and like it, it is the one place the physical wiring order is encoded. A unit test pins every anchor square.
D-34 — One continuous snake chain, and the chain order is the firmware pixel index
Context 512 LEDs in one data chain have to be wired across a 64-square board, and something has to define which pixel index lights which square.
Decision Wire one continuous boustrophedon chain — ranks alternate direction — and define the pixel index as the chain order, computed by the one mapping function above.
Rejected Straight raster wiring, which needs a long return trace at the end of every rank. Segmenting the chain into several shorter runs was not needed at this scale and would multiply the driver’s work.
Consequences The mapping is a two-line function rather than a table, and it is the single point where a wiring change would be reflected. Because there is exactly one such point, a rewired board is a one-function change — and because a wrong mapping produces a plausible-looking but mirrored board, the anchor-square test is not optional.
The power invariant¶
D-31 — A lit square always lights all 8 of its ring LEDs, and no more than 16 squares may be lit at once
Context 128 LEDs at full brightness draw roughly 2.3–2.4 A at 5 V.
Decision Squares are all-or-nothing, and the budget is a count of lit squares ($\leq$<!-- -->{=html}16) shared across every highlight category. LedController rejects a setSquare on a seventeenth distinct square and re-verifies before emitting a frame.
Rejected Partial-ring rendering to stretch the budget — it complicates the invariant into a per-LED count for no user-visible benefit. Treating 16 as a starting point to relax later was explicitly rejected: the ceiling is a trace-size limit on the board, not a supply limit, so no larger power supply makes it safe.
Consequences Because a lit square is always exactly 8 LEDs, the hardware limit reduces to a simple integer count independent of colour or brightness — which is what makes it enforceable as a last-line assertion in the driver rather than a policy the callers must respect. Brightness scales current further but does not change the count.
The queen problem¶
An open-board queen has up to 27 legal destinations. The budget cannot show them.
D-32 — When a lifted piece has more legal targets than budget, show its single best destination rather than a truncated set
Context Highlighting must degrade somehow when the 16-square budget cannot cover a piece’s legal moves.
Decision Fall back to a single square in the recommended colour — one clearly different “here is a strong move” marker.
Rejected Truncating the legal-move set to the first 16. A partial cloud of dots reads unambiguously as “these are all my moves,” which is false. The failure is not that the player sees less information; it is that they are actively misinformed, and they have no way to tell the two cases apart.
Consequences Almost every piece in a real game has 16 or fewer legal destinations, so the complete-set mode is the norm and the fallback is rare. Both modes are unit-tested, with an open-board queen FEN exercising the fallback.
Reacting to a lift¶
D-33 — Legal-move highlighting triggers on a physical piece lift, not on a completed move
Context The board can detect a move only once it has been made, but the question “where can this piece go?” is one the player asks while the piece is in their hand.
Decision Trigger on the lift: a source square clearing with no matching set square is a piece in the air, and that is when the destinations light up.
Rejected Polling for a completed move. Highlighting legal destinations after the move has been played answers a question nobody is still asking.
Consequences This is the only change the LED subsystem required in existing hardware code — a nullable observer on the scanner’s wait loop — and it is a no-op when unused. It is also what makes the budget fallback of D-32 matter: the answer has to arrive while the player is deciding, so it cannot be deferred until it is cheap to compute.
Legal-move highlighting has to appear while the player is deciding — the instant a piece is lifted, not once a move completes. BoardScanner therefore gained a nullable ScannerObserver in its wait loop:
struct ScannerObserver {
void (*onPieceLifted)(int square) = nullptr; // occupied -> empty mid-move
void (*onBoardSettled)() = nullptr; // back to a recognised state
};
A single cleared bit with no matching set bit means a lift. This is the only change the LED subsystem required in existing hardware code, it is a no-op when unused, and it is covered by the existing scanner simulation harness.
Legal destinations come from the engine’s own generateMoves filtered by source square, through a narrow ILegalMoveSource seam, so the policy layer never links movegen internals directly. Priority when categories compete is fixed: check first (safety-critical), then the lifted piece’s legal moves (the question the player is asking right now), then the engine’s recommendation, then last-move — which is suppressed first when space is short.