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. |
players | { "ns": "players", "prop": "update", "value": { "playerId", "name", "teamId", "lives", "connected", "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. 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. Only arrives while that player is moving, and only when the game's radar is on — a player who stops moving 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. |
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.
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, and set the master volume.
| Method | Property | Value | Description |
|---|---|---|---|
set | startTrack | file path (string) | Play an audio file, e.g. "/audio/default/pickup.wav". |
set | masterVolume | 0–30 | Set the master volume. |
set | stopTrack | track number | Stop a playing track. |
get | isTrackPlaying | — | Returns whether audio is currently playing. |
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, kills, deaths, points. 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 configuration of the game currently loaded. Every field is also readable individually through deviceState, but those tell you what changed rather than what things are — this returns the whole set at once, which is what you need to decide what to display.
| 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, trackerSensitivity, gpsTransmit, gpsMinMoveM, scoreBased. |
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 | 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 | 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 | 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