Minesweeper

Minesweeper on PSoC 5LP.

Minesweeper
Course
MIT 6.2060 Microcomputer Project Lab
Timeline
Spring 2026
Status
Complete

Overview

Minesweeper is a single-player logic puzzle popularised by its inclusion in Microsoft Windows in 1990. The player is presented with a grid of covered tiles concealing a fixed number of mines, and must deduce which tiles are safe using clues about how many mines adjoin each revealed cell. The player can mark suspected mines with flags and wins by revealing every non-mine tile without detonating one.

This project reimplements Minesweeper as a standalone embedded system on a PSoC CY8CKIT-050 development board, programmed entirely in C using PSoC Creator. The finished system is a complete, self-contained game: it has a graphical colour display with a Win95 aesthetic, physical joystick and button controls, three difficulty modes, a hardware-accurate elapsed-time counter, and a persistent high-score tracker stored in on-chip EEPROM. No PC, operating system, or external processor is involved at runtime.

The appeal of the project lies in the combination of algorithmically rich game logic — mine placement with first-click safety, adjacency calculation, iterative flood-clear, win detection — and the embedded systems challenge of driving all peripherals from a single microcontroller while keeping the display responsive.

Hardware. The system connects four subsystems to the PSoC. The display is a HiLetgo ILI9341 2.4" SPI TFT (240×320 pixels, RGB565), driven over an 8 MHz SPI Master component with bit-banged chip-select. Player input comes from a JOYSTICK-10K-C analog joystick module whose two axes are sampled by an ADC_DelSig through an analogue multiplexer, and three digital buttons (joystick pushbutton SW, onboard button REVEAL at P15[5], and external breadboard button FLAG at P6[1]) debounced in software. A fourth input, RESET, is an external button that returns to the menu or restarts the current game. The hardware timer is a PSoC UDB Timer component clocked at 24 MHz, configured to generate an interrupt every second. High scores are stored in the PSoC's 2048-byte internal EEPROM without any external component.

One PSoC coordinates the display, analog controls, timer interrupt, and persistent storage.
One PSoC coordinates the display, analog controls, timer interrupt, and persistent storage.

Software. All software runs on the PSoC in C. The program is organised into five source modules:

  • input.c samples the ADC and buttons each poll and returns a single InputEvent.
  • display.c drives the ILI9341 over SPI and renders all game screens using a sprite blitter that scales 16×16 pixel art tiles to fit each difficulty's grid.
  • game.c implements the complete Minesweeper engine — grid storage, mine placement, BFS flood-clear, flag toggling, win/lose detection — with no display or input calls.
  • comms.c manages the 1 Hz hardware timer ISR and EEPROM read/write for high scores.
  • main.c is the top-level state machine that ties all modules together in a menu → play → end → score loop.
The top-level state machine connects menu selection, play, game-over handling, and persistent scores.
The top-level state machine connects menu selection, play, game-over handling, and persistent scores.

Scope relative to proposal. The core game was completed in full: all three difficulty modes (Beginner 9×9/10 mines, Intermediate 16×16/40 mines, Expert 20×24/99 mines), the complete game engine, TFT rendering with sprite scaling, joystick and button input, the 1 Hz hardware timer, and EEPROM high-score storage.

1. TFT Screen

1.1 Overview and Planning

The display subsystem had two goals: establish reliable SPI communication with the ILI9341 controller, and render all Minesweeper game screens efficiently. Development proceeded in two phases. The first was purely electrical — getting a pixel on screen at all — and was treated as a prerequisite before any game logic was written. The second was graphical: deciding how to represent game state visually and what drawing primitives the rest of the system would use.

The central design decision was to use sprite blitting rather than rectangle drawing for cell tiles. The Minesweeper tile vocabulary is fixed and small — unrevealed, flagged, empty, numbered 1 through 8, mine, and cursor — so it made sense to pre-author each tile as a 16×16 pixel art array and push those pixels directly to the display. The chosen aesthetic was Windows 95 Minesweeper: flat grey unrevealed tiles with bevelled edges, the canonical blue-1 through red-8 numbering colours, and a red seven-segment HUD counter. This aesthetic maps cleanly onto the ILI9341's native RGB565 colour space because every sprite pixel is stored as a uint16 value that can be pushed to the SPI FIFO with no conversion.

Sprite data lives in sprites.h, which is listed at Appendix A and includes comments. Three families of static const uint16 arrays are defined there: cell sprites at SPRW × SPRH (16×16 pixels, 512 bytes each), HUD digit sprites at HUDW × HUDH (13×23 pixels), and smiley face sprites at FACEW × FACEH (24×24 pixels). Declaring them static const in a header causes the linker to place them in the PSoC 5LP's 256 KB program flash rather than its 64 KB data RAM. Total sprite data is roughly 19 KB, well within flash capacity. Throughout the blitter, a pixel value of 0x0000 is treated as transparent when a sprite is drawn in overlay mode, allowing the cursor highlight to composite over a cell without erasing it.

