Own project
2025 · STM32F429I-DISC1 · Embedded C
Bare-metal game engine with a predictive opponent
An FSM-driven Battleship engine on an ARM Cortex-M4 with no RTOS, no middleware and no HAL between the game and the panel - including the drivers, the opponent and the persistence layer.
- Year
- 2025
- Target
- STM32F429ZI
- Core
- Cortex-M4 @ 180 MHz
- Display
- 2.4" QVGA / LTDC
- Entropy
- on-chip TRNG
01The constraint that shaped everything
Battleship is a solved problem as a program. It stops being one when the target is an STM32F429I Discovery board and the rule is that the game logic owns the hardware directly - an ARM Cortex-M4 at 180 MHz, a 2.4" QVGA panel behind the LTDC controller, a resistive touch controller on I2C, 64 Mbit of external SDRAM for the frame buffer, and no operating system underneath any of it.
There is no event loop provided for you, no allocator worth the name, no compositor, and nothing that will schedule a redraw on your behalf. Every one of those has to be a decision you make explicitly, and the shape of the whole program follows from them.
ILI9341 over LTDC
Frame buffer lives in external SDRAM; drawing primitives are written against it directly rather than through a graphics library.
STMPE811 over I2C
Resistive touch, polled and filtered. Raw coordinates need calibration before they mean anything on the grid.
Hardware TRNG
The on-chip true random number generator seeds AI target selection, so the opponent is not replayable across power cycles.
Internal flash
Win/loss totals and the strike map survive power-off, written with page-erase guards so telemetry can never land on code sectors.
02One state machine, no hidden state
With no RTOS there is exactly one thread of control, so the entire game is a single explicit finite state machine driven from the main loop. Every screen, every turn and every transition is a named state, and rendering is a function of the current state rather than something any handler is allowed to do opportunistically.
That rule - draw from state, never from an event - is what kept the display consistent. The first version let the touch handler paint directly, and any interrupt during a redraw left half a grid on screen.
The turn loop, with the AI actually playing
The token walks the real transition graph. Every time it enters AI_TURN the opponent takes one shot on the board beside it.
The board is running the same hunt-and-target rule as the firmware: random search until something connects, then work the four neighbours of the hit before going back to hunting.
03The opponent
A uniformly random opponent is unplayable - it never appears to be trying. The firmware uses the standard two-mode heuristic instead, which costs almost nothing and reads as competent:
- Hunt. Draw a cell from the hardware TRNG, rejecting any already fired on. Because the seed is a genuine hardware source, the same board does not replay the same game.
- Target. On a hit, push the four orthogonal neighbours onto a small fixed-capacity queue and drain it before hunting again. No allocation, bounded worst case, and it finishes ships instead of wandering off them.
04Code layout
CubeMX regenerates main.c whenever the hardware configuration changes, so game
code lives strictly outside anything the generator owns. Peripheral drivers stay isolated
from game state; the split is what makes a pin remap a five-minute change instead of a
merge conflict.
| Module | Responsibility |
|---|---|
GameDriver.c | The FSM itself - turn loop, rule arbitration, win/loss evaluation |
Oneplayer.c | Hunt/target search driven by the hardware RNG |
TwoPlayer.c | Pass-and-play, including the blind transition that hides one player's board |
SharedPlayer.c | Board setup and placement routines common to both modes |
GameDisplay.c | Grid coordinate mapping, hit and splash rendering |
LCD_Driver.c / ili9341.c | Drawing primitives and the panel initialisation sequence |
stmpe811.c | Touch polling and coordinate filtering |
BattleStats.c | Flash-backed lifetime statistics with page-erase safeguards |
sdram.c | External SDRAM bring-up for the frame buffer |
05The two bugs worth writing down
- A blank display that was not a display bug. The panel initialised and drew nothing. The fault was SDRAM: the frame buffer lives in external memory, and if the SDRAM controller timing is not right before the first write, every pixel goes into a hole. The symptom appears at the end of the chain, several layers from the cause - which is the lesson, not the fix.
- Touch that was accurate in one corner only. Resistive panels return raw ADC counts, not pixels, and the mapping is neither centred nor perfectly linear. Two-point calibration with the extremes measured on the real panel fixed the grid alignment; assuming the datasheet's nominal range did not.
Both were found the same way - ST-Link over SWD at 4 MHz, live expression views on the volatile arrays, and hardware breakpoints in the interrupt handlers to catch rendering faults where they occurred rather than where they surfaced.