Protocol & Commands
One message envelope for every command, response, and event — plus the full command reference.
Requests
Write a message to CMD_IN. Every request has the same shape, shown here decoded (on the wire the keys are the integers 0–4 and ns / prop are dictionary ids; see Wire Format):
{
"kind": 1 | 2, // 1 = get (read), 2 = set (write)
"ns": "deviceState", // namespace (see below)
"prop": "PLAYER_SHIELD", // property within the namespace
"value": 100, // the new value (for set); omit for get
"id": "abc123" // your correlation id (echoed in the response)
}Give every request a unique id. Responses arrive on CMD_OUT carrying the same id, so you can match a reply to the request that produced it.
Responses
Responses are delivered as notifications on CMD_OUT:
// success: kind 3
{ "kind": 3, "ns": "deviceState", "prop": "PLAYER_SHIELD", "value": 100, "id": "abc123" }
// error: kind 4
{ "kind": 4, "key": "invalidValue", "msg": "…", "data": "prop", "id": "abc123" }| Error key | Meaning |
|---|---|
unknownValue | The namespace or property is not recognised. |
invalidValue | The value was the wrong type or out of range for that property. |
missingValue | A required field (kind / ns / prop) was missing. |
missingInput | The written value was empty. |
decode | The message could not be decoded: not a CBOR map, a dictionary id the device does not know, or malformed CBOR. The message text says which. |
Events
Events are unsolicited notifications on EVENTS_OUT, with kind 5. They use the same ns / prop / value fields and carry no id — treat them as a fire-and-forget stream.
| ns | Shape | When |
|---|---|---|
deviceState | { "ns": "deviceState", "prop": "<KEY>", "value": <any> } | A state value changed. Fires only while DEVICE_APP_CONNECTED is true. High-frequency values are rate limited (GPS ~2s, battery ~5s). |
fsm | { "ns": "fsm", "prop": "state", "value": "<state>" } | The device entered a new game / UI state (e.g. IN_GAME → DEAD). |
game | { "ns": "game", "prop": "config", "value": { "radarType": 1, "trackerRangeM": 100, … } } | The game configuration, pushed when an app connects and again whenever the device applies a config. Read radarType to know whether this game has a motion tracker at all before drawing one. |
game | { "ns": "game", "prop": "lobby", "value": { "players": [ … ], "canStart", "reason", … } } | The host's lobby changed: someone joined or left, a side or a name landed, a tagger took the settings, or whether it can start changed. The same shape as get game/lobby. |
game | { "ns": "game", "prop": "command", "value": { "playerId", "command", "ok" } } | How a command the host sent to one tagger went: command is player, remove or settings. ok false means the tagger never acknowledged it (switched off, or out of range). |
games | { "ns": "games", "prop": "install", "value": { "state", "id", "size", "received", "checksum", "error", "writeMs" } } | An install finished: state is done or failed, and error says why it failed. The same shape as get games/install. |
games | { "ns": "games", "prop": "remove", "value": { "id", "ok", "error" } } | A removal finished. |
games | { "ns": "games", "prop": "share", "value": { "role", "state", "id", "radioId", "irProtocol", "irRadioId", "session", "percent", "detail", "reason", "ms", ... } } | A game share in a lobby started, changed stage or ended. The same shape as get games/share. |
fleet | { "ns": "fleet", "prop": "rows", "value": { "rows": [ … ] } } | Fleet reports heard since the last event, while set fleet/watch is on: at most once a second and 16 rows an event, in the shape of fleet/list's rows (ageSecs 0), this tagger's own each time it reports. A change or an answer is sent twice, 400 ms apart, so a row can arrive twice. |
protocols | { "ns": "protocols", "prop": "install", "value": { "state", "id", "size", "received", "checksum", "error", "writeMs" } } | An IR protocol install finished: state is done or failed, and error says why it failed. The same shape as get protocols/install. |
protocols | { "ns": "protocols", "prop": "remove", "value": { "id", "ok", "error" } } | An IR protocol removal finished. |
players | { "ns": "players", "prop": "update", "value": { "playerId", "name", "teamId", "lives", "connected", "left", "kills", "deaths", "points" } } | One player's scoreboard row changed. Sent per player rather than as a whole board, and only for rows that actually moved. left turns true (and never back) when the player leaves the game early from their tagger's in-game menu; they are out, but their row and numbers stay. Kills and deaths are counted from kill events, so they are always present; points are only meaningful when the game config says scoreBased is true, and are zero otherwise. |
tracker | { "ns": "tracker", "prop": "contact", "value": { "playerId", "teamId", "lat", "lon" } } | Another player broadcast their position. Relayed from the mesh, so this describes a different player and is not deviceState. With the radar on moving players (GAME_RADAR_TYPE 1) it only arrives while that player's tagger says they are walking or running — a player who stops simply stops appearing. |
diagnostics | { "ns": "diagnostics", "prop": "timing", "value": { "windowMs", "loop": { "min", "max", "avg", "count" }, "items": [ { "id", "min", "max", "avg", "count" } ] } } | One loop timing window, once a second while diagnostics/timing is on: the whole loop pass and every timed item, in microseconds. Items are FSM, DISPLAY, BLE, WIFI, NFC, AUDIO, INPUT, LORA, GPS, LIGHTING, ACCEL, INTERVALS, COMMS, BATTLESCRIPT and BATTLESCRIPT_EVENT. |
diagnostics | { "ns": "diagnostics", "prop": "motion", "value": { "enabled", "sensor", "speed", "speedName", "msInState", "cadence", "jolt", "pitch", "roll", "ax", "ay", "az" } } | The motion classifier now, once a second while diagnostics/motion is on: whether there is a sensor, the speed (0 still, 1 walking, 2 running) and how long at it in ms, the steps a second and the median step jolt in g over the last 3 s, pitch and roll in degrees, and the acceleration on each axis in g. |
ota | { "ns": "ota", "prop": "progress", "value": { "phase", "status", "percent", "bytes", "total" } } | Firmware update progress during an OTA. |
raw | { "ns": "raw", "prop": "ir" | "esp-now", "value": { "tp", "raw", "state", … } } | The debug tap, while esp-bridge is enabled: every captured IR frame per sensor and every ESP-NOW frame, before any deduplication. The value is the tap object itself. |
Command reference
Commands are grouped by namespace (ns). Unless noted, the response value mirrors the request.
Team ids are the same everywhere one appears (teamId, winningTeamId, targetTeamId, PLAYER_TEAM_ID): 0–15 are teams, and 255 means none, as for every player in a free-for-all or a game no team won. Teams 0–3 are red, blue, yellow and green; 4–15 are teams too, with no colour of their own yet.
deviceState
Read, write, and subscribe to the device's live state. prop is any state key (see the Device State Reference). Any value that changes is also pushed as an event.
| Method | Property | Value | Description |
|---|---|---|---|
get | <KEY> | — | Read a value, e.g. PLAYER_SHIELD. GET GAME_TIME returns the live game clock rather than the stored field. |
set | <KEY> | new value | Write a value, e.g. set PLAYER_NAME to a string, or DEVICE_APP_CONNECTED to true. The value is coerced to the key's type; a type mismatch returns invalidValue. |
audio
Play and stop audio, set the speaker volume, and check the SD card.
| Method | Property | Value | Description |
|---|---|---|---|
set | startTrack | file path (string) | Play an audio file, e.g. "/audio/default/pickup.wav". |
set | masterVolume | 0–255 | Set the speaker volume. The tagger saves it and starts at it after every boot. Answers with the volume set. |
get | masterVolume | — | Returns the speaker volume now, 0–255: the saved volume, unless a game script has changed it since boot. |
set | stopTrack | track number | Stop a playing track. |
get | isTrackPlaying | — | Returns whether audio is currently playing. |
set | cardCheck | { autoFix: bool } | Check whether the audio board's SD card holds what it claims. The audio board restarts to check (sounds stop for a few seconds), puts back everything it overwrote, and restarts into audio. With autoFix, a fake card is reformatted to the storage it really has instead, which deletes everything on it; a genuine card, or a fake one already fixed this way, is left alone. Refused, with the reason, outside the home screen and menus, on a low battery, while a check runs, or when the audio board's firmware is older than 1.0.43. Answers with the check's status; progress and the result arrive as audio/cardCheck events. |
get | cardCheck | — | The check that is running, or the audio board's last result: supported, heard, running, autoFix, stage (0 starting … 7 formatting, 8 done), stageName, percent (of the stage), verdict (0 unchecked, 1 genuine, 2 wraps, 3 drops, 4 errors, 5 failed), verdictName, fixed (0 left as it was, 1 reformatted, 2 already fixed before, 3 reformat failed), claimedMb, realMb, partitionMb, wrapMb (MiB), elapsedMs, readKb, writtenKb and detail. Also pushed as an event as the check goes. |
rgb
Drive the addressable RGB LEDs directly.
| Method | Property | Value | Description |
|---|---|---|---|
set | setAll | [[r,g,b,brightness], …] | Set every pixel at once. One [r, g, b, brightness] array per pixel. |
set | setOne | [index,r,g,b,brightness] | Set a single pixel by index. |
players
The scoreboard. Every tagger derives this independently from the gossiped mesh event log rather than being told it by a host, so it keeps working if the host leaves the game.
| Method | Property | Value | Description |
|---|---|---|---|
get | roster | — | The whole board as an array: playerId, name, teamId, lives, connected, left, down, kills, deaths, points. left is true for a player who chose to leave the game early; they stay on the board with their numbers. down is true while a player waits to respawn (in DEAD or RESPAWNING), never for one who is out. Read this once for the initial paint — sixteen full records do not fit in a single BLE write, so ongoing changes arrive as players/update events rather than by re-reading. |
game
The game: the configuration currently loaded, and everything needed to set a game up and run it through the host tagger alone. The other taggers join by being bumped against the host; after that the host sends them what the app decides, so they need no screen and no buttons. A refused set answers with an error whose message says why.
| Method | Property | Value | Description |
|---|---|---|---|
get | config | — | Returns the game config as an object. Field names match the game's config file: gameId, bstId, name, isTeamBased, gameDurationMins, minPlayers, respawnMode, respawnTimeSecs, playerLives, mapType, radarType, trackerRangeM, gpsTransmit, gpsMinMoveM, scoreBased. |
get | status | — | Where the device is: state, role (2 host), gameId, playerId, teamId, scriptId, players, and the game clock (clockRunning, elapsedSecs, timeLimitSecs, 0 for no limit). teamId is 255 when the player has no team (every player in a free-for-all). Once the game has ended, endType (0 target, 1 time, 2 nobody left to play against, 3 ended by the host) and winningTeamId (255 for none). |
get | list | — | The games this device can play: an array of { id, name, source }. source is fs for a game built into the firmware's filesystem and sd for one on the audio board's SD card (see games). A built-in game wins over an SD game with the same id. |
set | create | { scriptId, gameId? } | Host that game, from the home screen. The lobby opens. gameId (1–255) puts it on that id, a room's fixed one, so the room's scoreboard follows each game played there; without it the tagger picks an id no nearby game is using. Two rooms in range need different ids. |
get | lobby | — | The host's lobby: scriptId, gameName, gameSource (fs or sd), gameChecksum (the first 16 hex digits of the game's checksum), isTeamBased, teams [{ id, name, minPlayers }], players [{ playerId, name, teamId, ready, connected, isHost, synced, busy, game, gameReason, irProtocol, irReason, gameProgress?, battery, headset }] (battery: the player's tagger's { percent, mv, minutes, packPresent, low, fault, warn, stale } from its fleet report, null until one is heard; headset: its headset as in a fleet row, or null), gameShare, configVersion, canStart and reason (why not). synced: the player's tagger has the latest settings. busy: a command to it is waiting for its acknowledgement. game: whether that tagger has the game with this checksum: has, loading (reading it from its SD card), missing, receiving (the host's audio board is sending it), failed, noBoard (no audio board, so built-in games only), noCard, different (a different version is built in) or unknown (not heard yet). A player that is missing the game, or has a different version, is sent it straight away by the host's audio board, and retried once if that fails. gameReason: why its game failed, in words (empty otherwise). irProtocol and irReason: the same for the game's IR protocol (see protocols), with the same words, plus loading or failed for a player whose headset is still getting the protocol or cannot decode with it; a player that lacks the protocol is sent it by the host's audio board, with the game when it lacks both. gameProgress: 0-100 while it is receiving, how far the host's share has got (one broadcast serves every player receiving it). gameShare: the host's latest share, the same shape as get games/share. The host cannot start until every player has it, and reason names who does not and why. ir: the game's IR protocol { id, name, known, teamCount, maxPlayers, protocolCarrierHz, carrierHz, carrierSource }: known once the protocol has run on the host (the counts are null until then); carrierHz is the carrier in use, the game's irCarrierHz (carrierSource game) or the protocol's own (protocol). The host refuses a join past maxPlayers, and cannot start with a team at or past teamCount, or with a player on no team or on a team the game no longer lists (players on a team the game drops are taken off it). warnings: things to say that do not block the start, such as a carrier other than the taggers' 38 kHz. Also pushed as an event when it changes. |
set | select | { scriptId } | Pick another game in the lobby. Settings changed for the old one are dropped, on every tagger. |
get | settings | — | Every setting of the lobby's game: values (in effect), defaults (the config file's), overrides (what was changed), descriptions (how to present each setting: label, type number / choice / bool / text / list / object, min, max, step, unit, choices, help, group, hidden, and fields for a list or object), version and configHash (identifies the config file). The text (each label, help, unit and choice label, and the game's name) is in the phone app's language and wording, set with device/appLanguage; language says which. A game's own translations are used first, then the tagger's, then the English. |
set | settings | { values?, clear?, reset? } | Change settings: values holds top-level keys to use instead of the file's (a list is replaced whole), clear names keys to put back, reset: true puts them all back. The shipped games' own settings include countdownVoice (the spoken countdowns), wording (player: each tagger's own, the default; standard; or family: the host's choice is on every tagger's screen while the game runs, and each goes back to its own after it), hudLayout (the in-game screen: halo, the default since 2026-10-02, a radar in the middle with the shield and armour above it and lives and rounds below, or classic; the round 240×240 screen always shows classic), showStandings (off: a player's tagger shows its own score but nobody else's, and no place) and headsetDownFlash (the headset pulses red while its player waits to respawn, and goes dark once they are out). The host applies them and sends them to every tagger in the lobby; it will not start until they all have them. Answers with the new settings. A MilesTag 2 clone the host hears in its lobby changes them the same way (the lobby's configVersion moves), and is announced with a game/clone event { ok, reason, changed, skipped }: changed names each setting set (weapons.<field> for the first weapon's), skipped each clone field the game has no setting for. |
get | balance | — | Balanced sides for the host's lobby, proposed, nobody moved yet (tagger backlog #33): { basedOn, teams, moves, sizesUneven, problem, pins }. Each player is weighed by their score in the last game the host saved (points, or kills when that game ranked on kills), matched by their tagger; a player who was not in it is a newcomer and counts as the average. teams lists each side's teamId, name, total and players ({ playerId, name, weight, newcomer, group, teamId now }); moves says how many would change side; basedOn names the game the weights came from, or is null. Totals come out as even as the host can make them, with sides' sizes within one and pinned players together; sizesUneven says a pinned group stopped that, problem that a side would be under its minimum. Refused outside a team game's host lobby, and for a game that picks its own sides (its config says balanceTeams: false, as Infection's does); the lobby's balanceTeams says whether it can be offered. |
set | balance | { pins } | { apply: true } | { cancel: true } | pins: groups of player ids who stay on one side, for this lobby; answers the preview again. apply: confirms the last preview, each move sent the way a set player is (acknowledged); answers { moved, failed }, and is refused if anyone joined or left since the preview. cancel: drops the preview; nobody moves. |
set | player | { playerId, teamId?, name? } | Set a player's side and/or name. The host sends it to that tagger, resending until it acknowledges; the tagger saves the name. The lobby shows the change once it has landed; a command event says if it never did. |
set | remove | { playerId } | Take a player out of the lobby. Their tagger goes back to its home screen. |
set | start | true | Start the game. Refused, with the reason, while there are too few players, a player has no side, a side is under its minimum, or a tagger does not have the latest settings. Ready does not matter. |
set | volume | 0-255 | Every tagger's speaker volume in the host's game, the host's own included, in the lobby or during play (the host only). Sent to each tagger and resent until it answers; each saves it as if set on it directly. A command event reports a tagger that never answered. |
set | environment | indoor | mixed | outdoor | Where the game is played, at HOME or in the host's lobby (not once it has started). The host sends its maximum shot range for that environment (device/environmentRanges) to every tagger in its game. Answers with the lobby, whose environment, shotRangeMax and environmentRanges show it. |
set | end | true | End the running game for everyone (the host only), as the host's in-game menu does. |
set | leave | true | Leave the lobby or game; the device goes home. From the host, this closes its lobby. |
games
Games on the audio board's SD card, beside the games built into the firmware. A game travels as one .bcgame bundle (a small container: the magic BCGM, a format byte, a sorted table of files, their bytes, and a SHA-256 of everything before it; the reference packer and reader are js/src/bcgame.js in battlecore-api). The bundle's SHA-256 is the game's checksum, and its first 8 bytes are how taggers in a lobby check they hold the same version. A built-in game's checksum is the one the same packer gives its config.bin and game.bin with an info.cbor of { id, name }. Installs and removals are refused during a game. In a lobby, the host's audio board sends the lobby's game to the players that lack it (see share).
| Method | Property | Value | Description |
|---|---|---|---|
get | list | — | Every game the tagger holds: { sd, games: [{ id, name, source, checksum, radioId, size, version?, author? }], loaded }. sd is where the card's listing stands: known, listing, unknown, noCard or noBoard. checksum is 64 hex digits; radioId is its first 16. loaded names the SD game read into memory, if any ({ id, radioId, state, bytes }). |
set | install | { op: "begin", id, size, sha256 } | Starts installing a bundle of size bytes (at most 1 MB) whose checksum (its last 32 bytes) is sha256, as 64 hex digits. Answers { received }: how much of this same bundle the tagger already holds from an earlier try, so send from there. Refused for a built-in game's id. |
set | install | { op: "data", offset, data } | The next piece: data is base64, offset is where it starts and must be what the tagger has received so far. Up to 16 KB a piece over Bluetooth. Answers { received }. |
set | install | { op: "end" } | Checks the whole bundle (every rule of the format, its checksum, that its info.cbor names the id begun with) and writes its files to the card in /games/<id>/, replacing an older version. Answers at once with { state: writing }; a games/install event says how it ended, and get games/install says where it is. |
get | install | — | Where an install is: { state (idle, receiving, writing, done or failed), id, size, received, checksum, error, writeMs }. |
set | remove | { id } | Removes an SD game from the card. Built-in games cannot be removed. Answers { id, state: removing }; a games/remove event says how it went. |
get | share | — | The game sent over the air in a lobby, by this tagger's audio board (the host's) or to it (a player's): the one in hand, or the last in this lobby. { role (send, receive or none), state (idle, exporting, starting, running, finishing, done or failed), id, radioId, irProtocol, irRadioId, session, bytes, stage, percent, expected, boards, complete, failed, lost, detail, reason, error, ms: { total, export, boardTook, call, data, board } }. irProtocol and irRadioId name the game's IR protocol when the share carries it too (empty otherwise); id is empty when it carries only the protocol. The host's audio board sends the lobby's game to every player's board that lacks it, over ESP-NOW on channel 11; each board checks the whole game's checksum before it replaces an older version, and keeps it on its card. A built-in game is written to the host's card first (exporting). expected, boards, complete, failed and lost count boards (the host only). reason says why it failed, in words. ms: total is this tagger's time from start to end; call and data split the share the board ran. Also a games/share event on every change. |
protocols
IR protocols on the audio board's SD card, beside the ones built into the firmware. A protocol is one compiled BattleScript file (how it is written: battlescript.io/docs/ir-protocols), kept on the card as /protocols/<id>/protocol.bin with its checksum beside it. Its checksum is the SHA-256 of the file, and the first 8 bytes of that are how taggers in a lobby check they hold the same version. A game names its protocol in its config (irProtocol); every tagger in the game decodes with that one. In a lobby, the host's audio board sends it to the players that lack it, in the same share as the game when they lack both (see games/share), and each tagger sends it to its headset over Bluetooth. An id is 1-24 letters, digits, - and _. Installs and removals are refused during a game.
| Method | Property | Value | Description |
|---|---|---|---|
get | list | — | Every IR protocol the tagger holds: { sd, protocols: [{ id, source, checksum, radioId, size, name, carrierHz, teamCount, maxPlayers, usable, reason }], loaded }. name, carrierHz, teamCount (how many teams a shot carries), maxPlayers (how many players it tells apart; 0 when the protocol does not say) and usable are what the protocol said about itself when it last ran on this tagger, kept per version; a tagger at home runs each new one once, so until then they are null and reason is "not checked yet". usable false: it will not install here, and reason says why. source is fs for one built into the firmware's filesystem and sd for one on the card; a built-in wins over an SD protocol with the same id. sd is where the card's listing stands: known, listing, unknown, noCard or noBoard (no audio board, or one too old to keep protocols). checksum is 64 hex digits; radioId is its first 16. loaded names the SD protocol read into memory, if any ({ id, radioId, state, bytes }). |
set | install | { op: "begin", id, size, sha256 } | Starts installing a compiled protocol of size bytes (at most 64 KB) whose SHA-256 is sha256, as 64 hex digits. Answers { received }: how much of this same file the tagger already holds from an earlier try, so send from there. Refused for a built-in protocol's id. |
set | install | { op: "data", offset, data } | The next piece: data is base64, offset is where it starts and must be what the tagger has received so far. Answers { received }. |
set | install | { op: "end" } | Checks the file (its SHA-256, and that it is a BattleScript binary) and writes it to the card in /protocols/<id>/, replacing an older version. Answers at once with { state: writing }; a protocols/install event says how it ended, and get protocols/install says where it is. Whether its IR settings install is known the first time it is used: one that will not leaves the tagger on its default protocol. |
get | install | — | Where an install is: { state (idle, receiving, writing, done or failed), id, size, received, checksum, error, writeMs }. |
set | remove | { id } | Removes an SD protocol from the card. Built-in protocols cannot be removed. Answers { id, state: removing }; a protocols/remove event says how it went. |
report
After-action reports. Every tagger counts its own game (shots, hits and damage per weapon, kills per weapon, streaks, time alive) and keeps its last five games. Any tagger that played, or a scoreboard that watched, gathers a game's report by asking each player's tagger in turn, even after everyone is back at the home screen.
| Method | Property | Value | Description |
|---|---|---|---|
get | games | — | The games this tagger kept (in scoreboard mode, the games it heard), newest first: gameId, token, gameUid, startedAt and hostMac when known (as in collect), scriptId, gameName, teamBased, durationSecs, endType, winningTeamId, players, current (still being played). |
set | collect | { gameId, token } | Gathers one game's report. Answers with { game, summaries, asking }: game has the description (with gameUid, startedAt and hostMac for telemetry: gameUid is 32 hex characters the host made as it started the game, the same on every tagger and scoreboard; startedAt is the host's start in seconds since 1970, present only when its clock was set with device/time; hostMac is the host's MAC, upper case; each is left out when unknown), the teams [{ teamId, name, score, members }] (every side the game plays with, even one with nobody left on it), every player's row (mac, the player's device, upper case, "" when unknown; kills, deaths, assists, points, lives, out, left) and killMatrix [{ killerId, victimId, count }]. A row can be real MilesTag 2 or LaserWar gear that killed someone, or a MilesTag 2 gun whose score data was heard after the game: foreign: true, gearId (its id on the air), note, a playerId of its own (1000 + side × 256 + id), and gear { roundsFired, totalHits, gameSecs, respawns, taggedOut, flags, hitsBy, teamHitsBy } when its score data was heard (hitsBy and teamHitsBy: [{ playerId, count }], the hits it took by shooter, and from its own side). A foreign row has no summary; summaries are the players' own counts already in hand. Each other player's tagger is then asked in turn (three tries each): its summary arrives as a report/summary event, and report/progress { asked, got, missing, done } follows each player. A summary is { playerId, teamId, shots, hits, damageDealt, kills, deaths, hitsTaken, damageTaken, bestStreak, longestLifeSecs, aliveSecs, lives, weapons [{ weaponId, name, shots, hits, kills, carriedSecs }] }. |
headset
Finding a lost headset, and its battery. A linked headset tells its tagger its battery over their own link (headset/battery { mac, percent, mv, packPresent, low, fault, read }) on linking, every 30 seconds, on a change and when the tagger asks (headset/batteryRead, for a fleet refresh); the tagger keeps it in the HEADSET_BATTERY_* state keys and carries it in its fleet report. The tagger remembers its headset's Bluetooth address and notes when and where the headset's link dropped. A search makes the headset strobe its lights and answer once a second. Every tagger that hears it says how strongly, with its position. The distances come from signal strength and are only a rough guide.
| Method | Property | Value | Description |
|---|---|---|---|
get | find | — | The search and what is known: known (the tagger has met its headset), mac, name, linked, searching, secondsLeft; lost { lost, secsAgo, state, hasPos, lat, lon } (when and where the link dropped, cleared when the headset links again); here { heard, secsAgo, rssi, rssiAvg, warmth (0-10, cold to hot), distanceM, battery } (this tagger's own reading); searchers [{ mac, rssi, distanceM, secsAgo, hasPos, lat, lon }] (the other taggers that hear it); estimate { from, hasPos, lat, lon } (a rough place from two or more readings with positions); findsHeard (other taggers' searches this tagger has heard). |
set | find | { seconds } | { stop: true } | Starts a search for this many seconds (120 by default, 600 at most), or stops it with 0 or stop. The headset strobes white and answers each second until then. Refused, with why, when the tagger has never met its headset (one with older firmware never says its address). Answers with get headset/find. |
spectator
Scoreboard mode: a tagger that listens to every game in range and plays in none, for a scoreboard screen (battlecore.io/scoreboard reads it over USB). It builds each game's board from the players' records, asks a game it did not hear from the start for its names and description, and follows a room's next game on the same game id.
| Method | Property | Value | Description |
|---|---|---|---|
get | status | — | { active, watching, gameCount, operationMode }. Also pushed as an event when scoreboard mode starts or stops. |
set | active | boolean | Into scoreboard mode (operation mode 3, kept through a restart), from the home screen or the menus, or back to the home screen. Answers with the status. |
get | games | — | Every game heard: gameId, token, phase (lobby, playing or ended), scriptId, gameName, teamBased, scoreBased, timeLimitSecs, elapsedSecs (−1 before the start), lives, hostName, players, heardAgoMs, and gameUid, startedAt and hostMac once a player has said them (as in report/collect). Also pushed as an event when it changes, at most every 2 seconds. |
set | watch | game id (0 for none) | The game whose board is pushed as spectator/board events: when it changes, and once a second while it plays. Answers with the board. |
get | board | — | The watched game: the games fields and version, endType, winningTeamId, playerList [{ playerId, name, teamId, kills, deaths, assists, points, lives, out, left, down }] (down: waiting to respawn), teams [{ teamId, name, score, alive, members }], feed, the last 20 kills [{ seq, killerId, victimId, agoMs }], and events, the last 20 events the game's script marked for the feed (a flag taken, captured or returned) [{ seq, event, playerId, teamId, targetTeamId, agoMs }]: event is the script's name for it, playerId who did it (an id nobody in the game has when nobody did, as for a flag going back by itself), teamId their side and targetTeamId the side it was against, 255 for none. The events count their own seq, apart from the kills'. |
fleet
The fleet battery monitor: every tagger and headset in range, with its last reported battery. Every tagger broadcasts a short report every 27–33 seconds in every state, at once (and again 400 ms later) when its low, fault, warning or pack-fitted flag changes, when its headset links, unlinks or changes, when its provisioning group changes, and 0–3 s after it joins or leaves a game. A linked headset's battery rides in its tagger's report. Every tagger keeps the reports it hears, keyed by MAC, and forgets a device after an hour of silence. The battery model (the discharge curve, minutes left, the timings) is @battlecore/api/battery. A row is stale after 75 seconds without a report.
| Method | Property | Value | Description |
|---|---|---|---|
get | status | — | { mac, groupHash, gameId, rows, watching, view, warnMinutes, reportEverySecs, staleAfterSecs, counts }: this tagger's MAC (upper case), its group's 24-bit hash (0 for none) and game, how many devices it holds, whether fleet/rows events are on and for which view, its warning threshold in minutes, the report period (30) and when a row goes stale (75). counts has reportsSent, reportsHeard, requestsSent, requestsHeard, requestsAnswered, requestsRefused and requestsFolded. |
get | list | — | The first page of every row: { view, total, from, count, rows }. Its own row is first (self: true), then the rest by MAC. |
set | list | { view?, from?, count? } | A page of rows. view is all (the default), game (inMyGame: the same non-zero game as this tagger) or group (inMyGroup: the same non-zero provisioning group, "My fleet"). count is 1–64 (32 by default). A row is { mac, kind (tagger), name (its Bluetooth name's own part, "" for the MAC), state (home, lobby, playing, out, ended, scoreboard or other), reason (periodic, change, refresh, headset or boot: why it was sent), percent (0–100 on the discharge curve, null before its first reading), mv (the pack, 0 with none), minutes (estimated minutes of play left, null with no pack), packPresent (false: on USB), low (under 3.2 V a cell), fault (under 2.8 V a cell: do not charge it), warn (its own warning: minutes under its threshold, or low), groupHash, gameId, playerId, versionCode, version ("1.0.853"), rssi (dBm, null on its own row), ageSecs, self, inMyGroup, inMyGame, headset }. headset is null when the tagger has had no headset since boot, else { mac, linked, percent, mv, minutes, packPresent, low, fault, warn }, its battery fields null, 0 or false while it is not linked; mac is the headset's base MAC, "" until it has reported. |
set | watch | true | false | { view } | Starts or stops fleet/rows events (for a view, as in set fleet/list). Answers with get fleet/status. Over Bluetooth they stop when the app disconnects; over USB they stay on until turned off. |
set | refresh | { mac } | { view } | Asks one device to read its battery now and report: { mac } answers { mac, via, headset, known } at once (via: the tagger asked; a headset's MAC is asked through its tagger, which asks the headset over Bluetooth and reports within 1.5 s with or without its answer; known false: never heard, asked anyway). The fresh row arrives as a fleet/rows event with reason refresh, usually within a second (two for a headset); if none comes in 2 s (3 for a headset), ask once more, then show no answer. A device answers only a tagger in its non-zero group or game, at most once every 2 s, with requests in between folded into that answer. { view } asks every row in the view that would answer, 20 ms apart, and this tagger itself: answers { count }. |
fsm
The device's state machine — its current game / UI state. State transitions are also pushed as events.
| Method | Property | Value | Description |
|---|---|---|---|
get | state | — | Returns the current state name, e.g. HOME, IN_GAME, DEAD. |
set | state | state name | Force a state transition (advanced — most integrations only read this). |
wifi
WiFi used by the device for firmware updates.
| Method | Property | Value | Description |
|---|---|---|---|
set | credentials | { ssid, password } | Store the WiFi network the device should use for OTA updates. |
get | channel | — | Current radio channel. |
provisioning
Fleet grouping and firmware-update triggers.
| Method | Property | Value | Description |
|---|---|---|---|
set | group | group name (string) | Assign this device to a named update group. Group update triggers only affect devices in the same group. |
set | checkUpdate | — | Check this device for a firmware update AND broadcast the trigger to every device in the same group over the mesh. |
set | checkUpdateSelf | — | Check only this device for a firmware update (no mesh broadcast). |
Advanced
Lower-level namespaces used internally by the official app. Included for completeness.
| Method | Property | Value | Description |
|---|---|---|---|
get | device · battery | — | The battery now: { percent, pinMv, packMv, present, low, fault, read, avgMv, cells, cellMv, minutes, warn, warnMinutes, bench }. percent is on the Li-ion discharge curve (0% at 3.2 V a cell) from avgMv, a smoothed pack voltage; packMv is this reading's own. minutes is the estimated minutes of play left (null with no pack); warn is on under warnMinutes, or at low (3.2 V a cell); fault means under 2.8 V a cell: do not charge it. |
get | device · batteryWarnMinutes | — | The battery warning's threshold, in minutes of play left: 30 until set. |
set | device · batteryWarnMinutes | 0–600 | Sets it: the tagger's battery turns red, and its fleet reports say warn, when the estimate is under it (and its headset's the same). 0 warns only at the 3.2 V a cell floor. Saved and backed up on this tagger only; it is not sent to other taggers. Answers with the minutes. |
get | device · time | — | The tagger's clock: { set, unixSecs }. unixSecs is null until the clock has been set since boot: a tagger has no clock of its own that knows the date. |
set | device · time | { unixSecs } | Sets the clock, in seconds since 1970 (UTC), after 2025. The app and the scoreboard page send it whenever they connect; the latest set wins. A game a tagger hosts while its clock is set carries its start time (startedAt) and a gameUid made from it. Answers as the get does. |
get | device · bleName | — | The Bluetooth name: { name, isDefault, full, prefix, maxLength }. It is always "BattleCore TR: " and then the tagger's own part, its MAC until it is given one; phones find taggers by the "BattleCore" at the front. |
set | device · bleName | string | The tagger's own part of its Bluetooth name: up to 14 letters, numbers, spaces or plain punctuation (the scan response holds 29 bytes of name). Empty goes back to the MAC. Saved, and advertised at once; phones see it at their next scan. Answers as the get does. |
get | device · language | — | The tagger's language and wording: { language, wording, effectiveWording, languages, wordings }. language is the code in use (en, de, fr, es). wording is the tagger's own: standard, or family, which says tag and out where standard says kill and dead. effectiveWording is the one on its screen now, which is the host's during a game that sets one. languages lists { code, name } for every language on the tagger, English included, each name in its own language. wordings lists the wordings there are for the current language. |
set | device · language | language code (string) | Sets the tagger's language. Saved, and kept in the settings backup; the screen changes at once. A code the tagger has no language for is refused with invalidValue. Only this tagger changes: a language never goes to the other taggers in a game. Answers as the get does. |
set | device · wording | standard · family | Sets the tagger's own wording. Saved, and kept in the settings backup. A wording the current language has no words for shows that language's standard words. During a game whose wording setting is standard or family, that setting is on the screen instead; this one comes back when the game ends. Answers with device/language. |
set | device · appLanguage | { language, wording } | The phone app's own language and wording, which can differ from the tagger's. game/settings then answers its text in them. Not saved: the app sends it each time it connects. Answers true. |
set | device · operationMode | 0–3 | What the tagger boots into: 0 owner, 1 commercial (operator), 2 mesh test, 3 scoreboard. Takes effect at the next restart; spectator/active switches scoreboard mode at once. |
get | screenController · currentScreenName | — | The name of the UI screen currently shown. |
set | diagnostics · timing | boolean | Loop timing on or off. Off by default; while on, a diagnostics/timing event arrives once a second. A get of the same prop returns { enabled, last }. |
set | diagnostics · motion | boolean | Motion diagnostics on or off, for tuning the motion thresholds with a tagger in the hands. Off at boot; while on, a diagnostics/motion event arrives once a second. A get of the same prop returns the figures now. |
get | motion · config | — | The motion classifier's thresholds: { values, defaults }, each an object of every threshold by name. stepMinG (a step's jolt above 1 g), stepMinGapMs, stillAfterMs (no step for this long is still), runCadence (steps a second) and runJoltG (both reached is running), speedHoldMs (a new speed holds this long before it counts), startPairMs (two steps within this start moving), shakeG and shakeWindowMs, impactG, fallG and fallMinMs (a drop), pointDeg and pointHoldMs, turnDeg and turnWindowMs. |
set | motion · config | object | Sets the thresholds named ({ stepMinG: 0.3 }), or every one back to its default ({ reset: true }). All or nothing: a name it does not know, or a value outside that threshold's range, changes nothing and answers invalidValue saying why. Kept in the tagger's settings and its backup; answers with { values, defaults }. |
set | esp-bridge · enabled | boolean | Relay ESP-NOW mesh traffic over the BLE event channel. |
get | device · crashReport | — | Whether a core dump from a crash is waiting in flash, and what it says: present, sizeBytes, attempts, resetReason, task, pc, cause, corrupted, elfSha, backtrace. The device normally sends a dump to the server by itself on the next boot when it has WiFi credentials, then erases it; this is for the app to show what is pending. |
set | device · crashReport | {upload: true} | Reboot into the crash-report boot now and send the waiting dump whatever the automatic attempt count says (three boots per dump). {discard: true} erases it instead. {trailReport: true} sends the breadcrumb trail alone, the way a brownout or hardware watchdog reset does by itself; the get also reports trailPending. |
get | device · diag | — | The breadcrumb trail from the diag partition, oldest first: {count, records: [{seq, ms, kind, text}]}. Kinds: boot (with the reset reason), state, command, wifi, reset, note, game. A set with {note: "..."} adds a line of your own. The newest forty ride with every crash report. |
set | bench · crash | "hang" | "null" | "abort" | Crash on purpose, to prove the report path: hang stops the main loop so the task watchdog panics after three seconds, null dereferences a null pointer, abort calls abort(). Test units only. |
set | bench · battery | { mv } | { off: true } | A pack voltage in place of the ADC's, read at once as often as the low and fault flags need to settle; { mv: 0 } is no pack, { off: true } gives the ADC back. Answers with get device/battery. Test units only. |
set | bench · headsetBattery | { mac, percent, mv, packPresent, low, fault, read } | A headset's battery report, as if the linked headset had sent it (set headset/battery over its own link). Test units only. |
set | irSender · 1 / 2 | — | Fire IR emitter 1 or 2. |
Worked example
Read the player's current ammo, then halve it. Decoded form first, then the bytes of the first request as they leave CMD_IN:
// request -> CMD_IN
{ "kind": 1, "ns": "deviceState", "prop": "WEAPON_AMMO", "id": 1 }
// response <- CMD_OUT
{ "kind": 3, "ns": "deviceState", "prop": "WEAPON_AMMO", "value": 48, "id": 1 }
// request -> CMD_IN
{ "kind": 2, "ns": "deviceState", "prop": "WEAPON_AMMO", "value": 24, "id": 2 }
// response <- CMD_OUT
{ "kind": 3, "ns": "deviceState", "prop": "WEAPON_AMMO", "id": 2 }
// ...and because the value changed, an event arrives on EVENTS_OUT:
{ "kind": 5, "ns": "deviceState", "prop": "WEAPON_AMMO", "value": 24 }
// the first request on the wire: frame header, then a 4-pair map
C1 01 00 01 A4 00 01 01 0B 02 18 A8 04 01