Developer reference

Last verified

This is the interoperability spec for the BRX tagger and headset: transport, framing, every known command and event with its field map, the $WEAP / $GSET / $PSET / $SIR tables, the optical IR word, the USB console, and the headset link. Every command, token, field name and wire value on this page is literal.

Credit, first. The BRX serial protocol was discovered and proven by LaserTagMods (the JEDGE / JBOX / NRFL-Bases projects, github.com/LaserTagMods). Everything on this page is an independent, clean-room restatement. We verified it on our own taggers, decoded it from public app metadata, or captured it off the air. It contains no Battle Company code or assets. If you build on this, credit them too.

Ground rule. Stock BRX firmware is never modified. Every capability below is reached over the tagger's own serial protocol, and power-cycling always restores a tagger to normal operation.

Transport, framing and safety

One text protocol, three ways in. The BRX speaks a plain ASCII, comma-delimited command language on a hardware UART. Gen1 exposes it over Bluetooth Classic, Gen2/3 over BLE, and the community drives it from a wire. The frames are identical on all three.

Who found this. Protocol discovery for the BRX platform is the work of LaserTagMods (JEDGE / JBOX). This page restates their findings independently, with our own bench verification.

Transport by generation

GenerationLinkSpeedHow you connect
Gen1Bluetooth Classic (SPP)57600 baudPair an HC-05 module (PIN 0001, master role). The headset must be connected for Bluetooth to function.
Gen2/3BLE (Nordic UART Service, NUS)UART bridge at 115200 behind the radioConnect from any BLE central: laptop (bleak), ESP32, phone. No pairing/PIN.
AnyHardware UART inside the gun115200Not what JEDGE drives day to day. Jay's JEDGE 6.0 ESP32 source links to the gun over BLE, as a Nordic UART central, for Gen2/3, or over Bluetooth Classic SPP as master with PIN 0001 for Gen1; an HC-05 module on a UART at 9600 baud is a third option. Only his serial test sketch uses Serial1 directly. No external accessory port exists on the BRX. A wired tap means opening the gun. Untested by us.
AnyMicro-USB "Programing Port"USB CDC (baud ignored)Not the $ protocol. It is a separate QUERY/SETUP console. See the USB serial console section.

BLE: Nordic UART Service UUIDs

ItemValue
Service6E400001-B5A3-F393-E0A9-E50E24DCCA9E
RX characteristic (write to tagger)6E400002-B5A3-F393-E0A9-E50E24DCCA9E
TX characteristic (notify from tagger)6E400003-B5A3-F393-E0A9-E50E24DCCA9E
Advertised nameTactix-XXXX (the last two bytes of the BLE MAC). The NUS service UUID is present in the advertisement, so scan-time generation detection works.
ATT MTUNegotiates to 23 bytes. Chunk writes to ~20-byte payloads. This is required, not defensive.
Round trip$PING,* to $PONG,* is about 59 ms over BLE.

Generation detection heuristic. Power on the tagger and run a BLE scan. If it advertises the UART service it is Gen2/3. If nothing appears on BLE but the device pairs over Bluetooth Classic it is Gen1.

Frame anatomy

$ + COMMAND + (, + token)* + ,*

Waking a gun and holding the link

  1. Connect and subscribe to the TX notify characteristic. Establishing a link is intermittent (the official app behaves the same) and the link holds once it is up. Retry. That is the fix.
  2. On a fresh power-up, send $STOP,* then $PHONE,* (after a power-cycle $PHONE,* alone wakes it). A just-booted gun ignores a bare $VERSION,* until then.
  3. $PHONE,* opens the event tap: the gun says "phone connected", answers $BUT,3,0,*, streams button events and $VOLTS telemetry, and locks its on-gun menu until a game is configured or it is power-cycled.
  4. Idle taggers are silent: outside app mode no unsolicited messages are sent, so no button, trigger or hit traffic.
  5. The official app's connect ritual (captured, fw v4.32) is $STOP,*, $PLAYX,0,*, $VOL,69,0,*, $PLAY,VA20,3,6,,,,,* ("connection established"), then once per session $NAME,<name>,* plus $VERSION,*. It never sends $PHONE,*.
  6. The headset must be linked or the gun will connect, answer a quick $PING, then drop within seconds and echo nothing to config. After a gun-initiated $DISCONNECT,*, back off at least 5 s before reconnecting.

The safety model. Three layers, in order of what they protect:

  1. Firmware is never written, so a power-cycle always restores a tagger.
  2. A host refuses malformed frames and requires an explicit confirm for any command outside the known-safe list (below).
  3. The panic sequence $CLEAR,* then $SP,99,* silences and stops a gun.

The panic sequence leaves the gun with no $SIR table, so it cannot be hit until it is re-armed or power cycled. That is intended for a panic stop and must not be mistaken for a playable state. Battle Company's official USB updater is the factory-restore path.

mcp/brx_mcp/protocol.py enforces four buckets with three outcomes, not one flat known-safe list:

  1. KNOWN_COMMANDS. The instrument sends these with no confirm. Each row records the agreed token count when available, whether the effect is bench-proven on v4.32, and an evidence note. Examples include $STUN,<ms>, $BUMP in its 5-field shape, $TMP, $DIE, $TEAM, $PID, $IRTX and $RADSK.
  2. CONFIRM_REQUIRED_COMMANDS. Recognised names with version-conflicted evidence: $PRES, $INVU, $BHIT and $FIREX. They remain unsupported until the named bench checks settle their shape and effect.
  3. DENIED_COMMANDS. Refused even with confirm=true, each with a reason: factory restore ($FACTORY); DFU ($CDFU, $HEADDFU, $DDFU); pairing ($PAIR, $PIN, $CLEARDEVICE, $INQ, $GPAIR, $GPAIRX); the gun's own radio-module control frames ($!…, $^…, $&…); the IR word-format switch ($IRT); factory and hardware tests ($FTST, $BURN, $DUTY the laser duty cycle, $SOL, $MUZ); provisioning writes ($DTYPE, $DEV, $SITE, $TSTRNAME); $VIBTOGGLE; $ASKSN (prints the headset PIN); $RESET; the zombie headset family ($ZOM, $ZTOG, $ZON, $ZOFF, $BOOM, $ZombieKeyActive); and the USB console word SETUP. HANG_PRONE_COMMANDS is a separate list, today only $DPLAY: the send tool passes it only with confirm=true and allow_hang=true together, one frame at a time, so a supervised bench run can try to make a lock-up on demand. Both lists reach the phone app as NODE_DENIED_COMMANDS, and the phone refuses to write any of them.
  4. Anything else needs confirm=true.
PANIC_SEQUENCE = ["$CLEAR,*", "$SP,99,*"]

Is the baud rate real over BLE?

No. BLE has no baud. 115200 is the UART behind the radio bridge. That is why BLE and a wire speak identical frames.

Why does my client drop at ~6.6 s?

The link holds fine once up (80 s+ sessions with the official app, multi-minute sessions with ours). Establishment is intermittent; retry in a loop. If it dies within seconds and echoes nothing to config, the headset is not linked.

Can a command brick the gun?

Nothing in the protocol writes firmware. Every state written over BLE is wiped by a power-cycle (except $NAME, which persists).

Command reference

Every known command, with args and meaning: host to tagger, tagger to host, and headset. Copy the frames exactly as written.

These tables are a summary for readers. The full row for each command, with its evidence and every retraction, is in the protocol reference. Where the two disagree, the protocol reference is right, and a repo test fails until this page agrees with it. The protocol was first discovered and proven by LaserTagMods (JEDGE / JBOX).

How to read the tables. "Direction" is host to gun (>>) or gun to host (<<). Some commands were sent and received on hardware; others carry field names taken from the Callsign app's IL2CPP metadata (names certain, wire position = declaration order) or come from LaserTagMods sources and community captures, unreproduced on hardware. Each row says which. Commands the app knows that are untested on hardware sit in their own table at the end of this section. Where a command's field map has its own section ($WEAP, $GSET/$PSET, $SIR), the row says so.

Host to tagger: lifecycle and configuration