display.c is listed at Appendix _ and includes comments. display.h defines the module's full public interface: RGB565 colour constants, screen dimension macros, cell encoding bit masks, face state indices, difficulty indices, and all function prototypes. It is listed at Appendix _ and includes comments.

1.2 TopDesign and Pin Assignments

The ILI9341 uses 4-wire SPI: clock, data, active-low chip select (CS), and a data/command line (DC) that distinguishes command bytes from pixel data. The PSoC Creator project uses the SPIM_TFT SPI Master component configured for 8-bit transfers, CPHA = 0, CPOL = 0, and MSB-first shift direction, as required by the ILI9341 datasheet. An external Clock_8MHz component is wired to the SPIM clock terminal, giving an 8 MHz SPI bus. Three cy_pins output components handle the control signals: TFT_CS at P3[3], TFT_DC at P3[1], and TFT_RST at P3[2], all set to Strong drive with hardware connection disabled. Disabling the hardware connection is critical — connecting a Logic High or Logic Low component to the pin's y_0 terminal in TopDesign permanently overrides firmware _Write() calls. MOSI and SCLK are assigned to P3[0] and P0[7] through the .cydwr file under the SPIM_TFT component, not as standalone pins. A TFT_MISO input at P0[5] is configured as High impedance digital and wired to the SPIM miso terminal, though it is unused since the display is write-only.

CS is not connected to the SPIM ss terminal. When ss is hardware-controlled, deassertion is tied to an internal SPI Done signal that never fires when CS is managed manually, causing the status poll to hang indefinitely. Keeping TFT_CS as a plain output pin gives direct control over transaction boundaries. SPIM_TFT_Start() is called in main.c before display_init().

The board's J10 and J11 jumpers are set to 3.3V, so all GPIO runs at 3.3V logic and the ILI9341 module can be wired directly with no level shifting. The backlight LED+ pin connects to the +5V rail through a 22 Ω resistor. The datasheet recommends 47 Ω; the lower value increases LED current and makes the backlight slightly brighter, but within tolerance for a demonstration.

Backlight-current calculation

I = (5V − 3.3V) / 47 Ω = 1.7 V / 47 Ω ≈ 36 mA

At 22 Ω: I = 1.7 V / 22 Ω ≈ 77 mA

A 100 nF bypass capacitor is placed across the TFT VCC and GND pins.

The display bus and control pins in PSoC Creator TopDesign.
The display bus and control pins in PSoC Creator TopDesign.
The six TFT signals mapped to physical PSoC pins.
The six TFT signals mapped to physical PSoC pins.

1.3 Low-Level SPI Driver

All ILI9341 communication follows the pattern from the 6.115 TFT User Manual: set DC, assert CS, write one byte, wait, deassert CS. write8_a0() drives TFT_DC low before asserting CS, so the controller interprets the byte as a command opcode. write8_a1() drives TFT_DC high, marking the byte as a data argument or pixel value.

Both functions call spiwait() before deasserting CS. The wait polls SPIM_TFT_STS_TX_FIFO_EMPTY rather than SPIM_TFT_STS_SPI_DONE, because the Done flag requires hardware SS deassertion and would hang indefinitely with manual CS. TX_FIFO_EMPTY fires once the byte leaves the FIFO and enters the shift register, which is slightly before the last clock edge completes. A CyDelayUs(2u) guard after the flag ensures the bus is idle before CS releases.

SPI guard-time calculation

8 × (1 / 8 MHz) = 1 µs per byte

The 2 µs guard provides a full extra byte-time of margin.

setwin() programs the drawing rectangle by issuing Column Address Set (0x2A) with four data bytes for the start and end X coordinates, Page Address Set (0x2B) with four bytes for Y, then Memory Write (0x2C). After setwin() returns the controller accepts pixel data directly. pushcolor() sends one RGB565 pixel: DC high, CS low, high byte, wait, low byte, wait, CS high. A NOP (0x00) closes each Memory Write session.

display_fill() opens one window over the full TFT_W × TFT_H = 240 × 320 screen and loops 76,800 times calling pushcolor(). It is used at startup to clear the screen to COL_BLACK and between major screen transitions. display_rect() does the same for any sub-rectangle of w × h pixels and is the workhorse for drawing HUD backgrounds, menu panels, and the Win95 border in COL_WIN95. The public interface also exposes display_sprite() and display_sprite_overlay(), which wrap the internal blitters described in §1.6, and text rendering functions display_char(), display_str(), and display_int() used throughout the menu and score screens.

The SPI master uses 8-bit transfers, mode 0, and MSB-first ordering.
The SPI master uses 8-bit transfers, mode 0, and MSB-first ordering.
The scope trace verified command boundaries and byte-level display traffic.
The scope trace verified command boundaries and byte-level display traffic.

1.4 Initialisation Sequence

display_init() begins with a hardware reset: TFT_RST is held high for 5 ms, pulled low for 20 ms, then returned high with a 150 ms settle delay. A Software Reset (0x01) follows with another 150 ms delay. Sleep Out (0x11) wakes the panel and requires 500 ms before further commands. Pixel Format Set (0x3A) with argument 0x55 selects 16-bit RGB565. Memory Access Control (0x36) with argument 0x48 sets the MX and BGR bits for correct portrait orientation on the HiLetgo module. Display On (0x29) activates the panel, and display_fill(COL_BLACK) clears any residual frame memory.

