A 40-game educational console for young players, running on an ESP32-32E board (E32R28T-1 — ILI9341 320×240 resistive touchscreen, 4 MB flash, no PSRAM).
Everything is baked into the firmware: no SD card, no accounts, no telemetry, no data collection. Two radios exist and both are narrow by design:
- Wi-Fi is used for exactly one thing — fetching the time from an NTP server (plus a one-off public-IP lookup to guess the time zone).
- Bluetooth LE is off by default. Turned on, it broadcasts a device
name (
Braino-<id>) and a hardware id. Turn on Nearby as well and it also broadcasts which game is open and the best score for it — anonymously, with no name or profile attached. The device shows you the exact bytes it is transmitting either way. See Privacy.
| Games | 40 |
| Flash | 2,581,433 / 3,145,728 bytes (82.1%) |
| RAM | 88,036 / 327,680 bytes (26.9%) |
| Artwork | 195 country flags, 50 state flags, 50 state outlines — 763 KB (34% of the image) |
Contribution workflow lives in CONTRIBUTING.md, alongside a code of conduct, a security policy and NOTICE.md on copyright and the one thing a fork is asked to do. What the firmware does not do yet is written down too, as open issues.
Board ports. Braino is developed and tested against the E32R28T-1, and
every supported board has been run on hardware (see
Which board do you have?). The Freenove FNK0104B
ships with three peripherals switched off (see
Freenove FNK0104B); the 4-inch E32R40T
has had its panel, backlight and touch confirmed on hardware but ships with four
peripherals not yet characterised (see E32R40T);
the 3.2-inch E32R32P has had its display, touch, battery sense and
radios confirmed on hardware, with the RGB LED order and the battery
divider still unverified (see E32R32P); and one
dual-USB CYD variant whose colours come out inverted has been verified on
hardware (see
ESP32-2432S028 dual-USB). A board
that is not in that list becomes supported when someone who owns one runs
it -- that report is the single most useful thing you can send. 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 now described in two
files and nowhere else: a profile header in include/boards/ and a
[board_*] section in platformio.ini. No file under src/ names a GPIO,
and the panel size the games draw on is derived from the profile rather than
stated.
Not every board can be supported. Braino needs a panel of at least 320×240 in landscape, a touch controller (the only input it has), the backlight on a GPIO, and 4 MB of flash for the 3 MB app; a board missing one of those fails to compile with a message saying which, rather than flashing into something unreadable. An SD slot, an RGB LED, a speaker and battery sensing are all genuinely optional — the firmware does without them.
And a board that can be supported has to be flashable from
the web installer, not just from a
toolchain: the page derives its board list from the same [board_*] sections,
and the checks fail a port that CI does not build. docs/PORTING.md
is the checklist, and AGENTS.md covers the hardware reference and
bring-up app a port needs before it ships.
Cases. A printable shell for the E32R28T-1 is in
cases/, one folder per board. A case is not required for a
board to be supported — but a builder who has never opened a CAD tool should
not be left with a bare board and a battery taped to the back, so ports are
encouraged to bring one. Print notes and fastener sizes are as valuable as the
mesh.
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.
Something that needs no hardware at all. There are no host-side tests yet, and a good deal of this firmware is pure logic that never touches a board — progress tracking, scoped storage, question selection, touch rotation. Rendering already goes through an interface, so a recording backend is possible without a panel. That is issue #44, and the rest of what this firmware does not do yet is on the tracker beside it.
Found a security problem instead? Don't open an issue — SECURITY.md has the private route.
https://iamankushpandit.github.io/Gume/ flashes the board from a browser over Web Serial — pick the board and the firmware, plug the device into USB, and press the button. Nothing to install; desktop Chrome, Edge or Opera only, since Firefox and Safari do not implement Web Serial.
The picker offers one image per board, and you have to choose the one that matches yours: two boards sold under the same name can carry different display controllers, and the wrong image gives inverted colours, dead touch or a blank screen rather than an error.
Five boards are supported, and each has been flashed and run on hardware. Tell them apart by screen size, touch type and USB ports:
| Screen | Touch | USB | Board | Pick in the installer | Vendor page |
|---|---|---|---|---|---|
| 4-inch | resistive | one USB-C | E32R40T | E32R40T | LCDWIKI 4.0inch ESP32-32E |
| 3.2-inch | resistive | one USB-C | E32R32P | E32R32P | LCDWIKI 3.2inch ESP32-32E |
| 2.8-inch | capacitive | one USB-C | Freenove FNK0104B, also sold as LCDWIKI ES3C28P | FNK0104B | LCDWIKI 2.8inch ESP32-S3, Freenove |
| 2.8-inch | resistive | one USB-C | E32R28T-1 (LCDWIKI E32R28T) | E32R28T-1 | LCDWIKI 2.8inch ESP32-32E |
| 2.8-inch | resistive | USB-C and micro-USB | ESP32-2432S028, inverted panel -- no battery; run it from USB power | ESP32-2432S028 dual-USB | none found |
Every board has its own page in docs/boards/: how to recognise it, what has been checked on it, its sources, and a pin table and diagram generated from the firmware itself.
The two 2.8-inch resistive boards look alike; the second USB socket is the difference -- and so is power. The dual-USB board has no battery hardware the firmware can use: no battery sense line, so no battery badge and no low-battery warning. Run it from USB: a wall adapter, a computer, or a USB power bank for a portable console (for example this 5,000 mAh bank -- a suggestion, not something we have tested with this board). The capacitive board is the same design under two brands: the LCDWIKI ES3C28P's published pins match the Freenove FNK0104B profile pin for pin -- display, touch, audio and battery -- so the one image runs both. If your board is not in this table, it is not supported -- a different image is likely to give a blank or wrongly coloured screen.
The Freenove FNK0104B is a 2.8-inch ESP32-S3 board with the same ILI9341 240×320 panel, an FT6336U capacitive touch controller, 16 MB flash and 8 MB PSRAM. It is flashable from the web installer and every one of the 31 games works on it. It was brought up on real hardware rather than from a published pin map: display, backlight, touch at all four rotations, battery sense and the audio path were each confirmed on a device.
Its USB-C is on a short edge, so unlike the other boards no landscape rotation puts the socket at the bottom — Braino uses rotation 3, which puts the cable on the left.
Sound works, and on this board it is the whole of the sound vocabulary rather than two beeps — including the spoken "Let's play Braino!" at boot. It plays through the ES8311 codec, capped at 80% volume. Two things do not work yet, and they are switched off rather than broken:
| Not working | Why | Issue |
|---|---|---|
| Status LED | One WS2812 addressable pixel on GPIO42; RgbLedProfile describes three PWM channels. The colour half of the feedback is a no-op — the sounds still play — and the screen saver loses its rally colour |
#72 |
| SD card | The slot is SDMMC 4-bit; SdProfile describes an SPI card. Optional SD content is simply not loaded, which everything has defaults for |
#73 |
The battery percentage is correct. The firmware no longer shows a charging state on any board, so the old question of whether this board's charger fooled the inference (#74) no longer arises. Partition sizing on the 16 MB part and a first-frame time worth checking are tracked in #75.
pio run -e s3diag is a standalone bring-up probe for this board: panel,
rotation, I²C scan, live touch, battery, and the full audio path including a
record-and-playback microphone test.
The E32R40T is the 4-inch board, and it is here for a reason the other
ports are not: a physically bigger, plainer screen for players who need one.
Braino stretches the games onto it rather than leaving them small in a corner
— playable games draw through Ui::ScaledRenderer, which maps the fixed
320×240 game canvas onto the panel's 480×320 and renders text at double glyph
scale, so text gains on its boxes rather than merely keeping pace.
It is the same reference PCB family as the E32R28T-1 and shares its display bus
exactly — MISO 12, MOSI 13, SCLK 14, CS 15, DC 2, HSPI at 40 MHz. One pin
differs, and it is the one that fails silently: the backlight is GPIO27, not
GPIO21. With the wrong value the panel is black while Wi-Fi, BLE, NVS and the
screen saver all run perfectly, so the serial log looks healthy and it reads as
a dead screen. Both values were measured rather than assumed — 21 was confirmed
dark from a clean reset — and BoardConfig.h static_asserts the profile
against TFT_BL so they cannot drift apart again.
Touch is an XPT2046 sharing the display's SPI bus with its own CS on
GPIO33, driven through TFT_eSPI's touch extension rather than a bit-banged bus
of its own. Its IRQ on GPIO36 is wired but unusable — an input-only pin
with no internal pull and no external pull-up fitted, so it floats low and
reports a permanent false pen-down. The profile says so (irqUsable) and the
firmware gates on pressure alone, which is clean here: idle noise reads 10–20
and a real press 2000+.
Four peripherals are switched off rather than broken, because nobody has
measured them on this board yet: the SD slot, the RGB LED, the speaker and
battery sense. They are plausibly wired like the 2.8-inch board's — and
"plausibly" is exactly the reasoning that produced a backlight pin nobody had
checked. PIN_NONE costs a feature the firmware already does without; a wrong
pin costs a fictional battery percentage. If you own one and can measure them,
that is the most useful thing you can send.
pio run -e diag4 is the standalone bring-up probe for this board: panel
identity over SPI, a backlight sweep, geometry and colour, rotation, both touch
wirings, ADC candidates, and a Wi-Fi/BLE coexistence test.
If your 2.8-inch CYD draws everything perfectly but every colour is wrong,
this is your build. Flash app_esp32_2432s028_inv.
The 2.8-inch "cheap yellow display" ships with several different
combinations of panel and backlight behind the same silkscreen, and you cannot
tell them apart by looking. This firmware supports exactly one of them, the
dual-USB board with an ILI9341 panel wanting runtime inversion and its
backlight on GPIO21 (esp32-2432s028-inv.h), because that is the one that has
been run on hardware. Other CYD variants -- the micro-USB ESP32-2432S028R, the
ST7789 Rv3 -- are not supported.
The symptom that identifies this one is specific and easy to misread: the board comes up with the backlight on, touch working, the layout correct and a completely clean serial log — and every colour channel inverted. The dark theme draws white, green draws purple, blue draws salmon. It reads as a theme bug rather than a wrong board.
Two things are worth knowing if you are porting another variant:
- A wrong backlight pin gives you a dark panel and a healthy log. Wi-Fi, BLE, NVS, touch and the screen saver all run perfectly. It reads as a dead board. The other dual-USB profile has its backlight on GPIO27; on this one GPIO27 gives nothing and GPIO21 lights it.
- TFT_eSPI's
TFT_INVERT_COLORSwill not fix inverted colours on an ILI9341 build. That macro is read only by its ST7789 and ST7735 init sequences, so on an ILI9341 build it is silently inert — it was tried twice here and looked like the flag being ignored. The board profile carriesinvertColours, whichBoard::begin()turns into a runtimeinvertDisplay()and which works under any driver.
Confirmed on hardware: panel, colour inversion, backlight, touch, rotation, flash size, and no battery sense: with a pack and USB connected, GPIO34 read a steady 0.21 V where a battery behind a 2:1 divider would read about 2 V. On a CYD that pin is the light sensor. So this board shows no battery badge, no low-battery warning and no battery rows in System Info, and it should be run from USB power -- a wall adapter, a computer, or a power bank (for example this 5,000 mAh one, a suggestion we have not tested with this board). Until 5.10.0 the battery and the RGB LED were copied from the E32R28T-1, and the gauge read a light level as a charge. The RGB LED now follows the published CYD pin map (red IO4, green IO16, blue IO17). Inherited and not exercised: the SD card. Sound comes from GPIO26 into the onboard SC8002B amplifier and the JST 1.25 speaker connector. Its touch clock is GPIO25, the other DAC pad -- the conflict that made 5.5.1 switch sound off on the 2.8-inch boards -- so the firmware hands GPIO25 back to touch once audio is up.
The E32R32P is the 3.2-inch board of the same LCDWIKI family, and it is the first port whose display, touch, battery sense and radios were all confirmed on hardware before the profile was written. It follows the 4-inch board's wiring rather than the 2.8-inch one on the fact that matters most: touch is an XPT2046 sharing the display's SPI bus with its own CS on GPIO33, not a separate bit-banged bus. Its IRQ on GPIO36 is unusable for the same reason as on the 4-inch board, so the firmware gates on pressure alone -- measured idle noise is 12-18 against a threshold of 350.
The panel is BGR, and it does not want inversion. Both were measured with the probe's pattern page: with the driver default the red block drew blue, blue drew red, green stayed green and yellow drew cyan -- red and blue exchanged with green untouched -- while the black background stayed black, which rules inversion out. This matters because the board arrives looking like it nearly works under a 2.8-inch firmware: flashed with the ESP32-2432S028Rv3 profile it draws a perfectly stable picture in wrong colours and has completely dead touch, which is easy to read as a broken board rather than a wrong profile.
Everything else is vendor-documented in the LCDWIKI pin table and cross-checked here: backlight GPIO27 active high, battery sense GPIO34 behind a 2:1 divider (measured 1910 mV at the pin), the SD slot on 5/23/18/19, and the RGB LED on IO22/IO16/IO17. Like the rest of the family it cannot detect a missing battery -- attaching the pack moved the reading by 11 mV.
Audio is wired and enabled on this board, which makes it the first CYD
variant here with sound. GPIO26 is DAC channel 2 feeding an onboard amplifier
whose shutdown input is IO4, active low. Two things that have each cost a
release cannot happen here: the 5.5.0 DAC-versus-touch-clock collision needs
GPIO25, and this board's touch clock is GPIO14; and the amplifier cannot be
held in shutdown by the LED driver, because IO4 is not also an LED channel here
(green is IO16). maxVolume is still inherited at 75 and wants confirming by
ear.
Two things remain unverified and are flagged in the profile: the RGB LED channel order -- a vendor pin table has already been wrong about exactly that field on the E32R28T-1 -- and the battery divider ratio against a meter.
pio run -e diag32p is the standalone bring-up probe for this board. It builds
the same src/diag4.cpp as the 4-inch probe rather than a fourth copy, pointed
at this board's pins.
The E32R28T-1 / ESP32-32E 2.8-inch resistive-touch board is the one this firmware is developed and tested against — use this Amazon board. Every board offered has been flashed and run on hardware: the E32R28T-1, the dual-USB ESP32-2432S028 with the inverted panel, the E32R32P, the E32R40T and the Freenove FNK0104B. Other ESP32 boards may accept a binary, but their display and touch pins will not match.
That page is generated by tools/gen_site.py and published by
.github/workflows/pages.yml, which builds every PlatformIO environment platformio.ini declares on
every push to main and puts the real binaries behind the button. The page
derives its version, game list and blurbs from the firmware for the same reason
the About app does — see Layout of the code.
Flashing replaces the firmware and leaves NVS alone; erasing throws away the touch calibration, the profiles, the scores and the mastery data.
About the images in this README. These are rendered mock-ups, not device photos. They are redrawn on a PC by
tools/gen_screens.pyusing the exact rectangles, fonts and colours from the C++ source, so the layout is accurate. The flag and outline artwork is the real pixel data, decoded from the generated arrays the device draws from. True screenshots aren't possible because the target board doesn't wire the panel's MISO line for read-back.
Ages are a guide, not a gate. Every game can be hidden from a player's launcher in Profiles → Edit → Games, so you can pare the list down to what one player needs without touching anyone else's. Only the admin can change it.
| Game | What it is | What it builds | Age |
|---|---|---|---|
| Counting | "How many objects?" — tap the matching number | One-to-one correspondence: the idea that counting means one number per object | 3–5 |
| Fingers | Alternates "How many fingers?" (count what's raised) and "Show me 7 fingers" (raise that many) | Counting on hands in both directions — recognising a quantity and producing one | 3–6 |
| Shape Arith | Objects appear one by one to add; subtraction splits left vs. take-away boxes | Makes arithmetic concrete before it's symbolic. The subtraction display keeps the removed group visible while the player counts what is left | 4–6 |
| Number Line | A marker hops along a number line to reach the answer | Turns addition and subtraction into movement — the mental model behind mental arithmetic | 5–7 |
| Math | "Tap the answer" — addition and subtraction, difficulty rises with level. From level 2 about a third of the questions arrive as word problems: "Nia has 12 shells and gives 7 away. How many are left?" | Recall speed once the concept is solid, then the harder half — working out which sum a sentence is asking for. Level 1 stays pure arithmetic, because a child still learning that 3 + 4 is 7 is being asked to read as well, and the reading is the harder job at that age | 5–8 |
| Multiply | "Tap the product" — times tables | Multiplication facts | 7–10 |
| Fractions | "Pick the matching fraction" against a pie chart | Connects the written fraction to the amount it represents | 6–9 |
| Money | "How much is this?" — count coins, compare amounts, make change | Coin values and everyday arithmetic | 5–9 |
| Percent | Read, make and calculate percentages on a circle | Percentage as a portion of a whole, before it is an algorithm | 7–11 |
| Sorting | "Tap smallest to largest" (or the reverse) | Ordering and magnitude comparison | 4–7 |
| Roman | Roman numerals, both ways round: read one, or write a number as one. The symbol key and the rule for the level stay on the screen, and every answer is followed by the number taken apart — XIV = X + IV = 10 + 4. The wrong answers are the real mistakes: reading XIV as 10 + 1 + 5, or writing 4 as IIII |
Place value in a system that has none, and the habit of checking a smaller symbol before a bigger one. Five levels, each adding the next letter: X, then L, then C, then D and M | 7–12 |
| Game | What it is | What it builds | Age |
|---|---|---|---|
| Flags | A real flag, name the country. Correct answers unlock a capital-city bonus | 195 flags with a genuine reward loop; capitals arrive as a bonus rather than a chore | 5–12 |
| US States | Name the state, then its capital | US geography as a name-then-capital pair, the same loop as Flags | 6–12 |
| State Flags | A real state flag, name the state, then its capital | Fifty more flags with the same reward loop | 6–12 |
| State Maps | A real state outline, name the state, then its capital | Map-shape recognition | 7–12 |
| Calendar | "What comes after Wednesday?" — days and months | Sequence and cyclical time | 4–7 |
| Time | "Which time is shown?" on an analogue clock | Reading a clock face | 5–8 |
| Space | Forty-four questions about the solar system and the layers of air above it — the planets in order, why Venus beats Mercury, where the ozone is, which layer the Space Station orbits in. Every answer, right or wrong, is followed by one plain sentence saying why | The sky as one continuous thing: "where does space start" and "how far up does the air go" are the same question, and a quiz that answers one and not the other leaves the join out. Three levels, so a five-year-old is never asked about the Karman line | 5–12 |
| Elements | Explore the real 118-cell periodic table, tap any square to read what it is and where you have met it; Quiz asks six kinds of question about it; Level decides how much of the table it may ask about | The periodic table as a place rather than a list — a player who has never taken chemistry can find Oxygen on the chart, learn that Helium is what makes balloons float, and never be asked about an element they have not seen | 5–12 |
| Piano | One octave, C to C, with the note name on every key; tap or hold a key and it sounds for as long as you hold it | The first thing here with no right answer — a child can make something rather than be tested on it, and the note names are on the keys so the naming comes for free | 3+ |
| Chess | The full rules, against a friend or against the console — on one device, on two in the same room over Bluetooth, or on your own at Easy or Medium. Tap a piece and every square it may legally move to is ringed, including castling and en passant. Check and checkmate are called out, and so are the draws, each with its reason: stalemate, too few pieces to mate with, and fifty moves without a capture. Captured pieces are shown for both sides, the game is remembered if you put the device down, and either player can end one nobody can finish | Learning chess by seeing what is legal rather than being told when you are wrong — and the one game here two people play against each other — or, on your own, an opponent that is beatable on purpose | 6+ |
| Sea Battle | Battleships on an 8x8 sea, for two players — passing one console, or against another in the room over Bluetooth. Your fleet is shuffled for you; hunt theirs a square at a time, watching your own sea take damage beside the board | Deduction with a memory: every miss narrows the search, and a child works out that hits come in lines long before anyone explains it | 6+ |
| Cursive | Joined-up handwriting, traced with a finger: capitals, lowercase, and forty-nine short words a five-year-old knows, covering every letter but q. A word is one unbroken stroke, not a letter at a time, with the stroke order a hand actually uses, drawn a third larger than the first version because young players found it too small to follow. A numbered arrow beside each stroke shows where it starts and which way it goes, the way a handwriting workbook marks it. The score counts how much has been practised and never stops going up | Cursive is a different skill from printing, not a decoration on it — the joins are the skill, and there is nothing here to win or lose, only practice | 5+ |
| Ludo | The classic race round the cross-shaped board for two to four — on one console, where each seat is a player or the computer at Easy or Normal, or across up to four consoles in the same room over Bluetooth, with computers filling any empty seats. A 6 brings a token out and rolls again, three 6s in a row lose the turn, landing on a lone token sends it home, and two tokens together make a block nobody can pass. The tokens that can move light up and a tap picks the nearest one; when there is only one move it plays itself. Every colour has its own shape as well, so the game works for a child who cannot tell red from green, and it is remembered if you put the device down | Counting on from where you stand, and the first real decisions about risk — whether to run a token home or wait on a safe square — against a computer that plays fair: it cannot choose its dice any more than you can | 5+ |
| Backgammon | The race for two, with the full rules — on one console, against the computer, or against another console in the room over Bluetooth. Roll, tap a checker and the points it can reach light up; take a move back with Undo until you press Done. Every forced-move rule is enforced — as many dice as possible, the higher die when only one can be played — and a game ends as a single, a gammon or a backgammon. The pip count shows how far each side has to go, and the game is remembered if you put the device down | Counting and adding on every turn, then the first real strategy: when to run, when to hold a point, and when a blot is worth the risk | 7+ |
| Go | The oldest board game there is, on a 9x9 board (19x19 on the 4-inch console), for two on one console, against the computer at Easy or Medium, or against another console in the room over Bluetooth. Five rule sets, chosen before the game: Capture 1, 3 or 5 -- first to take that many stones wins, the way it is taught in schools -- Area, and Territory, which ends with both players marking the groups that cannot live. A stone is placed in two taps, the first a ghost so a misplaced finger costs nothing, and on 19x19 the panel magnifies the five-by-five around it. Undo takes back your move and the computer's reply; the game is remembered if you put the device down | Surrounding rather than attacking, and the idea that a group lives or dies by its liberties -- the computer never fills its own eye, and a child who watches it learns why | 6+ |
Flags, Elements and the three US States games all use spaced repetition; Flags also uses adaptive difficulty — see below.
| Game | What it is | What it builds | Age |
|---|---|---|---|
| Cinnamon | Repeat the colour sequence | Working memory and sustained attention | 4–10 |
| Memory | Match pairs face-down | Visual working memory | 3–8 |
| Odd One | "Tap the one that is different" | Categorisation — spotting the attribute that doesn't fit | 3–6 |
| Shapes | Match each shape to its outline, over four rounds that each take an easy cue away: the four everyone knows ("red circle"), then four told apart by side count (pentagon, hexagon, heptagon, octagon), then lookalikes (oval, rectangle, diamond, trapezium), then concave against convex, with the word on the row | Holding two attributes in mind at once, then shape vocabulary a child can use: that a hexagon is a hexagon because it has six sides, that an oval is not a circle, and that concave and convex are the names for bending in and bulging out | 3–8 |
| Color Mix | "What do you get?" mixing two colours | Colour theory, and that mixing is predictable | 4–8 |
| Microku | 2×2 up to 6×6 grids | Constraint reasoning, scaled to a player's level | 6–12 |
| Slide | Slide numbered tiles into order | Planning several moves ahead | 6–12 |
| Maze | Drag a dot to the exit | Fine motor control and route planning | 3–7 |
| Whack | Tap the smiles before they vanish | Reaction time and visual scanning | 3–8 |
| Tic-Tac-Toe | Two players | Turn-taking and blocking — best played with a grown-up | 4+ |
| Trace | Trace uppercase, lowercase, digits and fifty-four short printed words following dots. A numbered arrow beside each stroke, outside the letter, shows where it starts and which way it goes, with an extra arrow at a sharp turn like the top of an A. Words are spelled out of the same lowercase letters, so a child writes 'cat' with the strokes they practised on c, a and t | Letter formation and stroke order, with Again and Next controls so a player can repeat any character, then putting letters together into words | 3–6 |
| GRE Words | Study flips a card to its meaning and an example; Quiz picks the right gloss from four | 250 GRE-level words, weighted by spaced repetition so a missed word returns soon. Aimed at an older student, not a preschooler | 15+ |
| Dice | Pick one, two or three dice and throw them | A physical randomiser to play board games with when the real dice are lost. Keeps no score, because a best total would be luck | 3+ |
| Coin Flip | Spin a coin, best of one, three or five | Settling an argument fairly, and seeing that best-of-five is not the same as one toss. Keeps no score, for the same reason | 4+ |
Questions used to be drawn uniformly at random, so a player saw Brazil exactly as often as Bhutan and got no extra practice on the ones they missed.
Flags and the US States games now keep a mastery score per item
(src/engine/Progress.cpp). A miss costs twice what a correct answer earns,
so a missed country returns quickly and only leaves the rotation after repeated
success. Weighting runs from 8× for a recently-missed country down to 1×
for a mastered one — mastered items still appear, just rarely, so the mix stays
varied.
Each game tracks separately: recognising Italy's flag says little about recognising Texas's outline. Mastery is also stored per player profile, so one player's progress never moves another's.
Three tiers — Easy (30 familiar countries), Medium (62), Hard (all 195). Six correct in a row promotes a tier automatically. The tier button still overrides by hand, and the choice persists.
Up to five players plus a permanent Guest, chosen at boot. Scores, mastery data and per-game visibility are all scoped to the active profile automatically — games never do anything to opt in.
Guest deliberately persists nothing. Every score and mastery write is dropped while Guest is active. That is what makes it a guest rather than a sixth player: a visitor can play without leaving results behind or disturbing anyone else's records.
Deleting a player deletes that player's profile-scoped NVS data and shifts later players down with their own scores, mastery and visibility intact; deleting the active player switches the device back to Guest.
Device settings — theme, layout, brightness, Wi-Fi, time zone, the BLE beacon — stay global.
Cinnamon originally repainted the whole screen on every step, producing a full-screen flash roughly once a second — uncomfortable generally and a genuine risk for photosensitive players. It now repaints only the pads that changed, so there is no full-area luminance change at all, and mirrors each colour on the case LED so the cue doesn't depend on the screen flashing. On a board with a codec each pad also has its own fixed note, so the sequence can be followed by ear alone — which is the point of the game and, for a player who cannot easily tell the four colours apart, the difference between playable and not.
On a board with an audio codec — today that is the Freenove FNK0104B — the console has a small fixed vocabulary of sounds rather than a beep for yes and a buzz for no. A point scored, a tile sliding, a level cleared, a personal best beaten, a mole about to vanish and a game lost each sound different, and they sound the same in every game that uses them, so a player learns them once.
None of it is a recording. There is no WAV, no MP3 and no sample bank anywhere in this firmware, and there is not going to be one: a single second of audio would cost more flash than several games. Every sound is generated as it plays, from a few oscillators, a noise source and three formant filters. The whole vocabulary — fourteen cues, four pad notes and a spoken phrase — costs under a kilobyte.
The formant filters are there because the console says "Let's play Braino!" when it starts up, in a deliberately robotic voice. That phrase is not a clip either. It is written down as the phonemes it is made of, and each phoneme is the first three resonances of a mouth shaped to say it; drive those with a steady buzz and you get a vowel, drive them with noise and you get a consonant. It is the same technique that made 1980s home computers talk, which is exactly why it sounds like one.
All of it can be turned off. Settings → Sound holds a mute switch and a volume slider capped at 85% for a device held near a child's ears, plus two buttons to hear the level while you set it — because setting a volume you cannot hear while you set it is guesswork.
A board with no codec is unchanged: the RGB case LED still flashes green for right and red for wrong, and every call to play a sound is silently a no-op. The Sound tab says which of those two situations you are in rather than showing a muted switch for a speaker that was never there.
One screen per game, in launcher order.
Four tabs. Device holds theme, menu layout, the case light, the BLE beacon, the Network button, the NTP resync interval, the Nearby switch, screen brightness, and a factory reset behind a two-tap confirm. Nearby greys out and reads needs Beacon while the radio is off — it rides on that radio, and a switch that flips without doing anything is worse than one that says why. Automatic NTP resync is 1–24 hours, default 6 hours; boot-time sync and the Network screen's Sync now action stay immediate. Power holds the idle policy, its two delays and the hold-to-unlock guard — see Screen saver and sleep. They were one screen until the sleep settings arrived and there was nowhere left to put them.
Sound holds the mute switch and the volume. Mute is a switch of its own rather than volume zero, so the level survives being silenced and comes back where it was; muting takes everything, including the startup phrase, and leaves the case LED still flashing green and red. The volume slider is capped at 85% and says 85 — a control that relabelled its ceiling as 100% would read better and lie. That ceiling is a hearing-safety limit for a device held near a young player's ears, the counterpart of the 25% brightness floor, and it was set by listening on the Freenove's own driver rather than picked as a round number. Two test buttons sit under it, because setting a volume you cannot hear while you set it is guesswork: one plays a cue, the other says "Let's play Braino!". Dragging the slider plays a note at the new level as you go. On a board with no audio codec the tab says so plainly rather than offering controls that do nothing — "this board cannot" and "you have muted it" are different things to tell an owner. See Sound.
The tab's last row is Find alert, which decides whether a Find from another console makes this one ring. It is the one control here that stays live while the console is muted, because muted is exactly the state it governs: on Ring a find briefly unmutes the console so it can answer and mutes it again afterwards; on Quiet the console still shows the banner and blinks its LED and never makes a sound. See Find my Braino.
Admin holds the PIN and Recalibrate touch. The calibration wizard runs on its own only when nothing is stored, which leaves one hole it cannot fill: a calibration that is present but wrong — drifted, or captured by a child tapping past the three targets — reports as fine and never re-runs, and the only cure was a factory reset behind a touch target nobody could hit. This is the way back. It is safe to press by mistake, because the wizard reads the panel raw and replaces what is stored only if the new three-point fit succeeds; time it out and the old calibration is still there. Boards with capacitive panels have nothing to fit and say so instead of offering the button.
Game visibility is not here — it is per player, so it lives with the player, under Profiles → Edit → Games. See The admin profile.
Brightness floors at 25%, not 0, deliberately: at lower duty the panel is unreadable and a player who dragged the slider to the bottom could not see the control needed to undo it.
Menu layout is launcher-only. Every game is authored against the fixed 320×240 landscape canvas, so Tall changes the home screen and nothing else.
Admin holds the PIN that guards the admin profile. See below.
Like everything else in Settings, the sound controls are readable by anyone and changeable only by the admin — the speaker belongs to whoever is in the room, so volume is a device setting rather than a per-player one.
One profile is the admin, and it is guarded by a four-digit PIN. It carries a small padlock in the profile picker, so the lock is visible before the tap rather than after it.
The PIN is asked for on the two routes into the admin profile:
- switching to it,
- opening its Edit menu — which reaches rename and that profile's per-player game list.
It is asked every time, including immediately after a correct entry. "Already admin" is not evidence that the person now holding the device is the one who typed the PIN — which is the entire situation this guards against, a device handed to a player mid-session.
The admin decides who plays what. Game visibility is per player, set from Profiles → Edit → Games, and only the admin can change it. Anyone can open the list and see which games are on or off — a player seeing that something is switched off is fine, and better than a launcher that is mysteriously short — but the checkboxes only respond to the admin. Before this the feature enforced nothing: a player opened their own row and turned back on everything that had been hidden from them.
Removing a player is also the admin's call, because it destroys that player's scores and mastery data permanently. Any player could previously delete a sibling in two taps. Renaming is deliberately left open — it is harmless, and a player wanting their own name spelled properly is not a threat.
Settings is not behind the PIN. Anyone can open it and read every page — there is nothing secret there, and a player being unable to see why the screen dims is worse than one who can look. What a non-admin cannot do is change anything: every control renders greyed, and every one of them is inert. The greying and the refusal are separate things, and it is the refusal that enforces it — the controls were grey and fully live for a while, which is exactly the bug this arrangement is written down to prevent.
The device also refuses to boot into the admin profile: if admin was active at power-off, the next boot drops to Guest. The picker's Done button goes home with whatever profile is already active, so a remembered admin selection would have handed out admin with no PIN at all.
The admin profile cannot be removed — the Remove button greys out on that row and says why.
A fresh device ships with the admin profile Admin and the PIN 0000. Change it from Settings → Admin → Change admin PIN: the new PIN is entered twice and is only written if both entries match. A mistyped PIN stored anyway would lock the owner out of their own device with no recovery short of a factory reset.
What the PIN is not: it is a parental control, not a security boundary. It is four digits, there is no attempt limiting, and anyone who can reflash the board can clear it. It is sized to stop a player changing settings, not an adversary.
Off by default. Turned on, the console listens for other Brainos in range and shows who is there, which game they have open, and their best score for it. When somebody's score beats the record on this device, a strip appears over the header for a few seconds and is then removed.
It is anonymous by construction. A peer is four hex digits of its own Bluetooth MAC — the same id already used to name the device — and the only other things that travel are a game index and a score. No player name, no profile name, nothing profile-scoped is read by the feature at all. Two players learn that someone with a Braino nearby has 9 on Maze and nothing whatever about each other.
Two switches guard it, in this order: the BLE beacon must be on, and then Nearby must be on. Turning the beacon off stands Nearby down with it. The scan is passive, so listening never transmits anything.
Poke. Each peer in the list carries a Poke button. Pressing it nudges that console: a strip appears over its header saying who poked it, and it makes a sound. It is the one notification here that does — everything else Nearby raises is ambient news about the room, while a poke is a person asking for your attention.
Two honest details about how it works, because the mechanism is visible and should be:
- A poke is a broadcast, not a message. The beacon is non-connectable
advertising, which has no addressing at all: every Braino in range hears that
A4F2pokedB1C3, and onlyB1C3reacts. The only identifier involved is the target's own four hex digits — the id that device is already broadcasting about itself every second. - It borrows the score's bytes for six seconds. The payload with Nearby on is exactly the 31 legal bytes, so there is nowhere to append a poke. While one is on air the best-score field is simply not sent; the game stays visible and peers keep the last score they heard rather than showing a zero. It is transmitted repeatedly for those seconds because a listener's scan windows have gaps, and a nonce makes sure the target reacts exactly once no matter how many copies it hears.
Find my Braino. Beside each peer's Poke there is a Find, and it is the one to press when the console is not in the room you are in. The far end does not blip once and hope somebody is looking at it: it rings a bell every second and a half for twenty seconds, blinks its case LED, and lights its screen, so you can walk towards it. Any touch, or the BOOT key, stops it; so does the twenty seconds running out.
Three things worth knowing, because they are the difference between a feature that works when you need it and one that does not:
- It will unmute a muted console, and mute it again afterwards. A console that has been silenced is exactly the one you cannot find, so a find flips the switch to answer and puts it back the moment the alert ends. If you would rather it never did that, Settings > Sound > Find alert: Quiet leaves the banner and the LED and takes the bell -- and with it the unmuting.
- It wakes the screen even from sleep, arriving at the lock screen if you use hold-to-unlock, which is the screen that shows the wordmark and the battery. It puts itself back to sleep if nobody picks it up.
- It sends nothing new. A find is the same poke on the same beacon with one spare flag bit set: no extra bytes, no new kind of data, and the same broadcast caveat as a poke -- everyone in range hears who is looking for whom, and only the named console rings.
Naming a device. A tag like A4F2 says nothing about whose console it is,
so the admin can label one -- up to 10 characters -- and the list and the poke
notification then say the name instead. The tag stays visible beside it,
because the tag is what actually travels and what that console calls itself; if
a label ends up on the wrong device, the tag is the only way to notice.
That label never leaves this device. The beacon builds its advertised name from the family id and the hardware id and does not read the label table at all, so what goes on air is identical byte for byte whether every peer is named or none is. It is the same idea as naming a contact in your own phone: your word for someone else's device, held locally. Storing is not collecting, and the list of what leaves the device is unchanged.
Names are global to the device and admin-only: the other consoles in the room are the same consoles whoever is holding this one, and a label every player sees should not be writable by any player. Naming a peer with an empty name forgets it.
The list scrolls, with a scroll bar, once there are more peers than fit.
Wi-Fi exists only to set the clock. The time zone is detected from the
public IP on first connect; the picker overrides it with named zones carrying
POSIX rules (CST6CDT,M3.2.0,M11.1.0), so daylight saving is handled without
anyone touching it in March and November.
Pong that plays itself. Paddles sweep opposite ways; every rally speeds the ball up and advances the colour, mirrored on the case LED. The product mark sits still in the middle, in a dim shade of the rally colour, so it changes with every paddle hit; it is the same artwork the case badge is cut from, carried in the firmware as a one-bit mask. Touching it returns you to whatever you were doing — not the home screen.
After the saver, the device sleeps: the backlight goes off and the panel drops into its low-power state, which is what actually saves the battery — a lit screen playing Pong to an empty room does not. A touch anywhere wakes it, and you land back on the screen you left.
Three policies, set in Settings → Power:
| Policy | What happens after the idle delay |
|---|---|
| Saver then sleep | Pong runs, then the screen blanks after the sleep delay |
| Sleep only | The screen blanks straight away — no Pong |
| Saver only | Pong runs and the screen never blanks |
Both delays are configurable: Idle after (30s / 1m / 2m / 5m) is the wait from the last touch, and Sleep after (15s / 30s / 1m / 2m / 5m) is how long the saver runs before the screen goes dark.
Sleep is not esp_deep_sleep. The CPU stays up so it can poll the touch
panel — there is no wake source wired for a true deep sleep on this board. The
main loop drops from 50 Hz to 10 Hz while asleep, and the battery still drains,
just far more slowly than with the backlight on.
A console in a bag or a coat pocket gets pressed constantly. A touch therefore lights the screen and nothing else: it lands on a lock screen, and only a deliberate press-and-hold of about a second on the unlock button hands the device back to the screen underneath. A progress bar fills while you hold, so it is obvious what the device is waiting for. The screen carries the Braino! wordmark and the battery level in a header, so somebody who finds the device locked can see what it is and whether it is about to die without touching anything. Neither drifts around the panel the way the screen saver's wordmark does — this screen is up for seconds, not hours.
This is an accidental-touch guard, not a password. It is nothing to do with the admin PIN — unlocking neither grants nor revokes admin, and you come back to exactly the profile and screen you left, in the orientation you left them in. It guards the screen saver and panel sleep alike, and it is deliberately tolerant of the way a resistive panel drops contact mid-press: gaps of up to 150 ms do not restart the hold. If nobody unlocks within 12 seconds the device goes back to sleep on its own.
Turn it off in Settings → Power → Hold to unlock if you would rather any touch went straight through. It is on by default, and like every device setting it is global and admin-writable only.
The padlock beside Home — and on the launcher's own header, in both layouts — blanks the screen at once, without waiting for an idle timeout. It is the same guard, reached deliberately: for a console being carried, handed across a car seat, or dropped into a bag with a game still open.
The next touch lands on the same Hold to unlock screen above, and a completed hold puts back the exact screen that was open. Pressing Lock yourself is a request, so it produces the lock screen even if Hold to unlock is switched off. Nothing is stored, no profile or score is touched, and the tap is consumed by the shell — whatever sat under the padlock is not pressed as well.
The small BOOT key on the board — the one the ROM uses for serial download mode — goes back to the launcher from any screen. It also wakes the panel and dismisses the screen saver, exactly as a touch does. Useful when a small player has wandered into a game and cannot find the way out, or when the top bar's Home glyph is simply harder to hit than a physical key.
It is a shortcut and never the only way to do anything. Everything it reaches is reachable by touch, so a board that does not wire the key behaves as it always did. Two places ignore it on purpose: the Hold to unlock screen, because a key pressed through the side of a bag is the accident that screen exists to catch, and the launcher, where you are already home.
Holding it while the console starts is unchanged and has nothing to do with this — that is a message to the chip's own ROM, which reads the pin at reset, long before this firmware runs. RESET, beside it, cannot be given a job at all: it pulls the chip's enable line, so there is no software on either side of it to notice.
Four more screens, all of them ordinary Game subclasses like everything else:
- Profiles -- shown at boot; picks whose scores are being written. Carries the Braino! product mark in a branded header, with the copyright line beside it.
- Scores -- two tabs:
- Mine shows bests and worsts for the active player, per game.
- Device shows the device-wide best and its holder across every player on the device. The holder's name appears in gold when it is the current player — a small recognition for being on top.
- About -- what each game is for, in a parent's words, plus a What the
radios do page. Every fact on that page is read from the running system
rather than typed in: the playable app list comes from
AppRegistry, the version from the firmware constant, Wi-Fi state fromBoard, and beacon state and the advertised name fromBleBeacon. It cannot drift out of date. The last page is This build -- the branch, the abbreviated commit and the build time, stamped in at compile time bytools/build_stamp.py. The version number cannot answer "which firmware is on this board?", because it is identical across every flash of a release; this can. A build made from a source tarball with no git saysunknownrather than guessing. - System Info -- five tabs of live telemetry: board, memory, network, BLE and app state. This is a diagnostics screen, not a toy: chip and reset reason, heap with a fragmentation meter, NVS usage and namespace counts, Wi-Fi throughput, watchdog stalls, and the branch, commit and build time this firmware was built from.
The gauge reads GPIO34 through the board's divider, converted with the ESP32's
own eFuse ADC calibration rather than a nominal 3.3V reference — at 11dB
attenuation the converter is only linear to about 2.45V and its reference varies
part to part, so the naive raw / 4095 × 3.3 is wrong twice over.
Percentage comes off a piecewise LiPo discharge curve, not a straight line: a cell sits near 3.7V for most of its life, so a linear map reads about 20 points high through the middle.
There is no charging indicator, because this board has no charge-status line. The charger's CHRG pin never reaches a GPIO, so the only thing the firmware can see is the cell voltage on GPIO34. It used to infer "charging" from how that voltage moved; that was a guess shown as a fact, so since 5.10.0 the icon shows the percentage and nothing else.
The battery icon shows the percentage as a number, inside the shell, the way an iPhone or an Android status bar does it — because eleven pixels of fill is not a figure anyone can read, and "how long have I got?" is a number question. Underneath the digits a two-pixel gauge still runs along the inside of the shell, so the analogue cue is there too: green above 40%, amber down to 16%, and at or below 15% the shell and the digits both go red. That red outline is what makes charge me visible across a room, which is why nothing else colours the shell.
This board cannot tell whether a battery is fitted, and the gauge does not claim to. The charger holds its BAT output at float voltage whether or not a cell is attached, so running on USB with no pack reads between the two states a real pack produces — measured here as 4.159 V with no pack, against 4.066 V on a pack alone and 4.224 V on a pack plus USB. No threshold can separate them in either direction. With no pack fitted the gauge therefore reads high, close to full, rather than showing an empty or absent battery. The digits blank only when the ADC reads outside a plausible range, which means a sensor fault, not a missing pack.
The icon is deliberately not a fixed size: it grows with its digits, widest
at 100, and the status rows around it are laid
out from its measured width rather than a constant offset.
At 15% or less a strip also appears across the top of whatever screen is open: Battery low — time to charge, escalating to Battery empty — plug in the charger at 5%. It shows for six seconds and repeats every two minutes, so it stays a warning rather than becoming furniture. It is driven by the percentage alone, so on the charger it clears once the reading climbs back above 15%.
The divider ratio itself is still an assumption pending a meter on the board.
A background watchdog supervises the main loop (src/hal/Watchdog.cpp). The
ESP32 hardware task watchdog reboots the device if any frame takes longer than
12 seconds, so a hung game cannot leave a player staring at a frozen screen, and
a low-priority monitor task samples frame times and heap once a second, logging
a stall long before that. After an unclean reset the breadcrumb -- which screen
was up, uptime, heap low-water mark -- survives the reboot and is printed on the
next boot.
No accounts, analytics, telemetry, or personal-data collection. The optional Wi-Fi and Bluetooth LE features make only the limited exchanges described below, and each one is listed. Scores and mastery data live in the ESP32's own flash and never leave it.
Wi-Fi connects only to reach an NTP server, plus one lookup to ip-api.com to
guess the time zone on first connect (the picker overrides it, and you can skip
Wi-Fi entirely). After the first clock set, automatic NTP resync is configurable
from 1 to 24 hours and defaults to 6 hours.
In the ESP32's own flash, in plain text. It is stored once -- the Wi-Fi
stack's habit of keeping a second copy of its own is switched off, so Forget
removes the only one there is -- and nothing ever sends it anywhere: not over
the radio, not over the cable, not to the screen.
It is not encrypted, and saying it plainly is better than implying otherwise. Encrypting it would need a key, and a key stored in the same flash protects nothing at all: this firmware is open source and published as a binary anyone can download, so a key inside it is a key everybody has. Real protection here means the ESP32's own flash encryption, whose key lives in one-time fuses the CPU can use and software cannot read -- the same shape of answer a phone gives, where the file is encrypted with a key held in hardware rather than by the app that wrote it. That is not switched on: it is burned into each chip individually, cannot be undone, and would stop the web installer from being able to flash that board again.
So: someone who picks the device up, opens the case and attaches a USB cable can read the network password out of it, the same as they could from most hobbyist hardware and from a router's own flash. Someone across the network cannot. If that trade is wrong for where you keep yours, the honest fix is flash encryption rather than a scrambled string, and it is worth opening an issue about.
A computer connected by USB can ask the device what it is (board, firmware version, an id the firmware generated) and what its settings are (as counts and on/off flags). It never answers with a player's name, a score, the Wi-Fi network's name or its password. Changing settings over the cable needs the admin PIN, the same one the device asks for on screen. Nothing about this involves a radio.
Once a day, a console with Wi-Fi configured downloads one small file listing the current firmware version of every supported board, compares it with its own version, and — if there is something newer — says so in the header and on About -> Updates. Braino never updates itself. Nothing is downloaded, nothing is installed, and no firmware image is fetched; a person decides, and does it from the web installer.
The request says nothing about your device. It carries no version, no board model, no profile and no query string, so it is byte-identical to the request every other Braino makes — which is exactly why the file lists all boards rather than being fetched per board. What it unavoidably reveals is the same thing any web request reveals: an IP address, and that something asked.
Two honest notes. The check is not separately declinable — if Wi-Fi is set up it runs, and the only way to stop it is to not configure Wi-Fi. And the address shown to you is compiled into the firmware, never taken from the downloaded file, so a tampered response can at worst display a wrong version number; it cannot point you somewhere else.
The beacon is off by default and opt-in from Settings -> Beacon. When it is on, the launcher header shows a Bluetooth badge -- drawn only while the radio is genuinely advertising, so it is never a question of whether an icon looks greyed out. It is placed per layout: landscape puts it on the clock's line, positioned off the measured width of the clock string, because the badge row there has about 8px of slack; portrait simply extends the badge row.
With Nearby off, what goes on air is a device name and a hardware id. That is all:
| AD type | Contents |
|---|---|
| Flags | LE General Discoverable, BR/EDR not supported |
| Complete Local Name | Braino-A4F2 |
| Manufacturer Data | company 0xFFFF, "BR", layout version, two MAC bytes, flags |
The id is the last two bytes of the factory Bluetooth MAC -- a hardware serial, stable so you can recognise your own device in a scanner. Nobody types it and it is not derived from anything a player entered. Advertising is non-connectable: there is no GATT server, so there is nothing to connect to.
Consoles can play each other over it. When players start a game from the Chess, Sea Battle, Ludo or Backgammon lobby, each console advertises its latest turn: a session number, a move number, two small numbers saying what was played, and how far it has caught up with everyone else. In Chess the two numbers are squares; in Ludo they are a seat and a token, in Backgammon the point a checker left and the point it reached, and the dice are never sent at all -- every console at the table works each roll out for itself and checks every move against it. Nothing about the format is specific to one game, which is why it talks about turns rather than pieces. That is all — no name, no profile and no score travels with a move, and the moves occupy the same four bytes the best score normally uses, because the advertisement is already full at 31 bytes. Ludo seats up to four consoles; that uses the same turn, read by more consoles, and adds nothing to what any one of them transmits.
If the other console goes quiet, the game pauses and says so. A battery going flat or a child walking into the next room cannot send anything, so the only sign is that the beacon stopped. After about six seconds without hearing the other console every nearby game pauses, names who it is waiting for and for how long, and offers Keep waiting or End game; after 45 seconds it says out of range. Waiting has no limit -- the game is saved after every move -- and a console that comes back into range picks the game up where it stopped, because each console's latest move is still on the air. Ending from that card tells the other console, if it returns, that the game is over. At a Ludo table the whole table pauses for one quiet console, and once it has been out of range for 45 seconds the host is offered Play without it: that seat becomes a computer seat the host plays, announced to the table as an ordinary numbered turn, and the console that left is sent back to its lobby if it returns, told why. Nothing new is transmitted for any of this: it is worked out from the beacon not being there, and the takeover is a new meaning for a field the moves already use. The one silence that can be seen coming is your own: when the battery is low, every nearby lobby says so before a game starts.
Consoles can be given names, and a name is what you see on screen -- but a name is stored on your own device and is never transmitted. The advertisement is identical byte for byte whether every console you know is named or none is.
It is a broadcast, and worth being plain about: anyone in range with the right software hears the moves, exactly as they can already hear that a device is present. Only the consoles in the game act on them. Every move received is checked against the receiver's own board and discarded unless it is legal there, so a bad actor cannot corrupt a game — at worst they can be ignored.
Not broadcast: player information, player name, profile name, location, Wi-Fi credentials, Wi-Fi SSID, IP address, game progress, usage history.
That list is structural rather than a promise. buildPayload() emits a name AD
and a manufacturer AD and nothing else, and nothing profile-scoped is reachable
from the radio path at all.
Nearby is a second opt-in on top of the beacon. While it is on, two more fields join the manufacturer data:
| Field | Contents |
|---|---|
| Open game | An index into the playable-game list — 12 is Maze |
| Best score | This device's best score for that game |
While it is off, those fields are absent from the payload rather than present and zeroed: the manufacturer block is five bytes shorter and the flag bit is clear. "Not transmitted" has to be structural to be worth claiming.
A poke adds one more thing, and only for the few seconds it is on air: the device id being poked, plus a counter that lets the target tell a repeat from a new poke. A find is that same poke with one spare flag bit set, so it adds nothing further at all -- what differs is entirely what the receiving console does about it. That id is not new information on the radio — it is the same id that device is already broadcasting as its own name. Because the payload is already full, the poke replaces the best-score bytes while it is live rather than being added to them.
Neither field says who is playing. There is no name, no profile, and no way to get from a score back to a player — the exchange is a leaderboard with nobody's name on it. Listening is passive, so a console that is only watching transmits nothing extra.
System Info -> BLE reports Open game and Best score as Broadcast or
Not Broadcast read from the same structure the controller was handed, so that
row cannot disagree with the radio — including the moment a poke has displaced
the score, where it reports the truth rather than the setting.
System Info -> BLE shows whether advertising is currently active, the actual name being advertised, every decoded field of the manufacturer data, the privacy list above, and -- under Show advanced -- the interval, TX power, advertising type, controller address and a hex dump of the bytes actually on air (27 of the 31 legal bytes with Nearby off, all 31 with it on, 30 while a poke is being transmitted).
Turn the beacon off and the same screen says Broadcasting: Nothing, relabelling the identity block as configuration so nothing reads as being transmitted when it is not.
None of that is UI copy describing the payload from memory. There is one
authoritative structure -- BleBeacon::Advertisement -- which is compiled into a
raw byte buffer, handed to the controller verbatim, and read back by the screen.
The display and the radio cannot drift apart. The contract is written down in
docs/BLE_BEACON_SPEC.md.
The governing principle: if the device transmits something wirelessly, the owner should be able to see what it is transmitting, from the device itself.
Current release: 5.12.1. See CHANGELOG.md for what has changed since.
pio run -e app -t uploadRequires PlatformIO. The huge_app.csv partition (3 MB) is already set in
platformio.ini — the default 4 MB scheme gives the app only 1.31 MB, which is
less than the country artwork alone.
Dependencies resolve automatically:
| Library | Purpose |
|---|---|
bodmer/TFT_eSPI |
Display and touch |
bblanchon/ArduinoJson |
Optional SD content configs |
h2zero/NimBLE-Arduino |
BLE beacon -- ~192 KB for host plus controller, against several times that for the core's Bluedroid stack |
map-n-flag |
Flag and outline artwork |
The console is on the web installer, one firmware per supported board — useful when the board is not on the machine that has PlatformIO. The diagnostics below are not offered there and are not attached to releases: they are bench tools, built from source by whoever is holding the board, and a copy frozen into a release would only ever be older than the one in the tree.
To check the page itself before pushing:
python tools/gen_site.pyThat writes site/_build/ (git-ignored) with the page, one manifest per
firmware, the six stills the page shows and a copy of site/assets/. The
binaries are not built locally; the flash button only works against the
published site, where CI has put them there. gen_site.py refuses to generate
if the page references an asset that is missing, or if an asset in the tree is
referenced by nothing -- a broken image on the landing page and dead weight in
every clone are both worth failing a build over.
Fourteen environments in platformio.ini are hardware probes rather than the
console — bringup, batdiag, audiodiag, wifidiag, s3diag, diag4,
diag32p and their per-board copies. Each environment declares which it is
with custom_env_kind, and tools/envs.py is what every workflow asks:
python tools/envs.py --product # the five that are Braino!
python tools/envs.py --diagnostic # the fourteen bench probesCI builds a probe when its own source or platformio.ini changes, and not
otherwise; nothing publishes one. Build the one you need by name:
pio run -e batdiag -t uploadAn isolated Wi-Fi radio test, built with no display, touch or game code:
pio run -e wifidiag -t uploadIt scans and prints every network with RSSI, channel and encryption. This is
what proved the radio was fine when the app's scan was returning nothing — the
async scanNetworks()/scanComplete() pair was silently failing on this board,
while a blocking scan found 58 access points.
The same serial port (115200) is a small console, and a running board answers
it without being reset. One command per line; every reply is one line,
ok key="value" ... or err <code> <message>:
identify ok v="1" device="R28T-9F3A2C71" board="E32R28T-1" version="…" build="…" …
get ok v="1" theme="Dark" brightness="80" layout="horizontal" sound="on" …
get theme ok key="theme" value="Dark" values="Dark|Light|Midnight|…"
help ok commands="identify get set wifi profiles profile-add …"
Those are open, and carry only facts about the device — never a player's name,
a score, the Wi-Fi network's name or the MAC. Everything else needs the admin
PIN: after unlock <PIN>,
| What | Commands |
|---|---|
| Settings | set <key> <value> for every setting the Settings and Wi-Fi screens offer: theme, brightness, layout, sound, volume, saver, sleep, idle, wakelock, light, beacon, nearby, ntp, ntp_hours, timezone |
| Wi-Fi | wifi "<network>" "<password>", wifi clear |
| Players | profiles (list), profile-add "<name>", profile-rename <slot> "<name>", profile-remove <slot> |
| Games per player | games <slot> (which are switched off), `game <slot |
then lock. Every change goes through the same code the screens use, with the
same refusals: the admin profile and the player currently in use cannot be
removed, and Nearby needs the beacon. The password is never echoed, and three
wrong PINs lock the console for 30 seconds. game all <id> off switches one
game off for every player at once. Chess, Sea Battle, Cursive, Ludo and
Backgammon cannot be hidden yet (a known limit of per-player visibility).
python tools/ESP32_boardUtil.py uses identify to say which board is on
which port. To set up several boards at once, copy
tools/bench_config.example.json to tools/bench_config.json (gitignored)
and run python tools/configure_boards.py.
The main firmware also traces the clock over serial at 115200:
[boot] ntp=1 creds=1 tzmin=-360
[time] wifi up, ip=192.168.1.142 rssi=-57
[time] configTzTime US Central (CST6CDT,M3.2.0,M11.1.0)
[time] UDP NTP OK, clock set from pool.ntp.org
[time] SYNCED 2026-08-11 01:00:16 (US Central)
include/
BoardProfile.h the contract every supported board fills in
BuildStamp.h which build this is, and why the time is not a -D
BoardConfig.h selects one board profile; derives the screen constants
boards/
e32r28t1.h the 2.8-inch board: pins, rotations, battery divider
src/
main.cpp bringup entrypoint + normal app setup/loop
BuildStamp.cpp branch/commit/build time; rebuilt every build
wifi_diag.cpp standalone radio test (env:wifidiag only)
battery_diag.cpp standalone battery/ADC calibration tool (env:batdiag only)
s3_diag.cpp standalone ESP32-S3 bring-up probe (env:s3diag only)
diag4.cpp standalone bring-up probe: 4-inch ST7796 and
3.2-inch ST7789P3 (env:diag4, env:diag32p)
audiodiag.cpp standalone DAC audio bring-up probe (env:audiodiag only)
engine/
AppCapabilities.h system-app capability flags
AppRegistry.cpp authoritative app registry + instance bindings
AppRuntime.cpp runtime loop, transitions, view state
AppRuntimeLauncher.cpp LauncherApp paging, tiles, header UI
AppRuntimeScreenSaver.cpp screen saver and panel sleep/wake
AppRuntimeLock.cpp hold-to-unlock guard on the way back
AppRuntimeNotify.cpp the header banner: battery, update, nearby
AppRuntimeIdentity.cpp boot banner: which board, which build
AppRuntimeNearby.cpp the nearby-play calls an app is given, forwarded
AppRuntimeConsole.cpp serial console: one command table, PIN-gated writes
AppRuntimeConsoleSettings.cpp console: get/set over one settings table
AppRuntimeConsoleProfiles.cpp console: players and their games (CRUD)
ConsoleText.h console argument parsing, allocation-free
Game.h base class; lifecycle + full vs partial invalidation
LauncherApp.h home screen lifecycle object
GameCatalog.cpp derived playable-game catalog view
ScoreCatalog.cpp derived scored-app catalog view
NearbyPlay.cpp anonymous peer scores, notifications, sharing switch
NearbySession.cpp games on nearby consoles: seats, invites, turns
Progress.cpp per-item mastery, spaced repetition
ContentLoader.cpp optional SD-card config (everything has defaults)
games/ one .cpp/.h pair per game and per system app
SettingsApp.cpp Settings: lifecycle, the four tabs, touch routing
SettingsPanels.cpp the Device, Power and Sound tab bodies
SettingsPin.cpp the admin PIN pad and the Admin tab
CountryData.cpp capitals, continents, difficulty tiers
CountryDataTable.cpp generated -- see tools/gen_country_facts.py
ElementData.cpp lookups over the 118-element table
ElementDataTable.cpp generated -- see tools/gen_elements.py
MazeData.cpp maze layouts kept out of the redraw logic
StateData.cpp 50 US states: code, name, capital, tier
TraceGlyphData.cpp letter/number stroke guides
LetterTracer.cpp finger-tracing engine shared by Trace and Cursive
LetterTracerArrows.cpp where the numbered direction arrows go
LetterTracerWords.cpp printed words spelled out of the alphabet
hal/
Board.cpp board bring-up, profiles, layout/idle settings
BoardAccess.h narrow display/touch/storage/power/network/feedback facades
BoardDisplay.cpp TFT access, rotation, BMP blitting
BoardTouch.cpp touch ADC, calibration, coordinate mapping
BoardButton.cpp the BOOT key, debounced by the frame rate
BoardPower.cpp battery telemetry, backlight, panel sleep/wake
BoardNetwork.cpp Wi-Fi credentials, timezone, NTP sync
BoardUpdate.cpp is a newer firmware available (a notice, not an OTA)
BoardFeedback.cpp RGB LED, BLE and Nearby toggles
BoardAudio.cpp the synthesiser: script, oscillator, resonators, task
BoardAudioBackend.cpp the hardware under it: codec, I2S, amp, DAC idle
BoardAudioCues.cpp the vocabulary: every cue, and the spoken phrase
Sound.h the console's sound vocabulary, as an enum
BoardStorage.cpp schema migration + app-scoped NVS keys
BoardStorageMaintenance.cpp profile moves + NVS telemetry
TouchTypes.h TouchPoint event type shared without display deps
BleBeacon.cpp the beacon radio: bring-up, advertise, poke, turns
BleBeaconPayload.cpp the one authoritative advertisement payload,
and decode(), its exact inverse
BleScanner.cpp passive observer for other Braino beacons
Clock.cpp time and date formatting
Watchdog.cpp loop supervisor, stall logging, crash breadcrumb
ui/
Renderer.h app-facing drawing interface, no TFT driver include
TftRenderer.h TFT_eSPI adapter used by firmware runtime
LauncherIcons.cpp launcher tile icon drawing
LogoMask.cpp the product mark, generated -- see tools/gen_logo_mask.py
LauncherLayout.cpp launcher header, profile and tile geometry
Ui.cpp theme, widgets, badges, map-n-flag blitting
Keypad.cpp the one on-screen QWERTY keyboard, bottom-anchored
RowList.cpp scrolling label/value list; fixed buffers, no heap
tools/
gen_country_facts.py regenerates the capital/continent table
gen_elements.py regenerates the 118-element table, checking it
gen_screens.py regenerates the images in this README
gen_site.py builds the GitHub Pages site and its flash manifests
check_docs.py fails if these docs have drifted from the code
check_boards.py fails if a board is described inconsistently
check_catalog.py fails if launcher metadata indices misalign
check_frame_rules.py ratchet on heap/String/delay in render paths
site/
index.template.html the landing page, with {{PLACEHOLDERS}} gen_site fills
assets/ its photos, hero video, poster and the product
lineup -- the only part of the page not derived
from the firmware
.github/
workflows/ci.yml runs checks and builds every firmware on a clean runner
workflows/pages.yml publishes the site with the same built firmware
ISSUE_TEMPLATE/ bug report, board port, game or feature idea
pull_request_template.md
docs/
BLE_BEACON_SPEC.md what the beacon broadcasts, and why that is checkable
PORTING.md adding a board: the two files, and the bring-up order
SD_CONTENT_SPEC.md optional SD content format
cases/
<BOARD_NAME>/ printable enclosure for that board -- STL, 3MF, print notes
AppRegistry matters more than it looks: each playable game declares its own
metadata once, including launcher icon, order and default visibility, and the
registry binds that metadata to its concrete game instance alongside the
launchable system apps. That replaced the old split between CATALOG_KINDS[],
launchKind() and the icon switch in main.cpp, which could drift silently.
Code in this repository is licensed GPL-3.0-or-later — see LICENSE. Copyright © 2026 iamankushpandit.
Every file carries that notice itself, as an SPDX header naming the
licence, the holder and what reuse requires — because the file, not the
repository, is what reaches a stranger who copies one thing out of it.
tools/check_licenses.py writes them (--fix) and CI fails a file that has
none.
The copyright and the brand are held by different parties on purpose, and
NOTICE.md is the short statement of which is which: the code is
the individual's and comes to you under the GPL, while Braino!, the game
names and GoodTime Micro Company™ are trademarks. The licence does not
grant a name — no licence does — so NOTICE.md also spells out the one thing a
fork is asked to do before shipping: rename. That is a single header,
include/AppVersion.h, because the firmware spells
the product exactly once.
In short: you may use, study, modify and redistribute it, but if you distribute a modified version or a device running one, you must offer the corresponding source under the same terms. That is deliberate — this is a project for players' hardware that people are invited to port, and the ports should stay available to the people who own the devices.
The bundled artwork and libraries keep their own, more permissive licences, all
of which are compatible with GPL-3.0: TFT_eSPI (FreeBSD), ArduinoJson
(MIT), NimBLE-Arduino (Apache-2.0, which is GPLv3-compatible) and
map-n-flag (MIT). Combining
them under GPL-3.0 does not relicense them; it licenses this work.
| Asset | Source | Licence |
|---|---|---|
| Country flags | lipis/flag-icons | MIT |
| US state flags | fonttools/region-flags | Public domain |
| US state outlines | Natural Earth | Public domain (ODC PDDL) |
| Capitals / regions | mledoze/countries | ODbL |
| Chess piece silhouettes | svgsilh.com | CC0 (public domain) |
| Cursive letterforms | FRB American Cursive ArrowPath, by Fredrick R. Brennan | GPLv3 — same licence as this project |
Every asset compiled into this firmware is MIT or public domain.
This used to carry a warning that the country outlines came from
djaiss/mapsicon, whose terms are "don't
resell them - I forbid it!", making the build unsuitable for commercial use.
That artwork went when the Countries game was removed. The current binary
contains no mapsicon data and no reference to it — mnf_map() is never
called and the library ships no country-outline arrays. The US state outlines
that replaced them are Natural Earth, which is public domain.
The remaining obligation is attribution, not restriction: mledoze/countries
is ODbL, so the capital/region data must keep its credit. Note also that the
Arduino-ESP32 core is LGPL — distributing a statically-linked binary
carries relinking obligations.
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.













































































