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.
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.
| Command | Code | Payload | Purpose |
|---|---|---|---|
| DISCOVERY / ACK | 0xC0 / 0xC4 | status | Find the other board and confirm the link |
| ACTIVE / INACTIVE | 0xC1 / 0xC2 | status | Signal that a cypher is locked in |
| GUESS | 0xC5 | 4 digits | The active player's guess |
| FEEDBACK | 0xC6 | exact, misplaced | Result of scoring that guess |
| GAME_OVER | 0xC7 | guess count | Sender 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
- Automatic dark mode. A dedicated task samples the ambient light sensor once a second and inverts the LCD in a bright room. Nothing in the assignment asked for this.
- Persistent best score. A win writes the guess count to EEPROM if it beats the record, so it survives a power cycle. SW3 clears it.
- Console tracing. Every state transition prints over UART, so a desync shows up as two diverging traces instead of a frozen board.
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.