1.5 Verification — test_tft

The first code written was test_tft() in tests.c, which is listed at Appendix D and includes comments. It calls display_fill(COL_RED), delays one second, display_fill(COL_GREEN), delays one second, display_fill(COL_BLUE), then halts. Three clean solid colours confirmed that the SPIM component was correctly configured, the init sequence was executing, the pixel format was accepted, and the window-set and pixel-push path was working. The scope confirmed CS toggled on command boundaries, DC was low only for command bytes, and MOSI was active across all eight clock edges of each byte. Once the colour test passed, the low-level driver was considered stable.

Red fill verifies the first complete pixel path.
Red fill verifies the first complete pixel path.
Green confirms the RGB565 channel ordering.
Green confirms the RGB565 channel ordering.
Blue completes the display bring-up test.
Blue completes the display bring-up test.

1.6 Sprite Blitter

display_sprite() transfers a w × h RGB565 sprite to screen position (x, y). It opens one setwin() window covering the full sprite rectangle and streams all pixels with pushcolor(). display_sprite_overlay() handles the same transfer but skips pixels equal to 0x0000, opening a 1×1 window for each non-transparent pixel instead. The overlay variant is slower but required for sprites that must composite over an existing background, such as the cursor highlight.

An internal blit_scaled() function performs nearest-neighbour scaling of a 16×16 source sprite to cell_px × cell_px output pixels. Cell size varies by difficulty — the grid must fit within TFT_W = 240 pixels while accommodating anywhere from 9 to 20 columns — so scaling is necessary. For each output row r and column c, the source pixel is taken from:

source row = (r × SPRH) / cell_px

source column = (c × SPRW) / cell_px

In the non-overlay path one window is opened per output row, batching cell_px writes and reducing setwin() calls from cell_px² down to cell_px. The overlay path opens 1×1 windows per non-transparent pixel as before.

cell_sprite() maps a packed cell byte to the correct sprite pointer. The cell byte encodes game state using the bit masks defined in display.h: CELL_MINE (bit 7), CELL_REVEALED (bit 6), CELL_FLAGGED (bit 5), CELL_HITMINE (bit 4), CELL_WRONGFLAG (bit 3), and the neighbour count in CELL_NBRMASK (bits 3–0). cell_sprite() tests these in priority order: a hit mine maps to SPR_MINEHIT; any other revealed mine to SPR_MINE; a wrong flag to SPR_WRONGFLAG; a revealed cell with non-zero neighbour count to SPR_N1 through SPR_N8; a revealed zero cell to SPR_EMPTY; a flagged cell to SPR_FLAG; and everything else to SPR_UNREV. SPR_CURSOR is never returned by cell_sprite() — it is always applied as a separate overlay pass by the caller.

1.7 Grid and Cursor Rendering

Two module-level variables, g_disp_rows and g_disp_cols, cache the grid dimensions set by the most recent display_grid() call. This lets display_cell() and display_cursor_move() convert grid coordinates to pixel positions without the caller passing dimensions on every incremental update. A companion function display_cell_px() centralises the mapping from grid dimensions to rendered cell size, so main.c does not need to know how cell_px is derived for each of the three difficulty modes DIFF_BEGINNER, DIFF_INTERMEDIATE, and DIFF_EXPERT.

display_grid() performs a full redraw. It saves rows and cols into the cache variables, draws the grid background, then calls blit_scaled() for every cell using cell_sprite() to select the sprite and cell_px for the current difficulty. After all cells are drawn it composites SPR_CURSOR over position (crow, ccol) in a second overlay pass. This function is called once at game start and once on reset.

display_cursor_move() handles the common case of the cursor moving one cell. It redraws the old cell at (oldr, oldc) with oldcell and no overlay, redraws the new cell at (newr, newc) with newcell and no overlay, then composites SPR_CURSOR over the new position — three sprite blits rather than a full 76,800-pixel redraw. Cursor movement is visually instantaneous as a result. display_cell() handles a single-cell update after a reveal or flag toggle: it redraws the cell at (row, col) with the provided cell value and, if that position matches (crow, ccol), reapplies the cursor overlay.

A highlighted cursor moves across the Beginner board without forcing a full-screen redraw.
A highlighted cursor moves across the Beginner board without forcing a full-screen redraw.

1.8 HUD and Screen Functions

display_hud() redraws the full HUD bar in one call, taking the flag count flags, elapsed time seconds, and a facestate index. The index selects among the four constants defined in display.h — FACESTATE_NORM, FACESTATE_PRESS, FACESTATE_WIN, and FACESTATE_DEAD — which correspond to sprites FACE_NORM, FACE_PRESS, FACE_WIN, and FACE_DEAD. For situations where only one element of the HUD changes, three partial updaters are also provided: display_hud_flags() redraws only the flag counter, display_hud_timer() redraws only the timer digits, and display_hud_face() redraws only the smiley. The two numeric counters decompose an integer into decimal digits and blit each from the HUD digit sprite family using display_sprite(). The face sprite is blitted at its native FACEW × FACEH size with no scaling.

