These rules apply to everyone changing this repository, human or agent. CONTRIBUTING.md covers how a change reaches the protected main and dev branches; this file covers what the change itself has to honour.
No AI attribution in commits -- and no AI in the names either. Never add
Co-Authored-By: Claude, Co-Authored-By: naming any AI, "Generated with..."
footers, or any trailer that credits a model or tool. This applies to commits,
amends, squashes and PR bodies. The history here records a human author.
The same goes for the word itself. No claude, and no other model or vendor
name, anywhere a contribution leaves a trace: branch names, commit subjects and
bodies, PR titles and descriptions, file names, identifiers, comments and TODOs.
A branch called claude/fix-the-thing records who typed rather than what
changed, and unlike a session it is permanent -- it is in the merge commit, the
pull request, and every clone, long after anyone remembers which tool was open
that day. Name a branch for its work instead: feat/<game-id>, fix/<area>,
docs/<topic>. If you find yourself on a branch that breaks this, rename it
before you open the pull request (git branch -m <new-name>).
The CLAUDE.md files are the one exception, because that filename is how an
agent finds this rulebook at all. The rule is about what a change carries, not
about what the rulebook is called.
Docs are part of the change, not a follow-up. If a change alters behaviour,
architecture, dependencies, screens, settings, the game list or the build, then
in the same commit update README.md (feature set, game count, the flash/RAM
figures from your own pio run, version), this file (architecture, invariants,
build), AGENTS.md (agent protocol) and the relevant directory CLAUDE.md.
A README claiming the wrong game count or a stale flash figure is a defect
belonging to whoever last changed the thing it describes.
Run python tools/check_docs.py and python tools/check_boards.py before
you commit. It fails if the version,
the game count, the source-tree listing or the build figures have drifted, and
if the About app has started restating facts instead of deriving them. These
rules existed and the docs went stale anyway -- the README shipped claiming 23
games when there were 26, and the source listing missed three files that had
been added. Asking for vigilance does not work at the end of a long change; the
check does. It is not a substitute for reading the prose, only for the parts a
machine can catch.
If your change alters a screen's layout, or adds or removes one, regenerate the
mock-ups in the same commit: python tools/gen_screens.py. docs/screens/ kept
images of the deleted Countries game for two releases, and the Settings picture
showed a grid that no longer existed. A mock-up of a screen that is not there
any more is a worse lie than a missing one.
The repository has been GPL-3.0-or-later since it was published, and LICENSE,
NOTICE.md and the README all say so. None of that travels. A licence at the
root of a repository is a claim about the repository; the thing that actually
reaches a stranger is one file -- a .cpp pasted into a forum answer, a tool
script copied into another tree, a page of documentation lifted into a wiki.
Split from its repository, an unmarked file carries no author, no terms and no
way back to either, and whoever took it is not being dishonest: they have
nothing to go on.
So every text file that supports a comment carries the notice itself -- the
SPDX licence identifier, the copyright holder, where the work came from, and
what reuse requires. Source, headers, scripts, workflows, platformio.ini, the
site template, the SVGs; documents carry it as a visible footer instead,
because a reader lifting a paragraph out of a rendered page never sees an HTML
comment. It grants nothing new and takes nothing back. It is the licence the
project already had, written where it can be seen.
python tools/check_licenses.py is the check, and --fix is the fix. That
is deliberate: a rule that has to be remembered 250 times is a rule that will
be missed, which is the same reasoning as check_docs.py. It runs in CI on
every pull request. Three things follow from it:
- A new file gets a notice in the commit that adds it. Run
--fix; do not hand-type one, and do not copy one from a neighbouring file with a different extension. - A generated file gets its notice from its generator.
header_for()incheck_licenses.pyis the one definition of the wording and every generator imports it, so regenerating a table cannot quietly strip the notice and two copies of the header cannot drift apart. - The holder in a header is
iamankushpanditand nothing else. A header that disagrees with LICENSE is worse than no header, because it is the one a downstream reader will rely on. The exemptions -- LICENSE itself, files with no comment syntax (JSON, binaries), and PlatformIO's stub READMEs -- are listed with their reasons at the top of the checker; add to that list only for a reason of the same kind, never for convenience.
The related rule this does not replace: trademark is not copyright.
NOTICE.md keeps them apart, and a fork renames the product while keeping the
attribution. See include/AppVersion.h.
AboutApp is the only documentation most owners will ever read, and the only
one they read while holding the device. It is part of the deliverable, not a
credits screen. Update it in the same commit as the change it describes.
It was already six games out of date once, because it kept a hand-written list. The fix, and the standing rule, is to derive rather than restate:
| About shows | Derived from |
|---|---|
| Version | BRAINO_VERSION |
| Game count and every game name/blurb | AppRegistry playable apps |
| Board name | BOARD_NAME |
| Wi-Fi status | Board::hasWifiCredentials() / isWifiConnected() |
| Beacon status and advertised name | BleBeacon::active() / configured() |
| Whether scores are being shared | BleBeacon::configured().sharesActivity |
| Branch, commit and build time | BuildStamp::branch() / commit() / builtAt() |
If you are about to type a fact into About that the firmware already knows, read it from the firmware instead. Anything genuinely static — the credits, the privacy statements — must be re-read whenever the thing it describes changes.
Nothing that identifies one physical device, one network or one person may ever be committed to this repository. Not in a tool file, not in a comment, not in a test fixture, not "temporarily", and not because a design needs it. If a workflow needs such a value, it lives on the machine that owns the hardware and it is gitignored.
This rule is written in the past tense of a failure. tools/board_registry.json
mapped six boards' MAC addresses to the exact firmware environment each was
running. It was tracked for three commits and shipped inside two release
tarballs before anyone noticed. It was created deliberately, by an agent, as
the sensible core of a tool that answers "which board is on which port" -- and
being sensible is not the test.
Understand why this particular mistake is unrecoverable. A MAC is burned into eFuse. It cannot be changed, regenerated or rotated: it names that board for the life of the silicon. Deleting the file does not un-publish it, because git history keeps every version and clones, forks, the API and release tarballs all keep their copies. And the harm is worse than a bare MAC, because the file paired each one with the firmware that board runs -- which is a list of specific devices, in someone's home, and what is on them.
So:
- Identify a board by
Board::deviceId(), not by its MAC. The firmware generates it on first boot from the MAC, the clock, 64 bits ofesp_random()and the time since boot, puts all of that through SHA-256, and keeps four bytes:R28T-9F3A2C71. The hash is what makes it safe -- it cannot be turned back into the MAC -- and theBoardProfile::idTagprefix says which model it is, which is a fact about a product rather than about a person's house. A factory reset issues a new one, which is the property a MAC can never have. - The boot banner prints
device=, notmac=. That line is what gets pasted into a public issue, which is how a "local" identifier stops being local. Do not add the MAC back to it. tools/board_registry.jsonis gitignored.board_registry.example.jsonships instead, with placeholder ids, andESP32_boardUtil.py --learnfills in the real one per machine. Reading a MAC off a chip with esptool is still correct when a board cannot introduce itself -- an empty flash, or after a merged-image install wiped NVS -- but it stays on that machine.python tools/check_identifiers.pyruns in CI on every pull request. It fails on MAC addresses in either separator style and on public IP addresses. It cannot recognise an SSID, a hostname or a child's name, so a clean run is not permission: it catches the shapes a machine can catch, and this rule covers the rest.
Storing is not publishing: a MAC held in NVS on the device, or in a gitignored file on the maintainer's laptop, is fine. Committing it is not.
Related, and still open: BleBeacon composes its advertised deviceId
from the last two bytes of the BT MAC (esp_read_mac(mac, ESP_MAC_BT)). Those
two bytes go out over the air. Moving it to Board::deviceId() would be
strictly better, but it changes what the device transmits and what peers key
their saved names on -- so it needs the maintainer's agreement first, like any
other change to the advertisement.
Braino collects nothing about the player using it, and no change may alter
that. It is not a setting that ships switched off; it is what the product is.
Exactly four things leave the device: an NTP time query, one ip-api.com
lookup to guess the timezone on first connect, the opt-in, non-connectable
BLE beacon -- which carries the Nearby fields and pokes when those are switched
on, and is still one flow rather than three -- and the daily update-availability
check.
The fourth was agreed in 5.7.0 as a deliberate change to what the product
promises, which is the only way this list may ever grow. Two properties are
what made it acceptable and both are enforced rather than intended: the
request says nothing whatsoever about this device -- no version, no board id,
no query string, which is why the manifest lists every board and the comparison
happens here -- and the address shown to the owner is compiled in, never
read out of the response, which bounds a hostile answer to being wrong about a
number rather than being able to send a child somewhere. check_privacy.py
asserts both. It runs only when Wi-Fi is already configured, and it is not
declinable; a device with no Wi-Fi never makes the request at all. See
include/UpdateChannel.h.
That list is closed. Do not add analytics, usage counters, crash reporting, any other HTTP/UDP/DNS request, any dependency that phones home at runtime, or anything transmitted that carries a player's name, profile name, score, progress or typing. A fifth outbound flow needs the maintainer's agreement in an issue before the code exists - it is a change to what the product promises, not a feature to be reviewed on merit.
Storing is not collecting: scores and profiles live in this device's own NVS and never leave it. The full statement, including what to update if a transmitted-data change is ever agreed, is in CONTRIBUTING.md.
A privacy claim that has drifted from the hardware is worse than none at all, because it is believed. If you add or change anything that transmits, stores or shares data, the About radio page and the README Privacy section are part of that change. Not a follow-up.
About is also a system app, so it follows the orientation rule: lay out against
tft.width() / tft.height(), never SCREEN_WIDTH / SCREEN_HEIGHT.
The loop runs at a 20ms budget (FRAME_BUDGET_MS) and paces to a deadline: it
sleeps only the remainder, so a slow frame is not punished twice. That budget is
the whole allowance for touch, logic and drawing. Three things have each eaten
most of it at some point in this codebase, and all three look harmless at the
call site:
- NVS reads.
Preferencesis flash-backed — every getter is a hash lookup, not a variable read.screenSaverSeconds()ran once per loop iteration andgameVisible()up to ~180 times per launcher repaint. Anything read more than once per screen change gets a write-through RAM mirror inBoard: the setter updates the mirror and NVS together, so a stale read is not possible. Theme, layout, brightness, idle timeout, active profile, game visibility, Wi-Fi credentials and the NTP hot-path settings all work this way now. Add to that list rather than reaching forprefs_in a hot path. - Blocking
delay()inside a getter. Battery sensing slept 10ms per call andUi::drawTopBar()calls two battery getters, so every top bar cost ~20ms — a whole frame — before anything was drawn. Nodelay()in anything a render path can reach. Sample on a cadence and cache. - Rebuilding content that did not change. System Info reassembled every row on every frame while scrolling. Scrolling changes an offset, not content. Gate rebuilds behind a stale flag.
- Sampling a sensor from a getter. The battery gauge cached for 2s, which
made the conversion rare without making it predictable: whichever frame
found the cache expired paid for it, and that frame was almost always a top
bar being drawn. A cache moves the cost off most frames, not off the
render path. Sampling belongs on a task --
Board::sampleBattery()andWatchdog's monitor are the two examples -- publishing a snapshot the loop only reads.
Before claiming a screen is fast: System Info's Memory tab shows loop load,
worst work, worst frame and NVS usage, and the watchdog logs a stall past
STALL_WARN_MS. A worst frame above ~40ms is a bug, not a heavy screen.
Related: full-screen repaints are ~150KB over SPI and ~30ms of visible blanking,
which is why Game has two levels of invalidation. Guard static chrome behind
needsFullRender() and repaint only what moved.
A screen transition is always a full repaint. Every screen is a static
instance reused for the life of the device, so it arrives carrying the dirty
flags its last visit left behind -- and the last thing that visit did was
clearDirty(). launch(), goHome() and relaunchActiveGame() therefore all
call requestRender(); a screen's begin() must never be relied on for it.
Getting this wrong does not look like a lost optimisation, it looks like a
screen with no top bar and the previous one showing through, because
renderStatic() is where both Ui::clear() and the chrome live. Partial
repaint is an optimisation within a screen's lifetime. Entering one is not the
place to be greedy -- a stable picture is worth more than the frame it costs.
And ask whether the screen needs to repaint at all. Three things change the
header on their own schedule rather than the screen's -- the clock, the battery
badge and the notification banner -- and the runtime used to answer each with
requestRender(), wiping all 240 rows to change something in the top 30. The
battery made that constant: one percent is about 2mV on the LiPo plateau, under
two ADC counts, so the reading crossed a boundary every couple of seconds and
turning the BLE beacon on -- whose supply ripple widened the noise -- made the
console visibly flash. Game::renderChrome() repaints the strip alone, an
eighth of the panel and inside the frame budget where a full repaint is 150% of
it, and PERCENT_DEADBAND stops the number twitching underneath it. A screen
with its own header overrides renderChrome(); one that cannot repaint its
chrome in isolation returns false and gets the old behaviour. If you add
something to the top bar, route its invalidation through
requestChromeRender(), not requestRender().
C++ gives you no GC, and FreeRTOS gives you a heap that can never be compacted.
So the thing that kills this device is not the leak you are picturing. There is
not one new, delete, malloc or free in this firmware — every screen is a
static instance and everything else is stack or a fixed member. Keep it that
way, and the classic leak is impossible by construction.
What actually kills it is fragmentation: many small allocations of differing sizes, made and freed over and over, chop the free space into pieces too small to satisfy a later request. Free heap looks fine right up to the allocation that fails, hours in. The only visible symptom beforehand is the fragmentation percentage, which is why System Info shows it.
The offender is almost always Arduino String. Every concatenation allocates,
every assignment may reallocate, and a String member that is rewritten each
frame is a long-lived block being freed and re-made 27 times a second.
Rules, in the order they bite:
- No raw owning allocation. No
new/delete, nomalloc/free, no owning raw pointers. If something genuinely must be dynamic, justify it in a comment and give it an owner with a destructor. - No
Stringin anything that runs per frame. Build text withsnprintfinto a stack buffer, or store it in a fixedchar[].RowList(src/ui/) is the worked example: it was 48 rows × 2Strings rebuilt every frame — about 96 long-lived allocations churning at frame rate — and is now flat char buffers that allocate nothing, ever. - Don't rebuild content on every frame. Rebuild when the data changed and
keep a
staleflag. Scrolling changes an offset, not the content. - A
Stringmember on a screen is a smell. A few exist for genuinely user-entered text (ProfileApp::draft_,WifiApp::password_) — that is the bar. Anything derived from state belongs in a fixed buffer. - Give back what you borrowed, in
end(). Every screen transition goes throughBrainoApp::leaveActiveGame(), which compares free heap against the value captured before that screen'sbegin()and logs[heap] '<screen>' left N bytes shortwhen a screen does not hand it back. Watch the serial log after adding a screen. - Prefer fixed-size members over growth. A statically sized array that is
occasionally half empty is cheaper and safer here than anything that grows.
Trading a few hundred bytes of static RAM for zero heap traffic is nearly
always the right call on this device —
RowListcost 864 bytes of RAM and saved 5.5 KB of flash. - Check the numbers before you claim it is fine.
pio runreports RAM and flash; System Info's Memory tab reports free heap, minimum free heap, largest allocatable block, fragmentation and NVS pressure. Read them.
ESP32 firmware (Arduino / PlatformIO, C++17) for a handheld educational console for young players. 40 games, all baked into flash. Target hardware is the E32R28T-1 / ESP32-32E (2.8-inch 240×320 resistive-touch board): ILI9341 320×240 TFT + XPT2046 resistive touch + onboard single-cell Li-ion/LiPo charging circuitry. Wi-Fi is used for NTP only — no accounts, no telemetry, no SD card required.
pio run # build (env:app, the default)
pio run -t upload # build + flash at 460800 baud
pio device monitor # serial, 115200 baudplatformio.ini declares fifteen environments; five of them are Braino!.
Each one states which it is, once, beside itself:
custom_env_kind = product ; or: diagnostictools/envs.py reads that and is the only place any workflow, checker or
packer learns which environments to build. Nothing may write a list of
environments into a workflow again. Three files each kept their own list --
ci.yml, pages.yml and the size-table loop -- all three named eighteen
environments, and none of them named audiodiag, diag32p or
audiodiag_e32r32p, which had been in this file for months. That is exactly
the failure pages.yml warned about in its own comment while committing it: an
environment nobody builds is one that is already broken and has not been told
yet. A hand-kept list can be checked for typos and cannot be checked for
completeness, so it has to be derived.
What follows from the classification:
- CI builds the five product environments on a push to
mainordev, and on a pull request buildsappplus whatever the diff reaches. - Each environment builds in its own job, in parallel.
ci.ymlis three jobs:planruns every repository check, asksenvs.pywhat to build and installs all the toolchains once into the one cache it alone saves;buildis a matrix with one runner per environment andfail-fast: false, each named after its board (build E32R28T-1 (app)--envs.py --matrixreadsBOARD_NAMEfrom the board section, so a red job says which board broke and the environment beside it says how to reproduce it), each restoring that toolchain cache read-only plus its own per-environment object cache;verifywaits for both and fails if either did. One runner building seven boards in turn was 14-15 minutes on a push todev.verifyis the required status check in the branch rulesets -- never rename it, and never make the matrix jobs required instead: their names carry the environment, so the set changes with the diff, and a ruleset can only wait for names it knows.verifyrunsif: always()so a docs-only pull request, wherebuildis skipped, still reports. - A diagnostic is built only when its own source or
platformio.inimoves --tools/envs.py --for-changesderives that from each one'sbuild_src_filter, so touchingsrc/battery_diag.cppbuilds the four batdiag environments and nothing else. A board header does not trigger one: the product environment for that board already compiles the same header, so building the probe again is a slower way of learning the same thing. - The Pages workflow builds product environments only. It offers no probe for download, so it has no business compiling one.
- A release publishes product environments only. Every tag used to attach
fourteen probe images nobody downloads; whoever needs one has the toolchain
open and runs
pio run -e batdiag -t upload, which gives them the current build rather than one a tag froze. - A new environment with no
custom_env_kindfails the checks. An unclassified environment cannot be a default in either direction without the wrong answer being silent.
Ten diagnostic environments exist for hardware triage. Build one by name when you need it:
pio run -e bringup— full tree with-D CYD_BRINGUP_ONLY;main.cppcompiles a display/touch/SD check instead of the app.pio run -e wifidiag— buildssrc/wifi_diag.cppalone (build_src_filter = +<wifi_diag.cpp>), so no TFT/touch/game code can interfere with the radio test.pio run -e batdiag— buildssrc/battery_diag.cppalone, an eight-page battery bring-up and calibration tool. It exists because the questions the battery poses cannot be answered from inside the app: the frame budget, the watchdog and the 2s telemetry cache are all correct product decisions that get in the way of watching an ADC for an hour. BOOT cycles pages; CSV (ms,raw,adc_mv,cell_mv,pct,state) streams to serial for capturing a full discharge. It reads the divider ratio and the ADC fault ceiling from the same board profile the product does. It still carries its own charge-inference constants, which the product no longer has -- 5.10.0 removed the charging display -- so they are a bench aid for watching a charge, not a mirror of the firmware.pio run -e s3diag-- buildssrc/s3_diag.cppalone, a bring-up probe for the Freenove FNK0104B. It skips the touch calibration wizard on purpose:env:bringupruns that on a board with no stored calibration, which on a capacitive panel the XPT2046 code cannot read strands the display check behind a dead crosshair.pio run -e diag4-- buildssrc/diag4.cppalone, a eight-page bring-up probe for the 4-inch ST7796 board. It exists because every fact that board needs stated in a profile is still a guess, and the three ways of being wrong -- wrong SPI bus, wrong driver, wrong backlight pin -- all produce the same dark screen with a healthy serial log. Page ID reads the controller's ID register back over MISO, which separates them without anything being visible; page BL sweeps candidate backlight pins in both polarities. It defines neitherTFT_BLnorGUME_BOARD_HEADER, deliberately -- TFT_eSPI owning the backlight would defeat the sweep, and a probe must not depend on the board profile it exists to produce. Both are boardless envs, listed incheck_boards.py'sBOARDLESS_ENVS, because a[board_*]section is a claim of support and neither board is supported yet.
tools/build_stamp.py is a pre-build script wired in from [esp32_common], so
every environment gets it. It writes the branch and the abbreviated commit into
a generated GumeBuildStamp.h in the build directory, src/BuildStamp.cpp is
the only file that includes it, and the firmware reads the values through
BuildStamp:: -- never the macros directly, outside include/BuildStamp.h.
About's last page, System Info's Device tab and the [boot] build= serial line
all read the same three accessors, so the answer to "which firmware is on this
board?" is one fact with three viewers.
BRAINO_VERSION cannot answer that question: it is identical across every
flash of a release, which is exactly the case where you need to know.
Nothing whose value changes per build may become a build flag. PlatformIO
folds the flags into its build signature, and env.Append(CPPDEFINES=...) in a
pre-script reaches the Arduino core and NimBLE as well as src/. The branch and
the commit were flags until 5.9.x, and the cost was measured rather than
assumed:
pio run -e app, nothing changed 66 s, 1 object
pio run -e app, branch name different 333 s, 336 objects
Every object, to change a string that one translation unit reads -- and since a commit hash changes on every commit, that was the cost of committing. It also meant CI could never cache build output at all, because every CI run is a new commit. The generated header fixes both: the include path is a flag and never moves, the header's contents are not a flag and move freely, and only the file that includes it is rebuilt.
The same reasoning is why the diagnostic environments carry -D CYD_BRINGUP_ONLY and friends in build_src_flags rather than build_flags.
Those macros are read only under src/, but as global flags they changed
NimBLE's compile command too -- so env:bringup measured 306 s, identical to a
full app build, to test one #ifdef in main.cpp. With them src-scoped and
build_cache_dir on, it reuses the objects the app build already made.
The build time stays out of all of this: __DATE__ and __TIME__ come from the
compiler for free, and the script deletes BuildStamp.cpp.o so they are always
current. The consequence to know is that the stamp is the build machine's local
clock in C's format, not UTC and not ISO -- it identifies a build, it is not a
timestamp to compute with.
On GitHub Actions the checkout is a detached HEAD, so the script prefers
GITHUB_HEAD_REF / GITHUB_REF_NAME over git rev-parse --abbrev-ref, which
would otherwise say "HEAD". A tree with no .git at all -- a source tarball --
is a supported way to build, and stamps "unknown" rather than inventing
something plausible.
- BLE pulls in NimBLE, not Bluedroid.
h2zero/NimBLE-Arduinocosts ~192 KB of flash for host plus controller; the core's Bluedroid stack costs several times that and this partition cannot absorb it. - NimBLE's log level is set on its own, to warnings. Left unset, NimBLE-Arduino copies
CORE_DEBUG_LEVEL(NimBLELog.h), so the core's INFO made it printNew advertiser: <MAC>for every device a Nearby scan heard -- 3.6 KB/s on the bench, 99.9% of all serial output, strangers' Bluetooth addresses in logs that get pasted into issues, and lines interleaved into the middle of[boot]and[ident]replies.CONFIG_NIMBLE_CPP_LOG_LEVEL=2in[common]fixes it without touching the core's own level. It did not change the frame rate -- NimBLE prints from its own task -- so do not expect it to explain a slowworst=. lib_ldf_mode = deep+is required onenv:app— transitive library headers do not resolve without it.- TFT_eSPI is configured entirely through
-Dflags inplatformio.ini(USER_SETUP_LOADED=1, pins,USE_HSPI_PORT, fonts, SPI speeds). There is noUser_Setup.h— editing one would do nothing. - One board = one
[board_*]section plus one profile header.platformio.inisplits into[common](true of every board),[esp32_common](the MCU), and a[board_*]section per board holding only the TFT_eSPI macros,BOARD_NAMEandGUME_BOARD_HEADER. An environment composes${common.build_flags}with exactly one${board_*.build_flags}. Put a board-specific-Din[common]and it becomes a claim about every board —check_boards.pyfails on that. The panel is described twice, to TFT_eSPI and to us, andBoardConfig.hstatic_assertsTFT_WIDTH,TFT_HEIGHTandTFT_BLagainst the profile so the two cannot disagree past the compiler. - Partition is
huge_app.csv(3 MB app). Flash is the scarce resource; artwork and data tables dominate. CYD_SCREEN_ROTATION=3is landscape with the USB edge at the bottom.Board::pollTouch()compensates for every rotation, so don't hand-correct coordinates in game code.
Assume you are not the only one editing this tree. Several agents (Claude or otherwise) and the author may be working concurrently, and files can change underneath you mid-task.
Start by reading git status. Uncommitted work that isn't yours is normal here. If a file you need is already modified, someone is probably mid-change in it — re-read it immediately before editing, and don't assume your earlier read is still accurate.
When a new requirement arrives, don't start editing the shared tree. Take an isolated copy:
git worktree add ../GUme-<slug> -b feat/<slug> devThen work entirely in ../GUme-<slug>. Branch names: feat/<game-id> for a new game, fix/<area> for a repair, docs/<topic> otherwise.
Use a worktree, not a bare git switch. Switching branches inside a tree that holds another agent's uncommitted changes either drags their work onto your branch or refuses outright — and if it succeeds, it silently strands their edits somewhere they don't expect. Right now this tree has substantial uncommitted work in it from at least one other agent, so treat the main checkout as occupied. (In Claude Code, worktree isolation can also be requested when spawning the work.)
A separate worktree also gives you your own .pio/ build directory, which removes the concurrent-build race described below.
Every feature and every fix starts from dev. Not main, not a release
branch, not whatever the last worktree happened to be sitting on. The only
exceptions are a change the maintainer has explicitly asked to be based
elsewhere, and a hotfix onto a release branch that has already been agreed --
in both cases said out loud, in the request, before the work starts. If nobody
said otherwise, the answer is dev.
This rule is not yours to overrule, and in particular it is not overruled by
dev looking wrong. It has already failed once exactly that way: an agent
saw dev sitting 55 commits behind main, concluded it was stale and
therefore the wrong base, branched from main instead, and wrote a paragraph
justifying it. The refs were local and had never been fetched. One
git fetch origin showed dev was five commits ahead with work main did
not have -- the reasoning was confident, articulate and built entirely on stale
data. So:
git fetch originbefore you form any opinion about a branch. A local ref is a memory of the remote, not the remote. Compare withgit rev-list --left-right --count origin/dev...origin/main.- If
devstill looks like the wrong base after fetching, stop and ask. Say what you measured and why it looks wrong. Do not decide it yourself, and do not proceed while explaining the decision -- an explanation is not an approval. - Basing on
mainand rebasing ontodevlater is not a shortcut, it is extra work with a hazard in it. The release commits inmain's history come along for the ride, so the feature branch quietly carries the version bump that drops-SNAPSHOTintodev, which is meant to keep it. Recovering from that means replaying your own commits with--onto, and knowing you had to is not something the rebase tells you.
Keep branches short-lived and rebase onto dev often -- Keep branches short-lived and rebase onto dev often — a branch that sits for days turns into exactly the merge this is meant to avoid. Build before you merge, and don't merge or push unless the user asks. main and dev are protected on GitHub: no force-push, no deletion, and every merge arrives through a pull request with the verify CI job green, so a direct git push origin main is rejected by the server rather than by convention. Releases go dev -> main after hardware testing. The full rules are in CONTRIBUTING.md.
Branching moves the risk rather than removing it. More parallel branches means more merges, and the failure below is a merge-time failure — so it gets more important, not less.
APP_REGISTRY[] (src/engine/AppRegistry.cpp) is the launcher spine. Each playable game's own AppMetadata now carries its launcher index, icon and default visibility, while the registry binds that metadata to a concrete GameInstances member. A bad merge can still duplicate or skip an index inside those metadata blocks, and the result still compiles; the symptom is a tile launching the wrong game or appearing in the wrong place.
After any merge, rebase, or conflict resolution touching AppRegistry.cpp or a game's local AppMetadata, re-run python tools/check_catalog.py before doing anything else.
- Leaves — safe in parallel: a game's own
src/games/*.{h,cpp}pair. One agent per game is fine. - Spine — expect collisions:
src/main.cpp,src/engine/AppRegistry.*,src/hal/Board.*,src/ui/Ui.*. Adding a single game still touches the registry andPLAYABLE_APP_COUNT, even though title/icon/index/visibility metadata now lives with the game. Make spine edits tight and land them quickly rather than holding them open across a long task.
There is one physical device and one serial port, and both are exclusive:
pio device monitorholds the COM port; anuploadfrom another agent will fail while it's open. Release it when you're not actively reading.- Flashing destroys shared physical state. NVS holds touch calibration, profiles and scores that another agent may be mid-test against, and
factoryReset()wipes all of it. Never flash or factory-reset on a shared board without saying so first. - One
.pio/build directory per working tree. Two concurrentpio runinvocations in the same tree race on it — use a separate git worktree if you need to build in parallel.
Worktrees isolate source and .pio/, but not the board, the serial port, or the toolchain's shared package cache. Those need an explicit lock, and it has to live somewhere every worktree can see — so it goes in the shared git common directory, not in .pio/:
$lock = Join-Path (git rev-parse --git-common-dir) "gume-board.lock"That path resolves to the same file from every worktree, and is never committed.
Acquire before any pio run, pio run -t upload, or pio device monitor — write your PID, the action, the worktree and a timestamp, so the next agent can tell whether you're alive:
"$PID|flash|$(Get-Location)|$(Get-Date -Format o)" | Set-Content $lock -Encoding utf8The lock is per agent, not per board. Two agents must never flash the
bench at the same time -- that is what it is for. But the one agent holding it
may flash several boards at once, and should: each board is its own USB device
on its own port. python tools/ESP32_boardUtil.py --flash does exactly that
under one hold of the lock -- it builds each distinct environment once, all at
the same time, then uploads to every port in parallel with -t nobuild, with
one log per build and per port in .pio/. When testing, add --board <BOARD_NAME> and flash only the board the change is for; the whole bench is
for when the owner asks for it, since a new commit makes every environment a
full rebuild. Parallel builds were measured, not
assumed: after a new commit, four environments took 210 s one after another
and 97 s side by side, because each rebuild is mostly single-core dependency
scanning and linking. Do not hand-roll a second parallel flasher; extend that
one.
Release it in all cases when the build, flash or monitor session ends — including on failure. Remove-Item $lock.
Do not just delete it, and do not build anyway. Work out whether it is live or stale:
- Read the file and take the PID from the first field.
- Check whether that process still exists:
Get-Process -Id <pid> -ErrorAction SilentlyContinue
- Cross-check for any build or flash actually in flight, in case the PID was recycled:
Get-Process | Where-Object { $_.Name -match 'platformio|pio|esptool|python' }
If a matching process is running, wait — poll every ~10 s rather than busy-looping, and say what you're waiting on. Builds take a couple of minutes; a flash plus verify is under a minute. Keep waiting while the process lives.
If no such process exists, the lock is stale — delete it and continue. Stale locks are normal here: an agent that was interrupted, crashed, or had its command cancelled mid-build never got to release it. A leftover file must not be allowed to block every future agent, so clearing it is the correct action, not a workaround.
Say so plainly when you remove one — note the PID and timestamp you found — so it's visible if a build really was killed halfway and left .pio/ in a bad state. A lock older than ~15 minutes with no live process is stale beyond doubt.
The same reasoning applies to any lock PlatformIO itself leaves in ~/.platformio after an interrupted dependency install: confirm nothing is running, then clear it.
Flash is global and nearly the binding constraint (2,581,433 / 3,145,728 bytes,
82.1%; NimBLE plus the BT controller account for ~192 KB of that). RAM sits
at 88,036 / 327,680 (26.9%) -- higher than it was, deliberately: RowList traded
864 bytes of static RAM for zero heap traffic and storage diagnostics keep their
profile-move buffers static. On this device that is a good
trade every time. Two agents can each add artwork that fits locally and together overflow it. Read the size line from pio run and report it when you add data tables or images.
If a file is becoming large (as a rule of thumb, src/main.cpp > ~400 lines of active logic in a single function, or any .cpp > ~600 lines total), refactor it into a more modular form first before making the requested change. Split into helper files, break large functions into smaller ones, or extract a new class — whatever fits the existing architecture. The refactor must not break existing functionality (build must still succeed and behaviour must be unchanged), and it must land as its own commit before the feature change that prompted it.
This keeps diffs reviewable, conflicts locatable, and prevents any single file from becoming a merge hazard.
- Stage explicit paths.
git add -Awill sweep up another agent's half-finished work. - Commit narrowly — one game or one concern per commit — so conflicts stay resolvable.
- Don't rebase or force-push shared branches, and don't revert changes you can't attribute; an unfamiliar edit is more likely someone's in-flight work than a mistake.
- Don't reformat, re-order includes, or opportunistically refactor files you aren't otherwise changing. Cosmetic churn in a spine file turns someone else's small diff into a merge conflict.
mainanddevare protected branches. Open a pull request againstdev; never try to force-push or delete either, and do not use an admin bypass to skip a failing check.src/games/CountryDataTable.cppis generated. Regenerating it rewrites the whole file — announce it rather than folding it into an unrelated change.
setup()/loop() in src/main.cpp delegate to a BrainoApp singleton defined in src/engine/AppRuntime.*, which owns every screen as a static instance and implements GameHost.
Runtime views are now only Game (including Launcher, Profiles, Settings and ordinary games), ScreenSaver (self-playing Pong that mirrors rally colour onto the case LED), Asleep (backlight off, panel in low-power state) and Locked (the hold-to-unlock guard between either of those and the screen underneath). Boot opens the Profiles app first; after a profile is chosen, goHome() activates LauncherApp through the same begin/update/render lifecycle as the rest of the screens.
The idle path is driven by Board::idleAction(): SaverThenSleep runs the
saver and then blanks after sleepSeconds(), SleepOnly blanks straight away
at screenSaverSeconds(), SaverOnly never blanks. View::Asleep polls at
SLEEP_POLL_MS (100ms) rather than the 20ms frame budget — there is nothing
to draw, and holding 50Hz behind a dark screen defeats the point. It is panel
sleep, not esp_deep_sleep: the CPU must stay up to poll touch, since no wake
source is wired. Board::displayWake() blocks ~120ms for the panel, so its
call site sits inside a Watchdog::Pause guard.
View::Locked sits between both idle views and the screen underneath, and is
gated on Board::wakeLockEnabled() (default on, RAM-mirrored, global like
every device setting). It is an accidental-touch guard, not access control:
it is disjoint from the admin PIN, neither granting nor revoking admin, and
resumeUnderlyingScreen() -- the single tail shared by exitScreenSaver(),
wakeFromSleep() and the unlock -- returns to exactly the screen and
orientation that were up before. It used to return the profile too; going
idle now ends an admin session, which is the entry paths' doing rather than
this one's -- see the invariant below. Three things about it are load-bearing:
- The press that got you here is swallowed.
enterLock()setsswallowTouch_, so a press held through a bag can never complete the gesture however long it lasts. - Text on this screen is measured against the live panel, never trusted.
Two different things chop a line and they look different on the device:
Ui::fitted()truncates and ends in a., while TFT_eSPI drops characters outright once x reaches the viewport's right edge (drawChar:if (xd >= _vpW) return) -- no mark, cut mid-word.tools/gen_screens.pycannot reproduce either: PIL has its own font metrics and clips nothing, so a mock-up showing a sentence whole is not evidence that the panel does. The footer therefore picks the longest wording that measures whole anddrawSentence()wraps and clamps it, andrenderLock()resets the viewport because this screen owns the panel and must not inherit anybody's clip. - The hold tolerates dropouts. Resistive contact falls below
TOUCH_PRESSURE_THRESHOLDmid-press as a matter of course, so gaps up toLOCK_CONTACT_GRACE_MS(150ms) do not restart the timer. - The header is fixed, and the vertical stack is measured against 240px.
The wordmark and battery badge answer what a person finding a locked
device wants to know without touching it. Nothing drifts: the saver moves
its wordmark because it is up for hours, this screen for
LOCK_TIMEOUT_MS, so the header is painted once inside thelockFullPaint_branch and the progress bar stays the only thing repainted per frame. The whole stack hangs offlockButtonRect(), whose offset below centre went from +12 to +25 to clear the new hairline; landscape is the tight case and every gap is stated in the comment there. The battery badge is variable width, so it is placed offUi::batteryBadgeWidth()rather than a constant -- same rule as the launcher header. It belongs top right, level with the middle of the mark, and it spent a release in the bottom corner instead on the reasoning that a status badge should not compete with the brand. The measurement disagrees: the footer is drawn centred across the full width andlockFooterText()deliberately picks the widest wording that measures whole, so a badge at the right-hand end of that row is in the footer's way. Up beside the mark there is nothing to hit -- the badge is 50px wide and centred, and 240px portrait is the tight case and still clears it by about 39px. It is centred on the mark's own height rather than a typed-in y, so it cannot drift if the logo size changes. Lockedis excluded from the idle-timeout block alongsideScreenSaverandAsleep; it runs its ownLOCK_TIMEOUT_MSand hands back to sleep (or to the saver underSaverOnly). Leaving it in that block re-arms the saver timer against an already-expiredlastActivityMs_.
BrainoApp::lockAndSleepNow() is the deliberate way in -- the Lock button --
and it is the same guard, not a second one: it sleeps through the ordinary
enterSleep(), leaves activeGame_ alone and comes back through
resumeUnderlyingScreen(). Two things go with it:
lockOnWake_overrides the setting, one time. Both wake paths gate onwakeLockEnabled() || lockOnWake_, so pressing Lock produces the lock screen even for an owner who has switched Hold to unlock off -- that press is the request.resumeUnderlyingScreen()clears it, because that is already the one place that decides the lock is over.- The Lock tap is consumed by the runtime, above the active screen, beside
the Home and gear routing and for the same reason: a tap delivered to the
screen as well would press whatever sat under the padlock, and the user would
find it done when they unlocked. Its slot comes from
LauncherLayout::topBarLockRect()(top bar) orLauncherLayout::lockRect()(the launcher, which draws no top bar), and both the glyph and the hit test read those same helpers. - The top bar has no spare pixels, and Lock was paid for. Home narrowed from 42px to 32px and the title's start moved from 48 to 62, costing the title 14px. The right-hand cluster is measured, not padded, and cannot give; check the longest screen title ("Finger Counting") before spending any more.
- The BOOT key is Home, and it is a shortcut rather than a route. It is
consumed in the runtime above the active screen's
update(), beside the Home, gear and Lock routing and for the same reason. Three deliberate holes in it: the launcher does not consume it (you are already home, and taking the frame would drop a simultaneous touch), the lock screen ignores it entirely (a key pressed through the side of a bag is the accident that screen exists to catch), andView::Asleepand the saver treat it exactly as a touch. It counts as activity, or the saver would arrive a moment after you pressed Home. Nothing may become reachable only through it --BOARD.hasBootButton()can be false and the console has to remain complete. It fires on the press edge, which is what a hold gesture would have to change first; seesrc/hal/BoardButton.cpp.
| Layer | Where | Responsibility |
|---|---|---|
Game / AppGame / GameHost |
src/engine/Game.h |
Screen lifecycle plus the split between ordinary app context and privileged system host |
Board |
src/hal/Board.h / BoardAccess.h |
Hardware aggregate plus narrow display/touch/storage/power/network/feedback facades |
Ui::Renderer |
src/ui/Renderer.h / TftRenderer.h |
Driver-free RGB565 drawing interface plus the firmware TFT adapter |
Ui |
src/ui/Ui.h |
Stateless themed drawing helpers; owns the colour palette |
| GameCatalog | src/engine/GameCatalog.h | Derived compatibility view over playable-game metadata |
| AppRegistry | src/engine/AppRegistry.h | Single source of truth for launchable apps and instance bindings |
Sound / BoardAudio |
src/hal/Sound.h / BoardAudioCues.cpp / BoardAudio.cpp / BoardAudioBackend.cpp |
The console's sound vocabulary, the synthesiser that generates every one of them a sample at a time, and the codec/I2S/DAC hardware under it |
Watchdog |
src/hal/Watchdog.h |
Background supervisor: reboots a hung loop, logs stalls and heap, keeps a crash breadcrumb |
BleBeacon |
src/hal/BleBeacon.h |
Opt-in non-connectable BLE presence beacon. Owns the one authoritative advertisement payload, and its inverse decode() |
BleScan |
src/hal/BleScanner.h |
Passive observer for other Braino beacons. Radio only -- no opinion about scores |
NearbyPlay |
src/engine/NearbyPlay.h |
Nearby play policy: peer scores, header notifications, the sharing switch, and the two-player session service |
-
The board is described in two files and nowhere else.
include/boards/<id>.hholds the whole board -- pins, rotations, the battery divider, which peripherals exist; the[board_<id>]section inplatformio.iniholds only what TFT_eSPI must be told at compile time. No file undersrc/may name a GPIO number, a panel size or a divider ratio. If you need one, read it offBOARD; ifBOARDdoes not have it, add the field toinclude/BoardProfile.hand fill it in for every existing board in the same commit.tools/check_boards.pyenforces the completeness, andBoardConfig.hstatic_asserts the overlap with TFT_eSPI. Seedocs/PORTING.md. -
Some boards cannot be supported, and that is a compile error, not a degradation. Braino needs a panel of at least
GAME_CANVAS_WIDTHxGAME_CANVAS_HEIGHTin landscape, a touch controller, the backlight on a GPIO, and 4 MB of flash.BoardConfig.hstatic_asserts each with a message naming what is missing. Optional hardware -- SD slot, RGB LED, speaker, battery sense -- isPIN_NONEplus ahas...()guard, and the firmware does without it. Do not blur the two: turning a requirement into a quiet degradation ships a board that flashes and then cannot be read or pressed. -
A supported board must be flashable from the web installer.
tools/gen_site.pyderives the picker's board list from the[board_*]sections rather than a hand-kept list, and refuses to generate until a new board has aBOARD_DETAILSlabel, an offered environment and a CI build behind it.tools/check_boards.pyreports the same gaps without a build. "Supported" means someone who owns the board can flash it from the page, not that it builds here. -
SCREEN_WIDTH/SCREEN_HEIGHTare derived, not stated. They come out ofBOARD.screenWidth()/screenHeight(), which rotate the panel's native size by the profile's landscape rotation. Do not reintroduce a literal 320 or 240 next to them; a board that states its size twice will eventually state it two different ways. -
Every screen is a
Game. Launcher, Settings, Wi-Fi, Profiles, Scores, System Info and About are allGamesubclasses with the samebegin/update/render/endlifecycle. -
end()is called on every screen change viaBrainoApp::leaveActiveGame(), before the next screen'sbegin(). Add new transitions through that funnel, not by assigningactiveGame_directly. Overrideend()for anything a screen holds that outlives a frame; nothing here runs off a task or timer, and the hook is what keeps that true. -
Never sample the battery ADC more than once per frame.
Board::readBatteryTelemetry()caches for 2s and everything else reads through it. Each accessor used to run its own blocking 10ms conversion, and a top bar calls two of them. Seesrc/hal/CLAUDE.md. -
Ordinary games should not receive the full board anymore. Use
AppGame+AppContextfor catalog games; that surface is limited toUi::Rendererdrawing, content, scoped persistence, feedback and basic navigation. The only screens still onGameHost&are Launcher, Settings, Wi-Fi, Profiles, Scores, About and System Info, and system screens must guard privileged actions withrequireCapability(). -
Profile scoping is automatic and invisible to games.
Board::scopedKey()is private; it prefixesp{N}_and translates plain game keys into compact app-scoped leaves insidegetScore/setScore/saveBestScore/worstScore/loadBlob/saveBlob.BoardStorage.cppowns the schema-versioned migrator from the older key format;BoardStorageMaintenance.cppowns NVS usage telemetry and profile deletion: removing a player clears that slot'spN_keys, shifts later slots down with their own persisted data, and clears the old last slot. Just call the storage API with a plain key and per-profile behaviour comes for free. Guest (GUEST_INDEX == 5) silently drops all writes — that is what makes it a guest rather than a sixth player. -
Device settings are global, not per-profile: theme, layout, brightness, sound on/off, volume, Wi-Fi credentials, NTP, NTP resync interval, timezone. Sound belongs on that list for a reason worth stating: the speaker belongs to whoever is in the room, and a console that came back loud because a different player picked it up is a poor thing to hand a child in a quiet house. Per-profile: scores, mastery blobs, game visibility.
-
The admin PIN gates every route to admin powers: switching to the admin profile, opening its Edit menu (rename plus its per-player game list), and the serial console's
unlock(see Hardware notes). One profile is admin (Board::adminProfileIndex(), oneuint16_tPIN beside it in NVS). Add another way to become admin and it needs the same gate. It is asked every time, including when already admin: being admin is not evidence about who is holding the device, which is the whole threat model. The console's unlock is the one that lasts beyond a single action -- a batch of commands, expiring two minutes after the last one -- because a script configuring a bench is one decision, not twenty;lockends it and the tool always sends it. -
Per-player game visibility and profile removal are admin-only; renaming is not.
ProfileAppgates onboard.isAdminProfile(board.activeProfile())— the actor, not the profile being edited. Those two are different questions and conflating them is exactly how Remove ended up available to every player. The Games list stays readable by anyone on purpose: a player who can see a game is switched off is better served than one facing a launcher that is short for unexplained reasons. -
Settings is readable by everyone and writable only by the admin. There is no lock screen on it. The enforcement is a single
if (!isAdmin(board))early return inSettingsApp::update(), sitting below tab switching so a non-admin can still page through and read. The greyed-out controls are a drawing decision and enforce nothing on their own: for a while every greyed row was still live and a player could toggle the lot. If you add a control, it is covered by that early return automatically — do not add a path above it. -
Boot must not leave the admin profile active.
BrainoApp::begin()drops to Guest if it finds admin selected. The picker's Done button goes home with whatever is already active, so a remembered admin selection is a PIN bypass, not a convenience. Do not "restore the last profile" here. -
Going idle must not leave the admin profile active either. Same fact, arriving a different way: the console was put down, and who picked it up is not something the device knows -- which is the whole threat model the PIN is written against.
endAdminSessionForIdle()is called by both idle entries,enterScreenSaver()andenterSleep(), so the saver, panel sleep, the Lock button and the lock screen's own timeout are all covered by one line each. Before it, an adult could open Settings, walk away, and whoever touched the panel next had every switch on it with no PIN asked; the lock screen does not close that, because it is an accidental-touch guard whose hold is deliberately not a secret. It drops on the way in rather than on the way out so that no exit can forget, and it setsadminEndedByIdle_so thatresumeUnderlyingScreen()starts the screen over instead of resuming it -- that part is not tidiness: Settings' change-PIN pad sits above that screen's own admin gate, being reachable only by an admin, so a resumed pad would let whoever came back set the PIN. -
A PIN's digit count is not derivable from its value.
0000and an empty field are both zero, so every PIN entry point tracks digits separately from the number, and only judges an entry once it is exactly four long. Both screens got this wrong first time and silently accepted a three-digit prefix. -
Both PIN pads are laid out against the live
tft.width()/height(). The first version hard-coded rows at y=220 and the action buttons at y=270 on a 240px-tall panel, so the bottom row, DEL and OK were all drawn off the screen and there was physically nothing to press. Anything added to either pad must still end abovescreenH. -
Each playable game declares its own metadata once.
AppMetadataowns id, title, screen title, subtitle, launcher label, blurb, score pointer, launcher icon, launcher index and default visibility.APP_REGISTRYonly binds that metadata to the concrete static instance. -
APP_REGISTRYholds the 40 playable games plus 7 launchable system apps. The launcher itself is not a tile in that table; it isLauncherApp, activated bygoHome(). -
Metadata launcher indices must stay contiguous and index-aligned.
check_catalog.pyenforces this now, but the failure mode is still the same: a misalignment launches the wrong game from the right tile. -
The launcher shows the profile name as plain text, not a button. The framed chip is what overlapped the status badges; the name itself is wanted.
launcherProfileRect()is both where it draws and the touch target, so the two cannot drift — in landscape it sits after the byline, not across it. -
The launcher status badges are packed to the pixel. Landscape runs from a hairline at
lW-138to the gear atlW-30, and the Lock badge sits at its left-hand end. The battery badge is variable width -- it carries its own percentage, so it grows with its digits, widest at100-- and in that widest state the row has only a few pixels spare. Everything on it is therefore laid out right-to-left offUi::batteryBadgeWidth()and the measured width of the clock string, never a constant offset; the hairline has moved out twice to buy those pixels --lW-110tolW-116for the battery percentage, then tolW-138for the Lock badge -- andLauncherLayout::profileRect()'s right limit moved with it both times. Lock is a badge, not a control: it is drawn at 18px beside the battery and Wi-Fi glyphs rather than at the gear's 26px, because it belongs to that family and a gear-sized padlock read as the most important thing on the header. Portrait has room to extend the badge row instead -- with one measured exception: the mute control does not fit that row in portrait. At 240px the badges reach about x=155 and the padlock starts atlW-64, which leaves roughly 20px for an 18px glyph plus its gaps, so it goes on the profile-name row above, whose right-hand half is empty because the name is capped at 112px. It sits in the padlock's column (speakerRect()takeslockRect().x) rather than mid-row: atlW-96it was beside nothing and above nothing, and it read off both portrait panels as an icon floating in an empty row. Anything new in that header needs the same treatment — measure, don't guess. -
The BLE advertisement has exactly one description.
BleBeacon::Advertisementis compiled into a raw AD buffer that is handed to the controller verbatim, and the System Info BLE tab reads that same buffer back.BleBeacon::decode()is the exact inverse and is what the scanner reads peers with -- never write a second parser. With Nearby play on the payload is exactly 31 bytes, so there is no room for another AD structure or a longer name -- which is why the poke had to displace a field rather than add one. Seedocs/BLE_BEACON_SPEC.md. -
Nearby play is off by default and gated on the beacon.
NearbyPlay::tick()re-derives that gate every frame rather than trusting an ordering contract with Settings, so turning the radio off takes the feature with it. What it shares is a game index and a best score, never a name or anything profile-scoped. -
Two-player-over-the-air is a SERVICE, not a chess feature.
NearbyPlayplusAppContext's nearby calls are stated in the terms every two-player game shares -- who is in the room, who offered whom a game, which of the two moves first, one numbered turn at a time, and either side stopping. Nothing in it knows what a move means, and the wire layer is namedturn/sessionrather thanchessfor exactly that reason. Two questions are answered once, here, so the next game cannot answer them differently: who moves first is a coin toss insidenearbyInvite()-- the console that asks for a game must not also claim the first move -- and "I am stopping" is a reserved turn encoding the service owns, surfaced to games asNearbyTurn::ended. If a future game needs something this cannot say, widen it here rather than reaching past it intoBleBeacon. -
Peer labels are a display concern that travels one way.
NearbySeatcarries the owner's own name for a console beside the tag it advertises, and every notification goes throughNearbyPlay'sdisplayName(), so naming a peer changes what the whole device calls it rather than what one screen does. The direction is the invariant: names are read from local NVS towards the screen and there is no path back to the radio.BleBeacondoes not read them and must never be given a reason to -- the advertisement is identical byte for byte whether every peer is named or none is. -
Two consoles can play each other, and the moves ride the same beacon. Agreed with the maintainer before the code existed, which is the rule for a change to what the device transmits. It is not a new outbound flow: it is the existing opt-in beacon, gated on the same two switches, still non-connectable. What goes on air is a session number, a move number, two square numbers and an ack -- thirty-two bits, in the four bytes the score was using, because the payload is already exactly 31 bytes and a move had to displace something. No name, no profile, no label, no score travels with a move, and that is structural:
AppContext's nearby surface is move-shaped, so a game cannot put arbitrary bytes on the air even if it wanted to. Every received move is checked for legality in the receiver's own position, which is what stops a hostile advertiser corrupting a board. It is a BROADCAST -- everyone in range hears the moves, only the two playing act on them -- and the docs must keep saying so. Ending a game rides the move field withfrom == to, which is never a legal move and so cannot be confused with one; it adds no bytes to a payload that has none to spare, and it is tested before the whose-turn check because a player gives up while they are waiting. -
Ludo seats up to four consoles on the same turn, and the wire did not change. Agreed by the maintainer in conversation on 2026-09-10, explicitly without an issue -- a departure from the rule above, recorded here so it is not mistaken for precedent. It needed no new flag and no version bump: a console reads every other console's turn by tag (
nearbyTurnFrom()), the host invites them one at a time, andLudo::Net(inLudoRules.h) spells Ludo's meaning into the samefrom/to/acksix-and-seven-bit fields -- a seat and a token, or a presence, or the host's start word. The die never goes on the air: every console derives each roll from the table's seed andLudo::Net::accept()refuses any move that does not fit it. Two things differ from two-player play and must stay stated: who moves first comes from the seed, not fromnearbyInvite()'s coin toss, because a toss between two cannot seat four -- the deal is a shuffle nobody chooses; and a console replaces its turn only once every other console has acked it (LudoGame::canPublish()), which is what makes a bonus roll or the host playing a computer seat safe on a medium that drops things. The service gained two calls for it, neither of which transmits anything:NearbySeat::forThisGame(an invitation names its game; a lobby must not accept another game's) andnearbySelfId()(so every console orders the table alike). Still a BROADCAST: everyone in range hears every move. A console that goes quiet pauses the whole table, and once it is Gone the host may play on without it:Ludo::Net::takeoverFrom(seat)is a numbered ply in thefromvalues 16..19 that nothing else uses, saying that seat is the host's computer seat from here on. Only the host's word counts, a guest whose own seat is taken goes to its lobby told why, and it is a new meaning for existing bits rather than a payload change -- which is the whole of why it needed no fresh agreement about the air. -
Backgammon's dice do not go on the air either, and that is what made it fit. An earlier plan ruled nearby Backgammon out because the turn has no field for dice. It does not need one: as in Ludo, both consoles derive every roll from
Bg::tableSeed(session, both tags), and a move is one checker --froma point or BAR (24),toa point or OFF (25) -- which is the two-player turn exactly as Chess sends it. Every move received is played only ifBg::findMove()finds it legal with the dice the receiver computed, the forced-move rules included. A turn goes on the air on Done, one checker per ply, each once the other console has acked the last; a turn with no legal move sends nothing, because both consoles compute that it has none. -
A console that goes quiet pauses the game, and the service says when. The second way a two-player game ends never arrives as a message -- a flat battery cannot send
nearbyEnd()-- soNearbyPlay::peerSilentMs()reports the silence off the scanner's ownlastSeenMs, andNearbyWatch(src/games/) turns it into Present / Quiet (PEER_QUIET_MS, 6s) / Gone (the scanner's 45s TTL) and one card: waiting for whom, for how long, Keep waiting or End game. Every nearby game hooks it at the same two places; none may decide "too long" for itself. Nothing transmits for it: it is derived from the absence of the beacon that is already there, which is why it needed no agreement about what goes on the air. Seesrc/games/CLAUDE.md. -
A game that persists needs a way to be abandoned. Chess writes its board to NVS after every move and on the way out, which is right -- children put the device down constantly and a game that evaporated is a game they stop starting. But it retires the oldest exit there was: before this, walking away ended a game nobody could finish, and now walking away brings it straight back. So End game is not a nicety bolted on beside persistence, it is the other half of it. The same applies to anything else here that learns to remember an unfinished state.
-
Local peer names never reach the radio, and that is structural.
Board::peerName()/setPeerName()hold up to 8 labels of 10 characters in one NVS blob with a RAM mirror.BleBeacondoes not read them and must never be given a reason to --buildPayload()composes the advertised name from the family id and the hardware id, so the payload is identical byte for byte whether every peer is named or none is. A label reaching the air is a privacy defect, not a bug. They are global, not profile-scoped:saveBlob()is transparently profile-prefixed and Guest silently drops writes, so a guest naming a peer would watch it work and lose it. Setting one is admin-only, enforced inNearbyApp::update()rather than by withholding the chip -- a chip is a drawing decision and enforces nothing, which is how every greyed-out Settings row stayed live once already. -
A poke rides the beacon and displaces the score; it is not a fourth outbound flow. There is no room for one: the sharing payload is exactly 31 bytes, so
FLAG_POKEswaps the four score bytes for a two-byte target and a one-byte nonce forPOKE_ADVERTISE_MS. Three consequences are load-bearing. Every field is gated on its own length indecode()-- version 2 tested game and score together, which a poke's shorter block answers wrongly for both, which is whyPAYLOAD_VERSIONis 3. The nonce is what makes it an event: the poke repeats for seconds because scan windows have gaps, and a receiver acts on a (device id, nonce) pair exactly once; it is never reset, or a second poke to the same peer would read as a repeat. It is a broadcast -- everyone in range hears who poked whom, only the target reacts -- and the docs must keep saying so rather than implying a private channel. The one identifier it carries is the target's own advertised id, so it adds an event to the radio, not a new kind of data. -
A find is a poke that asks to be heard, and it spends a reserved flag bit rather than a byte or a version.
FLAG_FIND(bit 4) makes the target ringSound::Bellon a cadence, blink its LED and wake its panel, forALERT_MS. It adds nothing to a payload that has nothing to add to. It does not bumpPAYLOAD_VERSION, and must not:decode()rejects the whole manufacturer block on a version mismatch, so a bump makes consoles either side of it invisible to each other in Nearby -- worse than the problem. That is safe here only because no length and no existing field changed meaning, so an older reader sees a poke and blips. A future flag that moves a field does have to bump the version. It is still a broadcast: everyone in range hears who is looking for whom, only the target rings. -
There are no audio files, and there must never be one. Every sound the console makes -- the cues in
hal/Sound.h, the four Cinnamon pad notes, and the spoken "Let's play Braino!" at boot -- is generated byBoardAudio.cpp(the cue tables themselves live inBoardAudioCues.cpp) from a script of oscillator, noise and formant segments. No WAV, no PCM table, no sample bank, and nothing decoded at runtime. This is a flash rule before it is an aesthetic one: one second of 16-bit 16kHz mono is 32 KB, so the vocabulary as recordings would cost more than the whole game catalogue's artwork, on a budget already at 82.1%. As synthesis it is under a kilobyte. The spoken phrase is a phoneme table, not text-to-speech -- there is no dictionary and there is no second phrase; adding one means writing its phonemes out by hand, which is the intended cost. -
Mute is gated in exactly one place,
Board::playSound(). Every sound in the firmware goes through that door -- both beeps and the boot phrase included -- so a switch labelled Mute cannot leave something still audible. The RGB pulse is deliberately not gated:beepOk()/beepError()pulse before they call it, so muting takes the sound and leaves the light, which is the whole of the feedback on a codec-less board anyway.soundEnabled()andvolume()are RAM-mirrored write-through settings because the first is on the path of every cue in every game.Exactly one thing may change the switch itself, and it puts it back. A find alert (see above) unmutes a muted console so that it can answer, and
NearbyPlay::dismissAlert()restores it -- which is why every way an alert can end, a touch and the BOOT key and its own timeout, funnels through that one function. Note what this is not: nothing reaches pastBoard::playSound(), so the invariant still holds literally -- a muted console is silent, and this console is briefly not muted. It is the same shape aslockOnWake_: a one-time override of a device setting, cleared in the one place that already decides the episode is over. If you need a second such override, be sure it can say the same two things. -
A screen makes a noise through
playSound(Sound::...)and nothing else.Board::beep(freq, ms)is private on purpose. A shared vocabulary is the point --Coinmeans the same thing in Whack-a-Mole as in Memory, and a game picking its own frequencies is exactly how that stops being true. Cinnamon's four pads are the one pitched exception and they are in the vocabulary for that reason. Adding a cue is adding a word to a language: do it when a game has something genuinely new to say, not when an existing cue is nearly right. -
A cue is armed, never played.
playSound()copies a script and returns in microseconds; a dedicated task,braino-audio, generates the samples. Never write a blockingi2s_writeon a render path -- a 300ms note is fifteen frame budgets andWatchdogwill log the stall.src/s3_diag.cppdoes block, correctly, because a bring-up probe has no frame budget. -
Audio generation is on a task because a frame is not a deadline it can meet. It used to run from
tickAudio()in the loop, on the argument that the DMA holds 96ms against a 20ms budget. That holds for a typical frame and fails for the one that matters: a launcher page turn repaints the whole screen, which on the 4-inch panel outlasts the buffer, so the cue armed just before it was cut off mid-sound. A deeper buffer only moves the threshold -- the worst frame is bounded by nothing. The task runs above the loop on the same core so it preempts the repaint, generates underaudioLockand blocks outside it, soplaySound()never waits on the DMA. This is the same move the responsiveness rule already prescribes for the battery gauge and the watchdog: work whose deadline is not the frame's does not belong on the frame. -
The loop is watchdogged.
Watchdog::feed()is the first statement inBrainoApp::loop()and a frame overTIMEOUT_SECONDS = 12reboots the device. Anything that blocks the loop task for longer on purpose — a calibration wizard, a network round trip — must sit inside aWatchdog::Pauseguard, or it will look exactly like a hang. Seesrc/hal/CLAUDE.md.
Adding or changing a theme means running these two, in this order, and looking at what the second one writes:
python tools/check_contrast.py # every pairing, against the WCAG floors
python tools/gen_screens.py --themes # docs/theme-sheets/<theme>.pngThe check is arithmetic and catches what arithmetic can: text that cannot be
read on what it sits on. It cannot catch a glyph drawn in a colour the palette
never chose, and that is what nine themes shipped with -- a Home button painted
TFT_WHITE on Classic's white bar, so the button was simply not there; launcher
tile labels fixed white over a fill the theme picks, unreadable on three of
them; a battery badge in one of two greys chosen for Dark, invisible on
Pocket's green; primary buttons in a hard-coded web blue on all nine. Every one
of those looked perfect in the Dark mock-ups, which were the only mock-ups
there were.
The sheets render a representative set of screens -- launcher, About, Settings, System Info, Scores, a game, a tracing canvas, the lock screen -- in each palette. Read them for three things:
- Is every glyph still there? A control that vanishes into its background is the failure this exists to catch, and it is invisible in a diff.
- Does anything look like it belongs to another theme? A colour that does
not move when the palette does is a constant that should be a role.
barText, the tile fills,radiusandaccentwere each found that way. - Is the ink on a coloured fill readable? Text over a themed fill takes
Ui::onFill()/Ui::onFillSoft(), never a chosen black or white.
A palette that passes the checker and fails the sheets is normal. The checker is a floor; the sheets are the design.
The old version of this list had five items, all of them code. Everything that
has since been shipped broken or stale was outside those five: the README game
table, the screenshot gallery, docs/screens/, the changelog. A game that
launches correctly and is invisible in every document describing the product is
not finished.
Work through all four groups. Nothing here is optional for a screen that ships.
src/games/NewGame.{h,cpp}— subclassAppGameunless it is a privileged system screen.- In the game's
.cpp, declare oneAppMetadatablock. Blurbs render at font 1 across ~292 px, so keep them under ~46 chars. Put the launcher icon, launcher index and default visibility in that metadata block, not in the registry. - If it records a score, declare one local
AppScoreInfoand point the metadata at it. src/engine/AppRegistry.cpp— add ametadataCatalogApp(...AppMetadata(), instance)entry at the intended playable position.src/engine/AppRegistry.h— updatePLAYABLE_APP_COUNT.
README.md— a row in the right game table (what it is, what it builds, age), or a bullet under the system screens if it is an app.README.md— an<img>in the matching screenshot gallery.CHANGELOG.md— an entry under the unreleased heading.
tools/gen_screens.py— a render function plus an entry inSCREENSorEXTRA_SCREENS. Take the geometry from the game's ownRecthelpers so the mock-up matches the device rather than approximating it.- Run
python tools/gen_screens.pyand look at the PNG. It is a generated image; nothing else will tell you it came out wrong. tools/gen_site.py— nothing, usually. The landing page shows six chosen stills (HERO_STILLS) rather than all of them, and its game list is names read fromAppRegistry, so a new game appears on it without anybody touching the generator. It showed all ninety-one once, with three per game card, and that is why this step used to be a wiring job; the page is now a page rather than a contact sheet, and the README gallery is where a reader goes for every screen. Only if you want the new still to be one of the six: add it toHERO_STILLSand give it aSCREEN_CAPTIONSentry, and take one out — six is the number because more stops selling and starts listing.
pio run— and put the new flash/RAM figures inREADME.mdandCLAUDE.md. They are the two places that disagree.python tools/check_docs.py— must be clean.- Flash it and actually play it. Take the board lock first.
These derive from AppRegistry and update themselves. Editing them by hand is
how About fell six games behind in the first place:
- the About app's game list, count and blurbs
- the Settings → Games visibility list
- the launcher tiles and paging
- the GitHub Pages site —
tools/gen_site.pyreads the version fromAppVersion.h, the game list and blurbs fromAppRegistry, the build figures fromREADME.mdand the board name fromplatformio.ini. Editsite/index.template.htmlfor wording and layout only;check_docs.pyfails if a version number is typed into it. The game list and the six-still wall are generated too, so do not add an<img>or a game name to the template — see step 11 above
Everything above still applies, plus:
- It must work in both orientations. Read
tft.width()/tft.height()at render time, neverSCREEN_WIDTH/SCREEN_HEIGHT. Settings and Wi-Fi currently violate this and are the reason the rule is stated so bluntly. - If it touches a radio, stores data, or changes what leaves the device, the About radios page and the README privacy section are part of the same change — and About must read the state, not restate it.
App-facing rendering uses Ui::Renderer: a driver-free RGB565 primitive
interface implemented on hardware by Ui::TftRenderer over TFT_eSPI. HAL
bring-up, calibration and BMP blitting may still use the raw panel driver, but
games and shared UI helpers should not. There is still no framebuffer. A full
320×240 wipe pushes ~150 KB over SPI — roughly 30 ms of visible blanking —
which is why Game carries two levels of invalidation:
markDirty()— content changed; repaint moving parts only.markFullDirty()— layout changed; repaint background/chrome too.
A FULL REDRAW IS THE EXCEPTION, IN EVERY GAME. Earn it.
markFullDirty() clears the screen and repaints the chrome: ~150KB over SPI
and roughly 30ms of visible blanking, which is one and a half frame budgets.
Doing it to change a few pixels is not a lost optimisation, it is a visible
flash in the player's hand, and on a screen that updates often it is the
first thing anybody notices.
Before reaching for it, ask what actually changed on the panel:
- Something appeared? Draw it. Nothing needs erasing, so nothing needs clearing: overdraw it and touch nothing else.
- Something moved? Erase its own box and repaint the guide over that box. Derive the box from the geometry that drew the thing; never type in a rectangle. Drawing functions that paint exactly what is already there in the same colours are idempotent, so re-running one whole is visually a no-op outside the box you cleared, which is usually cheaper and always simpler than working out precisely which pieces overlapped.
- Something animating on its own clock? Make it change colour rather than size. A marker that never grows never has to be erased, which is what keeps the incremental path available at all.
- The scene genuinely changed (a new question, a new letter, a new screen, an end-of-game banner)? Then repaint fully. A stable picture is worth the frame it costs when the whole picture is new.
This is not theoretical. LetterTracer's direction arrow was first written to
markFullDirty() whenever it moved, on the reasoning that a moving arrow
changes the picture's shape and turns are rare. Turns are not rare: a
three-letter joined word has about eleven, so tracing one word cleared the
screen eleven times, and it was reported from the device as the screen
flashing. The fix was fifteen lines (erase the arrow's own box, repaint the
ghost and the dots over it) and the reasoning that produced the bug was a guess
about frequency that was never checked.
The same rule holds for the top bar: route clock, battery and notification
changes through requestChromeRender(), not requestRender().
render()should guard static chrome behindif (needsFullRender())and draw dynamic parts unconditionally.
These three are protected; the public surface is needsRender(), clearDirty(), and requestRender() (which forces a full repaint, used when returning to a screen). First paint is always full.
A partial repaint's clear rectangle must be derived from what it has to avoid, never typed in. This is the most expensive mistake in this codebase to see, because the code reads correctly and the wrong pixels appear only after some other element redraws. TimeGame has now produced it twice. Its score header cleared two 140px-wide strips -- far wider than the text needed -- while the clock dial spans x=113..207, so every score change erased 37px off each shoulder of the clock and left the face as a narrow strip with square bites out of it. It happened on the first frame, on every board, from the day the screen was written; the 4-inch panel simply made it 48px a side. The same screen had already been caught clearing a prompt strip that took the bottom off two answer buttons.
So: write the geometry once, at file scope, and derive every clear rectangle from it with a few pixels of daylight -- HEADER_W = CLOCK_CX - CLOCK_OUTER - MARGIN - GAP rather than 140. Leave a static_assert where the derivation could collapse. Exact adjacency is not good enough on a scaled panel: positions scale by their axis and radii by the smaller of the two, and each rounds independently.
And note that tools/gen_screens.py cannot catch any of this. A mock-up draws elements in isolation, in the order the generator happens to use, with no clear rectangles at all -- so an ordering bug or an erase-over is invisible in it by construction. The Time mock-up also omitted the question label entirely, which is how a dial printed over that sentence survived every screenshot review. When a screen looks right in docs/screens/ and wrong on the panel, this is the first thing to suspect.
Clip scrolling content with tft.setViewport(x, y, w, h, false) and reset it after. Skipping rows that fall entirely outside the viewport is not enough — the row straddling the edge still draws in full and smears into the chrome above it, which is what System Info did into its own tab strip. vpDatum=false keeps drawing coordinates absolute, so nothing else in the draw loop changes.
Most games still repaint wholesale. Cinnamon is the reference for partial redraw — it was also a photosensitivity concern at full-flash rates, so prefer partial redraw for anything that updates rapidly.
Playable games are authored against a fixed 320×240 landscape canvas. Launcher and system/UI apps support portrait (LayoutMode::Vertical; the launcher uses 4 tiles/page vs 6 in landscape, and 9 -- a 3x3 grid -- in portrait on a panel whose short side is at least 320px, i.e. the 4-inch board. LauncherLayout::grid() is the one answer to "how many columns and rows", read by the tile rects, the page size and the tile colouring alike; in the 3x3 grid a subtitle too wide for its 88px goes onto two lines rather than being chopped).
System/UI apps (Settings, Wi-Fi, SystemInfo, Profiles, Scores, About, and any future app-style screens beyond the playable game catalog) must support both landscape and portrait orientations. They must read tft.width() / tft.height() at render time rather than the compile-time constants SCREEN_WIDTH / SCREEN_HEIGHT, and lay themselves out responsively. Use Ui::drawTab() + Ui::drawTabBaseline() for multi-section content; the tab strip width adapts by dividing tft.width() at render time.
include/BoardProfile.h the contract every board fills in
include/BoardConfig.h selects one profile; derives SCREEN_* from it
include/BuildStamp.h branch/commit/build-time accessors
include/boards/ one header per supported board -- the ONLY place
in the tree that names a GPIO include/AppVersion.h
src/main.cpp bringup entrypoint + normal app setup/loop
src/BuildStamp.cpp which build this is; recompiled every build
src/wifi_diag.cpp standalone radio test (env:wifidiag only)
src/s3_diag.cpp standalone ESP32-S3 bring-up probe (env:s3diag only)
src/diag4.cpp standalone 4-inch ST7796 bring-up probe (env:diag4 only)
src/engine/ Game, LauncherApp, GameCatalog, AppRegistry, NearbyPlay,
AppRuntime, AppRuntimeLock, AppRuntimeNotify (the
header banner), AppRuntimeIdentity,
AppRuntimeConsole (+Settings, +Profiles),
ConsoleText,
ScoreCatalog, Progress,
RecentQuestions, ContentLoader
src/games/ one .h/.cpp pair per game + GameInstances.h +
LetterTracer (the finger-tracing engine Trace and
Cursive share: logic, Draw, Arrows, Words and a
Layout header), CursiveGlyphData (generated) +
Country/State, Maze and Trace data.
Settings is three .cpp against one header --
SettingsApp (tabs + routing), SettingsPanels
(the tab bodies), SettingsPin (the PIN pad).
Ludo is six .cpp against one header -- LudoGame
(flow, input), LudoBoard (the board), LudoPanel
(the side panel), LudoLobby (both lobbies),
LudoTable (play across consoles) and LudoSave --
over LudoRules (the rules, the computer player and
the table protocol, pure C++ with no Arduino).
Backgammon likewise: BackgammonGame (flow, input),
BackgammonDraw, BackgammonNet (the nearby game),
BackgammonSave, over BackgammonRules and
BackgammonAi (pure, host-tested).
Chess is six .cpp against two headers -- ChessGame
(flow, input), ChessDraw, ChessNet (the lobby and the
nearby game), ChessSave, over ChessRules (the rules)
and ChessAi (both computer levels), which are pure
C++ with no Arduino and are host-tested; Sea Battle is four: SeaBattleGame
(flow, input, the fleet), SeaBattleDraw,
SeaBattleNet, SeaBattleSave.
NearbyWatch is the pause every nearby game shares
when the other console goes quiet.
GoRules (the rules, scoring and the wire encoding)
and GoAi (both computer levels and the dead-stone
estimate) are pure and host-tested, like
Backgammon's.
src/hal/ Board bring-up, BleBeacon (the radio) +
BleBeaconPayload (the one description of what goes
on air, and decode(), its exact inverse),
BleScanner, BoardAccess facades,
per-concern HAL units, BoardAudio (the synthesiser) +
BoardAudioBackend (codec, I2S, amp) + BoardAudioCues
(every cue and the spoken phrase),
Sound.h (the cue vocabulary), BoardButton (the BOOT
key), BoardUpdate (is a newer firmware available --
a notice, never an OTA), BoardStorage, storage
maintenance, TouchTypes,
Clock, Watchdog
src/ui/ Renderer, TftRenderer, Ui, Keypad, LauncherIcons,
LauncherLayout, LogoMask (generated -- the product
mark, as a one-bit silhouette)
tools/ gen_screens.py, gen_site.py, check_docs.py,
gen_logo_mask.py (the product mark, from
tools/braino-badge.svg -- writes a preview that MUST
be looked at),
gen_cursive_glyphs.py (cursive letterforms, from a
GPLv3 dotted teaching font -- writes a preview sheet
that MUST be looked at),
check_boards.py, check_catalog.py,
check_contrast.py (every theme's colours against
the WCAG floors),
check_frame_rules.py, check_identifiers.py (no MAC
or public IP may reach this repo -- see the rule
above),
check_licenses.py (every file states its licence,
its holder and what reuse requires; --fix writes
the missing ones, and every generator imports its
header_for()), build_stamp.py,
envs.py (which environments are the product and
which are bench probes -- the only list),
pack_release.py, split_render.py,
fetch_release_firmware.py (past releases, for the
installer's version picker),
configure_boards.py + bench_config.example.json
(set every board up from one config over the
serial console; the real config is gitignored),
ESP32_boardUtil.py + board_registry.example.json
(which board is on which port, keyed by the
firmware's own Board::deviceId(); the real registry
is gitignored because it names one person's boards)
site/ index.template.html — the GitHub Pages landing page;
assets/ — its photos, hero video and poster PDF,
the only part of the page not derived from firmware
.github/workflows/ ci.yml validates checks, then builds one job per
environment in parallel (`verify` is the required
check that judges them); pages.yml publishes
the site from the same firmware set;
release.yml publishes a tagged release with
every firmware image attached
docs/ SD_CONTENT_SPEC.md, PORTING.md, screens/,
boards/ (one page per supported board; the pin
tables and diagrams in it are generated)
cases/ printable enclosures, one folder per BOARD_NAME;
optional -- a board is supported without one
A release is a tag. Everything else is automatic:
git tag -a v5.0.1 -m "Braino! 5.0.1" && git push origin v5.0.1.github/workflows/release.yml then builds the five product environments --
tools/envs.py --product, never a list in the YAML -- packs them with
tools/pack_release.py, and publishes a GitHub release with all four parts
plus a single -merged.bin per environment, SHA256SUMS.txt and
FLASHING.txt. The diagnostics are deliberately not attached; they are built
from source by whoever is holding the board.
Before tagging, on main:
-
include/AppVersion.hcarries the real version -- not a-SNAPSHOT. The workflow refuses to publish one, becausedevcarries a snapshot between releases by design and the first mistaken tag would otherwise publish a "release" the firmware itself calls unreleased. -
CHANGELOG.mdhas a## <version>section with that day's date. The release notes are lifted from it verbatim -- notes written by hand are a second changelog that agrees with the first only on the day it is written. -
The build figures in
README.mdandCLAUDE.mdare from a build of the branch being released.tools/build_stamp.pycompiles the branch name into the image, so a figure measured on a feature branch is a few bytes out onmain; measure withGITHUB_REF_NAME=main pio run -e app, which is what CI does.BRAINO_VERSIONis in the image too, so measure after the version bump, not before. Dropping-SNAPSHOTis nine characters and moved the 5.2.0 figure by 16 bytes, which shipped tomainwrong because the build was run on the tree as it stood before the release commit. The consequence is thatdevandmainlegitimately carry different numbers between releases -- 2,376,053 on5.6.0-SNAPSHOTagainst 2,371,981 on5.5.1-- and that is not drift to be reconciled.check_docs.pycompares each document against whatever.pio/build/app/firmware.elfis sitting in your tree, so each branch has to state its own figure or the checks fail for anyone who builds it.The figure is also not portable across hosts: the same commit is 172 bytes smaller in flash and 48 smaller in RAM on the Linux runner than on the Windows machine these numbers were read from. Pinning the platform and the libraries fixes what the build is made of, not which toolchain binary assembles it. Read the number from your own
pio run; do not copy one out of a CI log.
The tag and BRAINO_VERSION must agree, and the workflow fails if they do
not. That check exists because a published release cannot be quietly
corrected: people have already downloaded it.
Afterwards, open the next version on dev as a -SNAPSHOT, so a board
flashed from dev cannot be mistaken for the release it is ahead of.
https://iamankushpandit.github.io/Gume/ flashes a board from the browser over
Web Serial. .github/workflows/pages.yml builds all four PlatformIO
environments on every push to main, runs tools/gen_site.py, and drops the
resulting .bin files beside the manifest that points at them. Nobody
regenerates it by hand, so it cannot go stale on its own — but three things
break it, and all three fail in someone else's browser rather than here:
- Adding or renaming a PlatformIO environment. The page offers one
firmware per entry in
gen_site.py'sVARIANTS; if the workflow does not build that env, its manifest points at binaries that do not exist and the flash fails partway.check_docs.pycross-checksVARIANTSagainstplatformio.iniand the workflow. - Changing what the firmware transmits or stores. The page carries a privacy section, and the same rule applies to it as to the About radio page and the README: a privacy claim that has drifted from the hardware is worse than none, because it is believed.
- Making CI unable to build.
platformio.iniis expected to build on a clean checkout, locally and in GitHub Actions. Iflib_depschanges shape, keep both.github/workflows/ci.ymland.github/workflows/pages.ymlbuilding all four environments in the same commit. - Touching
site/assets/. The photos, the one-minute hero video and the A2 poster are the one part of the page that is not derived from the firmware, so they are the one part that can rot with nothing noticing. Both directions are checked, for the same reason the stills are:gen_site.pyrefuses to generate when the page references an asset that is not there (a broken image on the landing page) or when an asset nobody references is sitting in the tree (weight in every clone, forever).gen_site.pycopies the folder into the build, so nothing on the page reaches outside the Pages origin for a picture.
python tools/gen_site.py writes site/_build/ locally so you can look at the
page. The flash button will 404 there — the binaries only exist in CI.
The installer used to offer exactly one firmware: whatever main last built.
When 5.5.0 shipped a defect that made the two 2.8-inch boards untouchable, the
only thing anyone could install was the broken one. A page that can only
install the newest build has no way back from a bad release, which is the one
moment somebody needs one.
tools/fetch_release_firmware.py downloads recent published releases' binaries
into site/_releases/; gen_site.py publishes each under
firmware/<board>/<env>/v/<version>/ with its own manifest, and the page grows
a Version selector. Four things about it are load-bearing:
- The bytes are copied, never rebuilt. What is served for 5.4.0 is what was published as 5.4.0, so an old version cannot quietly become a new build of old source, and a tag that no longer compiles today is still installable.
- They are copied rather than linked because of CORS. The obvious
implementation -- a manifest pointing at
github.com/.../releases/download/...-- does not work: GitHub's asset host sends noAccess-Control-Allow-Originheader at either hop (measured), so esp-web-tools'fetch()is blocked after the board is connected and the flash has begun. Serving from the Pages origin removes the question. - The version list belongs to the board, not the page. A board added in 5.4.0 has no 5.2.0 build, and a release that predates it must not appear in its dropdown. Switching to a board that lacks the selected version falls back to latest rather than leaving a stale manifest armed -- the same failure the firmware dropdown had once already.
- A release missing any of the four parts is skipped entirely rather than half-published. A partial build fails after the erase has started.
check_docs.py enforces the three pieces staying joined up: the workflow step,
the template's selector, and gen_site.py reading the cache. Dropping any one
of them leaves a page that still generates, still deploys and still flashes the
current version perfectly; the symptom appears only on the day somebody needs
an older one.
Pins live in include/boards/<board>.h -- one profile header per supported
board, and the only place in the tree that names a GPIO. Read the profile
rather than trusting generic ESP32 pinouts online, and never copy a pin map
between CYD variants: GPIO34 is battery sense here and the light sensor on the
ESP32-2432S028R. docs/PORTING.md is the checklist for adding a board.
- A panel can be colour-inverted, and TFT_eSPI's macro will not fix it.
A CYD panel can invert every channel relative to the driver it is built
with: the board comes up with backlight, touch, layout and a clean serial
log, and only the colours are wrong, which reads as a theme bug.
TFT_INVERT_COLORSis consulted only by TFT_eSPI's ST7789 and ST7735 init paths, so defining it on an ILI9341 build does nothing at all -- that was tried on the bench and looked like the flag being ignored. The fix isPanelProfile::invertColours, whichBoard::begin()turns into a runtimeinvertDisplay()afterinit()and before anything is drawn. - The boot log states what the board IS.
[boot] device=...is the firmware's own id (Board::deviceId()), never the MAC -- see "No identifiers in this repository".board=andpanel=are compiled in and therefore describe the firmware, not the hardware, which is exactly how a 2.8-inch board reported itself as a 4-inch for half an hour. - A running board answers
identifywith the same facts, unreset. One line, every value quoted;ESP32_boardUtil.pyasks before it resets anything. It carries only the banner's facts (no MAC, profile, score or SSID) and needs no PIN. Open the port with DTR and RTS already low, or opening it resets the board and defeats the point. - The serial port is a console, and it was widened on purpose. The
maintainer asked for bench boards to be set up from a cable rather than by
hand, and plans a layered framework whose shell reaches every service the UI
does (
docs/FRAMEWORK_PLAN.mdon the platform-separation branch), so the console is built to grow. It is three files:AppRuntimeConsole.cpp(the reader, the command table, the session and Wi-Fi),AppRuntimeConsoleSettings.cpp(every device setting) andAppRuntimeConsoleProfiles.cpp(players and their games), with parsing inConsoleText.h.- One line reader, one command table, one reply grammar. A command is a
row -- name, usage, help,
AppCapability, argument counts, handler -- andhelpis derived from the table. Every reply is exactly one line,ok key="value" ...orerr <code> <message>, and reply keys are word characters only (ntp_hours, notntp-hours), because every tool matches(\w+)="...". Add a command as a row, never as another string match, and never give it a second reply shape.identify?,settingsandsettings?are kept as aliases because tools ondevalready send them;ESP32_boardUtil.pyalso accepts the[ident] ...reply that pre-console snapshot builds give. - CRUD, where the entity has it. Settings are fixed keys, so they are
Read and Update:
get [key]andset <key> <value>over one settings table whose rows are exactly the settings the Settings and Wi-Fi screens offer, with those screens' choices -- a new setting is a row there too. Players are full CRUD:profiles,profile-add,profile-rename,profile-remove. Per-player games are Read and Update:games <slot>,game <slot|all> <id> on|off(allis the classroom case). Wi-Fi is create/update and delete (wifi,wifi clear), read only as a flag. - Device reads are open; writes, and reads of player data, need the
PIN.
needsUnlock()keys on the row's capability (settings, network, profiles, scores, factory reset), so a new row cannot forget the gate.profilesprints names, so it carries the profiles capability and is gated like a change. Three wrong PINs lock the console out for 30s, and only exactly four digits are judged. - The same refusals as the screens, and two more. The admin profile cannot be removed; the active player cannot be removed either (from a cable that would pull a profile out from under a running game); two players cannot share a name. Games at launcher index 32+ cannot be hidden yet -- visibility is a 32-bit mask and the catalogue is 38 -- and the console says so rather than answering ok.
- Serial only. A console over Wi-Fi or BLE would be a new outbound flow under the closed privacy list.
- It goes through the same doors as the screens: the same
Boardsetters and the same refusals (Nearby without the beacon), then a repaint of whatever screen is showing the old value. - Nothing personal comes back unasked:
getandidentifycarry no name; player names only from the PIN-gatedprofiles; never the network's name or password; and the line buffer is wiped after every command. - It is not activity: nothing touches
lastActivityMs_, andBoard::setBrightness()no longer lights the backlight over a sleeping panel -- unreachable from the slider, reachable from a cable. - Deliberately absent: factory reset, reading or clearing scores, changing the admin PIN or which profile is admin, peer labels, the update check and the NTP server. Each is a decision for the maintainer, not a convenience.
- One line reader, one command table, one reply grammar. A command is a
row -- name, usage, help,
- An EN reset can strand an E32R40T, and the flash tool now recovers it.
After the reset button, an RTS reset from a serial tool, a USB power surge
-- and, most often, the hard reset at the end of an upload -- the ROM loops
flash read err, 988orinvalid header: 0x20000368/RTCWDT_RTC_RESETevery ~350ms with the panel dark. It is not the GPIO12 strap, which was the first guess (GPIO12 is also the shared MISO): the ROM printsboot:0x17, whose MTDI bit is clear, so 3.3V flash was selected. Nor is the image damaged: read back while looping, the bootloader at 0x1000 was byte for byte correct, and the failing read differed from it by three bits at the same address -- a marginal read at the moment of some resets. Cause not yet found. What recovers it without a battery pull is a reset that comes from download mode:esptool --after no_reset flash_id, thenesptool --before no_reset --after hard_reset read_mac, repeating the pair (never the second step alone) if it fails.ESP32_boardUtil.py --flashasks every board it flashed for its build afterwards and runs that recovery on any whose serial shows the loop (check_boot()), so a flash no longer leaves a dark 4-inch board behind. By hand: capture the serial passively before reflashing; it cannot be flashed while looping. - There is no
id=field in the banner, and putting one back needs more care than it looks. 5.9.0 printed the controller's ID register and broke the display on two of the seven boards:tft.readcommand8()writes an undocumented 0xD9, toggles CS mid-sequence and restores neither the address window nor MADCTL, so a panel whose MISO is not wired back is left mid-command and the next write inherits it. The symptom is the top bar crushed into a band at one edge and the rest of the screen never painted, while the loop reports 48fps, a 23ms worst frame and a flat heap -- every instrument saying the firmware is healthy, because it is. The corruption was in the panel, put there by the diagnostic. Across all seven boards the field only ever answered00:00:00orFF:FF:FF; not one returned a real ID, so it could not do the job it cost a panel to attempt. The remaining three lines turn "which board is this?" into a paste rather than an afternoon. - The E32R28T-1's RGB LED is red IO22, green IO16, blue IO17, from the vendor's pin table, and IO4 is its amplifier enable, active low -- not an LED. It was once declared
rgb.r = 16,rgb.g = 4on the belief that red and green were crossed; the observations behind that (orange came out green, purple came out cyan) are exactly what IO16 being green produces, and driving IO4 as an LED held the speaker's amplifier in shutdown. Common anode, so drive is inverted -- which the profile states rather than the driver assuming. - Touch is bit-banged SPI on the E32R28T-1 and the ESP32-2432S028 variants
(the TFT owns HSPI); on the E32R32P and the E32R40T the XPT2046 shares the
display bus with its own CS on GPIO33, and
TOUCH_CSin the board section is what switchesBoardTouch.cppto TFT_eSPI's touch extension. Either way, 3-point affine calibration persisted in NVS behind a magic number.touch.pressureThreshold = 350,touch.hitSlop = 8in the profile. - Backlight brightness floors at
Board::BRIGHTNESS_MIN = 25— at lower duty the panel is unreadable and a player could not see the slider to undo it. audio.speakerPin = 26on the E32R28T-1, the ESP32-2432S028 inverted-panel variant, E32R32P and E32R40T reaches the JST speaker connector via the ESP32 built-in DAC (DAC channel 2 = GPIO26).GUME_HAS_AUDIO_DAC 1is set on all of them; the I2S peripheral drives the DAC directly viaI2S_DAC_BUILT_INwith no external codec. The full cue vocabulary and the spoken boot phrase play from the same synthesiser as the Freenove FNK0104B. The codec path isGUME_HAS_AUDIO_CODEC 1(FNK0104B only); boards with neither macro have no audio.maxVolumeinBoardProfile.audiois 85 for the codec board and 75 for the bare-DAC CYD boards (unamplifed driver distorts above 75%). Seesrc/hal/CLAUDE.md. Since 5.10.0 it is on for the E32R28T-1 and the inverted-panel CYD too, whose touch clock is GPIO25 -- DAC channel 1, the 5.5.0 collision.beginAudio()powers that channel down and returns the pad to the GPIO matrix, logging[audio] GPIO25 released: ..., andBoard::begin()re-applies the touch pins after it;BoardConfig.hallows a touch pin on the non-speaker DAC pad and nothing else.- Wi-Fi/NTP is a non-blocking state machine driven by
tickTimeSync()each frame, with a raw-UDPntpUdpProbe()fallback for when lwIP's SNTP never answers. The success-path automatic resync interval is a cached global setting, 1–24 hours with a 6-hour default; boot sync, manual sync and failure retries are separate. Timezone comes from a named POSIX zone or public-IP lookup — routers don't advertise one in practice.
- Hit testing:
Rect{...}.contains(touch.x, touch.y, TOUCH_HIT_SLOP).Rectis inUi.h,TouchPointinhal/TouchTypes.h. - Board facts come from
BOARD(include/BoardProfile.h), never from a literal. A peripheral a board does not wire isPIN_NONE, and the caller guards withBOARD.hasSdSlot(),hasRgbLed(),hasSpeaker(),hasBatterySense()orhasBacklightControl(). - Feedback:
board.beepOk()/board.beepError(). - Draw through
Ui::helpers so every theme is respected; avoid hardcoded colours outside icon art. There are nine, andUi::setTheme()fills the live palette from one table -- so a screen that reaches past the accessors is a screen that looks wrong in eight of them. Bar text, the three launcher tile fills and the button corner radius are palette entries too, for exactly that reason: each was a constant, and each made a theme impossible until it moved. src/games/CountryDataTable.cppis generated — edittools/gen_country_facts.pyand regenerate.swallowTouch_inmain.cppsuppresses the first press after a rotation change or screen-saver dismissal, preventing a phantom tap on freshly drawn UI.
Part of Braino! by iamankushpandit. Copyright © 2026 iamankushpandit, licensed GPL-3.0-or-later alongside the code — reuse of this document, in whole or in part, must keep this attribution and stay under the same licence. See NOTICE.md.