The device may broadcast a BLE presence beacon. It is off by default and opt-in from Settings → Beacon.
The governing principle: if the device transmits something wirelessly, the owner must be able to see what it is transmitting, from the device itself.
BleBeacon::Advertisement (src/hal/BleBeacon.h) is the single description of
the outgoing advertisement. buildPayload() compiles it into a raw AD-structure
buffer; startRadio() hands the controller that exact buffer via
NimBLEAdvertisementData::addData() rather than the per-field helper setters,
and the System Info BLE tab reads the same struct back.
BleBeacon::decode() is the exact inverse of buildPayload()'s manufacturer
block, and the scanner in src/hal/BleScanner.cpp uses it to read peers. That
is deliberate: a transmit copy and a separate receive copy of a wire format
drift apart, and the first symptom is two consoles that cannot see each other
for no visible reason.
There is deliberately no second, hand-written UI description of the payload. To
change what goes on air, change Advertisement / buildPayload(); the screen
follows. This is the only arrangement in which the display and the radio cannot
silently diverge.
| AD type | Contents |
|---|---|
0x01 Flags |
0x06 — LE General Discoverable, BR/EDR not supported |
0x09 Complete Local Name |
Braino-<id> |
0xFF Manufacturer Data |
see below |
| Offset | Bytes | Field |
|---|---|---|
| 0 | 2 | Company id 0xFFFF, little endian |
| 2 | 2 | Family tag "BR" |
| 4 | 1 | Layout version — 4 |
| 5 | 2 | Device id, the two MAC bytes |
| 7 | 1 | Flags. Bit 0 = shares Nearby activity, bit 1 = poking, bit 2 = game invitation, bit 3 = game turn, bit 4 = the poke is a find; bits 5-7 reserved, sent as zero |
| 8 | 1 | (any flag set) Open game, an index into the playable app registry |
| 9 | 4 | (sharing, no poke/invite/move) Best score for that game, little endian |
| 9 | 2 | (poke or invite) Target — the device id being poked or invited |
| 11 | 1 | (poke or invite) Nonce; for an invitation, 6 bits of session id plus bit 6 = the invited console moves first |
| 9 | 4 | (move only) Packed move, little endian — see below |
None of this is chess. Bits 2 and 3 carry a two-player game session --
whose invitations, turns and endings are the same shape whatever the game is,
and the payload already says which game via the Open game byte. NearbyPlay
is the service that owns this, and chess is simply its first caller. The field
names in BleBeacon.h say turn and session for that reason.
Version 4 exists because a turn is the same length as a score. A turn block and an activity block are both thirteen bytes, so length alone cannot tell them apart — only the flag can. A version-3 reader meeting a version-4 move would decode it as a best score and cheerfully show somebody several million points. That is the identical failure that took version 2 to 3 when the poke's shorter block arrived, and the lesson has now been learned twice: gate every field on its own length AND its own flag.
Thirty-two bits, little endian, in the four bytes the best score would otherwise occupy:
| Bits | Field |
|---|---|
| 31–26 | Session id (6) — which game, so two boards in one room do not merge |
| 25–19 | Ply (7) — move number, modulo 128 |
| 18–13 | From square (6) — 0–63 |
| 12–7 | To square (6) — 0–63 |
| 6–0 | Ack (7) — the highest ply of theirs this device has applied |
There is nothing else, and there is nowhere to put anything else: the sharing payload already uses all 31 legal bytes, so a move had to displace the score rather than follow it. While a game is running the score is structurally absent, exactly as it is during a poke.
Ply wraps at 128. That is safe because the two sides only ever advance one ply at a time and each carries the other's ack, so they can never be more than a move or two apart.
A poke stops after POKE_ADVERTISE_MS. A move does not: it stays on the
air until the next move replaces it. That is the whole reliability story — an
opponent may have missed three scan windows, or only just walked back into
range, and it can pick the game up from any later window. The acknowledgement
rides the opponent's own advertisement, so there is no separate ack message
that could itself go missing.
An invitation, by contrast, is an event and uses the poke's exact wire shape and timer. That reuse is deliberate: an invitation is the same kind of thing — aimed at one peer, repeated because scan windows have gaps, acted on once per (device id, nonce) — and sharing the layout means sharing the idempotence argument rather than writing a second one slightly differently.
The invitation's nonce byte doubles as the session id, in its low six bits, with bit 6 saying which of the two consoles moves first. That bit is set by the console sending the invitation, from a coin toss, so that asking for a game is not also a way to claim the first move. It travels with the invitation because the alternative is a second round trip on a medium that guarantees nothing.
A turn whose from and to are both 63 means "I am stopping" rather than
"I moved". Not a legal move in any game that moves a thing from one place to
another, so it is safe to reserve — and it is the service's encoding, not any
game's, so the next two-player game cannot invent a second one.
It has to be 63 and 63 specifically. A console answers an invitation by
publishing ply 0 with from and to both zero, which is a presence and
not an ending; testing from == to alone would confuse the two and would only
work as long as every reader remembered to check the ply first. Games never see
any of this — they see a flag.
Four tests, all required:
- From the peer we are playing. Another console's game must not leak in.
- In this session. Nor an earlier game between the same two consoles.
- The ply we are expecting. An advertisement repeats; acting once is what makes it a move rather than a stutter.
- Legal in the receiver's own position. This is the safety property: a move is applied only if it is legal on the board the receiver already has, so a confused or hostile advertiser cannot force a position that is not reachable by playing the game. At worst — and only by guessing both the session and the exact ply — it can play a legal move.
Ludo seats up to four consoles, and it uses this same turn with no change to the layout above. Nothing new is transmitted by any one console; more consoles read each other's.
- Invitations go one at a time. Only one invitation is on the air at once,
so the host rotates through the consoles it asked, each for the usual six
seconds, until each answers with a presence (ply 0,
fromandtozero). - The host's start is ply 0 with
from32–61. It packs how many consoles, how many computer seats, the computers' level, and a six-bit check over the sorted tags of everyone at the table. A console joins the game only when what it can hear matches both the count and the check. - A move is
from= 8 + seat,to= the token moved (0–3) or 4 for a roll that could not be used. The die is never sent: every console derives each roll from the table's seed, which comes from the session and the sorted tags, and refuses any move that does not fit the roll it computed -- the same "legal in the receiver's own position" test, applied to the dice as well. ackis the last ply this console has applied, and 127 before the start. A console replaces its own turn only once every other console's ack has reached it, so a console that missed a move can always still find it on the air.- Who sits where, and who moves first, comes from the seed, not from the invitation's move-first bit: a coin toss between two cannot seat four.
- Computer seats are played by the host and published in the host's own turn, with the computer's seat number, like any other move.
None of the Ludo encodings can produce from = to = 63, so the service's
reserved ending means the same at a table as it does between two.
Two consoles, the ordinary two-player turn. A move is one checker: from is a
point 0–23 or the bar (24), to a point or off the board (25). The dice are
never sent -- both consoles derive every roll from the session and their two
tags -- and a received move is played only if it is legal with the dice the
receiver computed, the rule that as many dice as possible must be used
included. A turn goes on the air when the player presses Done, one checker per
ply, each once the other console has acknowledged the last. Nothing here can be
from = to = 63.
None of the above carries a name. Consoles can be given local labels, and those
labels are what a player sees on screen, but they are resolved on the receiving
device from its own NVS and never transmitted. BleBeacon does not read
them and must not be given a reason to: the advertisement is identical byte for
byte whether every peer is named or none is.
Everything from offset 8 on is present only when the flag that names it is set. With Nearby off the block is eight bytes and stops at the flag byte — the fields are absent from the air, not present and zeroed, because "not transmitted" has to be structural to be worth claiming.
A poke displaces the score; it is not appended to it. The sharing payload is already exactly 31 bytes, so there is nowhere to put three more. While a poke is on air the block is twelve bytes: the game stays visible and the four score bytes are simply not sent. Peers keep the last score they heard rather than reading a zero, because absent is not nought.
That is why the version went to 3 and why every field is now gated on its own
length. Version 2 decided "is the game here?" and "is the score here?" with
one test against a single length, which a poke's shorter block answers wrongly
for both. decode() refuses to read any field unless its flag is set and the
block is long enough to have carried it, so a truncated advertisement can never
be read as a game, a score or a poke target that was never sent.
A poke is a nudge one console sends another: the target raises a notification and makes a sound. Four properties are part of the contract.
-
It is a broadcast, not a message. Non-connectable advertising has no addressing. Every Braino in range hears that
A4F2pokedB1C3; onlyB1C3reacts. The API says so and the UI must not imply a private channel, because there is not one. -
The only identifier it carries is one already on air. The target's device id is the same four hex digits that device broadcasts about itself, every second, as its own name. A poke adds no new kind of data to the radio — it adds an event, and names a party using that party's own public id.
-
It is an event, so it ends. The poke is transmitted for
POKE_ADVERTISE_MS(6 s) because a peer's scan windows have gaps and a single-shot poke can be sent perfectly and never heard. The nonce is what makes that repetition safe: a receiver acts on a (device id, nonce) pair exactly once, however many copies it hears. The nonce increases across pokes and is never reset, so a second poke to the same peer is a new event rather than a repeat of the last one. -
A poke can ask to be heard. Bit 4,
FLAG_FIND, turns a poke into a find: the target rings a bell repeatedly, blinks its LED and wakes its panel, instead of blipping once. This is what answers "where did the console get to?", and it is the reason the receiver's reaction is part of this document at all. It costs a reserved flag bit and no bytes -- the sharing payload is already exactly 31, so a field was never available.It does not bump the layout version, and that is deliberate. A reader rejects the whole manufacturer block on a version mismatch, so bumping would make consoles on either side of the change invisible to each other in Nearby -- a far worse outcome than the one it would be guarding against. Nothing a version protects is at risk here: no length changes and no existing field changes meaning, so a reader that does not know bit 4 sees a poke, ignores the bit, and blips. That is the correct behaviour, not a degradation to be apologised for. Spending a reserved bit is only safe on those terms -- a future flag that moves a field must bump the version.
A find is still a broadcast. Everyone in range hears that
A4F2is looking forB1C3; onlyB1C3rings. The docs and the UI must keep saying so.
Poking requires Nearby play to be on, which requires the beacon to be on. There is no path that pokes while the radio setting says the device is quiet.
Ringing additionally obeys the owner's Find alert setting (Settings >
Sound). With it set to Quiet the banner and the LED still happen and the bell
does not. On Ring, a find will briefly unmute a muted console so that it
can answer, and restore the mute when the alert ends -- see NearbyPlay.
- Device ID is the last two bytes of the factory Bluetooth MAC, rendered as four uppercase hex digits. It is a hardware serial, stable across reboots so a parent can recognise their own unit in a scanner. Nobody types it and it is not derived from anything a player entered.
- Company ID
0xFFFFis the SIG's reserved "no company / testing" value. We are not a member company; claiming another company's identifier would be worse than honestly using the reserved one. - Layout version is checked by peers before anything after it is read, so a future layout is ignored rather than mis-decoded by an older device.
- Open game is an index into
APP_REGISTRY's playable entries. Two devices agree on what index 12 means because they compile the same table; the version byte is what stops devices that do not from guessing. - No service UUID and no service data are advertised. Those fields exist in the struct at zero length, and the UI omits their rows rather than printing a value that is not on air.
- Advertising is non-connectable (
ADV_NONCONN_IND) at a 1000 ms interval, +3 dBm. There is no GATT server; there is nothing to connect to.
Total payload is 27 of the 31 legal bytes with Nearby off, exactly 31 with it
on, and 30 while a poke is being transmitted. There is no slack left: a longer device name or another AD structure
would push the manufacturer block off the air. buildPayload() logs and drops
the block rather than transmitting a half-advertisement if that ever happens.
src/hal/BleScanner.cpp observes other beacons while Nearby play is on. Three
properties are part of the contract, not incidental:
- The scan is passive. It never transmits a scan request, so a console that is listening puts nothing extra on air.
- A peer must match both the name prefix
Braino-and the manufacturer block, at a version we know. Either alone is not enough. - Nothing allocates. The sighting table is a fixed array of eight and the advertisement is parsed straight out of the controller's buffer — the callback runs at whatever rate the air is busy, which is exactly the churn that fragments this heap.
Structural, not a promise typed into the UI: buildPayload() emits a name AD
and a manufacturer AD and nothing else, so none of the following is reachable
from the radio path.
Player information · player name · profile name · location · Wi-Fi credentials · Wi-Fi SSID · IP address · game progress · usage history.
A poke does not change this list. It transmits an event and one device id that
the addressed device is itself already broadcasting; no field naming a person is
reachable from buildPayload() whether a poke is live or not.
Nothing profile-scoped (Board::scopedKey()) is read by BleBeacon at all.
engine/NearbyPlay does read the active profile's best score in order to
publish it — that is the one number the owner opted in to sharing, and it
travels with no name attached.
A peer is four hex digits of its own hardware MAC. There is no name, no profile, and no path from a score back to a player. Two players learn that someone nearby has 9 on Maze; neither learns anything about the other. They can now nudge each other, and still learn nothing: a poke says that tag wants your attention, which is the same anonymity with a doorbell attached.
The owner of a device may label a tag they recognise -- "RAVI" against A4F2
-- so that a poke says who rather than what. Three properties keep this on the
right side of the line, and all three are structural:
- The label never leaves the device.
BleBeacondoes not read it and must never be given a reason to:buildPayload()composes the advertised name from the family id and the hardware id, and a label reaching the payload would be a privacy defect rather than a bug. What goes on air is unchanged, byte for byte, whether every peer is named or none is. - It is the owner's word, not the peer's. The named device does not know it has been named, is not asked, and cannot see or set the label. This is the model a phone's contact list uses, not the model a social network uses.
- Storing is not collecting. The labels live in this device's own NVS
beside the scores and the profiles, exactly as
CONTRIBUTING.mddescribes, and leave it by no route at all.
They are global to the device rather than per player, because the consoles in the room are the same consoles whoever is holding this one; and only the admin profile may set one, so a label every player sees cannot be written by any player. The tag itself stays visible beside the name in the Nearby list -- without it nobody could work out which console "RAVI" actually is when the label turns out to be on the wrong one.
The BLE tab is part of the feature, not a nicety. It must show:
- whether advertising is currently active;
- the actual device name being advertised;
- every application-defined field in the manufacturer data, decoded — including the Nearby fields when, and only when, they are on air;
- under Show advanced: interval, TX power, advertising type, controller address, payload length, and the raw advertising bytes in hex;
- the privacy list above, with Open game and Best score reported as
Broadcast/Not Broadcastderived fromAdvertisement::sharesActivityrather than from typed-in copy; - when the beacon is off — Broadcasting: Nothing, with the identity block
relabelled
Configso no row reads as being on air.
BleBeacon::broadcasting() returns nullptr when the controller is not
advertising. That null is what separates "configured" from "on air"; the UI
must key off it rather than off the stored setting, which can be On while the
radio failed to come up.
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.