display_menu() draws the main menu: a Win95 raised-border dialog, the title, the smiley face, and three difficulty rows rendered using display_str() for the row labels. display_menu_select() redraws only the two rows whose selection state changed, avoiding a full-screen refresh on each joystick step. display_score() takes an array times[3] of best times indexed by DIFF_BEGINNER, DIFF_INTERMEDIATE, and DIFF_EXPERT, and renders the current run time alongside the stored best for the active difficulty using display_int(). display_win() and display_lose() are no-op hooks kept for API symmetry; the win and lose states are communicated entirely through the face sprite passed to display_hud() and the revealed grid drawn by repeated display_cell() calls at game end.

One menu exposes all three board sizes.
One menu exposes all three board sizes.
The completed board updates the flags, timer, and face together.
The completed board updates the flags, timer, and face together.
Best times persist independently for each difficulty.
Best times persist independently for each difficulty.

The font is a 5 × 9 bitmap table font5x9 with 45 glyphs covering digits, punctuation, and the Latin letters needed for all on-screen text. The two extra rows beyond a standard 5 × 7 allow descenders in g, p, and y to occupy rows 7–8 while the x-height sits in rows 2–6. display_char loops nine rows and five columns, testing each bit as font5x9[idx][row] >> (4 - col) & 1u, and delegates pixel output to display_rect at the requested scale. display_str advances the cursor by (5 + 1) × scale per character. char_idx maps a character to its table index; unknown characters fall through to index 11 (space). display_int right-aligns a signed 32-bit value in a fixed-width field of digits characters, clearing the field first with a background rectangle digits × 6 × scale wide and 9 × scale tall.

Five game screens are produced by dedicated functions. display_menu renders a Win95-style dialog with "Minesweeper" centred at scale 3.

11 characters × (5 + 1) × 3 = 198 px wide

x = (240 − 198) / 2 = 21 px

It also draws three difficulty rows with draw_menu_row. display_menu_select redraws only the previously and newly selected rows on a joystick move, cutting repaint cost from a full-screen fill to two 210 × 60 px rectangles. display_score renders the high-score dialog titled "High Scores" with three score rows at scale 2, reading best times from the array passed by main.c via comms_best. On a win, main.c calls comms_submit with the elapsed time and difficulty before calling display_score. display_win and display_lose are intentional no-ops; the win and loss states are communicated entirely through display_hud_face and the mine-reveal loop in main.c, so no overlay function is needed.

The sprite test exercises every cell, number, face, and HUD digit.
The sprite test exercises every cell, number, face, and HUD digit.
Loss rendering distinguishes the detonated mine from incorrect flags.
Loss rendering distinguishes the detonated mine from incorrect flags.

2. Input

Player input is handled by an analog joystick module and four digital buttons. input.c is listed at Appendix _ and includes comments; its public interface is declared in input.h, also listed at Appendix _. The goal was to translate raw hardware readings into discrete InputEvent values and expose them through a single input_poll() call per loop iteration. All hardware was validated with test_input before any game logic was written.

2.1 TopDesign and pin assignments

The joystick module's VRX and VRY analog outputs share one ADC_Joystick (ADC_DelSig, 16-bit, single-ended, Vssa to Vdda, Multi-Sample mode, 10 000 SPS) through an AMux_Joystick (2-channel analog mux). VRX was wired to AMux channel 1 and VRY to channel 0. The joystick pushbutton SW was assigned to P3[5] with drive mode Resistive Pull-Up, so the pin rests at 3.3 V and is pulled low on press. Three additional cy_pins digital input components handle game actions: REVEAL on P15[5] and FLAG on P6[1] use the board's existing pull-ups with drive mode High Impedance Digital; RESET on P6[6] connects to an external breadboard button pulled up to 3.3 V through a 10 kΩ resistor, also High Impedance Digital. SW and REVEAL both map to EV_REVEAL; FLAG maps to EV_FLAG; RESET maps to EV_RESET.

The analog multiplexer shares one ADC between the joystick axes.
The analog multiplexer shares one ADC between the joystick axes.
Joystick and action inputs occupy six PSoC pins.
Joystick and action inputs occupy six PSoC pins.

2.2 ADC sampling

input_init() starts ADC_Joystick and AMux_Joystick, selects channel 1, calls ADC_Joystick_StartConvert() to begin continuous conversion, and resets the joystick repeat state to idle. input_poll() samples VRX then VRY in sequence, each with an explicit ADC_Joystick_StopConvert() / ADC_Joystick_StartConvert() / ADC_Joystick_IsEndConversion(ADC_Joystick_WAIT_FOR_RESULT) sequence before calling ADC_Joystick_GetResult32(). Forcing a stop and restart after each AMux_Joystick_Select() call was necessary because the ADC_DelSig filter retains state from the previous channel; without it, IsEndConversion returns immediately on a stale result. Using GetResult32() rather than GetResult16() was required because the ADC_DelSig datasheet specifies that in 16-bit single-ended mode the output range is 0–65 535, which overflows a signed int16 near mid-supply, causing wrap-around to large negative values at rest.

