Two-Player Mastermind – PSoC 6 & FreeRTOS


Overview

Two PSoC 6 boards, each running its own FreeRTOS application, play Mastermind against each other over a single UART link. Each player picks a hidden four-digit cypher on a capacitive touch screen, then the boards trade guesses and feedback until one cracks the other's code.

There is no host and no referee. Both boards are peers, so everything a server would normally handle has to be negotiated between them: who goes first, when a turn ends, and how to recover when a message is missed. My partner and I wrote our firmware separately and agreed only on the wire format, so the protocol had to be right before either side could be debugged.

FreeRTOS PSoC 6 (CY8C6244) Custom UART protocol C I2C + SPI ModusToolbox

Architecture

Nothing polls for input. Button presses and inbound packets become bits in one FreeRTOS event group, and the game logic sits blocked on xEventGroupWaitBits() until a bit it cares about is set. Each state then names only the events it will accept, and the scheduler does the waiting.

Every shared peripheral is owned by exactly one gatekeeper task. Application code never touches the I2C or SPI bus directly; it posts a request on that device's queue and blocks on its own response queue while the gatekeeper takes the bus semaphore and does the transfer.

The Board-to-Board Protocol

Every packet carries a 0xAA start byte, a command, a sequence number, a payload, and an XOR checksum over everything between. A packet is rejected unless both the start byte and the checksum agree, so a reset on one board cannot inject a bogus guess into the other. Commands live in 0xC0–0xC8 and status codes start at 0xE0, which makes a stray byte cheap to discard.

CommandCodePayloadPurpose
DISCOVERY / ACK0xC0 / 0xC4statusFind the other board and confirm the link
ACTIVE / INACTIVE0xC1 / 0xC2statusSignal that a cypher is locked in
GUESS0xC54 digitsThe active player's guess
FEEDBACK0xC6exact, misplacedResult of scoring that guess
GAME_OVER0xC7guess countSender won, in this many guesses

My GAME_OVER payload is a single guess-count byte, chosen to be wire-compatible with the WIN message my partner had already implemented. Neither of us had to rewrite: the byte layout matches even though the command names do not.

Three Problems Worth Describing

1) Resolving the handshake race

Nothing stops both players pressing start at the same instant, and then each board waits for an ACK the other is too busy sending its own DISCOVERY to produce. I made the wait symmetric: a board waits on either an ACK or an incoming DISCOVERY. If its own DISCOVERY is acknowledged it is the first player; if the opponent's arrives first it yields and goes second. A five second timeout retries rather than deadlocking, and there is no case where both boards claim the same role.

2) A touch controller that lies about being released

Registering a digit on the rising edge of a touch produced doubled and tripled entries. The FT6236 intermittently reports "no touch" for a single sample mid-press, and the next sample looks like a fresh edge. Instead of a longer blind delay I used a settled-state debounce: a digit registers only while armed, and arming is restored only after three consecutive read failures, so a one-sample glitch cannot pass as a release.

3) Keeping two boards in agreement

If a guess is lost, both boards wait forever. After sending, a board waits one second for feedback and resends once if none arrives; duplicates are harmless because scoring is idempotent. Stale event bits were the subtler hazard: a packet arriving after its state had moved on would satisfy the next state's wait immediately and skip a turn. Clearing the game-related bits at the start of the handshake, before each guess, and at end of game fixed a class of bugs that only showed up when the boards were a turn out of sync.

Smaller Details

What I Would Change

The LCD is the one peripheral without a gatekeeper task. The game loop draws to it directly and so does the ambient light task, which means two tasks can reach the same SPI bus without arbitration. It never misbehaved, because the inversion command is short and infrequent, but it is a race that exists in the design rather than one I ruled out. The fix is the pattern already used everywhere else: an LCD gatekeeper fed by a queue, holding the SPI semaphore across each transfer.