GUme is the repository behind Braino!, a small firmware project with a large code surface: 40 built-in games, 7 system apps, hardware drivers, generated screenshots, a web installer and CI. The repo includes explicit instructions and machine checks to keep that surface consistent. Read those instructions first; they are part of the engineering process, not side notes.
Everyone taking part here is covered by the Code of Conduct. Security problems go through SECURITY.md, never a public issue. If you are looking for something to work on, the open issues are the current list of what this firmware does not do yet -- and #44 needs no hardware at all.
Start here:
AGENTS.md: repo-level rules that apply to every contributor.CLAUDE.md: architecture, build commands, invariants and workflow.src/engine/CLAUDE.md: screen lifecycle, app registry, progress tracking.src/games/CLAUDE.md: how games and system screens are structured.src/hal/CLAUDE.md: board, storage, profiles, watchdog and hardware rules.src/ui/CLAUDE.md: renderer, theme, widgets and drawing helpers.
These files exist because multiple contributors can work on this repo at the same time. They are the rules for every contributor, and a pull request is reviewed against them. They explain how to keep changes small, avoid stepping on another contributor's work and preserve the firmware's performance and memory constraints.
Braino collects nothing about the player using it, and no contribution may change that. It is not a default, a setting or an opt-in that happens to be switched off — it is what the product is. This is the one rule here that a maintainer cannot wave through in review.
Three things leave the device over the air. This is the complete list:
| Flow | What goes out | Controlled by |
|---|---|---|
| NTP | A time query to pool.ntp.org, or whichever server is configured |
Only once Wi-Fi is set up, and Wi-Fi can be skipped entirely |
| Timezone guess | One request to ip-api.com on first connect, which necessarily shows that host the device's public IP |
The same Wi-Fi switch; overridden by picking a zone by hand |
| BLE beacon | A device name and two bytes of the factory Bluetooth MAC, non-connectable. With Nearby play on, also a game index and a best score — and, while a game between consoles is running, the moves: a session number, a move number and two small numbers saying what was played (two board squares; for Ludo a seat and a token; for Backgammon the two points one checker moved between). No name, no profile, no score travels with a move | Off by default, opt-in from Settings → Beacon |
| Update check | A bare GET of one static file listing the current version of every supported board. Nothing about this device goes with it: no version, no board id, no query string — so the request is byte-identical from every Braino in the world, and the comparison happens on the device | The same Wi-Fi switch. It is not separately declinable; a device with no Wi-Fi configured never makes the request |
The update check was added in 5.7.0, and it is the example of how this list is
allowed to grow: agreed first as a change to what the product promises, then
built so the promise is enforced rather than asserted. Two properties carry it,
and tools/check_privacy.py fails the build if either is lost — the URL may
never gain a query string or a board/version parameter, and the address shown
to the owner is compiled into the firmware rather than read out of the
response. The first keeps the request anonymous; the second means the worst a
hostile network can do is display a wrong version number, rather than point a
young player at an address of its choosing.
Adding a fifth changes what the product promises its owners. Raise it in an issue and get agreement before writing the code, because a pull request that adds one gets closed on principle rather than on quality.
Concretely, do not add:
- analytics, usage counters, or crash and error reporting to any server;
- any HTTP, UDP or DNS request beyond the table above;
- a player's name, profile name, score, progress or usage history inside anything that is transmitted;
- an identifier derived from something a player typed;
- a dependency that phones home, checks for updates, or fetches remote content at runtime;
- a field in the BLE advertisement that identifies a person rather than a device — and there is no room for one regardless, the payload with Nearby play on is already exactly 31 bytes.
Storing is not collecting. Scores, mastery data and profiles live in the ESP32's own NVS and never leave it, which is the design rather than a compromise. Nothing needs an account, and there is nothing to sign in to.
If a change to what is transmitted or stored is agreed, four documents are part
of that same commit: the About app's radio page — which must read the state
from the firmware, not restate it — the README Privacy section, the privacy
section of site/index.template.html, and this file. A privacy claim that
has drifted from the hardware is worse than no claim at all, because it is
believed.
The project's own infrastructure holds the same line. No analytics on the
GitHub Pages site, and the web installer flashes over Web Serial from inside
the browser — no firmware, serial number or board detail is uploaded anywhere.
The one third-party request that page makes today is the esp-web-tools module
it loads from unpkg.com, which sees a visitor's IP the way any CDN does. Do not
add a second.
Board ports. Braino targets the E32R28T-1 properly, with a 4-inch ST7796
variant in progress. The CYD family has many variants whose differences fail
silently — backlight on GPIO21 versus GPIO27, GPIO34 as a battery sense here
but a light sensor on the ESP32-2432S028R. A board is described in two files
and nowhere else — a profile header in include/boards/ and a [board_*]
section in platformio.ini — and no file under src/ names a GPIO. The
mechanics are in docs/PORTING.md; everything a port needs
around the code is in
AGENTS.md → "Supporting a new board":
hardware reference first, then the profile and the PlatformIO env, then a
verification app, then games. The "known issues" section of the board doc is
not optional.
Cases. cases/ holds a printable enclosure per board,
in a folder named after the BOARD_NAME the firmware reports. A case is
optional — nothing about board support depends on one — but most people
building this have never designed an enclosure, and a port that ships a shell
is a complete package rather than a bare board. Print notes, fastener sizes and
"here is what I got wrong on the first print" are worth as much as the mesh
itself. cases/README.md has the layout to follow.
Tell us what's wrong with the code. Bug reports and code critique are genuinely valuable — please open an issue, and if you can, say how you'd fix it. Hardware assumptions that were never measured are the most valuable of all.
main and dev are both protected on GitHub. Neither can be force-pushed or
deleted, and changes reach them through a pull request whose CI has passed.
| Branch | What it holds | How work lands |
|---|---|---|
main |
Released firmware — what the web installer flashes | Pull request from dev, after the build has been tested on hardware |
dev |
Integration branch, and the base for everything below | Pull request from a topic branch or a fork |
feat/*, fix/*, docs/* |
One concern each, short-lived | Rebased on dev and opened as a pull request |
In force on both branches: a pull request is required, the verify job in
.github/workflows/ci.yml must pass, the branch must be current with its base
before it can merge, review conversations must be resolved, and force-push and
deletion are blocked. main additionally requires linear history, so rebase or
squash rather than merging a stale branch into it. Repository admins can bypass
these rules — that exists for an emergency such as a broken release, not as a
normal route, and using it skips the CI that would have caught the problem.
verify also runs on pushes to main and dev. That is deliberate
belt-and-braces: the pull-request trigger alone left main unchecked, and it
stayed red for weeks without anyone seeing it. If an admin bypass ever does
land something on main, the push run is what says so. It is scoped to those
two branches rather than to every branch because a topic branch with a pull
request open matches both triggers, and ran the whole job twice for every
commit.
The job always runs on a pull request, whatever the change touches, because
verify is the required check: a required check that never runs does not read
as passed, it reads as still expected, and the pull request waits for a status
that will never arrive. So the skipping happens a level down. A pull request
touching only docs/, cases/, site/, LICENSE or Markdown skips the
firmware build and reports on the repository checks alone; anything else
builds all three environments as before. paths-ignore: on the trigger is the
obvious way to do this and is the trap -- it would make every
documentation-only pull request permanently unmergeable.
git push origin main will be rejected. That is the protection working; open a
pull request instead.
Forking is the normal path, and the only one available if you are not a
collaborator on this repository. Base your work on dev, not main:
gh repo fork iamankushpandit/Gume --clone
cd Gume
git remote add upstream https://github.com/iamankushpandit/Gume.git
git fetch upstream
git switch -c fix/<slug> upstream/devWork, commit, then push to your fork and open the pull request against dev:
git push -u origin fix/<slug>
gh pr create --repo iamankushpandit/Gume --base devKeep the branch current — git fetch upstream && git rebase upstream/dev —
because the protection rules will not let a stale branch merge. Leave "allow
edits by maintainers" ticked so a maintainer can rebase or fix a check for you.
CI runs on pull requests from forks with a read-only token, so verify will
report on your PR without any secret being exposed to it. That one job covers
three things, and it is worth knowing which of them your change triggers:
- The repository checks and site generation always run, on every pull
request, including a documentation-only one.
check_docs,check_boards,check_catalog,check_frame_rules,check_privacyandgen_site.pyare cheap and are exactly what a docs change can break. - The firmware build is skipped when a pull request touches only
docs/,cases/,site/,tools/,.github/, Markdown orLICENSE. - When it does build, a pull request builds selectively --
appalways, plusbringupif you touched the HAL or a board profile,batdiagfor battery files,wifidiagfor Wi-Fi files. All ten environments are built on a push tomainordev, which is the safety net rather than the first line of defence. If your change touches something every board shares, build the full derived set locally before opening the PR.
What CI cannot do is flash a board: anything touching hardware, touch, storage or a screen still needs a human with the device, so say in the PR what you tested and on which board.
Maintainers with push access can branch inside the repository instead of
forking — the worktree flow below — but the pull request and CI requirements
are identical either way. There is no path that merges to main or dev
without one.
- One concern per branch. Branch from
dev. - Prefer a separate worktree for a new requirement:
git worktree add ../GUme-<slug> -b fix/<slug> dev. - Check the worktree before editing:
git status --short --branch. - Read the relevant
CLAUDE.mdfile before changing a subsystem. - Keep the change scoped. Stage explicit paths; do not use
git add -Ain a mixed worktree. - Do not add AI attribution to commits. No
Co-Authored-Bylines naming an AI tool, and no generated-by footers. - Update docs in the same commit when behavior, architecture, screens, settings, dependencies, build figures or privacy claims change.
Build the app:
pio runBuild every firmware environment. The list is derived from platformio.ini
rather than typed out, because typing it out is what went wrong: this section
said pio run -e app -e bringup -e wifidiag for most of the project's life,
which quietly omitted batdiag, and then three more boards arrived and it was
missing seven. .github/workflows/release.yml builds the same set the same
derived way.
pio run $(python -c "import re,io; print(' '.join('-e ' + e for e in re.findall(r'^\[env:(\w+)\]', io.open('platformio.ini', encoding='utf-8').read(), re.M)))")That is currently ten environments: an app, a bringup and a batdiag for
each of the three supported boards, plus the board-independent wifidiag. It
takes a while. For everyday work, the default board's four are usually enough:
pio run -e app -e bringup -e batdiag -e wifidiagBuild the whole set before opening a pull request that touches the HAL, a board
profile, platformio.ini or anything else every board shares -- CI builds all
ten on a push to main or dev, and a break that only shows up on the third
board is a break you want to find here.
Before any build, upload or serial monitor session, take the shared board lock. It lives in the common git directory so every worktree sees the same lock:
$lock = Join-Path (git rev-parse --git-common-dir) "gume-board.lock"
"$PID|build|$(Get-Location)|$(Get-Date -Format o)" | Set-Content $lock -Encoding utf8Release it when the command finishes. If a lock already exists, read
CLAUDE.md for the stale-lock protocol before touching it.
Flash the connected board only when you are ready to overwrite the device state:
pio run -e app -t uploadFlashing can disturb calibration, profiles and scores on the shared physical board. Say what you are doing before flashing in a shared session.
Run the checks that match your change. For normal firmware work, run:
python tools/check_catalog.py
python tools/check_frame_rules.py
python tools/check_docs.py
python tools/check_boards.py
python tools/check_privacy.py
python tools/check_licenses.py
pio run -e app -e bringup -e batdiag -e wifidiagThose six checks plus site generation are what CI's required verify job runs
on every pull request, so running them locally is running the gate:
python tools/gen_site.py --out /tmp/gume-site-checkgen_site.py writes the landing page and the installer's flash manifests. It
is ungated in CI -- it runs even on a documentation-only pull request, because
changes under site/, docs/, tools/ and .github/ are the ones most likely
to break the installer, and they are exactly the ones the firmware build skips.
If a screen layout changed, regenerate screenshots:
python tools/gen_screens.pyThen inspect the changed PNGs in docs/screens/.
Use src/games/CLAUDE.md and the full checklist in CLAUDE.md. The short path
is:
- Add
src/games/NewGame.handsrc/games/NewGame.cpp. - Subclass
AppGameunless the screen needs privileged system access. - In the game's
.cpp, declare oneAppMetadatablock with the stable id, title, subtitle, blurb, launcher icon, launcher index and default visibility. - Add an
AppScoreInfoonly if the game records a score. - Add the game instance to
src/games/GameInstances.h. - Bind metadata and instance in
src/engine/AppRegistry.cpp. - Update
PLAYABLE_APP_COUNTinsrc/engine/AppRegistry.h. - Add README game table text and screenshot references.
- Add or update a renderer in
tools/gen_screens.py, then run it. - Run catalog, frame, docs and PlatformIO checks.
- Flash and play the game on the device.
Keep game code on the app-facing API: AppContext, Ui::Renderer, scoped
storage and feedback helpers. Normal games should not include Board.h or draw
directly through TFT_eSPI.
System apps are screens such as Settings, Wi-Fi, Profiles, Scores, About and
System Info. They can use GameHost only when they truly need privileged board
access.
System apps must:
- Work in both landscape and portrait.
- Read
tft.width()andtft.height()at render time. - Use
requireCapability()before privileged actions. - Keep privacy and radio claims in README and About accurate.
- Derive catalog facts from the registry rather than restating them.
Start from the symptom and keep the repair narrow:
- Reproduce or inspect the affected game or screen.
- Read the subsystem
CLAUDE.mdfile. - Preserve the dirty/full-dirty rendering model. Do not replace it with a generic UI framework.
- Avoid heap churn in hot paths. Prefer fixed buffers and
snprintfover ArduinoStringin per-frame logic. - Add or update focused checks when the behavior can be tested on the host.
- Regenerate screenshots if layout changed.
- Run the relevant checks and build all environments.
- Flash and verify on the board when the bug is visual, touch-related, storage-related or hardware-related.
Small fixes are preferred. If a file grows past the modularity thresholds in
AGENTS.md or CLAUDE.md, split it first and keep that refactor separate from
the behavior change when possible.
Do not hand-build NVS keys. Use the storage helpers on AppContext or Board.
Profile prefixing, app scoping, guest write suppression and legacy migrations
are owned by the HAL. If you change persisted formats, update the schema
migration and test profile deletion and slot shifting.
A release is a tag, pushed on main. 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 builds every environment platformio.ini
declares, packs them with tools/pack_release.py, and publishes a GitHub
release carrying, per environment, all four flash parts and a single
-merged.bin to write at 0x0 -- plus SHA256SUMS.txt and FLASHING.txt.
Release notes are lifted from the CHANGELOG.md section for that version, so
there is no second set to keep in step.
Check three things before tagging:
include/AppVersion.hcarries the real version, not a-SNAPSHOT. The workflow refuses to publish a snapshot:devcarries one between releases by design, and the first mistaken tag would otherwise publish a "release" that the firmware itself calls unreleased.CHANGELOG.mdhas a## <version>section, dated.- The build figures in
README.mdandCLAUDE.mdcame from a build of the branch being released.tools/build_stamp.pycompiles the branch name into the image, so a figure measured on a topic branch is a few bytes out onmain-- measure withGITHUB_REF_NAME=main pio run -e app.
The tag and BRAINO_VERSION must agree, and the workflow fails if they do
not. A published release cannot be quietly corrected, because people have
already downloaded it.
To rehearse the packing without tagging anything, run it against a local build:
pio run $(python -c "import re,io; print(' '.join('-e ' + e for e in re.findall(r'^\[env:(\w+)\]', io.open('platformio.ini', encoding='utf-8').read(), re.M)))")
python tools/pack_release.py --out dist --strict--strict wants every environment present, so this needs the full derived
build above, not the four-environment shorthand.
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.
Open it against dev unless you are a maintainer cutting a release, which is
the one case that targets main.
A useful PR includes:
- What changed.
- Why it changed.
- How it was tested, including exact
piosize figures when firmware changed. - Screenshots or regenerated mockups for UI changes.
- Any hardware validation that was done or still remains.
Before asking for a merge: verify green, the branch rebased on its base, and
every review conversation resolved. The protection rules enforce all three, so
a PR that is not ready simply will not offer the merge button.
Firmware that has only been built, never run, does not go to main. CI proves
it links and fits in flash; it cannot prove a screen draws, a touch target is
reachable or a battery reading is sane. dev is where a change waits for
somebody to hold the board.
This project is licensed GPL-3.0-or-later — see LICENSE, and
every file says so itself. Each source file, script, workflow and document
carries a short notice naming the licence (as an SPDX identifier), the
copyright holder, where the work came from, and what reuse requires. Add a new
file and python tools/check_licenses.py --fix will write it for you;
python tools/check_licenses.py runs in CI and fails on a file that has none.
Do not delete a notice, and do not put a different holder in one -- a file
whose header disagrees with LICENSE is worse than a file with no header,
because it is the header a downstream reader will rely on.
The reason for having them at all is that a licence at the root of a
repository is a claim about the repository, and the thing that actually
reaches a stranger is a single file: one .cpp pasted into a forum answer,
one script copied into another tree, one 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.
By opening a pull request you agree that your contribution is licensed under those same terms. There is no separate CLA, and contributing to this repository needs no trademark permission -- the names stay where they are.
Copyright in this source is held by iamankushpandit; Braino!, the game names and GoodTime Micro Company™ are trademarks of GoodTime Micro Company™. Those are two different things under two different bodies of law, and NOTICE.md keeps them apart. The practical consequence is one line long and only applies if you fork and ship, not if you contribute here: rename the product before you distribute a build, which is an edit to a single header because the firmware spells its own name exactly once. If you port Braino to another board and distribute it, or ship a device running a modified build, GPL-3.0 requires you to make the corresponding source available under the same licence — which is the point: ports should stay available to the people holding the hardware.
The bundled libraries and artwork keep their own permissive licences
(TFT_eSPI FreeBSD, ArduinoJson MIT, NimBLE-Arduino Apache-2.0,
map-n-flag MIT), all GPL-3.0-compatible. Do not add a dependency under a
licence that is not — anything GPL-incompatible, or "non-commercial" and
similar source-available terms, cannot ship in this firmware. If you are
unsure, raise it in the issue before writing the code.
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.