Skip to content

Latest commit

 

History

History
364 lines (296 loc) · 19.7 KB

File metadata and controls

364 lines (296 loc) · 19.7 KB

BLE Beacon — design contract

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.

One authoritative payload

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.

What is broadcast

AD type Contents
0x01 Flags 0x06 — LE General Discoverable, BR/EDR not supported
0x09 Complete Local Name Braino-<id>
0xFF Manufacturer Data see below

Manufacturer data, layout version 4

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.

The packed move

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.

Why a move is a state and a poke is an event

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.

Ending a game

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.

What a receiver must do with a move

Four tests, all required:

  1. From the peer we are playing. Another console's game must not leak in.
  2. In this session. Nor an earlier game between the same two consoles.
  3. The ply we are expecting. An advertisement repeats; acting once is what makes it a move rather than a stutter.
  4. 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.

Tables of more than two

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, from and to zero).
  • The host's start is ply 0 with from 32–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.
  • ack is 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.

Backgammon

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.

The poke, and what it actually is

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 A4F2 poked B1C3; only B1C3 reacts. 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 A4F2 is looking for B1C3; only B1C3 rings. 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 0xFFFF is 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.

Listening

src/hal/BleScanner.cpp observes other beacons while Nearby play is on. Three properties are part of the contract, not incidental:

  1. The scan is passive. It never transmits a scan request, so a console that is listening puts nothing extra on air.
  2. A peer must match both the name prefix Braino- and the manufacturer block, at a version we know. Either alone is not enough.
  3. 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.

What is never broadcast

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.

The anonymity argument, in one line

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.

Local names, and why they do not weaken any of that

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. BleBeacon does 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.md describes, 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.

What System Info must show

The BLE tab is part of the feature, not a nicety. It must show:

  1. whether advertising is currently active;
  2. the actual device name being advertised;
  3. every application-defined field in the manufacturer data, decoded — including the Nearby fields when, and only when, they are on air;
  4. under Show advanced: interval, TX power, advertising type, controller address, payload length, and the raw advertising bytes in hex;
  5. the privacy list above, with Open game and Best score reported as Broadcast / Not Broadcast derived from Advertisement::sharesActivity rather than from typed-in copy;
  6. when the beacon is off — Broadcasting: Nothing, with the identity block relabelled Config so 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.