All four button pins are read with SW_Read(), REVEAL_Read(), FLAG_Read(), and RESET_Read() and stored into g_input_raw, an InputRaw struct declared extern in input.h so test_input can display live values without touching the ADC. InputRaw fields are vry (int32), vrx (int32), sw (uint8), reveal (uint8), flag (uint8), and reset (uint8). The InputEvent enum defines EV_NONE=0, EV_UP, EV_DOWN, EV_LEFT, EV_RIGHT, EV_REVEAL, EV_FLAG, and EV_RESET.

The joystick ADC runs in 16-bit multi-sample mode at 10,000 samples per second.
The joystick ADC runs in 16-bit multi-sample mode at 10,000 samples per second.

2.3 Directional thresholding

Measured ADC values at rest and full deflection were used to set THRESH_LO and THRESH_HI.

Rest
VRY ~32,000 · VRX ~33,000
Up
VRY >64,000 · VRX ~32,000
Down
VRY <1,000 · VRX ~32,000
Left
VRY ~33,000 · VRX <1,000
Right
VRY ~33,000 · VRX >64,000

Hardware was validated with test_input in tests.c before integration. The harness draws an 11×13 grid of COL_DKGREY cells and moves a COL_CYAN cursor cell with joystick events using a simple REPEAT=5 count-down throttle. Four indicator rectangles at y=290 flash COL_RED on press and return to COL_GREEN on release: SW/REVEAL share indicators at x=10 and x=60, FLAG at x=110, and RESET at x=160. Live vry (yellow) and vrx (cyan) ADC counts from g_input_raw are displayed each poll via display_char and display_int, allowing direct observation of ADC values at rest and at full deflection without a serial terminal. This readout was used to measure the values in the table above, tune THRESH_LO and THRESH_HI, verify all buttons fire exactly once per press, and confirm cursor boundary behaviour at grid edges.

The on-device harness exposed live ADC counts and button states without a serial terminal.
The on-device harness exposed live ADC counts and button states without a serial terminal.

The deadband around rest is the interval [THRESH_LO, THRESH_HI] = [1 000, 64 000]. At a nominal rest reading of 32 767 (half of 65 535):

Joystick deadband calculation

Lower margin = 32 767 − 1 000 = 31 767 counts

Upper margin = 64 000 − 32 767 = 31 233 counts

Both margins are ~48 % of full scale, providing generous noise immunity while requiring roughly 52 % deflection to register a direction.

input_poll() tests VRY before VRX, establishing priority if both axes are simultaneously deflected: VRY > THRESH_HI → EV_UP; VRY < THRESH_LO → EV_DOWN; VRX > THRESH_HI → EV_RIGHT; VRX < THRESH_LO → EV_LEFT.

2.4 Joystick repeat state machine

A held joystick must scroll the cursor continuously; a short flick must move exactly one tile. This is handled by a three-variable state machine: joy_held (InputEvent) tracks the direction currently held, joy_phase (uint8) tracks whether the machine is idle (0), in hold-delay (1), or repeating (2), and joy_ticks_left (uint32) counts down between events.

When a new direction is detected, the event fires immediately, joy_phase is set to 1, and joy_ticks_left is loaded with JOY_HOLD_TICKS (40). While the same direction is held and joy_ticks_left > 0, the counter decrements and no event is returned. When joy_ticks_left reaches zero in phase 1, joy_phase advances to 2, the event fires, and joy_ticks_left is reloaded with JOY_REPEAT_TICKS (12); subsequent firings repeat every 12 polls. Returning the stick to centre resets joy_held to EV_NONE and joy_phase to 0.

  1. Immediate event A short flick moves exactly one tile.
  2. 40-poll hold delay The cursor waits before continuous movement begins.
  3. 12-poll repeat A held direction advances repeatedly until the stick returns to centre.

Each poll takes two ADC conversions at 10 000 SPS plus SPI display refresh cost. The ADC conversions alone take:

Input-timing calculation

2 channels × (1 / 10 000 SPS) = 2 × 0.1 ms = 0.2 ms per poll

This gives a minimum loop rate of ~5 000 Hz. Display refresh dominates in practice; empirically the loop runs at roughly 1–3 ms per iteration. Taking 2 ms as a representative estimate:

JOY_HOLD_TICKS = 40 polls × 2 ms/poll = 80 ms

JOY_REPEAT_TICKS = 12 polls × 2 ms/poll = 24 ms

This corresponds to ~42 tiles/second at full repeat rate.

2.5 Button debounce

Button debounce uses a hysteretic saturating counter. The BtnState struct holds cnt (uint8) and fired (uint8). debounce(b, raw, press_ticks) increments cnt toward press_ticks on each low sample and decrements it toward zero on each high sample, so a glitch nudges the counter by one rather than resetting it. When cnt reaches press_ticks and fired is clear, fired is set to 1 and the function returns 1, reporting exactly one press event. fired clears only when cnt drains back to zero, preventing re-trigger until the button is fully released.

