Design specification · rev D · for firmware
OceanBattle
Two consoles, one umpire. For the nerd in us all, here are more details than anyone could ever want. Well except someone that want's to build their own. I have that stuff too.
Architecture
The brain is the umpire and the sole owner of game state. Consoles are terminals: they report presses and encoder motion, and they render exactly what they are told to render. No console computes a hit, decides a turn, or knows anything about the opponent's fleet.
This costs a little protocol verbosity and buys three things that are otherwise expensive. Desync between the two consoles becomes structurally impossible, since there is only one copy of the truth. A console can be yanked and replugged mid-game and recover completely, because the brain simply re-pushes the full board. And information leaks become impossible by construction — a console cannot reveal what it was never sent.
When Announce Ship Sunk is off, the attacker's console is never told which ship went down. Not told and instructed to hide it — never told. The same principle governs every fact in the protocol: each console receives only what its player is entitled to know.
Host
Both consoles show the title screen at power-up. Whoever presses the dial first becomes the host — the brain honours the first packet it receives and the other console falls to standby. There is no port-order privilege, so the two players never have to swap seats to swap roles.
The host chooses board size and rules, and fires first. There is no coin flip and no turn-order prompt.
| State | Owner | Shared? | Notes |
|---|---|---|---|
| Board size | Host | Yes | Pushed to guest before placement |
| Rules | Host | Yes | Displayed on the guest's standby screen |
| Turn order | Brain | Yes | Host fires first, always |
| Placement method | Each player | No | One may auto-place while the other places by hand |
| Volume | Brain | Yes | One speaker, so one volume. Either console may set it; last write wins |
| Brightness | Console | No | Local setting |
Brightness is per-console because each console has its own panel. Volume isn't a console setting at all — the single speaker lives in the brain, and so does its control, on the brain's own screen and dial. Board size and rules are the only shared settings, and they're host-owned, which reduces the whole "first entry wins" problem to one arbitration at the title screen.
Mode state machine
Every transition below is caused by a brain message. A console never changes mode on its own initiative — it asks, and the brain answers.
| Mode | Grid | Encoder | Panel |
|---|---|---|---|
| TITLE | Attract mode — rainbow snake | Press bids for host | Title, “Press Dial to begin” |
| MENU_MAIN | Dark | Rotate to move, press to select | Menu list |
| MENU_SIZE | Dark | Rotate, press | Four sizes + back |
| STANDBY_WAIT | Dark | Volume / brightness only | “Player 1 is choosing…” |
| PLACE_AUTO | Shows the generated fleet | Rotate: Shuffle / OK. Press to select | Fleet list, Shuffle / OK |
| PLACE_MANUAL | Cell selection, live validation | Rotate to pick ship, press to confirm | Current ship, remaining, refusal reason |
| READY | Own fleet, static | Volume / brightness | “Stand by for other player” |
| PLAY_MY_TURN | Firing view. Arm, fire | Rotate: volume. Press: menu. (Salvo: press commits) | Own fleet + status |
| PLAY_THEIR_TURN | Own fleet. Inert | Rotate: volume. Press: menu | Firing view + status |
| GAME_OVER | Own fleet, full reveal | Rotate, press | Result, opponent's fleet, options |
| LINK_LOST | Dark | Inert | “Link lost — check cable” |
The grid and the panel trade roles every turn: the grid is your firing board on your turn and your own fleet on theirs. Two invariants keep this legible. White never appears on the firing view — so a glance at the grid tells you whose turn it is without reading a word. And the panel carries a permanent header, YOUR FLEET or YOUR SHOTS, naming what it is showing.
During PLAY_THEIR_TURN the grid is displaying your own ships while your hand is resting on it, so presses must be dead. Not silently dead — a brief dim-and-restore on the pressed cell acknowledges the press and communicates “not now” better than nothing happening at all.
Menus
One encoder means one gesture vocabulary: rotate, press. Keep every list two levels deep at most, and end every list with an explicit way back — nobody discovers a long-press on their own.
Out of game
OCEAN BATTLE
Start Game
6 x 6
8 x 8
10 x 10
12 x 12
< back >
Rules
Hit — Fire Again [NO]
Salvo [NO]
One-Hit Sink [NO]
Ships Can Touch [YES]
Announce Ship Sunk [YES]
< back >
Brightness: 6 this console's panel only
Back to Title
Rules is top-level, not a step in the start flow. Most games use the same rules as the last one, and burying them inside Start Game makes them friction every single session.
Placement method is not in this menu. It is a per-console prompt that appears after the host commits a board size, so one player can auto-place while the other places by hand.
Volume is not on the console. The single speaker lives in the brain, and so does its control — a 0–5 dial on the brain's own screen, where 0 renders as OFF. Nothing about volume crosses the link, so the console menus carry only Brightness, which dims that console's own panel. This deletes the old two-controls-for-one-thing problem entirely — there is no volume on the console to contradict anything.
In game
Brightness: 6this console's panel onlyEnd Game Restartsame size and rules, re-place fleetsBack to Title< back >Resume
Ending the game takes effect immediately on both consoles — no cross-console confirmation, no permission request. The two players are sitting at the same table and can speak to each other. The opponent's console does get told why it left the game, so it shows PLAYER 1 ENDED THE GAME rather than dropping to the title with no explanation.
Likewise, a player wandering into settings on their own turn is not a state the firmware needs to broadcast. They will get told to hurry up by the person opposite.
Rules
Five toggles, host-owned, shared, persisted. All five are available at every board size.
| Rule | Default | Effect |
|---|---|---|
| Hit — Fire Again | NO | A hit grants another shot, repeating while you keep hitting. Shortens games; a hot streak can end a 6×6 almost immediately. |
| Salvo | NO | Fire one shot per surviving ship, all declared before any result returns. Accelerates the winner and handicaps the loser, which is what fixes long games on big boards. |
| One-Hit Sink | NO | Any hit sinks the whole ship. Much faster, much lower skill ceiling — length becomes target size and nothing else. |
| Ships Can Touch | YES | Off forbids contact including diagonals, shrinking the legal placement space and making the hunt harder. |
| Announce Ship Sunk | YES | Off tells the attacker only that something sank. Withholding the name is meaningfully harder — you cannot infer what lengths remain. |
Interactions the firmware must handle
Salvo greys out Hit — Fire Again. An extra full volley per hit compounds catastrophically: five shots landing three hits would spiral immediately. When Salvo is on, the Hit — Fire Again row is shown greyed with the reason on the panel rather than silently ignored.
No-touch needs a robust auto-placer. With contact forbidden, three ships plus their exclusion halos nearly fill a 6×6, and incremental placement can paint itself into a corner. Implement as full restart on failure with an attempt cap, not backtracking — simpler and faster in practice.
Sunk display is gated per player, not globally. The defender always knows their own ship died, so their fleet view goes dark regardless of the Announce setting. Only the attacker's view is gated: with Announce off, sunk cells stay red on the firing view because the attacker has not been told.
Where rules are shown
The guest never chooses the rules, so the guest must be told them before placing a fleet. The standby and placement screens both carry a rules strip: Rules: Salvo · No-Touch, listing only non-default settings. A player placing ships without knowing whether contact is legal is being set up to fail.
Fleets
Eight hulls, each with a distinct root word. Same-length pairs — the two 4s and the two 3s — carry no disambiguating hit-count, so their names do all the work and are deliberately far apart.
| Cells | Name | 6×6 | 8×8 | 10×10 | 12×12 |
|---|---|---|---|---|---|
| 6 | Mega Carrier | — | — | — | ● |
| 5 | AntiSub Cruiser | — | — | ● | ● |
| 4 | Battleship | — | ● | ● | ● |
| 4 | Frigate | — | — | — | ● |
| 3 | Submarine | ● | ● | ● | ● |
| 3 | Destroyer | — | ● | ● | ● |
| 2 | Patrol Boat | ● | ● | ● | ● |
| 2 | Torpedo Boat | ● | — | — | ● |
| Board | Cells | Ships | Ship cells | Density | Opening salvo |
|---|---|---|---|---|---|
| 6 × 6 | 36 | 3 | 7 | 19% | 3 |
| 8 × 8 | 64 | 4 | 12 | 19% | 4 |
| 10 × 10 | 100 | 5 | 17 | 17% | 5 |
| 12 × 12 | 144 | 8 | 29 | 20% | 8 |
The 10×10 fleet is the classic complement at the classic density. The 12×12 is deliberately denser — 144 cells hunting a sparse fleet is a twenty-minute game — and it is the board where Salvo earns its keep.
Setup & placement
Auto
The brain generates a legal fleet and pushes it to the console, which lights it on the grid in white while the panel offers Shuffle and OK. Shuffle requests a fresh layout; OK commits.
An auto-placed fleet is editable — press any ship to lift it back into selection and reposition it. This costs nothing once the manual validator exists, and it turns Shuffle from take-it-or-leave-it into a good starting point.
Manual
Rotate to choose a ship. Press the cells it occupies. Press a selected cell again to drop it. Encoder press confirms, and stays inert until the selection is legal.
Validity is three conditions, and between them they reject bends, gaps and wrong lengths:
- Every cell shares a row, or every cell shares a column
- Span from lowest to highest equals the number of cells selected — no gaps
- That count equals the ship's length
Plus, when Ships Can Touch is off, no selected cell may be orthogonally or diagonally adjacent to a committed ship.
Refusals are explained, never silent. The panel says which condition failed — SHIPS MUST BE IN A STRAIGHT LINE, 3 OF 5 PLACED, SHIPS MAY NOT TOUCH — while the encoder simply does not respond to a press. An inert control with a stated reason beats a beep.
There is no “Replace ship?” dialog. Pressing a placed ship lifts it. Direct manipulation handles what a modal would otherwise interrupt for.
Committing
When every ship is placed, the panel offers Ready. Confirming sends the fleet to the brain and the console enters READY: STAND BY FOR OTHER PLAYER, with the grid holding the fleet in white so it can still be reviewed. When both fleets arrive, the brain starts the game with the host firing first.
The play loop
Standard fire
Press a cell to arm it — flashing yellow. Press the same cell again within the window to fire. Because the window is a full five seconds, that first press is genuine aiming, and the cursor is a real UI element with its own rules:
- A press on any other cell only disarms. Whatever you press within the window — fired or unfired, adjacent or across the board — clears the armed cell and arms nothing. That press is spent entirely on clearing, exactly as if the 5 seconds had elapsed. To arm the new cell you press it again.
- So the cursor has exactly two ways to end: press it a second time to fire, or clear it (any other press, or the timeout) and start over. There is no way to move an armed cursor — arming is always a fresh press on an idle cell.
- The trade is deliberate: a stray brush can cost you your aim, but it can never cost you a shot.
- Expiry is visible — the cursor fades over the final second rather than vanishing mid-decision.
- Encoder press cancels the cursor, since the encoder is otherwise idle during your turn.
- Already-fired cells are blocked as targets. Pressing one clears any armed cursor like any other cell, but it cannot itself be armed — brief flash,
ALREADY FIREDon the panel, nothing armed.
On the second press the cell fast-flashes yellow, then resolves to red or dim green. Results are computed by the brain and pushed to both consoles simultaneously — the attacker sees a hit appear on their firing view, the defender sees the same cell go red on their fleet at the same instant.
Salvo fire
Salvo declares all shots before any results return — that is the entire point, and it means the double-tap-resolve gesture cannot be used. Salvo gets its own interaction:
- Press cells to arm them, each flashing yellow, up to your surviving-ship count
- Press an armed cell again to disarm it
- Encoder press commits the whole volley
- One FIRE tone plays at commit. Then results resolve one cell at a time, each with its own tone and animation — the tone and the button light in lockstep, so the sound always matches the cell currently resolving
The panel carries the count: VOLLEY: 5 OF 7 ARMED. A partial volley is permitted but the panel says so before committing, so nobody throws away shots by accident.
With Announce Ship Sunk on, a single volley can sink more than one ship, so the sink message must handle a list rather than one name.
Turn end
After the dwell expires the brain advances the turn and both consoles swap surfaces. With Hit — Fire Again on and a hit landed, the turn does not advance and no swap occurs — the panel says HIT — FIRE AGAIN so the reason for the missing swap is explicit.
Game over
When a player's last ship sinks the brain declares the result and pushes both fleets to both consoles. Each grid shows its own fleet in final state; each panel shows the opponent's complete layout — including the ships that were never found, which is the most interesting information in the game and the reason to reveal at all.
Options, on both consoles, host selects:
- Rematch — same fleets, same rules, straight back into play
- New Game — same rules, re-place fleets
- Back to Title — host bidding starts over
Colour language
This is what players actually read, and it is the spec most likely to be got wrong by eye later. Two principles govern the whole table.
Brightness carries meaning independently of hue. Hits are bright, misses and water are dim. This keeps hit-and-miss separable for a colourblind player — red and green being the classic collision — and it makes the interesting cells pop out of the board. It also keeps 144 pixels inside the current budget, since the dominant colour is dim.
White appears only on your own fleet. The firing view is blue, green, red and yellow, never white. That single rule is what makes the two views distinguishable at a glance through diffusing silicone.
A sunk ship going fully dark would destroy the record of where you hit — which is exactly what you use to infer what is left afloat. Sunk cells go dark but retain a very dim red, so the blackout still reads as a kill while the plot stays legible.
Two pairs were deliberately separated. Water is deep and dim rather than light blue, because a pale blue through silicone lands very near white and those are the two colours that must be told apart most often. The cursor is yellow rather than orange, for a wider gap from red — the cursor must never read as a result.
Attract mode
At the title screen the grid runs a rainbow snake — a short trail, eight to twelve cells long, wandering the board with hue advancing along its body and cycling slowly over time. It never reverses into itself and it bounces off the edges; there is no collision to detect because nothing is at stake.
Two reasons this is the right attract mode rather than an ambient wash of the whole board. It draws a fraction of the current, since only a dozen pixels are lit at once. And a moving object reads as alive from across a room in a way that a slow fade does not, which is the entire job of an object sitting on a shelf.
The snake is drawn by the console, not the brain — the brain says MODE: TITLE and the console owns every frame. The two consoles run independent snakes and will drift out of step, which is correct: they are two separate objects, not one display.
What each surface shows
Your fleet
Grid on their turn · panel on yours
Your shots
Grid on your turn · panel on theirs
Sound
Six sounds, one speaker, and the speaker lives in the brain. No menu ticks, no key clicks, no turn chimes, no confirmation blips. The grid and the panel already confirm every press visually; a sound that only says “you pressed a thing” is noise.
| Sound | Fires when | Notes |
|---|---|---|
| FIRE | A shot is committed | Plays on the fast-flash, before the result |
| MISS | Result resolves to water | |
| HIT | Result resolves to a hull | |
| DESTROYED | A ship's last cell is hit | Replaces HIT on the killing shot — never both. Suppressed entirely when Announce Ship Sunk is off |
| WINNER | Last ship of a fleet sinks | Plays once, heard by both |
| VOLUME | Volume value changes | Plays at the new level. The one permitted menu sound |
The volume tone
Stripping out menu sounds leaves the volume control with nothing to be judged against — you turn the dial and hear silence until somebody fires. So volume gets its own tone, played at the level being set. It lives entirely on the brain, on the same screen and dial as the speaker it controls.
This is the single exception to the no-menu-sounds rule, and it holds because it is not confirming a press. It is the setting, made audible.
- Constant pitch, varying amplitude. If the pitch rose with the level too, you could not tell which of the two you were hearing — and higher pitches already read as louder. Only loudness should change, or the tone is not an honest test of loudness.
- One tone when the dial settles. Turning tracks the level silently; a beat after the knob stops (~120 ms), a single tone plays at the level you landed on. That keeps a fast spin from machine-gunning the module while still telling you exactly where you ended up.
- Level 0 plays nothing, which is the correct demonstration of
OFF. - No cross-link message. Volume lives on the brain, which owns the speaker, so it sets the level and plays the tone locally. Nothing about volume travels over the link, and the protocol carries no volume message at all.
What one shared speaker changes
Sounds become events in the world, not feedback to a player. A single speaker in the middle of the table cannot say “you were hit” — it can only say that something was hit. That is thematically stronger than two consoles chirping at their owners, and it costs nothing, because the panel already names ownership in words. The speaker carries the event; the screen carries whose it was.
Volume lives entirely in the brain. One speaker, one volume, one place to set it — the brain's own screen and dial, saved in brain NVS. It never becomes shared console state, so there is no cross-console sync, no last-write-wins, and no volume in the console menus at all. Brightness stays per-console, since each console has its own panel and grid.
A shared speaker cannot tell one player something and not the other. So when Announce Ship Sunk is off, DESTROYED is suppressed for everyone — including the defender, who is entitled to know and will still see their own cells go dark. That is the price of one speaker, and it is a small one, but it is a real asymmetry with the panel, which still tells the defender by name.
Sequencing
- Salvo fires once. One FIRE tone at volley commit, not one per shot — eight in a row is a stutter, not a broadside.
- Then each cell resolves in turn, tone locked to its own animation. A cell's HIT, MISS or DESTROYED tone plays while that cell runs its animation; the next cell does not begin until the current one's clip finishes. The audio is therefore always describing the button that is lit — never a cell ahead or behind.
- Resolution order is the brain's to choose and need not match arming order. Whatever sequence it picks, the tone follows the light. A natural choice is misses first and the killing blow last, so a volley builds rather than fizzles — but that is a flavour decision, not a correctness one.
- A volley may sink more than one ship, so DESTROYED can play more than once in a single resolution sequence.
- WINNER waits for the final DESTROYED to finish rather than overlapping it.
Hardware consequence
Audio moves off both consoles and onto the brain: one I2S amplifier and one speaker instead of two of each. Both ESP32-S3s get their audio pins back, and the brain gains an I2S peripheral it did not previously need. This is a change to the wiring document, not just this one.
Screen copy
Strings are IDs in the protocol, not literals — the brain sends STR_SANK_THEIRS plus a ship index, and the console composes. That keeps the wire narrow and makes wording changes a console-side edit.
| ID | Text | When |
|---|---|---|
| STR_TITLE | OCEAN BATTLE / Press Dial to begin | Title |
| STR_WAIT_HOST | PLAYER 1 IS CHOOSING… | Guest, host in menus |
| STR_STANDBY | STAND BY FOR OTHER PLAYER | Fleet committed, opponent still placing |
| STR_SETTINGS_HINT | Press Dial for Settings | Small, bottom corner, standby and play |
| STR_PLACE | PLACE SHIPS | Manual placement header |
| STR_PLACE_COUNT | %d OF %d PLACED | Partial selection |
| STR_ERR_LINE | SHIPS MUST BE IN A STRAIGHT LINE | Bent or gapped selection |
| STR_ERR_TOUCH | SHIPS MAY NOT TOUCH | No-touch rule violated |
| STR_READY | READY? | All ships placed |
| STR_YOUR_FLEET | YOUR FLEET | Permanent panel header |
| STR_YOUR_SHOTS | YOUR SHOTS | Permanent panel header |
| STR_YOUR_TURN | YOUR TURN | Turn start |
| STR_THEIR_TURN | THEIR TURN | Turn start |
| STR_ALREADY | ALREADY FIRED | Blocked repeat shot |
| STR_HIT | HIT | Attacker, hit |
| STR_MISS | MISS | Attacker, miss |
| STR_HIT_TAKEN | THEY HIT YOUR %s | Defender, hit taken |
| STR_SANK_THEIRS | YOU SANK THEIR %s | Attacker, Announce ON |
| STR_SANK_ANON | SHIP SUNK | Attacker, Announce OFF |
| STR_SANK_YOURS | THEY SANK YOUR %s | Defender — always named |
| STR_FIRE_AGAIN | HIT — FIRE AGAIN | Hit—Fire Again, turn not advancing |
| STR_VOLLEY | VOLLEY: %d OF %d ARMED | Salvo arming |
| STR_VOLLEY_PART | COMMIT %d SHOTS? | Salvo, under-armed |
| STR_WIN | FLEET DESTROYED — YOU WIN | Game over, winner |
| STR_LOSE | YOUR FLEET IS LOST | Game over, loser |
| STR_ENDED | PLAYER %d ENDED THE GAME | Opponent quit |
| STR_LINK | LINK LOST — CHECK CABLE | Heartbeat timeout |
| STR_PRACTICE | PRACTICE — ONE CONSOLE | Single console detected |
Note that STR_SANK_YOURS is always named while STR_SANK_THEIRS is gated. The defender knows their own fleet; only the attacker's knowledge is rationed.
Message set
UART at a deliberately low baud (19 200) with generous filtering, brain-authoritative. Consoles report physical events; the brain returns render instructions.
Cell state enum
Eleven states, so four bits. A dirty-cell update is one byte of index — 144 cells fits comfortably — plus a nibble of state, packed two cells to three bytes.
| Val | Name | Colour |
|---|---|---|
| 0 | WATER | Deep blue, dim |
| 1 | SHIP | White — own fleet only |
| 2 | HIT | Bright red |
| 3 | SUNK | Very dim red, keeps the plot legible |
| 4 | MISS | Dim green |
| 5 | ARMED | Slow-blink yellow (reserved — aiming is console-local) |
| 6 | LAUNCHING | Fast-blink yellow — brain-commanded during the launch phase |
| 7 | OFF | Inert / dark |
Animation is console-side. The brain sends a state, not a frame — LAUNCHING means “fast-blink this yellow until told otherwise,” and the console owns the blink rate. This is what keeps the link at a few hundred bytes a second.
Aiming and placement visuals are console-local and never cross the link. The slow-blink armed cursor, the pulsing “pending” ship during manual placement, the confirmed-white and the red reject-flash are all rendered by the console itself — the brain isn't told about them and carries no state for them. The brain only hears the committed result (a FIRE, or a finished placement). That's why ARMED sits in the enum as reserved: the launch phase uses LAUNCHING, while the armed cursor is drawn locally.
Console → Brain
| Message | Payload | Meaning |
|---|---|---|
| HELLO | fw version, player id | Console booted; player id from the ID resistor |
| BID_HOST | — | Dial pressed at title |
| KEY | index, down/up | Grid press |
| ENC | signed delta | Encoder rotation |
| ENC_PRESS | — | Encoder pressed |
| SET_SIZE | board size | Host committed a board |
| SET_RULE | rule id, value | Host toggled a rule |
| SHUFFLE | — | Request a fresh auto fleet |
| FLEET_OK | ship list, cells | Placement committed |
| FIRE | cell | Standard shot |
| VOLLEY | cell list | Salvo, committed |
| END_GAME | restart / title | Player ended the game |
| AGAIN | rematch / new / title | Game-over choice |
| PING | — | Heartbeat |
Brain → Console
| Message | Payload | Meaning |
|---|---|---|
| ROLE | host/guest, player no. | Result of the host bid |
| CONFIG | size, rule flags | Shared settings — pushed to both |
| MODE | mode id | Enter this mode |
| FLEET | ship list, cells | Auto-generated layout to display |
| CELLS | dirty list: index + state | The core render message |
| CELLS_ALL | full board | Recovery after link loss |
| PANEL | screen id, args | Selects a fixed console string; args fill its blanks |
| TURN | mine / theirs, shots | Turn ownership and salvo count |
| RESULT | cell, outcome (miss/hit/sink), ship id or none | Shot outcome, already gated |
| OVER | win/lose, both fleets | Game over and reveal |
| PONG | — | Heartbeat reply |
There is no audio message in either direction. The brain owns the speaker and plays its own sounds as it resolves each shot, so the consoles never learn that a sound occurred — which is one more thing they cannot leak.
RESULT carries a ship id only when the receiving player is entitled to it. With Announce Ship Sunk off, the attacker's RESULT has no ship id at all — there is nothing on the console to leak.
Timings
| Event | Duration | Notes |
|---|---|---|
| Cursor window | 5 s | Long enough that the first press is real aiming |
| Cursor fade | final 1 s | Expiry must be visible, not sudden |
| Launch — sound on | = FIRE clip | Neutral fast-yellow, held for the launch clip (via the BUSY pin) |
| Launch — sound off | 0.4 s | Fixed snappy beat when volume = 0 |
| Result — sound on | = result clip | MISS / HIT / DESTROYED clip length (via BUSY) |
| Result — sound off | 0.6 s | Fixed snappy beat when volume = 0 |
| Salvo reveal | 1 launch + result / cell | One launch for the whole volley, then each cell resolves in turn |
| Rejected press flash | 0.3 s | Red, then off |
| Heartbeat | 500 ms | |
| Link-lost threshold | 2 s | Four missed beats |
| Idle timeout | 10 min | Attract mode only, never in-game; then grid dark, panel asleep, wake on any press |
When sound is on, the hold is however long the clip runs — a 1.5 s splash gives a 1.5 s miss animation, a three-second explosion gives a three-second sink. The brain doesn't need to know the clip's length: it watches the DFPlayer's BUSY pin and holds the render state until the clip finishes, then advances. Swap a sound file for a longer one and the animation stretches to match with no code change.
When sound is off (volume 0) the clip still plays silently, so the brain ignores BUSY and uses short fixed beats instead — launch ~0.4 s, result ~0.6 s — keeping a muted game snappy. There is no skip: the shot sequence is uninterruptible, and a press during resolution does nothing. Turning the volume off is the fast path, so there's no separate skip control to collide with the fire button.
Tying the two together means the sound files now set the pace of the game. That is fine for hit and miss, but FIRE plays before the result and must stay short — a long launch sound is dead air on every single shot, sixty times a game. Keep FIRE well under a second; spend the length budget on DESTROYED, which is rare. And in Salvo, FIRE plays once at commit while each cell still waits for its own clip, so a slow HIT clip multiplies across a volley — worth hearing on hardware before locking the clip lengths.
Persistence
Settings survive a power cycle, stored where they are owned.
| Setting | Stored on | Store |
|---|---|---|
| Volume | Brain | NVS — one speaker, one setting |
| Brightness | Console | NVS |
| Rules (all five) | Brain | NVS |
| Last board size | Brain | NVS — pre-selects in the size menu |
Nothing about an in-progress game is persisted. A power cut ends the game, which is the honest behaviour for a tabletop object.
Practice & link loss
Practice mode
If the brain sees only one HELLO, it offers practice: the single console plays against the brain, which places a fleet and takes turns. Every rule, board size and interaction works identically — the brain is already the umpire, so the only new code is an opponent that chooses cells.
That opponent is hunt-and-target. It fires at random until it lands a hit, then switches to working the four orthogonal neighbours of that hit; once two hits line up it follows the axis until the ship sinks or the trail runs dry, then returns to random. Perhaps thirty lines, and the difference between a test harness and something worth playing.
This is worth building early. It exercises the entire play loop with half the hardware, which matters a great deal while the second console is still a pile of parts.
Link loss
Four missed heartbeats and the console enters LINK_LOST: grid dark, LINK LOST — CHECK CABLE on the panel, all input inert. Freezing on a stale board while quietly doing nothing is the failure mode to avoid — a dead grid is honest and a frozen one is not.
On reconnection the brain pushes CONFIG, MODE and CELLS_ALL, and play resumes exactly where it stopped. Brain-authoritative state is what makes this a non-event rather than a lost game.
Still open
Six slots are defined; what actually plays is not. DESTROYED is the one worth spending effort on — it is the moment the whole game is built around, and it is rare enough to be allowed some length. VOLUME wants the opposite: short, neutral, and deliberately unlike any game sound, so it never reads as an event on the board.