CommandDirArgsMeaning
$PING,*>>n/aConnectivity check. Reply $PONG,*.
$STOP,*>>n/aStop. First frame the official app sends on every (re)connect; also part of the end-of-game tail. Confirmed 2026-09-18: it blocks a hit's damage, not the hit itself (a hit while stopped still returns $HIR, with no $HP and no pool change), and it survives $SPAWN: only $START,* (plus a $GSET/$TID re-send) reopens it. Design rule: anything that sends $STOP must send $START before the next life.
$PHONE,*>>n/aApp-controlled mode: opens the live event tap (buttons, $VOLTS), locks the on-gun menu. Reply $BUT,3,0,*.
$CONNECT,* / $INIT,*>>n/aOn the known-safe list. Sent on v4.32: no observable reply.
$CLEAR,*>>n/aClear current game state. First frame of every arm sequence; half of the panic sequence. It also wipes the $SIR table, and because unmatched $SIR cells are silently ignored, a gun left with no rows ignores every hit while still reporting alive: no $HIR, no headset flash, pools untouched. Always re-send $SIR after $CLEAR.
$START,*>>n/aBegin the configuration sequence. Gun echoes $LCD,0,0,0,0,0,0,*.
$GSET,…,*>>8 tokensGlobal game settings: friendly fire, APK-named outdoorMode, region, ambient light, gyro, BT secondaries, crit modifier, mods. Token 2 is a measured receive gate, not the physical indoor/outdoor toggle. No respawn/time/lives token. See the $GSET and $PSET section.
$PSET,…,*>>id, team, HP, armor, shield, crit bonus, deathAlarm, voice-pack…Player settings: token 1 = player id (0-63), tokens 3-5 = HP/armor/shield pools, token 6 = crit damage bonus (Battle Company's sheet name), then a 17-sound voice pack at t7-t23. See the $GSET and $PSET section.
$WEAP,<slot>,…,*>>slot 0-5 + ~43 tokensDefine a weapon in a slot: damage, fire interval, fire mode, clip/reserve, reload, sounds, IR type. See the $WEAP section.
$SIR,<proto>,<subtype>,<sound>,<fn>,p5,p6,p7,p8,*>>8 tokensIncoming-IR effects matrix: what an IR word with protocol B / subtype U does to this gun. Unmatched cells are silently ignored. See the $SIR section.
$BMAP,<button>,<function>,<swap0..3>,*>>button id, function, 4 swap slotsRemap physical controls. Buttons: 0 trigger, 1 alt-fire, 2 reload handle, 3 select, 4 left, 5 right, 8 gyro. Functions seen: 0 fire, 97 reload, 98 (select/left/right), 100 weapon-cycle, 4 melee (gyro). Mandatory: without it the trigger only chirps "disabled". With one $WEAP slot loaded, function 100 has nothing to cycle to and falls back to reloading (bench 2026-09-17: "the alt button is reloading the charge rifle"). A single-weapon loadout therefore maps ALT to 98, the inert function, so alt-fire does nothing.
$TID,<team>,*>>teamTeam id. Use only 0-3. The gun masks the team into the 2 bits the IR word carries (team & 3), but the victim compares the full $TID value, so 4-7 are not harmless aliases: two teammates on tid 4 each transmit team 0 and each reads 0 against its own 4 as an enemy, so they damage each other and friendly fire cannot be turned off for them; their shots read as friendly to a real tid-0 player and take nothing off; and a gun can kill itself off a nearby reflective surface (bench 2026-09-07: a gun on tid 5 shot its own headset down through armor and HP, with $HIR naming the shooter's own player id). Values 4-7 are accepted and breathe the matching palette colour, which is why they look like extra teams. Drives the gun LED colour at $SPAWN (1 = blue, 2 = yellow observed) and is echoed as $HIR token 4 on the victim. A live write changes hit resolution immediately but does not repaint LEDs.
$SPAWN,,*>>one empty tokenGo-live and respawn. Restores HP/armor and (on respawn) ammo; echoes $LCD,<hp>,<armor>,0,0,<mag>,<reserve>,*. Also clears the $SIR fn-23 state ($ALCD token 2 back to 100). $SPAWN,* (no empty token) is not the same command. Leave at least 3 seconds after a death before respawning, or the headset stays stuck in the green out-blink.
$AMMO,<slot>,<mag>,<reserve>,<flag>,*>>slot, magazine, reserve, 1Load magazines. Must follow $SPAWN at initial go-live or the gun is live with no ammunition. e.g. $AMMO,0,36,108,1,*. A bare $WEAP re-push resets ammo to the frame's baked values. Re-send $AMMO after any weapon swap.
$PLAYX,0,*>>0Stop/clear sound playback. Sent right after $STOP on connect and just before the go-live cue.
$PLAY,<sound>,<vol>,<prio>,<announcer>,,,,*>>8 tokensPlay a sound id (2,477 on the gun; see the sound catalog for what each one is). An id the gun does not have plays a fallback sound, not silence. Two slots that behave differently, and the behaviour belongs to the slot, not to the kind of id (bench 2026-09-11, six trials): token 1 INTERRUPTS, cutting whatever is playing in either slot, mid-word if it has to, and token 4 QUEUES, waiting its turn behind whatever is ahead of it (depth 3 or more observed). An effect id dropped into token 4 queues exactly like a voice line. $PLAY,,4,6,V3A,,,,* speaks "kill" with token 1 empty; $PLAY,VSF,4,6,JAY,,,,* uses both in one frame, with no layering. A sound that must not step on an announcer line goes in token 4; a hit-path or urgent sound goes in token 1 and accepts that it can cut a line off. Tokens 2-3 are required: $PLAY,VA33,,,,,,,* is silent, $PLAY,VA33,4,6,,,,,* speaks. Numeric values vary by client (3,9 Android app, 3,6 iOS, 4,6 JEDGE). APK field names: soundName, addToQue1, addToQue2, loopingTime, stun, isNeedQueue.
$VOL,<0-100>,<n2>,*>>volume, 0Master volume. Android app sends $VOL,100,0,*; iOS $VOL,69,0,*. Levels to use: see Sound.
$NAME,<name>,*>>nameSets the gun's persistent name (the USB Gun Name field; survives power-cycle). Opening the official app rewrites it to Tactix2.
$VERSION,*>>n/aQuery firmware. Reply $VERSION,v4.32,?,4,,devhost.03,*. Token 2 is the headset firmware (hds.59) when a headset is linked.
$SP,<n>,*>>nEnd-of-game / stop. $SP,99,* is the second half of the panic sequence. Do not probe it mid-game hoping for a score. The gun keeps none. (LaserTagMods / community.)
$QUERY,*>>n/aOver BLE returns a $-framed status array ($QUERY,0,0,0,0,0,,1,0,,0,…) plus a $LCD. Not the USB device record (that is USB-only; see the USB serial console section). On a dead gun the $LCD half arrives at once and the rest of the body about 2 s later, with no trailing * (bench 2026-09-18), so a repeated poll should use $LIFE,* instead and keep $QUERY for a one-off correction.

Host to tagger: in-game effects, feedback and pools

CommandDirArgsMeaning
$SFLASH,*>>n/aThe shooter's green-sight kill-confirm flash. The host sends exactly one per kill the holder scores, ~0.4 s after the trigger burst ends. (The APK lists it under notifications; on the wire the phone sends it.)
$LIFE,<hp>,<armor>,<shields>,*>>addedHP, addedArmor, addedShieldsGrant or drain a pool. Additive, clamped at the pool max, never an absolute set, and it does self-emit $HP (bench 2026-09-09). It accepts negative values and drains: $LIFE,0,-5,0,* took armour 66 to 61. A negative is per pool and floors at 0 with no spill into the next pool. A negative that empties health really kills the gun, but announces it with $LCD,0,0,0,0,<mag>,<reserve>,* and never $HP,0,0,0. A bare $LIFE,* or an all-zero $LIFE,0,0,0,* is a read probe: it changes nothing and self-emits $HP regardless of whether the gun is alive or dead, so a dead gun answers $HP,0,0,0 at once (bench 2026-09-18). Poll gun state this way, not with $QUERY. ⚠️ A 4th token is a revive switch: any non-zero mode brings a dead gun back. $LIFE,30,0,0,1,* (bench 2026-09-18) on a dead gun returned $HP,30,0,0 and the gun took hits again at once. Keep a probe on mode 0 (or the token omitted); send a non-zero mode only when a revive is intended.
$BUMP,<amount>,<hp>,<armour>,<shield>,<sound>,*>>amount, then three pool flags (0/1) and a sound idConfirmed in full (bench 2026-09-18). A negative amount removes from armour first and overflows into HP once armour is empty; a positive amount heals HP first and overflows into armour once HP is full. Each of the hp/armour flags gates its own pool independently: $BUMP,-5,1,0,0,,* moves HP only, $BUMP,-5,0,1,0,,* moves armour only. With every pool flag 0 the gun does nothing at all and sends no $HP reply. Callsign's own shield recharge sends $BUMP,12,,1,,,* (armour flag only), adding 12 to the armour pool up to its maximum (capture, 2026-09-18). The shield flag and the sound token are confirmed too (bench 2026-09-18): the shield flag gates the shield pool independently, the same as hp/armour, and a negative amount drains it first and cascades into whichever other selected pool is open; the sound token plays the named id and stays silent when empty.
$BHIT,…,*>>version-conflictedThe available evidence disagrees about this command's shape. The instrument therefore requires confirm=true; levers §3 must settle the v4.32 shape on the bench before this is a supported host hit.
$HFIRE,…,*>>11 fields (names not recorded)Heavy or burst fire. The metadata read on 2026-09-04 gives 11 fields including a FlashLED flag; the four names this row used to list came from an earlier partial read. The real shape is untested. The five shapes tried on v4.32 were 4-field guesses and emitted zero IR, with a receiver control passing before and after.
$IRTX,<Direction>,<BulletType>,<PlayerId>,<ImmuneTeamColor>,<Damage>,<IsCriticalShot>,<PowerLevel>,<IrRange>,<ToggleIRLoop>,<TimeFireLoop>,<FlashLED>,*>>11 fieldsRaw IR transmit, named in Battle Company's own sheet. Direction 0 = front, 100 = all. Field 4 is not the shooter's team; BC's own note says the gun treats team 2 as the broadcast team. ToggleIRLoop: 0 = no loop, 1-99 = fire that many times, 100 = loop; TimeFireLoop = ms between pulses. The 2018 BC app sent $IRTX,0,14,<pid>,<team>,1,0,0,<range>,1,,1,* once a second as a revive beam. A sample from BC's sheet: $IRTX,100,15,63,0,6,1,0,100,100,5000,1,*. Bench-confirmed 2026-09-18 (levers §13/§16): the host gun forwards $IRTX out through its own headset and the headset emits the exact word, on a LIVE gun and on a DEAD one alike (a dying gun otherwise emits no IR of its own). The loop fields work as read: ToggleIRLoop = 100 with TimeFireLoop = 1000 made the other gun register the same $HIR once a second, 8 times, with no further host frame; ToggleIRLoop = 0 stopped it at once. A revive-beam-shaped word (protocol 14) registered on a gun armed with $SIR,14,0,NULL,34,,,,,* even while that gun was dead (fn 34 registers on a dead gun), and a follow-up $LIFE,30,0,0,1,* revived it to take hits again.
$MELEE,<intensity>,*>>intensityFires no IR. An early bench saw $BUT,4,0,* come back, but on 2026-09-24 $MELEE gave no reply at all, the same as an unknown command, so that reply was a coincidence. A physical swing is the melee path (see below).
$STUN,<ms>,*>>one token, millisecondsA real, native, SILENT stun (bench 2026-09-18). $STUN,6000,* blocked the trigger ($BUT,0,1 with no $ALCD, so no round left the gun) for about 6 s, then fired normally again. The gun plays no sound of its own while stunned, so a host that uses this must play its own cue; bench pick by ear is X17, matching Battle Company's own UART sheet entry for the concussion grenade. A bare $STUN,* (no duration) is a no-op, which is the earlier "no effect" reading.
$TMP,<t1>..<t11>,*>>up to 11 optional tokens, per-player modifiersConfirmed over BLE for t4, t8 and t9 (bench 2026-09-18), none of them touching the magazine unless noted. t4 sets live accuracy as an ABSOLUTE value (not cumulative), matching $ALCD token 2 at once and holding with no walk-back; a $WEAP push does not clear it. t8 reduces incoming damage: -50 took 4 off a 9-damage hit, -100 took 0 (spawn protection with no $SIR table needed). t9 tops the magazine up by clip x t9 / 100 RAW ROUNDS at once and raises the reload cap to clip + t9; sending the same t9 write twice adds again rather than replacing. An $AMMO set-mode write ($AMMO,<slot>,<n>,<reserve>,1,*) clamps back to the base clip and ignores a raised t9 cap, so re-assert t9 after any $AMMO set. $SPAWN zeroes every $TMP token (so a protection write must land AFTER $SPAWN, never before); $CLEAR also zeroes it. t4 is last-writer-wins against the gun's own $SIR fn 23 (smoke) effect, and fn 23's own ~6 s timer resets t4 to 0 regardless of the last write. Any host writer of t4 must never write while a smoke is active and must re-send its own value once the smoke's timer ends. t5 (fire interval, full-auto only), t6 (reload time) and t7 (outgoing damage) are confirmed too (bench 2026-09-18). t5 and t6 both scale their $WEAP interval by (100 + t) / 100: t5 slowed a full-auto AR from a round every 105 ms to about 205 ms at t5 = 100, with no effect measured on a shell-reload weapon; t6 scaled a reload from 1784 ms to 2756 ms at t6 = 50 (x1.55, close to the x1.5 prediction). t7 scales the barrel word's outgoing damage before it leaves the shooter's gun: a control 9-damage hit read 13 at t7 = 50.
$GLED,<led1>,<led2>,<led3>,<apply-gate>,<brightness>,,*>>three LED coloursGun LED colour, per LED. Tokens 1 to 3 are the three body LEDs, each a direct palette index. The palette is nine colours: 0 red, 1 blue, 2 yellow, 3 green, 4 purple, 5 teal, 6 white, 7 pink, 8 orange; 9 and 10 are dark. Token 4 is an apply gate, not an effect enum and not an off switch: it decides whether the colour tokens in the same frame take effect at all. Values 0, 6, 7, 8, 9 and 10 apply the colours at full brightness. Value 5 turns the LEDs OFF, whatever the colour tokens say. Values 1, 2, 3 and 4 are no-ops: the colour tokens are ignored and the gun keeps whatever it was already showing, which is why a sweep of this token reads differently depending on whether it blanks between rows. No token-4 value animates. $GLED,,,,5,,,*, the frame Callsign itself sends on death, blanks the gun because token 4 = 5 is the off value. Token 4 = 5 is therefore the one gate value never to send with real colour tokens: it discards them. Sending the blank once takes the strip out of the firmware's breathing loop for the rest of the life, and it is mandatory before any paint holds: a dark paint sent without a prior blank is simply overwritten by the breathing. It is idempotent, and after it every revert should be a dark PAINT ($GLED,9,9,9,0,10,,*) rather than another blank. An EMPTY colour token is RED, not "leave this LED alone". A blank field parses as 0, and 0 is red. Measured on the gun 2026-09-09 by a controlled test: with the strip held at three solid purple, $GLED,,9,,0,10,,* produced red, dark, red. Always write all three colour tokens, or the ones you leave empty turn red. There is no way to move one segment without restating the others. Token 5 is brightness and is the ONLY brightness control: 0 off, 1 dim, 2 and above full, saturating at 2 so that 2 through 255 are indistinguishable. It is global, applying to all three LEDs at once, so a bar of two bright segments and one dim one is not possible; a partial step has to be a blinking segment instead. Token 5 = 1 is what night mode uses. A held paint survives ordinary game traffic ($AMMO, $PLAY, $HLED, $LED) untouched, and keeps its hue at the dim setting. On a spawned gun the set colour alternates with the team colour, because a spawned gun is also using these LEDs as its own health gauge. Verified on the bench 2026-08-30: a gun held on team 1 took six different colours on command, and $GLED,3,2,1,0,10 was predicted and confirmed as green, yellow, blue. Palette completed on 2026-09-02 with a camera rig, and token 4 re-measured the same day from a known lit start, three trials per value; normalised R/G/B signatures, all three LEDs agreeing on every row: 0 = 1.00/0.16/0.26, 1 = 0.19/0.59/1.00, 2 = 0.88/1.00/0.59, 3 = 0.18/1.00/0.54, 4 = 0.59/0.49/1.00, 5 = 0.23/1.00/0.85, 6 = 0.73/0.79/1.00, 7 = 1.00/0.30/0.66, 8 = 1.00/0.38/0.30. The camera separates the indices from each other; it does not name absolute hues, so the reading rests on relative separation measured back to back under identical conditions, on three LEDs agreeing, and on a match with an independent community source. Token 4 = 5 is OFF, not dim: measured on a blanked, host-owned strip, alternating $GLED,3,3,3,0,10 against $GLED,3,3,3,5,10 four times, it is bright then off with no step in between. Token 5, A/B tested the same way, is the only control that dims. Do NOT repaint this at speed to hold a colour against the gun's own animation: it takes roughly 30 writes a second to win, and the result strobes. Flicker in the 10 to 25 Hz band is the photosensitive epilepsy trigger range. Signal an event with a short burst of three flashes instead.
$GREN,…,*>>iRType, crit, modifier, indoorMode, operationMode, channel, GrenadeType, MaxCountSmart Grenade configuration frame, addressed to the gun. GrenadeMode enum: FlashBang / Gas / Confusion / Molotov. Sent on the bench: the gun emitted IR, but the emitted bits did not track the arguments.
$PBGAME,$PBTEAM,$PBWEAP,$PBPERK,$PBLIVES,$PBTIME,$PBSPAWN,$PBINDOOR,$PBLOCK,$PBSTART>>enum indexThe "playbook" pre-battle family mirroring the on-gun menu. A second remote-start path captured on fw v4.30 ($PBGAME,0 = FFA, $PBWEAP,0 = M4 AUTO, $PBPERK,2 = Body Armor, $PBLIVES,2 = 5 lives, $PBTIME,5 = infinite). $PBWEAP,0,* produced a "game starting" reload sound on our v4.32. (Community.)
$DD,<killerId>,<killerTeam>,<victimId>,<nonce>,*host to hostn/aJEDGE's device-to-device kill notification. A host-side convention, not a tagger command. (Community.) Bench-confirmed 2026-09-18: in a hosted (BLE app-mode) game, the gun did not send $DD on a one-hit kill ($HP,0,0,0 then $LCD,0,… followed, with nothing in between); native mode is untested. Do not build a death detector on $DD from the gun.

Headset commands (host to gun to headset)

CommandArgs (APK)Meaning
$HLED,<colour>,<effect>,,,,,*LedColorType (White, Pink, Orange; plus green via isUsedGreenLed), BlinkLoopType (Once, ThreeTimes, Infinite), LedEffectType (incl. Heartbeat)Headset LED. $HLED,,6,,,,,* is sent in the app's end-of-game tail and in the lobby. Effect 6 is the blank, and sending it disables the firmware's own death-flash loop for the rest of that life (bench 2026-09-07). A colour write does not. Never send it during play: the flash it removes is the brightest signal a downed player has, and nothing reports that it is gone. Use colour index 9 for a dark headset in play instead. Token 1 is a colour index sharing the gun's palette for 0 to 7 (0 red, 1 blue, 2 yellow, 3 green, 4 purple, 5 teal, 6 white, 7 pink); the two devices diverge at 8, which reads red on the headset and orange on the gun. 9 and 10 are dark. Measured 2026-09-02 with the camera rig, all visible headset modules agreeing.
$HLOOP,<mode>,<period_ms>,*a, bDrives the headset's small flash LED as a repeating loop, at the firmware's own drive level. $HLOOP,2,750,* on a downed player flashes it at about the rate the stock down signal uses, and an operator judged it at least as bright as native. $HLOOP,0,0,* stops the loop and is what the app sends about 1.7 s after every death. $SPAWN also clears it. It restores the flash even on a life where an $HLED blank had suppressed it. Rates of 750 and 2000 ms both work; the usable ends of the range are not measured.
$BLINK,3,0,300,300,10,*Holds the headset's big LED solid green (bench 2026-09-04). The fields after the colour are not settled.
$LED,9,1,1,1,*One green flash on the headset's small flash LED; the big LED is left alone (bench 2026-09-04).

Tagger to host: events and echoes

MessageFieldsMeaning
$PONG,*n/aReply to $PING.
$VERSION,<gun fw>,<headset fw>,<n>,,<host image>,*e.g. v4.32,?,4,,devhost.03Version reply; token 2 reads hds.59 with a headset linked, ? otherwise. devhost.* = developer/host image.
$DISCONNECT,*n/aGun-initiated disconnect notice (e.g. the moment its headset is switched off).
$VOLTS,<pack_mV>,<cell_mV>,<t3>,<t4>,*e.g. 7662,3921,55,70Battery telemetry, about every 30 s in app mode. Token 1 = pack millivolts (7.662 V), token 2 = cell millivolts (3.921 V). Tokens 3-4: (unknown).
$LCD,<hp>,<armor>,<shield>,<weaponSlot>,<mag>,<reserve>,*e.g. 45,70,0,0,36,216Health/armor HUD echo. $START gives all zeros; $SPAWN gives pools plus the current weapon's ammo; death gives $LCD,0,0,0,<slot>,<mag>,<reserve>,* (bench 2026-09-18: $LCD,0,0,0,0,6,24). The 2018 BC app's parser names tokens 3 and 4 shield and weaponSlot. Token 3 is the live shield: after $LIFE,0,0,30, a $QUERY gave $LCD,45,70,30,0,36,216 (bench 2026-10-16). Bench 2026-10-17: token 4 is the slot of the last shot, not the selected slot. After an ALT with no shot, $QUERY,* still gave the old slot: $LCD,45,70,0,0,30,188. A zeroed $LCD after $SPAWN means "no config loaded" (post power-cycle tell).
$ALCD,<mag>,<t2>,<slot>,<reserve>,<heat>,*e.g. 36,100,0,108,0Ammo/weapon HUD stream. Per-round during fire and reload (mag 0, 1, 2 and up as reserve drains). Token 2 is live accuracy, one value per round fired: it starts each life at the weapon's $WEAP t21 ceiling, walks down toward the t22 floor under sustained fire, and resets to the ceiling on reload. ⚠️ The walk is not the same on every gun (2026-09-17, three guns, one frame): one gun walked to the floor in 9 to 16 rounds, repeatedly, and it is the gun the 2026-09-09 model was measured on; the other two took one or two steps and then held, through a re-arm, a power cycle, both indoor and outdoor modes, still and moving. The hit roll itself works on all three. Treat the walk as gun-dependent. The cause is unknown, and Open BRX frames set t21 = t22, so they never walk. See the $WEAP t21/t22 rows above. It also drops to 0 after a $SIR fn 23 hit and recovers over about 6-8 s (see the $SIR function map). Token 3 = weapon slot. Token 5 = weapon heat: raw, above 100 at lockout, and non-zero only on a weapon whose overheat is switched on by $WEAP t38. An Energy Rifle at t38 = 150 climbed about 3 per shot under full auto, stopped firing at 99 after about 30 rounds, and read 102 while locked (2026-09-17). Self-cooling is per weapon: the Charge Rifle cooled about 30 per second on its own, and the Energy Rifle did not fall at all while locked. Working the reload lever vents about 35 per pull. Only streams on ammo events. Silence is not "no change".
$HIR,<sensor>,<irProto>,<shooterId>,<shooterTeam>,<magnitude>,<crit>,<subtype>,*e.g. 4,0,19,2,9,0,3Hit received. See the events section for the full decode.
$HP,<hp>,<armor>,<shield>,*e.g. 43,0,0Pools after a hit; arrives in the same millisecond as its $HIR. $HP,0,0,0 = death.
$BUT,<id>,<state>,*id 0-5, or 8 for the gyro melee swing when $BMAP,8,… maps it; state 1 press / 0 releaseButton or gyro event (ids match $BMAP). Streams only in app mode.
$QUERY,…~11 value,, pairsStatus array in reply to BLE $QUERY,*.
$WEAP / $PERK / $HSn/aSelection echoes from the on-gun menus (LaserTagMods).

Seen in the app's vocabulary, not exercised by us. These are facts about the Callsign app's request namespace only. On-tagger behaviour has not been observed.

CommandDirectionAPK fields / enumsNote
$VIB>>isEnableVibrationUntested.
$ZOOM>>n/aUntested.
$FSET>>~38 event to sound slots: ActionKey, DeathAlarm, TickTock, HitHp, HitArmor, HitShield, HitCrit, EmpStart/Loop/End, IncendiaryStart/…, TearGasHit, …Untested.
$ASSIST>>soundName (+ SetVolume)Untested.
$DLC / $ASKDLC to $GOTDLC>> / <<hiddenFeaturesThe app's premium-content (BattleCoins) handshake. The metadata names three premium modes: Generals, Commanders, Swarm. Untested.
$CHASE>> (headset)n/aListed with the headset commands. No visible effect in any shape tried.
$TIME<<n/aListed as a notification. Never observed on the wire.

Complete vocabulary from the app's own request namespace. AMMO ASKDLC ASSIST BHIT BMAP BUMP CLEAR DLC FSET GLED GREN GSET HFIRE IRTX LIFE MELEE NAME PLAY PLAYX PSET SIR SPAWN START STOP STUN VERSION VIB VOL WEAP ZOOM · headset BLINK CHASE HLED HLOOP LED · notifications ALCD BUT GOTDLC HIR HP LCD SFLASH TIME VERSION VOLTS. $PING, $TID, $SP, and the $RV/$RP/$UR/$KK/$DD family are not in it. They come from LaserTagMods and our bench. Absence from the app does not mean non-existent.

The arm sequence: from $CLEAR to a live gun

The exact frame order that takes a tagger live, respawns it, and ends the game. Send these frames in this order. We captured the order from the official app and reproduced it with our own host.

The headset is a second device and it needs time. A command that the tagger must relay to the headset is received, processed and executed there, not instantly. Send $SPAWN within about 2 seconds of a death and the headset never executes it: it stays stuck flashing the green out-blink while the gun is alive and registering hits normally, so the player looks dead while playing normally. Measured 2026-09-02: gaps of 1.0 s and 2.0 s stick, and 2.5 s, 3.0 s and 6.0 s are clean. Leave at least 3 seconds. The same applies to anything else with a headset side effect, such as $HLOOP and $HLED. Note the gun queues commands and drains them one at a time, so a frame echoing back proves the gun received it, not that the headset executed it.

$CLEAR wipes the $SIR table and the gun then ignores every hit. This is the most confusing failure mode the gun has: the gun arms, spawns, reports full pools, answers $QUERY normally and looks perfectly healthy, while every shot that reaches it is discarded. There is no $HIR, the headset stays dark, and the pools never move, so it presents as a broken headset or a dead sensor. It is neither. The $SIR matrix decides what an incoming IR word does to this gun, unmatched cells are silently ignored, and after $CLEAR there are no cells at all. Re-sending the $SIR rows alone restores it immediately. Bench-proven 2026-09-02: deterministic 5/5, and independent of how long you wait between $CLEAR and $SPAWN (tested 0.05 s to 1.0 s). Note the table SIZE does not matter, only its absence: a one-row table and the full ten-row table both registered 24/24 in an interleaved A/B.

This sequence is the official iOS app driving a live game on firmware v4.32, reproduced byte-for-byte on real taggers. Three pieces are easy to leave out, and the gun spawns wrong without them: $AMMO after spawn, $BMAP before and after spawn, and the empty token in $SPAWN,,*.

$CLEAR,*
$START,*
$GSET,1,0,1,0,1,0,50,1,*
$PSET,0,0,45,70,70,50,,H44,JAD,V33,V3I,V3C,V3G,V3E,V37,H06,H55,H13,H21,H02,U15,W71,A10,*
$WEAP,0,...                (primary)
$WEAP,1,...                (secondary)
$WEAP,4,...                (melee, always sent)
$SIR,... x10               (incoming-IR effects table)
$BMAP,0,0,,,,,*   $BMAP,1,100,0,1,99,99,*   $BMAP,2,97,,,,,*
$BMAP,3,98,,,,,*  $BMAP,4,98,,,,,*          $BMAP,5,98,,,,,*
$BMAP,8,4,,,,,*
$TID,<team>,*              (ours; the app relies on defaults)
$PLAYX,0,*
$PLAY,VA81,4,6,,,,,*       (go-live voice cue)
$SPAWN,,*                  <-- go-live (note the empty token)
$AMMO,0,36,108,1,*         <-- load magazines
$AMMO,1,6,12,1,*
$BMAP,0,0,,,,,*            <-- trigger re-mapped AFTER spawn

$BMAP,1,100 only works with two weapons loaded. The sequence above sends $WEAP,0 and $WEAP,1, so ALT cycles between them. Send one $WEAP slot and function 100 has nothing to cycle to: the gun reloads instead (bench 2026-09-17). A single-weapon loadout maps ALT to 98, the inert function, so the button does nothing.

What the gun echoes back

AfterEchoMeaning
$START,*$LCD,0,0,0,0,0,0,*Cleared state
$SPAWN,,*$LCD,45,70,0,0,36,216,*Live: HP 45, armor 70, mag 36, reserve 216 (this config)
each shot$ALCD,35,100,0,108,0,* …Mag decrementing, slot 0, reserve, heat
each trigger$BUT,0,1,* / $BUT,0,0,*Press / release
~30 s$VOLTS,7634,3770,53,45,*Telemetry continues in-game

The config head is silent and safe. Writing $CLEAR through $TID without $SPAWN plays nothing, and a configured-but-unspawned gun ignores IR: no $HIR, no $HP. "Get some" and the cocking sound belong to $SPAWN. A head held unspawned for about 2 minutes then spawned went live with config intact.

Death and respawn are host-driven

  1. Victim reports $HP,0,0,0,* then $LCD,0,0,0,<slot>,<mag>,<reserve>,*. The gun does not revive itself, and a dead gun's trigger produces $BUT events but no $ALCD decrement (it cannot fire).
  2. The app sends $HLOOP,0,0,* about 1.7 s after death.
  3. After the game's respawn delay (the app's own timer, about 10 s in the capture; community reports a per-death ramp capping at 45/90 s) the host sends $SPAWN,,*.
  4. Gun echoes $LCD,45,70,0,0,36,216,*: HP, armor and ammo restored with no $AMMO needed.
  5. A dead gun in a hosted game ignores incoming IR: 448 distinct words, including every grenade-beacon shape, failed to revive one. Only the host can. The one exception is fn 34, which registers a $HIR on a dead gun and moves nothing (bench 2026-09-18). The 2018 BC app's revive beam is fn 34 on protocol 14, counted by the phone. Our 448-word test had no fn 34 or fn 35 row in the table.

After a BLE drop, re-send the whole head. Re-sending the full sequence ($CLEAR, $START, …, $SPAWN,,*, $AMMO) on a fresh link brought a gun back in every bench case.

# Clean end-of-game, as the official app does it (note the ~3.9 s settle before the last $PLAY):
$VOL,69,0,*
$HLED,,6,,,,,*
$STOP,*
$CLEAR,*
$PLAY,VSF,4,6,JAY,,,,*     # victory sting (slot 1) + "victory" announcer (slot 4); solo game: $PLAY,VS6,4,6,,,,,*

Do I need the reload-handle pull?

No. The manual's reload-handle pull is the local start; $SPAWN,,* is the remote one. Both exist.

Where do respawn time, game time, lives and score-to-win go?

Nowhere on the gun. Three captures at respawn 5/15/30 s gave byte-identical $GSET/$PSET. Your host keeps the clock.

Is the second $BMAP,0,0 (after $SPAWN) needed?

Yes. Omit it and the trigger is dead.

$WEAP: the weapon definition

Forty-odd comma-separated tokens make a weapon out of data: damage, cadence, fire mode, ammo, sounds, IR type. This section maps every token.

A weapon is data, not firmware. The gun has no weapons baked in; the host sends a full $WEAP frame into one of six slots (0-5) by Callsign convention, not a firmware limit: the 2018 BC app loaded weapons into slots 0-7, with melee in slot 7 ($BMAP,8,7). The field names come from the Callsign app's metadata (declaration order = wire order); the positions and meanings below were then pinned by capturing the 19 stock weapons (20 frames) with the operator naming each one, and by flipping single tokens on a live gun.

# Two known-good frames (slot 0 Assault Rifle, slot 1 Charge Rifle):
$WEAP,0,,100,0,0,24,0,,,,,,,,100,850,32,32768,1400,0,0,100,100,,0,,,R01,,,,D04,D03,D02,D18,,,,,32,9999999,75,,*
$WEAP,1,,100,8,0,150,0,,,,,,,,1250,850,100,32768,2500,0,14,100,100,,14,,,E03,C15,C17,,D30,D29,D37,A73,C19,C04,20,150,100,9999999,75,,*
# A captured Shotgun (slot 1): the only stock weapon with the extra-headset block populated:
$WEAP,1,2,100,0,0,45,0,,,,,,70,80,900,850,6,24,400,2,7,100,100,,0,,,T01,,,,D01,D28,D27,D18,,,,,6,12,75,30,*

Token map (0-indexed: $WEAP,<t0>,<t1>,…,*; the slot is t0)

tokFieldARCharge RifleMeaning
0slot01Weapon slot 0-5. Slot 4 = melee by convention (gyro swing, $BMAP,8,4).
1slotType / mode flag (BC sheet: WeaponIRSource)n/an/a2 on exactly the three weapons carrying an extra-headset payload (t12/t13/t42 populated); 1 on melee; 0 rail gun; empty otherwise. Not fire mode. Battle Company's own sheet names this field WeaponIRSource, an enum of 0 GunLaser, 1 HeadSetOnly, 2 GunAndHead, 3 DoubleGun, 4 DoubleGunAndHead, 5 DRY_FIRE. Every stock melee row ships 1 (HeadSetOnly).
2gunRangeOutdoor100100The outdoor range lever (bench 2026-09-17, garden ladder, two guns in outdoor mode). Every captured gun reads 100 here (melee 90). A value of 5 landed 0 of 38 shots, even muzzle to dome. Values from 13 to 26 are a transition band. From about 31 to 100 is a flat shelf where every value behaved alike at every distance the garden could pace, so differences we ship above the shelf are probably not felt. This corrects the earlier reading that named t41 as the range lever. Open BRX ships a per-weapon starting value outdoors from this token (docs/weapon-design.md §4.2) and never compiles a value under 13. A firmware code reading of how the token works is in the protocol file, §6.
3primaryDamageType08The IR word's B field / $SIR protocol key. Writing a type here is echoed by the victim in $HIR token 2 and selects its $SIR row. DamageType enum: 0 Standard, 1 MedicHeal, 2 ActivateShield, 3 RallyPulse, 4 Radiation, 5 Cryogenic, 6 ArmorPiercing, 7 EMP, 8 Shrapnel, 9 StickyBomb, 10 StandardLethalExplosive, 11 NonLethalExplosive, 12 ShottyPellets, 13 MeleeDamage, 14 Plasma. Stock: 8 charge, 10 rocket, 11 gas, 13 melee. The wire position is bench-proven; the enum names come from the APK.
4primaryPowerType00IRSource enum: DeviceCommand, IRSource, GunLaser, HeadSetOnly, GunAndHead, DoubleGun, DoubleGunAndHead, DRY_FIRE, MuzzleFlash, MuzOnly, VibOnly, MuzAndVib. Order relative to t3 was settled by t3 behaving as damageType.
5primaryDamage24150The raw magnitude put in the IR word (= $HIR token 5). Applied damage depends on the victim's $SIR row. The stock AR emits 9. The 24 in the sample frame comes from Battle Company's own "Boss WEAP Modified" sheet, which boosts enemy stats; it is not a stale manual figure or an error.
6primaryCriticalChance00Crit chance in percent, rolled by the gun (bench 2026-09-18). t6 = 20 gave 9 crits in 64 hits (14.1%); t6 = 50 gave 54 in 119 (45.4%). A crit lands the magnitude x1.5, truncated (9 becomes 13), and the victim's $HIR token 6 reads 1 on it (0 on a normal hit). 0 on every stock weapon. The $GSET t7 scaling is a separate axis: it rides fn 36/37 by SENSOR.
7-11secondaryFireChance, secondaryDamageType, secondaryPowerType, secondaryDamage, secondaryCriticalChancen/an/aDormant: empty on all 20 captured stock frames. No stock BRX weapon has a secondary fire mode.
12extraHeadsetDamagen/an/aPopulated with t1=2: Shotgun 70, Rocket 115, Plasma Sniper 80.
13extraHeadsetRangeOutdoorn/an/a80 on the same three weapons.
14fire interval / charge time (ms)1001250Proven by one-field flip: a sniper with t14=1250 slowed to one shot every 1.25 s (timed by ear as roughly one per second). Stock cadences read from the captured frames: burst 75, SMG 90, AR 100, sniper 300, AMR 360, launcher 360, shotgun 900, melee 1000, rail gun 1200, charge rifle 1250. For charge weapons this is the hold time.
15weaponSwapDelay850850Weapon-swap delay in ms: how long after an ALT (weapon-cycle) press the gun refuses to fire while it draws the other weapon. Bench 2026-09-04: 1700 doubled the swap, 425 halved it, 100 ran at 100; linear with no floor. The gun uses the larger of the two loaded slots' values in both directions, so a fast pistol paired with a rifle draws at the rifle's speed. Melee ships 100.
16maxClip32100Magazine size.
17maxAmmo3276832768The reserve the player carries (corrected 2026-09-18). Always 2 × t40 in captured frames (or 32768 as an unlimited flag). With a compiled AR frame (t17 192, t40 96) the gun reports 96 after $WEAP, then 192 after $SPAWN and after the $AMMO restore.
18reloadSpeed (ms)14002500Reload time.
19ReloadType00Battle Company's own sheet names this field ReloadType, an enum of 0 Magazine, 1 Quiver, 2 Shells, 3 SingleBolt, 4 BoltWithMagazine, 5 AutoReload, 6 NoReloadRequired, 10 bottomless magazine (melee ships 10). Untested on our bench, both stock frames read 0.
20fire mode (BC sheet: GunWeaponType)014Proven by one-field flip: 0 full-auto, 7 single-shot/bolt, 9 burst (cycle in t23), 2 charge, auto-release (tap = weak shot), 3 hold-to-charge, auto-fire (tap = sound only), 14 tap-fire OR charge-release, 13 melee. Battle Company's own sheet names this field GunWeaponType, a fuller enum: 0 FullAutoFire, 1 Bow, 2 ChargeAndAutoRelease, 3 ChargeAndRelease, 4 TapChargeAndRelease, 5 ChargeAndLooping, 6 Passive, 7-12 Burst1-Burst6, 13 MeleeWeaponSlot, 14 PlasmaPistol. This agrees with our bench: 7 = one round per pull, 9 = three rounds per pull.
21maxAccuracy100100The accuracy ceiling. $ALCD token 2 (live accuracy) starts each life here and never rises above it. Proven by one-field flip: t21=0 read 0 on $ALCD token 2 at arm time, before a shot was fired. The ceiling applies on every gun tested (2026-09-17: a gun armed at 50/50 landed 12 of 32 shots, about 38%).
22singleShotAccuracy100100The accuracy floor. Under sustained fire $ALCD token 2 walks down from the t21 ceiling toward this value and holds there; it does not go lower. Proven by one-field flip: t22=50 walked down and held at exactly 50, t22=0 walked to 0. ⚠️ Measured on one gun. Two other guns on the same firmware and frame took one or two steps and then held (2026-09-17, F230), so a floor below the ceiling does much less on them. Stock ships t21 = t22 = 100 on every weapon, which makes the ceiling equal the floor and disables the model.
23burstWeaponTime (ms)n/an/aBurst cycle: 275 Burst Rifle, 250 Force Rifle, empty on everything else.
24overheat (heat per shot)014SMG 5, Energy Rifle 6, Charge Rifle 14, Plasma Sniper 30. Inert unless t38 is set.
25MuzzleFlashn/an/aBattle Company's own sheet names this field MuzzleFlash. Untested on our bench.
26Volumen/an/aBattle Company's own sheet names this field Volume; it is the Suppressor's second populated field, alongside t25. Untested on our bench.
27primaryFire_SoundNameR01E03Fire sound id. A sound, not an identity. Rocket Launcher and Rail Gun both fire C03 with different stat lines.
28extra action sound An/aC15Engage sound. C… ids = charge (rail gun C08, laser cannon C11, charge rifle C15); D… ids = extra reload parts on five-part reloads (sniper/AMR/force rifle D20, ion sniper D32).
29extra action sound Bn/aC17Release sound. Present exactly when the weapon has a distinct release event (charge rifle C17, bolt weapons D19/D31); absent on auto-firing chargers.
30secondary_Mix_SoundNamen/an/a
31reloadPart1_SoundNameD04D30
32reloadPart2_SoundNameD03D29
33reloadPart3_SoundNameD02D37
34noAmmo_SoundNameD18A73
35weaponFeatureAn/aC19Overheat sound. The captured ids are SMG D11, Charge Rifle C19, Plasma Sniper and Energy Rifle D122, and they are not all the same cue: heard on a real lockout (2026-09-17), D11 is the overheat sound, C19 is the charge weapon's early-release cue and D122 sounds like a reload. Open BRX gives the Energy Rifle D11. Battle Company's own sheet lists weaponFeatureA/B as sound ids.
36weaponFeatureBn/aC04Second feature sound.
37charge tap damagen/a20The damage a charge weapon's tap lands (bench 2026-09-17). t37 = 30 made every tap land 30 while the full charge still landed t5. It is not a heat token: the older reading, that t37 and t38 were an enable pair, is retracted. Battle Company's own sheet names t37/t38 HeadsetDirection/HeadsetRepeat; the bench result stands and the disagreement is open.
38overheat enablen/a150t38 alone switches the overheat system on (bench 2026-09-17). An Energy Rifle frame with only t38 = 150 added locked out at heat 99 after about 30 full-auto rounds, from a frame that had never overheated. The heat gauge then streams in $ALCD token 5.
39clipStartingAmmo32100Equals t16 in every captured frame.
40ammoReserv99999999999999What a bare $WEAP leaves in the reserve; 9999999 = unlimited. t17 == 2 × t40 in stock frames. The gun's $ALCD reserve reads t40 only in the ~200 ms between $WEAP and $SPAWN; $SPAWN sets it to t17 (bench 2026-09-18). A mid-match $WEAP re-push resets the reserve to t40, so send an $AMMO restore with it.
41gunRangeIndoor7575APK name gunRangeIndoor. It reads 75 on all eighteen guns and 20 on melee. Corrected 2026-09-17: it does nothing outdoors (two slots differing only here, 5 vs 75, scored 27/27 vs 55/57 at every paced distance from 3 m to about 200 ft). Indoor behaviour of this token is untested; $GSET token 2 does not select an emitted-range profile either, and is a different command (see the $GSET table).
42extraHeadsetRangeIndoorn/an/aAPK name extraHeadsetRangeIndoor. It is 30/30/40 on the three t1=2 weapons and blank elsewhere. Its effect is untested.

Two positions to get right.

  1. The metadata's field order has rateOfFire before weaponSwapDelay, but the wire has the rate at t14 and the constant 850 at t15. A compiler that trusts the field order ships every weapon at 10 shots/s.
  2. Keying weapons by their fire sound (t27) silently merges distinct weapons. The Rocket Launcher and the Rail Gun both fire C03.

Stock weapon signatures (fire sound to weapon to behaviour, as named by the operator at capture)

t27WeaponFire mode / notes
R01Assault Riflefull auto
R18Burst Rifle3-round burst (t20=9, t23=275)
R23Force Rifle3-round burst (t23=250), five-part reload
R12Bolt Riflesingle shot; carries no t28/t29
G03SMGfull auto, overheat mechanic (t24=5)
Q06Suppressorfull auto, quiet, no muzzle flash (t25=2, t26=50)
E11Stingerfull auto
E12Energy Riflefull auto, overheat (t24=6), 300-round clip
S16Snipersingle shot, bolt action (t28/t29 = D20/D19)
S07AMRsingle shot, same bolt pair
E07Ion Snipersingle shot, 2-round clip (D32/D31)
E17Plasma Snipersingle shot, overheat (t24=30)
T01Shotgunsingle shot (t20=7), t19=2, extra-headset block, magnitude 45
J15Energy Launcherclip 1, t40 3, so t17 6 (the player carries t17)
C03Rail Guncharges on hold, auto-fires ~1.2 s (t20=2, t28=C08)
C03Rocket Launchersingle shot, protocol 10, magnitude 115
C06Laser Cannonmust be held (t20=3, t28=C11)
E03Charge Riflehold, fires on release; overheats (t20=14)
M92Meleegyro swing, protocol 13, magnitude 90

Overheat is a balance lever on any weapon. Set t38 to switch the mechanism on, t24 for the heat per shot and t35 for the overheat sound, and the live heat gauge streams in $ALCD token 5. Measured on 2026-09-17: an Energy Rifle at t38 = 150 climbed about 3 per shot, locked the trigger out at heat 99 after about 30 rounds and read 102 while locked. Cooling is per weapon, so measure it. The Charge Rifle cooled about 30 per second by itself and restarted from 0 after a 4.8 s lockout. The Energy Rifle did not cool at all while locked: only the reload lever vented it, about 35 per pull, and the same held pull recharged the cell. A HUD heat bar needs no new protocol.

Accuracy is a live per-shot value, not a static stat. $ALCD token 2 is a hit probability, not a threshold: a shot fired below the ceiling can still emit IR at magnitude 0, which is the miss the manual describes elsewhere. It starts each life at t21 and drops under sustained fire in five steps of one fifth of the t21-to-t22 range, until it reaches t22 and holds. The steps do not land one per shot: the two walks below reached their floor on shot 8 and shot 11. It resets to t21 on reload. A native time-based recovery races the drop, so the fire interval decides how hard the model bites: single shots about 2 s apart at t22=0 held a flat 80 and never went lower, while a held trigger reached 0 in eight rounds. The recovery rate itself is not yet measured. Two full-magazine walks on the Assault Rifle (t14=100 ms, trigger held, t21=100):

  • t22=100 (stock): token 2 held at 100 for all 32 rounds. No drift at all.
  • t22=0: 80, 60, 40, 40, 40, 20, 20, 0, then 0 for the rest of the magazine.
  • t22=50: 90, 90, 90, 90, 90, 80, 70, 70, 70, 60, 50, then 50 for the remaining 22 rounds.

Each run fell in five steps, and each step is one fifth of that weapon's ceiling-to-floor range: steps of 20 when the floor is 0, steps of 10 when it is 50. The number of shots differed (8 and 11), so how many rounds it takes to reach the floor is not characterised from two runs. Stock ships t21 = t22 = 100 on every weapon, which is why every capture ever taken of this model showed it inert. The exact hit probability at a given accuracy value is not calibrated; do not treat it as a percentage.

Can I build a semi-auto rifle?

Yes. Set $WEAP t20 to 7: one shot per trigger pull. The Shotgun and the Rocket Launcher both ship that way.

What bounds a custom weapon?

The firmware's behaviour vocabulary: the DamageType and PowerType enums, ReloadType, six slots, and the 2,477 on-gun sound ids. Any combination with arbitrary numbers is buildable; a brand-new damage behaviour is not.

Does a $WEAP re-push mid-game keep the ammo count?

No. It resets mag/reserve to the frame's values. Re-send $AMMO.

$GSET and $PSET: game and player settings

These two frames set the on-gun rules and the player's pools, identity and voice pack.

$GSET,<t1>,…,<t8>,*, validated against the captured $GSET,0,0,1,0,1,0,50,1,*

#FieldCapturedMeaning
1friendlyFire0 / 1Firmware-enforced, both directions. 0 blocks same-team damage and heals from enemies; 1 opens the gate. Replicated twice with alternating values plus control (one 2026-09-07 bench run registered team hits the other way round and is still unexplained; treat the rule as documented, not proven).
2outdoorMode0APK field name only. This is not the physical ALT-hold toggle and it does not change emitted range. It gates reception on the gun receiving a shot: at 30 ft, t2=1 registered 0 hits from a full clip and worked only from inches; t2=0 registered 16 of 27 shots and then every aimed shot under the same field conditions. Open BRX pins it to 0 at both venues, including try-outs and utility paths. What t2=1 physically changes, whether it affects every sensor equally, and whether it rejects indoor reflections are untested. The physical ALT toggle separately widened aim tolerance by roughly 2x on three guns; native shots reached about 200 ft in both toggle states. See Operating the BRX.
3gunLaserRegion1APK name only. Its effect is untested on the bench.
4autoAmbientLight0APK field name. The user guide describes a sunlight IR-noise filter; whether this field is that control is unmapped. Not exercised on the bench.
5gyroscope1APK field name; not exercised on the bench.
6secondaryBluetoothWeapons0APK field name; not exercised on the bench.
7criticalShotModifier50APK field name. Not score-to-win (byte-identical across captures with different win conditions).
8gameMods1APK field name; not exercised on the bench.

There is no respawn, time, lives or score token. Three captures at respawn 5/15/30 s and different clocks produced byte-identical $GSET and $PSET, and the 8-field map from the app metadata contains none of them. Those live in the host. Stop looking.

$PSET,<t1>,…,*, sample $PSET,6,0,45,70,70,50,,H44,JAD,V33,V3I,V3C,V3G,V3E,V37,H06,H55,H13,H21,H02,U15,W71,A10,*

tokFieldSampleMeaning
1player id60-based, 0-63 (6 bits). The app's UI shows 1-64 and writes id−1 (app 7 gives wire 6, app 64 gives 63, an out-of-range 69 clamps to 63). Ends up in every IR shot's P field and comes back as $HIR token 3 on whoever you hit.
2team0The gun keeps ONE team byte, and $TID, $TEAM and this token all write it: the last one sent wins (V4_31 disassembly; confirmed at the wire level 2026-09-18). Our earlier reading, "0/1/7 gave byte-identical behaviour, inert", was measured with $TID sent after the $PSET, so $TID was the last writer in every trial and this token's own effect never showed. A $PSET re-send with this token at 0 therefore silently resets the gun's team to 0 until $TID is sent again, which was the root cause of F206 (team games registering no hits). Send the real team in this token, or re-assert $TID after every $PSET.
3HP45Starting/max HP. Echoed as $LCD token 1 after $SPAWN.
4armor70Armor pool ($LCD token 2, $HP token 2).
5shield70Shield maximum, not a starting value: the pool starts at 0. It fills from an IR $SIR grant function (or armor overflow), and over BLE from $LIFE,0,0,<n>,*, which adds to the pool and takes negatives (bench 2026-09-11, measured with no shield row in the $SIR table). Both saturate at this token.
6CriticalDamageBonus50Field name from Battle Company's own sheet. Shipped as 50. The bench measured a crit as x1.5 with $GSET t7 at 0 (2026-09-18). The 2026-09-07 sweep of this token had no crits in it, so it could not see a crit effect.
7deathAlarm(empty)Empty in every Callsign capture; the 2018 BC app sends NA0 here. This is the first slot of a seventeen-sound voice pack that runs t7-t23, not sixteen from t8: Battle Company's own sheet names 17 fields at t7-t23.
8-23positional voice packH44 JAD V33 V3I V3C V3G V3E V37 H06 H55 H13 H21 H02 U15 W71 A10The remaining sixteen ids of the voice pack. See the table below for the full seventeen-slot map.

The voice pack, slot by slot. The alignment was confirmed on the bench on 2026-09-07: a voice line placed in the hitShield position was heard on a shield hit, and an intermediate "the slots are swapped" reading was retracted after a control showed the sound had not moved. That bench result was measured on the token positions below, which is why the table still holds: the ids on the wire are unchanged, only the field name bound to each token is corrected here, from Battle Company's own sheet, which names seventeen fields for seventeen wire slots (t7-t23), not sixteen from t8.

TokenField nameCaptured id
7deathAlarm(empty)
8stealthDeathScreamH44
9musicMixOnDeathJAD
10deathScreamV33
11battleRespawnCryV3I
12meleeGruntV3C
13shortPainV3G
14longPainV3E
15painReliefV37
16missShotHitH06
17hitHpH55
18hitArmorH13
19hitShieldH21
20hitCritH02
21emptyUnboundButtonSoundU15
22ammoOrGearPickUpW71
23energyShieldLoopA10

Our own frame builder (gameconfig._PSET_HEAD) has always sent ids in these positions, and two of them are ear-confirmed against this corrected map: H06 at t16 is the near-miss (bench 2026-09-09), and the hitCrit slot at t20 (bench 2026-09-11).

Three more things about this tail, all measured the same day:

Numbering a fleet is one token. Give every gun a distinct $PSET token 1 at arm time and per-player kill attribution is BLE-native: no cable, no IR receiver. Show operators 1-based ids; write id − 1.

$SIR: the incoming-IR effects matrix

What an IR hit does to a gun is decided by the victim's table, not by the shooter's weapon. This section shows how to write that table.

The key idea. An incoming IR word carries a 4-bit protocol (B) and a 2-bit subtype (U). The victim looks up the $SIR row with that <protocol, subtype> key; the row's function decides what the word's 8-bit magnitude is applied to: damage, heal, armor, shield, or a status. No matching row means the hit is silently ignored. The same is true of a wrongly-teamed shot: damage applies only from an enemy team and support only from your own, and a rejected frame emits no $HIR at all. It never reaches BLE. 16 × 4 = 64 addressable cells, all writable per game over BLE.

ItemValue
Format$SIR,<irProtocol>,<subtype>,<soundID>,<function>,<p5>,<p6>,<p7>,<p8>,*
<soundID>Plays on the victim when the row fires (VA16 "armor suit", VA8C "shields online", H29 stim-pack).
<p5> to <p8>Do not scale damage (0,0,1, 0,50,1, 0,100,2,60, 0,200,2,60, 50,100,2,60 all landed exactly the magnitude).
Max distinct IR recognitions per game14 (community figure).

Battle Company's own sheet names p5-p8: p5 Modifier, p6 RangeOutdoor, p7 Random, p8 RangeIndoor. Our own five-shape probe found p5-p8 do not scale damage.

# The stock 10-row table the official app sends (Team Arena):
$SIR,0,0,,1,0,0,1,,*        standard weapons (AR, SMG, snipers, shotgun…): plain damage
$SIR,0,1,,36,0,0,1,,*       Force Rifle / Sniper Rifle: fn 36 (floor of magnitude ×1.25)
$SIR,0,3,,37,0,0,1,,*       AMR / Bolt Rifle / Burst Rifle: fn 37 (magnitude ×2)
$SIR,1,0,H29,10,0,0,1,,*    respawn + add HP
$SIR,2,1,VA8C,11,0,0,1,,*   add shields
$SIR,3,0,VA16,13,0,0,1,,*   add armor
$SIR,6,0,H02,1,0,90,1,40,*  Rail Gun
$SIR,8,0,,38,0,0,1,,*       Charge Rifle (fn 38 HALVES the magnitude, F225 -- MC re-keys this cell to fn 1)
$SIR,9,3,,24,10,0,,,*       Energy Launcher (fn 24: a phantom hit generator, see the function map)
$SIR,10,0,X13,1,0,100,2,60,* Rocket Launcher
$SIR,11,0,VA2,28,0,0,1,,*   Tear gas
$SIR,13,0,H50,… / 13,1,H57 / 13,3,H49   Energy Blade / Rifle Bash / War Hammer (melee)

Function map. Measured at magnitude 20, baseline HP 45 / armor 70 / shield 0, at the gun-body sensor ($HIR tok1 = 4) from about 40 cm. Protocol independence is measured for 10 of the 41 functions (fn 1, 3, 8, 23, 24, 25, 26, 27, 28, 35). Those ran on the enemy team at subtype 0 only, across protocols 0, 5, 7, 9 and 10. That is 50 cells, none of which varied. Applying the result to the grant and ally functions is an extrapolation, not a measurement. The two multiplier functions, 36 and 37, were measured again on 2026-09-02 across magnitudes 20, 40, 9 and 7 and across 8 different $SIR row-tail shapes, 16 trials, each with an fn 1 control that had to read the magnitude exactly. The row tail does not change the multiplier.

The headset-dome question is now answered (bench 2026-09-11). Fn 36 and 37 only scale on the headset dome (sensor 0 measured; sensors 1-3 untested): the gun body applies the raw magnitude for every function, fn 36/37 included. The headset scale is a function of the $GSET critical shot modifier (t7): fn 36 lands floor(magnitude times (1 plus t7/200)), fn 37 lands floor(magnitude times (1 plus 2 times t7/100)). At t7 equal 50, that is the x1.25 and x2 read on 2026-09-02, so both earlier readings were correct at once; the 2026-08-27 run that read x1.0 was pinned to the gun body the whole time. A t7 equal 0 closing control read fn 37 back to a plain x1 on the headset, confirming t7 as the driver.

ClassFunction idsMeasured behaviourPolarity
Standard damage1, 3, 4, 5, 7, 29, 30, 33−20 per hit, drains shields, then armor, then HPenemy only
Armor-piercing2, 6 (+17, 21 enemy-side)HP 45 to 25 to 5 with armor and shields untouchedenemy only
×1.25 damage on the headset, ×1 on the gun body36Gun body: always ×1 (magnitude 20 lands as 20). Headset at the captured t7=50: magnitude 20 lands as 25, 40 as 50, 9 as 11, 7 as 8. The result is the floor: 7 × 1.25 = 8.75 lands as 8, not 9enemy only
×2 damage on the headset, ×1 on the gun body37Gun body: always ×1 (magnitude 20 lands as 20). Headset at the captured t7=50: magnitude 20 lands as 40, 40 as 80, 9 as 18, 7 as 14enemy only
×0.5 damage38Bench 2026-09-17 (F225), headset front dome, gun sensor covered: a Charge Rifle charge of magnitude 100 landed 50, a tap of magnitude 20 landed 10, at $GSET t7 0 and 50 alike. Not one of the 10 protocol-independence-tested functions and not re-checked on the gun-body sensor, so its earlier place in the Standard damage row above was an assumption, not a measurement; MC no longer keys any weapon to it (_SIR_TABLE's <8,0> cell moved to fn 1)enemy only, headset-measured
Add HP, overflow to armor9, 12, 16, 1915 to 35 to 45, then +armorally only (16/19 also damage enemies)
Add HP, clamp10, 1715 to 35 to 45, no overflowally only (17 also AP-damages enemies)
Add HP, overflow to shield14, 2115 to 35 to 45, then +shieldally only
Add armor13, 15, 20, 220 to 20 to 40; overflow spills to shieldsally only (20 also strips enemy armor)
Add shield11, 180 to 20 to 40ally only
$ALCD token-2 drop23Registers a hit, no pool change; $ALCD token 2 drops 100 to 0 and recovers over about 6-8 s while the gun keeps firing. The state clears on $SPAWN,,*.enemy
Registers, no pool changeenemy 8, 28, 35 · ally 31, 32, 34$HIR fires and no pool changes at the cell itself. fn 28 registers with no sound, no headset flash and no vibration (bench 2026-09-10), so a host can read an IR event the player never perceives. fn 8 is silent but still flashes and vibrates. The enemy functions are identical on protocols 0/5/7/9/10, fn 28 on protocol 5 included. The ally functions are not protocol tested. The reading is scoped, not general. The victim is at full health for these trials (HP 45, armour 70), and a heal or armour grant into a full pool is clamped, so it reads as no change: fn 10 is respawn plus add HP and belongs in that class, not this one. The enemy functions here moved no pool with 150 shield available, so for them the reading is real. fn 3 drains shield exactly as plain damage does and is classed as damage.n/a
Phantom hit generator24, 25, 26, 27No damage, then an endless fake hit (bench 2026-09-18, two guns, single hand-aimed shots). The shot registers on the struck sensor with the pools unchanged. From about 5 s later the victim's gun raises a $HIR on sensor 0 every 5.07 s, with the original magnitude, shooter id and team, and the pools still unchanged, until the next $SPAWN (13 replays over 61 s; the shooting gun was idle). Each hit starts its own timer. To the player it is the same as being shot: the ticking clip, the hit sound, vibration and a headset flash. fn 25, 26 and 27 behave the same; fn 28 and 35 do not. Never ship fn 24-27 on a cell a weapon can reach.enemy, as measured
No registration0, 39-45n/a. 0/39/40 measured 2026-08-27; 41-45 untested.n/a

Support functions are team-gated in firmware. With $GSET friendlyFire = 0, heals/armor/shield grants register only from a same-team source, and damage registers only from another team. Set friendlyFire = 1 and everything lands from anyone. A medic gun enforces "allies only" with zero host logic (one 2026-09-07 bench run registered team hits the other way round and is still unexplained; treat the rule as documented, not proven).

Is there a stun?

Yes. $STUN,<ms>,* is a native, silent stun on v4.32 (bench 2026-09-18): it blocks the trigger for the given time, then the gun fires normally again. The gun plays no sound of its own, so the host must play the cue. See the $STUN row above. Over IR there is no stun function: $SIR fn 23 is the only function that visibly changes anything without touching a pool, and it leaves the gun firing. The Callsign app's own abilities are a separate matter: it ships stun content of its own, such as the Sentinel's EMP blast and the Concussion Grenade, driven by app-side logic this page does not cover.

Can I read a native game's $SIR table?

No. The gun never reports it. Capturing an ability's IR word tells you its protocol, not what a native victim binds to it.

Which protocols are free?

Stock uses 0, 8, 10, 11, 13 and 15 (grenade beacon); the app's table also ships rows on 1, 2, 3, 6, 9. Jay's sample arm sequence uses protocol 4 too ($SIR,4,0,,1,0,0,1,,*, a "captured flag pole" row), so protocol 4 is not free either. Truly unused: 5, 7, 12. Every cell is still re-definable per game, since you push the table.

Events: what the gun tells you

Hits, health, HUD echoes, buttons and telemetry, plus the proof that the gun keeps no game state.

$HIR,<sensor>,<irProto>,<shooterId>,<shooterTeam>,<magnitude>,<crit>,<subtype>,*

tokFieldValuesNotes
1sensor that caught the IR0 headset front dome · 1 headset back dome · 4 gun bodyIsolated with every other sensor covered. Trust for directional logic only at field distance. Point-blank floods every receiver, and the first to decode reports. At 2 m the gun-body sensor still caught every headset shot (bench 2026-09-17), so cover the gun sensor for any sensor reading at the bench.
2shooter's IR protocol0 standard · 10 rocket · 13 melee …= the shooter's $WEAP t3 / IR word B field.
3shooter player id0-63= the shooter's $PSET token 1. 32/32 hits both directions on two guns with distinct ids.
4shooter team0-3= the shooter's effective $TID & 3.
5raw magnitudee.g. 9, 45, 80, 115The IR word's D field (= shooter's t5). Not the applied damage where a multiplier row or crit is in play. Derive damage from the $HP delta. On a killing blow it can report the victim's remaining pool instead (overkill clamp).
6crit flag0/1Echoes the IR word's C bit. 0 on every stock weapon.
7subtype (alias: power / PowerLevel)0-3Echoes the IR word's U field (sniper = 1). The 2018 app and Battle Company's own sheet name this field power/PowerLevel; either name, it is the same U field.

Not every $HIR is damage. Pickups, heals and status effects arrive on the same message type. The protocol/subtype tells you which row fired.

The other events

MessageDecode
$HP,<hp>,<armor>,<shield>,*Pools after the hit; same millisecond as its $HIR. $HP,0,0,0 = death. Example run at 9/hit: armor 70 to 61 down to 0, then HP 45 to 43 to 34 down to 0. A $LIFE write does self-emit $HP, but only while it is non lethal: a $LIFE that empties a pool sends $LCD,0,0,0,0,<mag>,<reserve>,* instead, and no $HP at all.
$LCD,<hp>,<armor>,<shield>,<weaponSlot>,<mag>,<reserve>,*Health/armor HUD echo on $START/$SPAWN/death. Token 3 is the current shield. Token 4 is the slot of the last shot, not the selected slot. Bench 2026-10-17: after shots on slot 0 and an ALT with no shot, $QUERY,* still gave slot 0 ($LCD,45,70,0,0,30,188). After a shot on slot 1 it gave slot 1.
$ALCD,<mag>,<t2>,<slot>,<reserve>,<heat>,*Ammo/weapon HUD: one frame per round fired and per round reloaded; slot changes on alt-fire cycle (0 to 1 and back); melee (slot 4) appears as an isolated frame. Token 2 is live accuracy (see the $WEAP t21/t22 section). Heat is a raw level that goes above 100 at lockout, and only on a weapon whose $WEAP t38 switches overheat on.
$BUT,<id>,<state>,*0 trigger · 1 alt-fire · 2 reload handle · 3 select · 4 left · 5 right · 8 the gyro melee swing (when mapped); 1 press / 0 release. In phone mode pre-game the trigger reports but does not fire.
$VOLTS,<pack_mV>,<cell_mV>,<n3>,<n4>,*Battery every ~30 s in app mode. Only reliably returned at good RSSI. Weak-signal guns in a fleet sweep returned none.
$DISCONNECT,*The gun is hanging up (headset switched off, or the app closing).

The gun keeps no game state. Three separate proofs:

  1. Three captures at respawn 5, 15 and 30 s produce byte-identical config. Nothing on the wire encodes a respawn time or a clock.
  2. The complete end-of-game tail is $VOL, $HLED, $STOP, $CLEAR, $PLAY. The app never asks the gun for a score.
  3. Reconnecting after out-of-range play yields zero frames, and a bare $UP,* gets no reply.

The phone tallies $HIR/$HP live, and it is the only place the score ever existed. Anything needing respawn, a clock or scoring needs a host in range for the whole match.

Per-player attribution and native kill feedback over BLE: the recipe

  1. At arm time give every gun a distinct $PSET token 1 (0-63) and a $TID.
  2. On the victim, store <shooterId, shooterTeam> from each $HIR; when $HP,0,0,0 arrives, the stored shooter gets the kill.
  3. Send the shooter's gun $SFLASH,* (green-sight kill confirm) and $PLAY,,4,6,V3A,,,,* ("kill" on the announcer slot). The official app does exactly this, three kills giving three pairs.
  4. Score lines go to every gun's announcer slot from its own host: on a lead change, $PLAY,,4,6,VB17,,,,* ("takes the lead"). Nothing propagates gun-to-gun; there is no nRF score channel to discover.
  5. Game end: $PLAY,VSF,4,6,JAY,,,,* on the winner's guns (victory sting plus "victory").

Why does a capture show shooter id 0,0?

Every gun on that capture sat on the default id. The field is always present; $PSET token 1 is what makes it vary.

Why is $SFLASH in the victim's capture "never near a hit"?

Because a kill you score is invisible in your own $HIR/$HP stream. Correlate it with $BUT trigger bursts.

Can a dead gun fire?

No: $BUT,0,1/0 with no $ALCD decrement.

The IR word: what a shot carries through the air

A 25-bit pulse-width-encoded word on a 38 kHz carrier, decoded from LaserTagMods' base-station source and verified on our own receiver and emitter. Read this section if you are building your own IR sender or receiver.

Credit. The layout was decoded from LaserTagMods' NRFL-Bases node1.ino (a referee-free domination base that receives BRX shots), then bench-verified: timings, bit count, field offsets and the parity rule were all confirmed by pushing known $WEAP frames over BLE and watching only the expected bits move. A stock tagger then accepted a fully synthetic word from our emitter (invented player 42 / team 2 / damage 33 landed as a real $HIR and killed the player).

Physical layer

ItemValue
Carrier38 kHz, 980 nm (the wavelength on the gun's Class 1 IEC 60825-1 laser label; we have not measured it ourselves). Many hobby IR parts are centred on 940 nm, so pick 980 nm-capable receivers. A standard VS1838B/TSOP demodulating receiver recovers this carrier. Laser rated 16.9 mW on the gun's USB record.
SyncOne ~2 ms LOW pulse before the frame, measured 1988-1991 µs.
BitsEach bit is a LOW pulse; long ≈ 1000 µs = 1 (measured 990-994), short ≈ 500 µs = 0 (489-512), spaces 489-512 µs; decision threshold ~750 µs.
End of frameA trailing short pulse (< 250 µs in node1's test).

Sync gating. A > 1500 µs sync gate is not BRX-unique (a Sony SIRC remote's 2390 µs header passes it). Bound sync to about 1800-2200 µs and require 25 bits plus the parity rule.

Word layout (transmit order after sync; 25 bits)

FieldBitsOffsetMeaningBench evidence
B40-3IR protocol / damage type = $WEAP t3 = $HIR tok2 = $SIR protocol keyAR read 0; rocket (t3=10) read 10; native melee read 13
P64-9player id 0-63 = $PSET token 1 = $HIR tok3matched the registry
T210-11team id 0-3 = $TID & 3 = $HIR tok4matched
D812-19magnitude = $WEAP t5 = $HIR tok5pushed 22, then 9, then 115; only these bits moved
C120critical flag, echoes to $HIR tok6; damage effect unconfirmed (read 0 on every measured headset hit; the t7 multiplier rides fn 36/37 by sensor, not this bit)emitted crit=1 gave $HIR,…,1,…
U221-22$SIR subtype to $HIR tok7U=0/1/3 registered with rows; U=2 (no row) ignored
Z223-24parity trailersee rule

A genuine captured melee swing decodes as 1101000111010101101000110 = protocol 13, player 7, team 1, magnitude 90, crit 0, subtype 1.

Parity: what genuine frames emit vs what the gun checks. Real BRX frames set Z by parity over bits 0-22: odd number of ones gives 01, even gives 10 (4/4 captured frames). But the gun's acceptance test is only Z0 ≠ Z1: 01 and 10 both land 8/8, 00 and 11 are rejected 0/8. Compute the true parity for fidelity; use the mismatch to tell your own traffic from a real gun's.

def encode_word(proto: int, player: int, team: int, magnitude: int, crit: int, subtype: int) -> str:
    """25-bit BRX IR word as a bit string (MSB of each field first), Z = genuine-frame parity."""
    bits = (f"{proto & 0xF:04b}{player & 0x3F:06b}{team & 0x3:02b}"
            f"{magnitude & 0xFF:08b}{crit & 1:01b}{subtype & 0x3:02b}")
    ones = bits.count("1")
    return bits + ("01" if ones % 2 else "10")

def decode_word(bits: str) -> dict:
    assert len(bits) == 25
    payload, z = bits[:23], bits[23:]
    return {
        "proto": int(payload[0:4], 2), "player": int(payload[4:10], 2),
        "team": int(payload[10:12], 2), "magnitude": int(payload[12:20], 2),
        "crit": int(payload[20]), "subtype": int(payload[21:23], 2),
        "accepted": z[0] != z[1],
        "parity_matches": z == ("01" if payload.count("1") % 2 else "10"),
    }

Native emissions captured off the air

SourceWordNote
Assault Rifleproto 0, magnitude 9The stock AR emits 9, not the printed 24
Shotgunproto 0, magnitude 45
Sniperproto 0, subtype 1, magnitude 80
Rocket Launcherproto 10, magnitude 115
Melee (gyro swing, native game)proto 13, subtype 1, magnitude 90Subtype 1 = Rifle Bash
Supremacy Sentinel death-nova (headset)proto 10, magnitude 125, player/team = the dying playerOut-damages the rocket; credits kills to the corpse
Smart Grenade, Respawn station: boot wordproto 15, player 0, team 0, magnitude 56Sent once at power-up; before a game starts it arms a tagger to the station (self-respawn off)
Respawn station: beaconproto 15, player 0, team = owner, magnitude 6Every ~2.5 s; revives a dead, armed gun of that team (4/4; wrong team 0/1). Does not arm a running game
Respawn station: buttonproto 15, team = owner, magnitude 6, crit 1The beacon with the crit bit set; arms a tagger mid-game. All three were replayed from our ESP32 emitter with the grenade out of the building (2026-09-04); host-driven games ignore them

Headset emission cannot be forced over BLE. $IRTX, $HFIRE, $MELEE and $BHIT produced zero IR with a receiver control passing before and after. The headset emits only for a physical melee swing and for the Sentinel death-nova. A physical melee swing works in a compiled, host-driven game too, with the shipped $WEAP,4 melee frame and $SIR,13,* rows (bench-confirmed 2026-09-24, two guns, two hits): it is not native-game-only. An emitter A/B (bench 2026-09-24, small n) confirms the swing itself leaves the shooter's headset, not the barrel: covering the barrel by hand still landed a hit, and covering the headset domes landed none in two swings.

Do I need a victim gun to test an emitter?

No. A VS1838B on an ESP32 decodes the word, and the sync/mark timings above are the acceptance spec.

Can a station revive a dead player by IR?

Not in a hosted game. There a dead gun ignores IR, except that fn 34 registers a $HIR and moves nothing, so the host can see the beam and revive the gun over BLE. The 2018 BC app's revive beam used $SIR fn 34 on protocol 14. In a native game, yes: the Respawn station beacon revives a dead gun that the station armed (see Native emissions captured off the air).

Why do my captured frames come out as prefixes (16/17/20/21 bits)?

Your capture sketch is printing while the next frame lands. Turn the RAW dump off.

The USB serial console: QUERY and SETUP

The micro-USB "Programing Port" is a Teensy serial console with two commands. It is not the $ protocol and not SSH.

The BRX has two ports: charging, and a separate micro-USB "Programing Port". Plugged into a computer it enumerates as a Teensyduino USB Serial CDC device (Windows COMx, macOS /dev/tty.usbmodem*, Linux /dev/ttyACM*, VID 16C0). Baud is ignored. This is what the community means by "PuTTY into the tagger". The command set came from LaserTagMods' headset-pairing note.

What the port does and does not do

SentResult
$PING,*, $VERSION,*, any $ frameEchoed back (local echo is on, which is easy to mistake for a reply); with CR: ERROR
?, help, AT, status, …ERROR
QUERY + CRDumps the device record (below). Case-insensitive.
SETUP + CREnters factory provisioning; prompts (EN/中文) for the headset's serial number
Hold SELECT while powering on with USB connectedMass-storage mode exposing the on-board sound storage (the sound-pack update path). This is a different mode from the console (community)
QUERY
Gun Info
Gun Version: v4.32
Serial Number/Head PIN: <SERIAL>      <- matches the sticker on the paired headset
Gun Name: Tactix2                     <- the field $NAME writes
Headset Version: hds.59               <- reads '?' briefly after a power-cycle until the headset re-handshakes
Gun: 7.671 VOLTS
PlayerID 0                            <- device-level player id (separate from the per-game $PSET id)
FieldID1
NRFhost 1
NRFslave 1
devHost 1                             <- developer/host image, not retail
Head: 3.837 VOLTS                     <- headset battery
Head Tested:
Head BURN in test: 0
Gun BURN in test: 3hours28minutes
Grenade Pin: <PIN>
Laser: 16.9 mW
PCB-5
BTchip- 4
BT central V: devhost.03

Real-format quirks the parser has to survive: Gun Name is NUL-padded, lines end \r\r\n, Laser can read UNTESTED instead of a number, Grenade Pin is a real non-zero value. brx-mcp ships parse_query() and python -m brx_mcp usb-query [port], which saves a backup to ~/.brx-mcp/device-backups/.

SETUP: the provisioning/re-pair path (identity lives here)

  1. Run QUERY on both gun and headset-side records first; SETUP on the gun asks for the headset's serial number. That is the gun to headset pairing mechanism.
  2. The "Factory Defaults" banner is a mode header, not an action: entering SETUP and power-cycling out changed nothing (field-by-field diff).
  3. The community's "change tagger ID / re-pair the headset" procedure is this console (LaserTagMods' headset-pairing note); the identity fields it concerns are the ones QUERY prints: PlayerID, FieldID, Serial Number/Head PIN, Grenade Pin.
  4. Only the first prompt (the headset serial) has been walked on our bench. Do it only on a gun you can afford to re-pair.
  5. For per-game identity you do not need this: $PSET token 1 over BLE sets the player id each game.

Firmware backup is impossible; do not reflash. Teensy's HalfKay bootloader is write-only by design, so no image can be read back. Rollback depends entirely on Battle Company supplying the original image. The official app's version gate (supports "until v2.01e") is an upper bound; it warns and still runs a game.

Community fixes, from Jay's JEDGE install document (untested by us):

We do not record any code, PIN or unlock value here.

Is $QUERY,* over BLE the same thing?

No. Over BLE it returns a $-framed status array with no serial, PIN or version. The device record is USB-only.

What is the advertised BLE name vs Gun Name?

Two fields: the advertisement is Tactix-XXXX from the MAC tail; Gun Name is what $NAME writes.

Which state lives where, and what a BLE drop, a headset switch-off, or a power-cycle each wipe. Read this before you write code that reconnects to a gun.

State survival matrix

StateBLE drop / reconnectHeadset switched offPower-cycle
Game config ($GSET/$PSET/$WEAP/$SIR/$BMAP)(unknown). Re-send the full head after a reconnectGun sends $DISCONNECT,* and drops the link; config: (unknown)Wiped. $SPAWN then echoes $LCD,0,0,0,0,0,0 + $ALCD,0,0,0,0,0
Alive/dead + poolsSurvives (a dead gun stays dead)n/aReset
AmmoSurvivesn/aWiped
$TID team / LED colourSurvives; colour is painted at $SPAWN. Any $PSET clears the team (shots carry team 0) until $TID is sent again; $SIR and $SPAWN do not (bench 2026-09-16)n/aReset
$NAMEPersistsPersistsPersists (only the official app rewrites it)
Player id ($PSET t1)Survives with confign/aWiped (the USB PlayerID is separate and persistent)
Score, clock, respawn timerNever on the gunn/an/a
$SIR fn-23 state ($ALCD token 2 at 0)Persists until $SPAWNn/aCleared
$SIR function table (the rows themselves)Only $CLEAR wipes it. $SPAWN and death do not (bench 2026-09-18): a row sent once at arm still registered hits after a $SPAWN,,* with no re-send.n/aWiped
$TMP tokens (t4 accuracy, t8 incoming damage, t9 magazine, …)Zeroed by $SPAWN, so a per-life write must land AFTER $SPAWN (bench 2026-09-18)n/aCleared

Headset off means no BLE. Switching a linked headset off makes the gun send $DISCONNECT,* and drop. A headset-less gun "connects", answers a quick $PING, then dies within seconds and echoes nothing to a config head. A power-cycled gun needs its headset re-linked before BLE holds. A held link plus $ALCD echoes is the headset check. There is no dedicated probe. The official app silently drops a headset-less gun within about 1.2 s.

Headset behaviours (native and autonomous: they work under any host's game head)

Headset LEDWhenEvidence
Slow rainbow blinkDisconnected / not paired. The pre-game tell for "this gun will not join"operator, repeatable
Team colour (red/blue…)Pre-game only; goes dark once the game startsoperator
DarkDuring play (normal)operator

Other headset facts

FactSource
The headset syncs team colour from the taggerbench observation
$VERSION token 2 = headset firmware (hds.59); USB QUERY shows headset version and batterycaptures / USB
Headset disconnecting mid-game locks the gun until it reconnects (anti-cheat); a gun booted with no headset shoots normally in local playBattle Company manual V7
Headset pairing can take up to 3 minutes with many BT devices nearbymanual V7
Headset sensor ids: $HIR tok1 0 = front dome, 1 = back dome (4 = gun body)shield-isolated bench
Gun to headset pairing PIN = the headset's serial, set via USB SETUPLaserTagMods note + USB
Headset LED commands $HLED/$BLINK/$CHASE/$HLOOP/$LED exist. $HLED, $HLOOP, $LED and $BLINK work on our guns ($LED,9,1,1,1,* gives one green flash; $BLINK,3,0,300,300,10,* holds the headset LED green); $CHASE shows no effectAPK + captures + bench 2026-09-04

Screamers. A tagger left powered all day can stop holding BLE. It still advertises, but the connection drops or hangs. The community calls this the "screamer" state. Power-rest guns between sessions; keep them charged (firmware will not re-pair below a battery threshold).

What a gun answers in each state, and other bench facts (2026-10-16)

All bench-proven on v4.32 (Tactix-9498 and Tactix-E20D, 2026-10-16).

Tooling and captures

The brx-mcp command line and MCP server, which is how this project drives a tagger, is documented in the mcp/ directory of the repository, in its README.md.

How every fact on this page was captured, and how to take the next capture, is the capture runbook.