SW and REVEAL use PRESS_TICKS_FAST = 3 because they are low-noise PCB-mounted buttons; bsw and breveal both return EV_REVEAL. FLAG uses PRESS_TICKS_SLOW = 6 via bflag because it is an external mechanical switch; triggering was unreliable at 3 polls, consistent with longer bounce observed on the oscilloscope. RESET uses PRESS_TICKS_FAST via breset. Button debounce is only evaluated when no joystick event is active this poll, preventing simultaneous joystick and button events.

3. Game Logic (game.c / game.h)

The game engine is self-contained in game.c, listed at Appendix _ and includes comments, with its public interface declared in game.h, listed at Appendix _. No hardware is accessed from either file — all PSoC-specific calls are limited to CyGetUniqueId for seeding and the standard C rand() / srand() pair for mine placement.

The grid is a flat uint8 array of MAXCELLS = 480 bytes (24 × 20, the worst case for Expert). Each byte encodes one cell: bit 7 is CELL_MINE, bit 6 is CELL_REVEALED, bit 5 is CELL_FLAGGED, and bits 3–0 hold CELL_NBRMASK, the count of adjacent mines (0–8). Two display-layer annotation bits, CELL_HITMINE (bit 4) and CELL_WRONGFLAG (bit 3), are set only in main.c when building the loss frame and are never written to the game array itself.

Three difficulty modes are supported:

  • DIFF_BEGINNER gives a 9 × 9 grid with 10 mines.
  • DIFF_INTERMEDIATE gives a 16 × 16 grid with 40 mines.
  • DIFF_EXPERT gives 24 rows × 20 columns with 99 mines.

The Expert dimensions were chosen to fill the portrait screen while keeping cells large enough to read; the standard 30 × 16 layout was discarded because it produced 7 px cells that were too small at the available display resolution. The dimensions and mine counts are stored in the GROWS, GCOLS, and GMINES arrays indexed by mode.

game_init() clears the grid with memset, sets gstate to STATE_UNTOUCHED, places the cursor at the grid centre (currow = grows / 2, curcol = gcols / 2), initialises flagcnt to the mine count, and resets revealcnt and firstclick. It does not call srand — seeding is deferred to first click so that entropy can be accumulated beforehand. game_reset() simply calls game_init(gmode) to restart at the same difficulty.

Randomness. Early versions seeded srand once at game_init time using CyGetUniqueId XORed with a static LCG counter gseed. This produced the same mine layout for a given first-click tile on every hard reset, because CyGetUniqueId is a silicon constant and the LCG restarts from 1 after every power cycle. The fix is continuous entropy accumulation. game_seed() is called once at startup to initialise g_seed from the UID. From that point, game_stir(adc_x, adc_y) is called on every pass through the main loop, mixing the raw ADC readings from the joystick into g_seed via an xorshift32 step:

g_seed ^= adc_x ^ (adc_y << 16)
g_seed ^= g_seed << 13
g_seed ^= g_seed >> 17
g_seed ^= g_seed << 5

The ADC LSBs carry thermal noise and fluctuate by ±1–3 counts even with the joystick centred. By the time the user clicks the first tile — typically hundreds to thousands of polls after startup — g_seed has been stirred enough times to be unpredictable across resets. srand((unsigned int)g_seed) is then called at the top of placemines(), so the seed captures the full accumulated entropy at the exact moment mines are laid.

Mine placement. placemines(saferow, safecol) implements first-click safety. It builds a candidate array of every cell index that falls outside the 3 × 3 neighbourhood of the clicked cell, then runs a partial Fisher-Yates shuffle to select gmines positions, setting CELL_MINE on each. calcnbrs() then iterates the full grid in a 3 × 3 window pass, writing the neighbour count into bits 3–0 of every non-mine cell. gstate transitions from STATE_UNTOUCHED to STATE_INPROGRESS on the same first click.

Reveal and flood-clear. game_reveal(row, col) is the central action. If the target cell is a mine, it sets CELL_REVEALED on that cell, calls reveal_all_mines() to mark every mine as revealed, and sets gstate = STATE_LOSE. Otherwise it calls flood(row, col), an iterative BFS using a flat queue array of cell indices. The queue starts with the clicked cell; at each step, if the dequeued cell has a zero neighbour count (CELL_NBRMASK == 0), all eight unrevealed, unflagged, non-mine neighbours are enqueued. Every revealed cell increments revealcnt and is recorded in g_flood_cells[]; g_flood_count holds the total. main.c reads these after each reveal to update only the changed tiles rather than redrawing the full board.

Win detection. After every successful flood, checkwin() compares revealcnt to grows * gcols - gmines. If equal, every non-mine cell has been revealed and gstate is set to STATE_WIN. At that point game_reveal flags every mine cell with CELL_FLAGGED and sets flagcnt = 0, so the board displays the completed flag pattern without any further input.

