All work

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
ST-LINK/V2 Cortex-M4 180 MHz no RTOS no HAL no middleware hand-written drivers ILI9341 · SPI STMPE811 · I2C SDRAM 8 MB · LTDC opponent seeded from TRNG
STM32F429I-DISC1: Cortex-M4 straight to an ILI9341 panel, nothing in between.

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.

display

ILI9341 over LTDC

Frame buffer lives in external SDRAM; drawing primitives are written against it directly rather than through a graphics library.

input

STMPE811 over I2C

Resistive touch, polled and filtered. Raw coordinates need calibration before they mean anything on the grid.

entropy

Hardware TRNG

The on-chip true random number generator seeds AI target selection, so the opponent is not replayable across power cycles.

persistence

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.

ships placed shot fired AI shot fleet alive fleet sunk BOOT MENU PLACE P_TURN AI_TURN RESOLVE OVER

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.
The queue is a fixed array with a head and tail index, sized to the largest ship. There is no heap on this board worth using, and a bounded structure means the worst-case memory is knowable by inspection rather than by testing.

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.

ModuleResponsibility
GameDriver.cThe FSM itself - turn loop, rule arbitration, win/loss evaluation
Oneplayer.cHunt/target search driven by the hardware RNG
TwoPlayer.cPass-and-play, including the blind transition that hides one player's board
SharedPlayer.cBoard setup and placement routines common to both modes
GameDisplay.cGrid coordinate mapping, hit and splash rendering
LCD_Driver.c / ili9341.cDrawing primitives and the panel initialisation sequence
stmpe811.cTouch polling and coordinate filtering
BattleStats.cFlash-backed lifetime statistics with page-erase safeguards
sdram.cExternal 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.