Braino! describes each board it supports in two files, and nowhere else:
| Where | What it holds |
|---|---|
include/boards/<id>.h |
the whole board: pins, rotations, the battery divider, which peripherals exist at all |
platformio.ini [board_<id>] |
only the macros TFT_eSPI insists on being told at compile time, plus the board's name and the header naming it |
No file under src/ names a GPIO number, a panel size or a divider ratio. If
you find yourself adding one, the board profile is missing a field — add it to
include/BoardProfile.h instead, and fill it in for every existing board in the
same commit.
Not every ESP32 display board can run Braino!, and one that cannot is out of scope rather than a porting task. Four things are hard requirements. A board missing any of them fails to compile, with a message saying which — that is deliberate, because the alternative is firmware that flashes and then cannot be read or pressed.
| Requirement | Why | If it is missing |
|---|---|---|
| A panel at least 320×240 in landscape | The playable games are drawn on a fixed canvas and position controls by arithmetic on it. A smaller panel puts buttons off the edge, and a player cannot press what is not there | Not supportable |
| A touch controller | It is the only input this firmware has. There are no buttons | Not supportable |
| The backlight on a GPIO | Brightness is a setting with a readability floor, and the idle path blanks the screen rather than only dimming it | Not supportable |
| 4 MB of flash | The app partition is 3 MB (huge_app.csv) and the firmware is most of it. No partitioning makes a 2 MB part fit |
Not supportable |
Everything else is optional, and a board without it gets a firmware that
quietly does without — no code change, just PIN_NONE:
| Optional | What the firmware does without it |
|---|---|
| SD card slot | sdReady() is false; optional SD content simply is not loaded. Everything has defaults |
| RGB LED | beepOk()/beepError() become no-ops. There is no audio either way |
| Speaker | Already stubbed on every board |
| Battery sense | The gauge blanks its digits, which is the honest reading for a board that cannot see a cell |
A larger panel clears the size requirement, but see What is not decoupled yet before assuming the games look right on it.
If a board can be supported, the web installer has to offer it. Someone who
owns the board should be able to put the firmware on it from
https://iamankushpandit.github.io/Gume/ without installing a toolchain;
"supported" that only means "builds on the maintainer's machine" is not
support.
That is enforced rather than remembered. tools/gen_site.py derives the
picker's board list from the same [board_*] sections in platformio.ini, so
a new board appears on the page automatically — and refuses to generate the
site until it has a label, an environment offered in VARIANTS, and a CI build
producing the binaries its manifest points at. tools/check_boards.py reports
the same gaps without needing to build. Both are in the checklist below.
- Check the board clears the four hard requirements above.
- Write
include/boards/<id>.h. - Add
[board_<id>]toplatformio.ini. - Add
[env:<something>]pointing at it. python tools/check_boards.pypio run -e <something>- Flash it and check the six things in Bring-up.
- Put it on the web installer and in CI, and update the docs — Landing a port.
Copy include/boards/e32r28t1.h and work through it. Two rules:
Label every value with its field name in a /* comment */. BOARD is
aggregate-initialised, so the initialisers are positional and carry no names of
their own. tools/check_boards.py reads those labels to prove the board filled
in every field the contract declares — without them, a field added later would
compile on your board with a silent zero in it.
An optional peripheral the board does not wire gets PIN_NONE. The
firmware checks BOARD.hasSdSlot(), hasRgbLed(), hasSpeaker(),
hasBootButton() and hasBatterySense() and degrades quietly. A board with no battery connector
needs no code change; the gauge blanks its digits, which is the honest answer.
PIN_NONE is not a way past the four hard requirements above — those are
asserted, and a board that trips one is telling you it cannot run this
firmware.
Things worth getting right the first time:
landscapeRotationis the quarter-turn that puts the USB socket at the bottom.portraitRotationis a quarter-turn from it, andBoardConfig.hstatic_asserts that the two are not the same axis.Board::pollTouch()compensates for every rotation, so nothing in game code needs adjusting.- The panel size is native (portrait), matching
TFT_WIDTH/TFT_HEIGHT. The landscape canvas the games draw on is derived —SCREEN_WIDTHandSCREEN_HEIGHTcome out ofBoardProfile::screenWidth(). Do not state it twice; the second statement would eventually be the wrong one. commonAnodeon the LED means a channel lights when its line is driven LOW. Get it backwards and the LED is on and stays on.bootPinis the BOOT key, which nearly every ESP32 board brings out on GPIO0 because the ROM needs it for serial download mode. The firmware reads it only at runtime, as a shortcut to Home; holding it while the board starts is a message to the ROM, not to us. SetactiveLowto match the wiring —truefor the usual pull-up-and-short-to-ground — because a board described the wrong way round reads as a key held down forever.PIN_NONEis fine and costs the owner nothing but the shortcut.dividerRatiois the multiplier from the voltage at the ADC to the voltage at the cell. Read it off the vendor's own documentation, then confirm it against a meter withpio run -e batdiag— that tool reads the same profile, so what it measures is what the product will report.- Use the vendor's pin table, not a generic ESP32 pinout, and never another CYD variant's. GPIO34 is battery sense on the E32R28T-1 and the light sensor on the classic ESP32-2432S028R. They look like the same board.
[board_<id>]
build_flags =
-D BOARD_NAME=\"<what the owner should see>\"
-D GUME_BOARD_HEADER=\"boards/<id>.h\"
-D <DRIVER>_DRIVER=1
-D TFT_WIDTH=...
-D TFT_HEIGHT=...
-D TFT_MISO=... -D TFT_MOSI=... -D TFT_SCLK=...
-D TFT_CS=... -D TFT_DC=... -D TFT_RST=...
-D TFT_BL=...
-D TFT_BACKLIGHT_ON=HIGH
-D SPI_FREQUENCY=...
-D SPI_READ_FREQUENCY=...Only what is specific to this board goes here. Everything shared —
USER_SETUP_LOADED, the fonts, the language standard — is already in
[common], and check_boards.py fails if a board-specific macro drifts up
into it, because in [common] it would be a claim about every board.
The panel is now described twice: once for TFT_eSPI, once for us.
include/BoardConfig.h static_asserts TFT_WIDTH, TFT_HEIGHT and TFT_BL
against the profile, so the two cannot disagree past the compiler.
TFT_BL deserves its own paragraph. With the wrong backlight pin the panel
is completely dark while Wi-Fi, BLE, NVS, the LED and the screen saver all run
perfectly — the serial log looks healthy and it reads as a dead screen or a
wrong driver. The static_assert exists because that afternoon has already been
spent once.
[env:<something>]
extends = esp32_common
build_src_filter = +<*> -<wifi_diag.cpp> -<battery_diag.cpp>
lib_deps = ${common.lib_deps}
lib_ldf_mode = deep+
build_unflags = ${common.build_unflags}
build_flags =
${common.build_flags}
${board_<id>.build_flags}One environment targets exactly one board; check_boards.py enforces that too.
Flash it, open pio device monitor, and check these in order. Each failure
mode below has actually happened.
- Serial says
[boot] board=<name> rot=…. If nothing appears, it is the partition or the upload, not the board profile. - The backlight comes on. If the log is healthy and the screen is black,
it is
TFT_BL— and the static_assert means the profile and the ini agree, so both are wrong. Try the alternates for your panel. - The image is the right way round and not garbled. ST7796S accepts most ILI9341 commands, so the wrong driver gives you a real but mirrored, partly-corrupt picture rather than nothing — easy to misread as "sort of working".
- Touch lands where you tap, after the calibration wizard. If it is
mirrored or transposed, that is
landscapeRotation, not the touch pins. - The battery percentage matches a meter.
pio run -e batdiagis the tool for this; page CALIBRATE resolves the real divider ratio. - The LED shows the colour asked for. Wrong colour, not no colour, means
the channels are crossed; LED permanently on means
commonAnodeis wrong.
If the board has no battery, no SD slot or no LED, set those to PIN_NONE and
skip the corresponding step — that is a supported configuration, not a
limitation.
A board that boots is not a board that ships. In the same commit:
python tools/check_boards.pyandpython tools/check_docs.py, both clean. Between them they check the board is complete, that its two descriptions agree, and that the web installer can actually offer it.README.md— say which boards are supported, and record the flash/RAM figures from your ownpio runfor the environment you added.CLAUDE.md— the same figures, and the environment in the build section.tools/gen_site.py— aBOARD_DETAILSentry (label, chip family, where to buy one), and aVARIANTSentry for any new environment. The board list itself is derived fromplatformio.ini, so this is the part that cannot be. A picker entry whose firmware CI never built fails halfway through erasing somebody's board, so.github/workflows/pages.ymlandci.ymlmust build it too.check_boards.pyandcheck_docs.pycross-check all of this.site/index.template.html— the copy still describes one board in prose.gen_site.pyrefuses to generate with a second board until that is generalised, on purpose: offering a firmware the page then mislabels is worse than not offering it.docs/boards/<BOARD_NAME>.md— how to recognise the board, what has been checked on hardware, and its sources with the date checked and an archived copy where one exists; leave an empty GENERATED block and runpython tools/gen_board_docs.py, which fills in the pin table and draws the pin diagram from the profile. CI fails on a supported board with no page. Link the vendor's photos and drawings rather than copying them, and strip a photo of your own of its metadata -- the tool does, and CI refuses one that still carries any. Add a row todocs/boards/README.md.CHANGELOG.md.
The playable games are authored against a fixed landscape canvas and lay
themselves out against SCREEN_WIDTH/SCREEN_HEIGHT. Those constants now
follow the board, so a differently sized panel gets the right numbers — but a
game that positions things by arithmetic on them has not been checked at any
other size. The launcher and the system apps read tft.width()/tft.height()
at render time and are responsive by rule (see CLAUDE.md); Settings and Wi-Fi
are known violators.
A port to a board with the same 320×240 landscape canvas needs nothing beyond this document. A port to a different panel size needs the games looked at, one at a time, and that is the work — not this layer.
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.