Cursor movement. game_move(dir) clamps currow and curcol within the grid bounds for the four DIR_* directions. game_flag(row, col) toggles CELL_FLAGGED on an unrevealed cell and increments or decrements flagcnt accordingly; it is a no-op on revealed cells and during win or lose states. The accessor functions game_state, game_cell, game_grid, game_cursor_row, game_cursor_col, game_flag_count, game_mine_count, game_rows, and game_cols expose read-only views of the engine state to main.c and display.c without exposing the grid array directly.

4. Game Loop Integration (main.c)

main.c is the top-level state machine coordinating all subsystems. It declares four local variables: appstate, which tracks the current screen; menusel, the highlighted difficulty; endwon, a flag distinguishing a win from a loss when leaving APP_END; and cpx, the cell pixel size for the current game. The initialisation sequence is CyGlobalIntEnable → SPIM_TFT_Start → display_init → input_init → comms_init → game_seed → display_menu(menusel). SPIM_TFT_Start must precede display_init because the first ILI9341 command bytes are clocked out inside display_init; calling it with the SPI peripheral stopped would produce no output. game_seed is called after comms_init but before the loop so that g_seed is initialised from CyGetUniqueId before any game_stir calls begin.

The for(;;) loop calls input_poll() and game_stir((uint32)g_input_raw.vrx, (uint32)g_input_raw.vry) unconditionally on every iteration, then dispatches on appstate. Stirring every poll — including during menu navigation and the end screen — maximises the entropy accumulated in g_seed before placemines is ever called.

APP_MENU. Joystick EV_UP and EV_DOWN events decrement or increment menusel within 0–2, and display_menu_select(prev, menusel) redraws only the two affected rows rather than the full screen. On EV_REVEAL, game_init(menusel) and comms_reset() are called, cpx is computed by display_cell_px, a full display_hud and display_grid are drawn, and appstate advances to APP_PLAY.

APP_PLAY. Direction events map EV_UP, EV_DOWN, EV_LEFT, and EV_RIGHT to DIR_UP, DIR_DOWN, DIR_LEFT, and DIR_RIGHT via a ternary chain, call game_move, and then call display_cursor_move with the old and new cell coordinates and the current cpx. This redraws exactly two cells per joystick event.

On EV_REVEAL, prevstate is saved before calling game_reveal(cr, cc). If prevstate was STATE_UNTOUCHED and game_state() has advanced to STATE_INPROGRESS, comms_start() is called to begin the hardware timer on the first click rather than at game initialisation. Three outcome branches follow. If game_state() is STATE_LOSE, comms_stop() is called, and the loss rendering pass iterates every cell: mine cells have CELL_HITMINE OR-ed in if the cell matches (cr, cc) and are passed to display_cell; non-mine flagged cells are passed with CELL_WRONGFLAG. display_hud_face(FACESTATE_DEAD) updates the smiley, endwon is cleared, and appstate moves to APP_END. If game_state() is STATE_WIN, comms_stop() is called, the win rendering pass calls display_cell for every CELL_MINE cell (all already carrying CELL_FLAGGED from game_reveal), then display_hud_face(FACESTATE_WIN) and display_hud_flags(0) update the HUD, endwon is set, and appstate moves to APP_END. For a normal reveal, if g_flood_count equals 1 a single display_cell call is made; otherwise the loop iterates g_flood_cells[0..g_flood_count-1], recovering row and column as fi / cols and fi % cols, and calls display_cell for each. In all non-terminal cases, only changed tiles are redrawn.

EV_FLAG calls game_flag, redraws the single affected cell with display_cell, and calls display_hud_flags(game_flag_count()) to update only the flag counter. EV_RESET calls comms_reset(); if game_state() is STATE_UNTOUCHED the loop returns to APP_MENU with display_menu; otherwise game_reset() is called and a full display_hud and display_grid redraw the board. After the inner switch, display_hud_timer((int)comms_elapsed()) is called unconditionally every poll while game_state() returns STATE_INPROGRESS, so the timer display updates as fast as the loop runs without any separate scheduling.

APP_END. EV_RESET calls comms_reset() and game_reset(), recomputes cpx, redraws the full HUD and grid, and returns to APP_PLAY. Any other non-EV_NONE event branches on endwon: a win calls comms_submit(menusel, comms_elapsed()) to persist the score, builds the times[3] array from comms_best(0..2) (substituting −1 where comms_best returns 0xFFFFFFFF), calls display_score(times), and enters APP_SCORE; a loss calls display_menu(menusel) and returns to APP_MENU with no score screen shown.

APP_SCORE. Any non-EV_NONE event calls display_menu(menusel) and returns to APP_MENU.

5. Timer and EEPROM (comms.c / comms.h)

Two persistent features required a hardware timer and non-volatile storage: an elapsed-time counter displayed in the HUD during gameplay, and a per-difficulty best-time tracker that survives power cycles. Both are implemented in comms.c, listed at Appendix _ and including comments, with its interface declared in comms.h, listed at Appendix _.

Timer hardware. A Timer_1 component was added to TopDesign configured as a 32-bit UDB timer with the internal bus clock as its source and the interrupt-on-TC option enabled. The period register was set to 23,999,999 so the terminal count fires exactly once per second:

