Firmware Architecture¶
This page describes how the firmware source is organised, how the program starts, and how the pieces cooperate at run time. For how to compile and flash it, see Firmware; for the card encoding it depends on, see the Optical Code System.
The firmware is plain object-oriented C++11. It targets the Arduino Nano (ATmega328P) — a small 8-bit AVR with 2 KB of RAM — which shapes many of the design choices below (no STL, manual memory accounting, no threads).
Source layout¶
The source code for the firmware lives under firmware/src/:
| Area | Files | Role |
|---|---|---|
| Entry point | Main.cpp |
Arduino setup() / loop(); wires everything together |
| Game logic | Game.*, Player.*, PlayerChooser.* |
Turn flow, player ring, startup player-count selection |
| Cards | Cards/Card.* + subclasses |
Card behaviours and the code→card factory |
| Devices | Devices/CodeDetector.*, OutputDevice.*, PiezoBuzzer.*, SevenSegmentDisplay.* |
Hardware drivers |
| Mock devices | Mock/CodeDetectorMock.*, Mock/OutputDeviceMock.* |
Serial stand-ins for the card reader and pump/valve, compiled in with -D MOCK_CODE_DETECTOR / -D MOCK_OUTPUT_DEVICE |
| Scheduling | Tasks/Task.h, Tasks/TaskScheduler.* |
Cooperative multitasking |
| Helpers | Helper/Vector.h, Helper/Diagnostic.h, Helper/Log.* |
STL-free container, RAM probe, printf logging |
| Config | Constants.h |
Pins, codes, display glyphs, note frequencies, the flash-string printf macro, build switches (AUTOSTART_GAME, MOCK_CODE_DETECTOR, MOCK_OUTPUT_DEVICE, DETECTOR_CALIBRATION) |
The Mock/ subtree lets the game run with missing hardware: build with
-D MOCK_CODE_DETECTOR and/or -D MOCK_OUTPUT_DEVICE and Main.cpp swaps that
device for a serial-driven stand-in (type codes in, watch the balloon volume
print out). Each is independent. See Mock devices below for
how it works.
There is also a Devices/ trio that is not part of the running game:
Button, Led and SoundDetector are now unused general-purpose drivers
used during development. SoundDetector was used in experiment to detect the
actual pop of the balloon and react to it, instead of the modelled
volume described in How It Works.
It was discarded for the final game.
Startup and object ownership¶
Most hardware drivers are singletons. Each exposes a public
static X* instance pointer, which setup() populates with a single new at
boot:
// Main.cpp (excerpt)
SevenSegmentDisplay::instance = new SevenSegmentDisplay(PIN_9, PIN_10, PIN_8);
TaskScheduler::instance = new TaskScheduler();
OutputDevice::instance = new OutputDevice(PIN_6, PIN_7);
CodeDetector::instance = new CodeDetector(PIN_A0, PIN_A1, PIN_A2, PIN_A3, PIN_A4);
PiezoBuzzer::instance = new PiezoBuzzer(PIN_11);
PlayerChooser::instance = new PlayerChooser(CODE_15, CODE_17, CODE_19, CODE_21, CODE_23);
Any part of the code can then reach a device through, for example,
OutputDevice::instance->apply(...) without passing pointers around. Because
these objects are created once and live for the whole program, there is no
teardown to worry about. The one non-singleton top-level object is the game
itself, declared as a file-scope global in Main.cpp:
Game game;
setup() also seeds the random-number generator from the floating analog pin
A5 (randomSeed(analogRead(PIN_A5))) — which is why the
wiring insists that A5 stay unconnected.
The pin numbers, code values, and display glyphs all come from Constants.h,
which also carries four compile-time switches: DETECTOR_CALIBRATION and
AUTOSTART_GAME, described under Build modes, and
MOCK_CODE_DETECTOR/MOCK_OUTPUT_DEVICE, described under
Mock devices.
Cooperative multitasking¶
The AVR has no operating system and the firmware uses no interrupt-driven
scheduler. Instead there is a tiny cooperative scheduler in
Tasks/TaskScheduler. The contract is a one-method interface:
class Task {
virtual bool applyChange(unsigned long timeMs) = 0; // returns "still busy?"
};
A task advances its own animation by looking at the current time and returns
true while it still has work to do. Both SevenSegmentDisplay and
PiezoBuzzer are Tasks, so a melody and a display animation are each just a
task that the scheduler ticks. The scheduler drives them on a fixed 20 ms
cadence (TaskScheduler::SLEEP_MS = 20) and offers two ways to run:
loop()— blocking. Ticks every registered task every 20 ms and keeps going until all of them report done, then clears the task list. Used when the game genuinely wants to wait for a jingle/animation to finish (e.g. the turn-change fanfare).iterate()— non-blocking. Ticks every task exactly once and returns immediately (clearing the list only once everything is done). Used when other work must continue in parallel.
The payoff shows up during balloon inflation. OutputDevice::applyIntensities()
runs a delay(5 ms) loop while it pulses the pump/valve, and inside that loop
it calls TaskScheduler::instance->iterate() on every step. So the buzzer tone
and the seven-segment "fill" animation keep advancing concurrently while the
balloon is being inflated or deflated, even though everything is single-threaded
and there are no interrupts.
Game state and turn flow¶
The player ring¶
Players are modelled as a circular doubly-linked list of Player objects
(prevPlayer / nextPlayer). The list is built by a recursive constructor:
new Player(numPlayers) creates the whole chain in one call and stitches the
ends together so that following nextPlayer forever cycles through the table.
Each Player owns its currently-attached cards in a Vector<Card*>.
Turn order is just "advance to nextPlayer". The
Change of course cards work by literally swapping
every player's prevPlayer/nextPlayer pointers (Player::changeDirection()),
reversing the direction of play.
Two-phase card insertion¶
Game::loop() reads the card reader once per iteration and drives a small state
machine around a single currentCard pointer:
- No card pending (
currentCard == 0): the detected code is handed to theCard::playCard(code)factory.- If it returns a card that does not need a target
(
needsPlayerSelection() == false), the card is applied to the current player immediately. - If it does need a target (the Blocking, Devil's messenger, and
Apocalypse cards override
needsPlayerSelection()totrue), the card is held and the machine waits for a second card. - If the factory returns
nullptr, the code is not a valid play card and the player gets an error beep.
- If it returns a card that does not need a target
(
- Card pending: the next card must be a player card
(
PlayerChooser::isPlayerCode). If it is, the held card is attached to that target player; otherwise it is an error.
After a card is applied the machine sets waitCardRemoved, and the turn only
advances once the slot reads CODE_ALL (the "card removed" sentinel — see the
optical codes page). This is what forces the
"insert, then pull the card back out" rhythm of play described in the
Rules.
Losing¶
There is exactly one loss condition, checked every Game::loop():
if (OutputDevice::instance->volume > 100.0f) { /* current player loses */ }
volume is the modelled balloon fill (a software time-integral, not a
sensor reading — see How It Works).
When it exceeds 100 %, the current player loses, the device runs a reset/deflate
sequence, and the game waits for a card to be removed before it can be
restarted.
The card model¶
Card is an abstract base class. Concrete behaviours subclass it and override
play():
VolumeCardis the workhorse: apply a percentage volume change at a given intensity, then discard itself. Most "pressure" cards are justVolumeCardwith different numbers.- Subclasses add behaviour:
ChangeDirectionCard(volume change and reverse the player ring),AngelCard/DevilCard(delayed relief / malicious inflate),FiftyFiftyCard(random ±50 %),UpDownCard(oscillate),MissRoundCard,SuddenDeathCard, andPushToLimitCard(inflate to 99 %).
The mapping from optical code to card lives in a single static factory,
Card::playCard(code), which is a plain switch — the authoritative,
code-verified rendering of that mapping is the deck catalog in
The Card Deck. A card marks itself discard = true
when its effect is spent, and Player::loop() sweeps discarded cards out of the
player's vector.
Helpers and diagnostics¶
Because the AVR toolchain here ships no STL, the firmware carries a few hand-rolled utilities:
Vector<T>(Helper/Vector.h) — a minimalstd::vector-like dynamic array (push_back, iterators, copy-assign, grow-by-5 reserve). Used for the per-player card list and the scheduler's task list.freeRam()(Helper/Diagnostic.h) — reports free SRAM by measuring the gap between the heap break and the stack. Handy on a 2 KB part; it is printed on each turn change.Log(Helper/Log.cpp) — redirects Cprintfto the ArduinoSerialport, so the rest of the code can log with ordinaryprintf(...)format strings. Logging is always on; aprintf→printf_P(PSTR(...))macro inConstants.hkeeps the format strings in flash so it costs almost no RAM.
Build modes and logging¶
Constants.h exposes several compile-time knobs that change what gets built:
DETECTOR_CALIBRATIONreplaces the gameloop()with a bring-up harness that just prints raw analog readings from the card reader, used to tune the reader thresholds. Off by default (the normal game). See the Optical Code System.
Serial printf logging is always compiled in — the format strings live in
flash (printf_P/PSTR), so it costs almost no RAM.
There is also an AUTOSTART_GAME switch that skips the player-count selection
and jumps straight into a 2-player game — a convenience for testing.
Mock devices¶
The Mock/ subtree is a desk-testing path: run the game with missing
hardware. Each device is mocked independently by its own build flag, so you can
stand in for one and keep the other real, or mock both:
CodeDetectorMock(build with-D MOCK_CODE_DETECTOR) replacesCodeDetector. Instead of reading the five photoresistors, it reads a card code you type into the serial console — a number followed by<enter>"inserts" that card, an empty line "pulls it out" again. It subclassesCodeDetectorand overridesreadCode().OutputDeviceMock(build with-D MOCK_OUTPUT_DEVICE) replacesOutputDevice. Instead of driving the 12 V pump and valve, it prints the balloon volume to serial after each inflate or deflate, as a percentage and a little ASCII bar. It keeps the real volume model (fill/deflate timing) and only swaps out the physical pin writes.
Either flag can be set on the command line or uncommented in Constants.h.
To make the output mock possible without duplicating the volume model,
OutputDevice exposes three protected virtual hooks — writeMotor(),
writeValve(), and afterApply() — that the real device implements as pin
writes and a no-op, and the mock overrides to suppress hardware and report
volume.
Because the mocks talk over serial, a build with either mock also gets a 2.5 s
startup delay so the serial link settles before the opening prompt prints
(printf logging is always on). Combine them with -D AUTOSTART_GAME to boot
straight into a 2-player game and watch the balloon fill on the console.
The unbuilt mode system¶
There was a design for a game-mode selector built around the Yin Yang card (optical code 27) that was specced but never implemented. The idea, captured in the design note Anforderungen Moduswechsel ("mode-change requirements"), was that the Yin Yang card would cycle through four play modes: you insert the card repeatedly before committing a player card, each insertion advances the selected mode (shown on the display), and you pull the card out once the mode you want is selected.
The four intended modes were:
| Mode (German) | English gloss | Display glyph | Behaviour |
|---|---|---|---|
| Standard | Standard | S |
Normal game, no extras. |
| Himmel | "Heaven" | circle | Every third round, deflate a random player's balloon a little. |
| Hölle | "Hell" | minus | Every third round, inflate a random player's balloon a little. |
| Provokation | "Provocation" | P |
Start the game with the balloon already partly filled. |
What actually shipped for code 27
The mode system was never built. In the shipped firmware, optical code 27
is instead wired to a single card behaviour — PushToLimitCard(255),
which inflates the balloon to 99 % in one go (see
The Card Deck). So the Yin Yang card exists and is
playable, but as a one-shot "push to the limit" wildcard rather than as the
four-way mode selector its design note envisioned.
The design note itself is explicit that these were early ideas ("these are just ideas for now"), so nothing here was a broken promise — it is simply a feature that stopped at the design stage.
Source: the original German design note is archived alongside this page as
anforderungen-moduswechsel.txt.