One-second hardware timer

(23,999,999 + 1 cycles) / 24,000,000 Hz = 1.000 s

The tc output terminal of Timer_1 was wired to an ISR_Timer interrupt component. The ISR, timer_isr, calls Timer_1_ReadStatusRegister() first to clear the sticky TC flag — without this read the level-sensitive interrupt line stays asserted and the CPU re-enters the ISR immediately after returning. It then increments the volatile uint32 g_elapsed counter if the volatile uint8 g_running flag is set. comms_init() registers the handler with ISR_Timer_StartEx(timer_isr) and starts the timer with Timer_1_Start(). A Fixed Function timer configuration was attempted first but produced a placement error (mpr.M0064); switching Timer_1 to UDB resolved it.

The main loop reads g_elapsed through comms_elapsed(), which wraps the read in CyEnterCriticalSection() / CyExitCriticalSection() to prevent a torn 32-bit read on Cortex-M3 if the ISR increments the value mid-read. comms_start() zeroes g_elapsed and sets g_running; comms_stop() clears g_running; comms_reset() does both. In main.c, comms_start() is called when the game transitions from STATE_UNTOUCHED to STATE_INPROGRESS on the first tile reveal, detected by snapshotting prevstate before calling game_reveal() and comparing after. comms_stop() is called immediately on STATE_WIN or STATE_LOSE. During APP_PLAY, display_hud_timer((int)comms_elapsed()) is called on every poll iteration so the HUD counter updates in real time.

The hardware timer updates independently of the rendering workload.
The hardware timer updates independently of the rendering workload.

EEPROM storage. The PSoC 5LP contains 2 KB of dedicated EEPROM at array ID EE_ARRAY = 0x40, accessed via CyWriteRowData() and direct memory-mapped reads at base address EE_BASE = CYDEV_EE_BASE. EEPROM is physically separate from code flash, so writes cannot corrupt program data, strings, or sprite tables — an earlier approach storing scores in a const array in .rodata and writing back the containing flash row caused exactly that corruption, overwriting font and string data. The EEPROM row size is EE_ROWSIZE = 16 bytes; one row holds all three best times with 4 bytes to spare. The layout places DIFF_BEGINNER at bytes 0–3, DIFF_INTERMEDIATE at bytes 4–7, and DIFF_EXPERT at bytes 8–11, each as a little-endian uint32. The sentinel value NOSCORE = 0xFFFFFFFF indicates no score recorded, matching the erased EEPROM state.

To avoid reading from the EEPROM address space during gameplay — which could conflict with a concurrent write — comms_init() calls loadcache() once at startup to populate the RAM array g_best[NDIFF] from EEPROM. All subsequent reads go through the cache: comms_best(mode) returns g_best[mode] directly. comms_submit(mode, secs) checks the cache first and returns immediately if secs >= g_best[mode]. If a new best is recorded, writeerow() rebuilds the full 16-byte row from the cache, patches the one changed entry, and calls CySetTemp() then CyWriteRowData(). Both SPC calls require global interrupts to be enabled; CyGlobalIntEnable is called at the top of writeerow() to guarantee this. The write is gated on CySetTemp() returning CYRET_SUCCESS so a failed temperature read cannot produce a corrupt write. After a successful write, g_best[mode] is updated to keep the cache consistent.

comms_submit() is intentionally not called at win-time. Calling it inside the EV_REVEAL handler while the display was mid-update caused the SPC write to block for several milliseconds, freezing the board with no response to input. Instead, comms_submit() is called in APP_END when the player presses a button to advance to the score screen — at that point the display is fully updated and the main loop is idle waiting for input, so the write delay is imperceptible. comms_init() also calls CyEEPROM_Start() to enable the internal EEPROM voltage pump required for writes.

A UDB timer drives the one-second interrupt while internal EEPROM stores scores.
A UDB timer drives the one-second interrupt while internal EEPROM stores scores.
The score screen reads the cached best time for every mode.
The score screen reads the cached best time for every mode.

7. Conclusions and Reflections

Summary of what was built relative to the proposal:

  • Core game complete: all three difficulties, full engine, TFT display, joystick + buttons, 1 Hz hardware timer, EEPROM high scores.
  • What was not built: MP3 Voice Prompter audio (no UART), USB spectator mode.
  • scores.c was proposed as a separate module but merged into comms.c.

What worked well: incremental display strategy (cursor moves feel instant), hardware timer ISR (accuracy independent of rendering load), xorshift + ADC noise RNG (no repeated boards across power cycles), Win95 aesthetic (clean mapping to flat-rect primitives and RGB565).

What was difficult or required iteration: SPI timing (the 2 µs spiwait delay), ILI9341 init sequence, EEPROM write blocking (discovering the APP_END deferral was necessary), joystick threshold and repeat timing tuning for good tactile feel.

A fresh Beginner board.
A fresh Beginner board.
The denser Intermediate layout.
The denser Intermediate layout.
A completed game with every mine flagged.
A completed game with every mine flagged.