Skip to the content
2 layers100 × 80 mmLayouts, the printed circuit board in 3D

Reverse-engineering, decoding, and transmitting Vatos infrared laser-tag signals across two ESP32 boards:

Size
100 × 80 mm
Copper layers
2
Thickness
1.6 mm
Parts
38
Nets
32
Pads
108
Tracks, vias
247, 9
Pours
1
Routed
Yes
KiCad
KiCad 10.0 (file 20260206)
Created
13 Jun 2026
Updated
2 Aug 2026

Overview

Layouts is an open-source printed circuit board by jamesburton, from the jamesburton/Lolin32OLEDLaserTagTest repository on GitHub. It is a 2-layer board of 100 × 80 mm with 38 parts on 32 nets, routed with 247 tracks and 9 vias.

It is built around the 100UF 10V 4*7, 2N2222A and 470UF 10V 6.3*7. Its parts include 12 connectors, 7 capacitors, 5 resistors, 2 switches and 1 diode.

Main components

3

README

README.md

Lolin32 OLED Laser Tag

Reverse-engineering, decoding, and transmitting Vatos infrared laser-tag signals across two ESP32 boards:

  • Lolin32 OLED (ESP32 + 128×32 SSD1306) — IR monitor / decoder / transmitter and the C# trainer feeder. Reads NEC remotes and Vatos shots to the OLED, and fires Vatos shots via an IR LED.
  • ESP32-S3-Matrix (ESP32-S3 + 8×8 WS2812) — a wearable target: idles in a rainbow, tracks its own health, flashes the firing team's colour and goes briefly dark when hit, and holds dark when its health reaches zero.

Both boards support wireless OTA updates and UDP telemetry (see Wireless). A network control plane (REST + UDP) configures and controls devices, with a .NET client library on the host side (see Control plane). Shared logic lives in libraries: lib/Vatos (decode/encode), lib/IrFramer (IR edge framing), lib/TagNet (WiFi + OTA + HTTP + UDP), and lib/ControlProto (the protocol-agnostic wire codec for the control plane).

The Vatos IR protocol is not documented publicly; the protocol description in docs/gun-protocol.md was reverse-engineered from scratch in this project.


Table of contents


What it does

  • Receives 38 kHz IR and decodes it live:
    • NEC remotes → address:command (e.g. 0707:04)
    • Vatos laser-tag shots → team + damage (e.g. Blue 2)
  • Displays the decode on the on-board OLED, with a blink LED on every hit.
  • Transmits valid Vatos shots via an IR LED (38 kHz carrier, correct frame
    • checksum), triggered by the BOOT button or any byte over serial.
  • Trains/recognises signals from a PC via the C# IrSignalTrainer app, matching NEC codes exactly and other protocols by full-frame fingerprint.
  • Networks the target: broadcasts discovery heartbeats and hit/state telemetry over UDP, serves a JSON REST API for config/control, accepts low-latency UDP control broadcasts, and is driven from a host-side .NET client library (see Control plane).

Hardware

Carrier PCB (ESP32-S3-Matrix target)

A custom 100×80 mm 2-layer carrier board for the S3 target lives in hardware/lasertag-carrier/: socketed ESP32-S3-Matrix, MAX98357A audio, microSD, IR RX + transistor IR-TX driver, WS2812 strip output, OLED header, GP2 role selector, power switch and M3 mounting holes.

lasertag-carrier rev1 front render

Lolin32 OLED (monitor / decoder / transmitter)

FunctionPinNotes
OLED I²CSDA = GPIO5, SCL = GPIO4SSD1306, 128×32, address 0x3C
IR receiver (VS1838B)OUT = GPIO25demodulating; idles HIGH, pulses LOW
Activity LEDGPIO26blinks ~80 ms on each received frame
IR transmit LEDGPIO1338 kHz carrier via the LEDC peripheral
Test-shot triggerGPIO0 (BOOT) or serialany serial line also fires a shot

ESP32-S3-Matrix (target)

FunctionPinNotes
8×8 WS2812 matrixGPIO14on-board, 64 LEDs (NeoPixel)
IR receiver (VS1838B)OUT = GPIO1GPIO10–14 are taken by the IMU/matrix
Activity LEDGPIO7blinks on each received frame
(reserved)GPIO10–13on-board QMI8658 IMU

Behaviour: rainbow when idle → on a Vatos hit, subtract the shot's damage from its health (max health 32), flash the firing team's colour (Blue/Red/Green/White) 4×, then go dark for a brief configurable "stunned" interval (default ~1–5 s; tune via the control plane) → resume rainbow, keeping accumulated damage. The 4 central columns of the 8×8 matrix form a health bar: they deplete top-down as health drops (outer 4 columns stay rainbow). At 0 health it holds dark ("dead") until a respawn / reset. The device is authoritative for its own health and emits EVT hit / EVT state telemetry as it changes (see Control plane). Matrix current is capped to 500 mA (setMaxPowerInVoltsAndMilliamps) for USB safety.

Notes:

  • The on-board OLED on this unit is a 128×32 SSD1306 (not the 128×64 the Lolin32 normally ships with). Driving it as 128×64 renders garbled, interlaced text — the src/display_test.cpp diagnostic cycles candidate driver/geometry configs to identify the right one.
  • The OLED is not on the ESP32 default I²C pins, so Wire.begin(5, 4) is required.
  • The receiver/transmit/indicator LEDs in this build are driven without series resistors at the ESP32's minimum drive strength (GPIO_DRIVE_CAP_0, ~5 mA) to protect the pins. This is fine for bench/loopback testing but limits IR transmit range — a transistor driver + resistor is needed for real range.

Build, flash, monitor

PlatformIO project (Arduino framework; boards lolin32 and esp32-s3-devkitc-1, plus a native env for unit tests).

sh
pio run -e lolin32                                   # build
pio run -e lolin32 -t upload --upload-port COM14     # flash
pio device monitor -p COM14 -b 115200                # serial monitor

Environments:

  • lolin32 — Lolin32 firmware (src/main.cpp).
  • lolin32_displaytest — the OLED config finder (src/display_test.cpp).
  • esp32-s3-matrix — Matrix target firmware (src/matrix_main.cpp).
  • native — host-compiled unit tests for lib/ControlProto (no board).
  • *-ota — wireless-upload variants (see Wireless).
sh
pio run -e esp32-s3-matrix -t upload --upload-port COM7   # flash the Matrix
pio test -e native                                        # run ControlProto unit tests

Flashing workaround (Lolin32)

This particular board has no BOOT/EN buttons and its USB auto-reset into the bootloader is unreliable, so uploads may fail with Wrong boot mode detected (0x17). To flash:

  1. Jumper GPIO0 → GND.
  2. Run the upload — the chip now enters download mode and flashes.
  3. Remove the GPIO0–GND jumper and reset (re-plug USB) so it boots normally.

If the board disappears from Windows entirely, unplug/replug the USB to re-enumerate the CP210x serial port.


Wireless

Both firmware targets bring up WiFi, ArduinoOTA, and UDP telemetry via the shared lib/TagNet library.

Set WiFi credentials (over serial)

Credentials are stored in NVS, set with serial commands (no rebuild):

powershell
./tools/set-wifi.ps1 -Port COM14 -Ssid "MyNetwork" -Password "s3cret"

or manually in a serial monitor: ssid <name>, pass <pw>, wifi-save (also wifi-status, wifi-clear). The board prints its IP once connected.

Board-config overrides (cfg command)

A whitelisted subset of board-profile fields can be overridden at runtime without a rebuild:

text
cfg <key> <value>

Examples: cfg matrixOrder 1, cfg matrixPin 27, cfg activityLedPin 26. Changes are saved to NVS and applied on the next reboot.

OTA updates

After one USB flash + WiFi provisioning, update over the air — no cable, no GPIO0 jumper.

For the S3 fleet, prefer the host's ota command (see Game manager): every board now runs 2.1.0, which serves POST /api/update, so ota all flashes the whole fleet over HTTP with no Python and no per-board IP chasing. The espota route below is now only needed to bootstrap a board running pre-2.1.0 firmware (it has no /api/update), and for the Lolin32.

sh
pio run -e lolin32-ota -t upload          # -> lasertag-lolin32 (IP in platformio.ini)
pio run -e esp32-s3-matrix-ota -t upload  # -> lasertag-matrix  (IP in platformio.ini)

⚠ ota all sends one binary to every online outdated board and cannot tell an ESP32 from an ESP32-S3 (heartbeats carry no chip field). With the Lolin32 (ESP32) on the network alongside the S3 fleet, target it explicitly with ota <id> rather than all.

Both *-ota envs target the boards' IP addresses in platformio.ini. mDNS (lasertag-*.local) resolves fine for ping/REST on this dev machine, but espota is unreliable with mDNS names, so OTA sticks to IPs — resolve the current one with ping lasertag-matrix.local (the matrix roams via DHCP) and update upload_port if it changed, or set a DHCP reservation. OTA can take several minutes over a weak link (low RSSI), but is reliable.

Telemetry monitoring

Devices broadcast discovery heartbeats and hit/state telemetry as UDP lines on port 4210 (see Control plane for the grammar). The tools/TagMonitor console app prints them raw:

sh
dotnet run --project tools/TagMonitor
# lasertag-matrix HB id=752b38 ip=192.168.1.34 fw=2.1.0 team=2 mode=idle hp=32 online=1
# lasertag-matrix EVT hit victim=752b38 shooterTeam=2 dmg=2 proto=vatos hp=30 ts=12345

mode= in a heartbeat is the board's role (idle/target/scoreboard), not the match phase — a board reads mode=idle throughout a running match. There is no per-board match-phase readback; the host is authoritative.

Discovering boards: listen to the heartbeat roster (TagMonitor, the host's devices, or either manager) and give it ~30 s. Don't port-scan the subnet — it misses boards that answer slowly, and every board roams on DHCP, so any IP written down here or in platformio.ini may already belong to something else.

No telemetry but REST works? That's a missing inbound firewall rule or a lossy weak-RSSI link. Rule out the firewall with tools/setup-firewall.ps1 (Windows, self-elevating) / tools/setup-firewall.sh (Linux/macOS).


Control plane (V2)

A protocol- and mode-agnostic network layer for configuring, controlling, and monitoring devices. REST (reliable, JSON) handles config/CRUD/status; UDP (fast, fire-and-forget on port 4210) handles discovery, telemetry, and low-latency broadcast control. Devices decode IR into a generic TagEvent (behind an IrProtocol abstraction — Vatos is the first protocol) and teams are a generic indexed set, so game modes and the host never hard-code Vatos.

The full design and the authoritative wire contract (with golden test vectors) live in docs/superpowers/specs/.

REST API (served by the device)

MethodRoutePurpose
GET/api/statuslive runtime status (mode, hp, team, online, fw, uptime, rssi)
GET/api/configpersisted config (deviceId, ownTeam, enabledTeams, protocolId, brightness, teamColours)
PATCH/api/configpartial update; persists to NVS; unknown field → 400
POST/api/modeset runtime activeMode + timings (not persisted)
POST/api/commandone-shot actions: identify, bright, test hit, debug

Identity + preferences persist in NVS; game state (mode, timings, health) is runtime only, so a reboot returns the device to a neutral idle. Write requests must send Content-Type: application/json — the ESP32 WebServer discards a urlencoded body (curl's default); the .NET client sets the header automatically. (GET / and GET /cmd?c= remain as deprecated aliases.)

UDP line-protocol (port 4210)

One message per packet; device→broadcast lines are prefixed with the hostname.

text
HB  id=752b38 ip=192.168.1.24 fw=2.0.0 team=2 mode=idle hp=100 online=1   # heartbeat (~2 s)
EVT hit victim=752b38 shooterTeam=2 dmg=2 proto=vatos hp=80 ts=12345      # telemetry
EVT state s=stunned hp=80 ts=12500                                        # ready|idle|stunned|dead|respawn
CTL start ts=30000   |   CTL stop   |   CTL reset hp=100                  # host -> device control

EVT/HB come from the device; CTL is sent to devices. The device is authoritative for its own health; the host tallies match state from the event stream. Send CTL to the subnet broadcast (e.g. 192.168.1.255) — the limited broadcast 255.255.255.255 is not delivered on this LAN.

.NET host library

dotnet/LaserTag.Client (net10.0) is the typed host client:

  • LaserTagClient — REST client for /api/* (GetStatusAsync, PatchConfigAsync, SetModeAsync, SendCommandAsync, …).
  • UdpMessageParser — parses HB/EVT lines into typed records; formats CTL.
  • DeviceRoster — live roster with liveness timeout (6 s) + rejoin detection.
  • NetworkDiagnostics — advisory firewall/port hints.

dotnet/openapi/lasertag.yaml describes the REST surface (for generating other clients). dotnet/LaserTag.Smoke is a throwaway harness that exercises the library against a live device; run it for a quick end-to-end check:

sh
dotnet test  dotnet/LaserTag.sln                              # unit tests
dotnet run --project dotnet/LaserTag.Smoke -- 192.168.1.24 20 # live REST + UDP roster

Game manager (host)

dotnet/LaserTag.Host orchestrates matches over the control plane (spec: docs/superpowers/specs/2026-07-12-game-manager-design.md):

sh
dotnet run --project dotnet/LaserTag.Host            # auto-detects the subnet broadcast
# devices | start dm 5m [--kill 5 --hit 1 --waves 30s] | start elim [--timer 10m]
# start chase <dur|--first N> [--min d] [--max d] [--gap d] [--penalty N] [--dark]
# score | stop | reset [id] | activate [id] | deactivate [id] | quit
# team <id|all> <0-4|none>  — assign a board's team (0/none = neutral target)
# teams split <n>           — deal the online roster round-robin into n sides
# fs ls|put|get|rm|play|startup <id> …  — manage sound clips in on-board flash
# fw [bin]                — fleet firmware table: running vs available (from the
#                           LTFW: marker embedded in firmware.bin)
# ota <id|all> [--force]  — push firmware over HTTP to online boards; `all`
#                           targets outdated boards only (--force re-pushes)

Fleet OTA (firmware ≥ 2.1.0): every board serves POST /api/update (multipart firmware upload → verified flash → reboot) and a browser upload form at GET /update, so updates work from the host CLI, a browser, or the future phone app — no espota/python needed. Spec: docs/superpowers/specs/2026-07-27-fleet-ota-design.md. Boards on older firmware need one last espota flash to gain the endpoint.

Sound clips in on-board flash (firmware ≥ 2.4.0)

Clips live in the board's on-board flash, in the partition table's otherwise-unused 1.375 MB data partition (LittleFS). No microSD, no socket, no wiring. Manage them remotely:

sh
fs ls <id> [dir]              # storage usage + directory listing
fs put <id> <local> <remote>  # upload, e.g. fs put e45614 assets/sfx/startup-rise.wav /sfx/startup.wav
fs get <id> <remote> <local>  # download
fs rm  <id> <remote>          # delete (files only; directories are refused)
fs play <id> <remote>         # play a clip now
fs startup <id> <remote|none> # set/clear the power-on cue

REST equivalents on each board: GET /api/files?path=, POST|GET|DELETE /api/files/file?path=, and POST /api/command {"cmd":"play","path":"…"}.

Clips must be 16 kHz / 16-bit / mono WAV — the parser rejects anything else rather than converting. Generate one with python tools/gen_sfx.py --wav out.wav --clip startup|death, or convert any source with ffmpeg -i in.mp3 -ac 1 -ar 16000 -sample_fmt s16 out.wav.

There is no clip length limit. Playback streams from flash in chunks and feeds the task watchdog, so clip size is bounded by the partition (~1.4 MB, about four 10-second clips) rather than by free RAM. Both former limits are gone: clips used to be capped at ~3 s by the watchdog, and at ~270 KB by having to fit in the heap.

Startup sound defaults to none. Set it per board with fs startup; it is stored as startupSfx and played once at the end of boot. An unset, missing or malformed clip is skipped silently so a board always comes up.

Clips survive firmware updates — OTA replaces the app partition only.

A clip that no config field references is reachable only via the API, which is how to keep a "secret" sound with no on-board trigger.

Every caller-supplied path is validated on the device: absolute, no .. segment. That check is the only thing between a LAN request and the filesystem, so it is unit-tested natively.

microSD is optional and currently unproven. The code remains (sdprobe, sdpins, sdscan are useful hardware diagnostics), but no board here has ever mounted a card — see the handoff for the electrical findings.

Teams (firmware ≥ 2.2.0)### Teams (firmware ≥ 2.2.0)

A device's team lives in its persisted config (ownTeam), not the control plane: a match reads each board's team from its heartbeat and snapshots it when the lobby forms. Assign teams from any surface:

sh
# Host CLI
team <id|all> <0-4|none>   # one board, or the whole roster
teams split <n>            # deal the online roster round-robin into n sides

# Web / Android manager: the team buttons on each Devices card
# JSON API
curl -X POST http://<host>:5080/api/team -H 'content-type: application/json' \
     -d '{"id":"752b38","team":1}'

Team 0 = none = a neutral target, and it is the default. A neutral board is shootable by everyone, hits on it score for the shooter's team, and it can never win a match — the right default for a target that isn't playing for a side. This matches what the firmware has always done physically: it has never own-team filtered, so every decoded shot damages the board that receives it. Boards provisioned before 2.2.0 keep whatever team they were given (most were 2); team all none resets them.

teams split uses a stable order (by device id), so the same fleet always splits the same way rather than reshuffling sides between matches.

⚠ Teams take effect from the next match. The lobby fixes each participant's team at start, so reassigning mid-round does not move a player — that would silently rewrite who the existing scores belonged to.

⚠ The lobby is fixed at match start. Devices that come online after the countdown are ignored for the rest of the match (a device that drops and returns does rejoin, and gets a re-issued CTL start id=).

Match rules live in dotnet/LaserTag.Game (IGameMode: Deathmatch, Elimination, Chase — see docs/superpowers/specs/2026-07-27-chase-mode-design.md). Scoring is per-team — the IR protocol carries the shooter's team, not a player id. CTL grammar v2 (countdown, gameover, activate/deactivate, optional id= addressing) is emitted by the host today.

Boards double as scoreboards during any match — the host pushes CTL Score updates on change plus a 1 s live refresh, so a board's own OLED/matrix shows the running score without extra wiring. A board can also run standalone as a dedicated scoreboard outside of a match via POST /api/mode {"mode": "scoreboard"}.

Firmware compatibility: firmware at or above this build enforces id= addressing, so an id=-addressed reset/start/activate/deactivate only reaches the targeted device. Boards still running older firmware ignore the id= filter and apply addressed CTLs to every device on the arena — reflash all boards to the current firmware before running a multi-device match.


Managers: web and Android

Two graphical front ends for running a game, alongside the console REPL. Both render the same screens from the same code and run the same match engine — they differ only in which machine the engine runs on.

text
        LaserTag.Ui (Blazor screens + IGameSession)
                    /                \
      LaserTag.Web                    LaserTag.App
   engine on the PC                engine on the phone
                    \                /
              LaserTag.Runtime (GameService, UDP listener, 4 Hz tick)
                    LaserTag.Game + LaserTag.Client

Screens: Devices (roster with identify/reset/activate per board), Match (mode picker with per-mode parameters, start/stop), Live (scoreboard, player table, event feed), Firmware (running-vs-available versions and one-tap OTA, reusing the fleet-OTA pipeline).

Web manager

sh
dotnet run --project dotnet/LaserTag.Web            # http://<pc-ip>:5080
dotnet run --project dotnet/LaserTag.Web -- --broadcast 192.168.1.255

Binds 0.0.0.0:5080 over plain HTTP so any phone or laptop on the LAN can open it — no install, and iPhone guests work too. A PC must be running at play time.

A JSON API sits alongside the UI for scripting:

EndpointPurpose
GET /api/devicesRoster with team, hp, firmware, online
GET /api/matchPhase, clock, scores, players
POST /api/match/startBody e.g. {"mode":"chase","duration":"2m","firstTo":5}
POST /api/match/stopStop the running match
POST /api/controle.g. {"kind":"activate","id":"eb20f8","t":1500}

Android manager (the phone is the host)

sh
dotnet build dotnet/LaserTag.App -f net10.0-android
dotnet publish dotnet/LaserTag.App -f net10.0-android -c Release -p:AndroidPackageFormat=apk
# -> dotnet/LaserTag.App/bin/Release/net10.0-android/publish/com.jamesburton.lasertag-Signed.apk

The phone binds UDP 4210, runs the engine and broadcasts CTL itself, so no PC is needed at play time — the point of the exercise. Requires the maui-android workload (dotnet workload install maui-android).

Two Android-specific traps are handled in code, both of which produce a silently-empty roster rather than an error:

  • Multicast lock. Android drops inbound broadcast UDP unless the app holds a WifiManager.MulticastLock. AndroidMulticastGuard holds one for the listener's lifetime; the manifest needs CHANGE_WIFI_MULTICAST_STATE.
  • MAUI never starts hosted services. Unlike the Generic Host, MauiApp builds a service provider but does not run IHostedService, so the listener and tick loop are started explicitly in App.OnStart.
Android appWeb manager
Android Devices screenWeb Devices screen

Status: the Android app runs — installed and driven on an Android 15 emulator, where the shared screens render, navigation works and broadcast discovery picked up the right subnet. What is still unverified is the one thing that matters most: receiving real telemetry. An emulator sits behind NAT, so host broadcasts never reach it, and the multicast lock therefore could not be exercised. Treat the first run on a real phone, on the same Wi-Fi as the boards, as the true test.

Board capability HAL

Each board is described by a compile-time BoardProfile struct (selected with -D BOARD_LOLIN32 or -D BOARD_S3_MATRIX in platformio.ini) that declares its capabilities: IR rx/tx pins, hit-display type (WS2812 matrix, 3-pin RGB, or none), OLED geometry, audio output (piezo or I2S DAC stub), and NVS storage. Firmware and game-mode code target the HAL — HitDisplay, IrTx, Sound — rather than raw pins, so the same game logic runs on any profiled board.

A whitelisted subset of profile fields (e.g. matrix data pin, pixel order, activity LED pin) can be overridden at runtime via the cfg <key> <value> serial command (see Board-config overrides); overrides are persisted in NVS and applied at boot.


How it works

Receive path

An interrupt on the receiver pin timestamps every edge. Edges are grouped into frames separated by ≥ 50 ms of silence (chosen so the gun's full-auto bursts stay within one frame). Each completed frame is:

  1. attempted as NEC (leader + 32 bits, validated by the command/inverse byte) → emits NEC addr=0x.... cmd=0x..;
  2. otherwise, if it is 41 edges, attempted as Vatos → emits VATOS team=N(name) dmg=N;
  3. always emitted raw as FRAME n=.. data=L..,H..,... for fingerprinting.

The optional decoded line is printed before the FRAME line so a consumer can attach it to the frame that terminates the event.

Verified board-to-board (2026-07-29): an IR-TX-equipped board firing at another decodes exactly — all four damage values cost exactly their damage, and all four shooter teams arrive correctly in EVT hit shooterTeam=. Two things to know when testing: range is very short on a raw GPIO drive (aim the boards at each other, close), and shots arriving during the post-hit flash/stun window are ignored by design (applyHit runs only in the rainbow idle state), so space test shots ~4 s apart — a rapid burst looks like a fault but isn't.

Transmit path

Vatos::encode() builds the 41-symbol bit pattern for a {team, damage} shot (including the correct checksum). The firmware then gates a 38 kHz LEDC carrier: even symbols are IR bursts (carrier on), odd symbols are gaps, each lasting ~380 µs (0) or ~800 µs (1). Pointing the IR LED at the VS1838B closes a full loopback: transmitted shots are received and decoded by the same board.

Serial protocol (115 200 baud)

text
NEC addr=0x0707 cmd=0x04            # optional, precedes its FRAME
VATOS team=1(Blue) dmg=2           # optional, precedes its FRAME
FRAME n=75 data=L4520,H4490,...    # always; H/L = level held, value = µs
TX team=1(Blue) dmg=2              # printed when a shot is transmitted

The Vatos protocol (reverse-engineered)

A Vatos shot is a 41-edge frame on a ~38 kHz carrier: 21 IR bursts interleaved with 20 gaps (it starts and ends with a burst). Every symbol is short (~380 µs = 0) or long (~800 µs = 1); quantising at 600 µs gives a stable 41-bit pattern.

The frame encodes the firing team and the shot's damage — not the weapon:

FieldBits (MSB-first)Values
Preamble / framing0–21constant
Team22–24Blue=001(1), Red=010(2), Green=011(3), White=100(4)
Separator25–29constant
Damage30–32001–100 = damage 1–4
Separator33–36constant 0000
Checksum37–40fixed (nonlinear) function of team + damage

Key findings

  • It encodes damage, not weapon. Per the manual: Pistol = 1, Shotgun = 2, SMG = 2, MG = 3, Rocket = 4. Because Shotgun and SMG both deal damage 2, they transmit an identical frame — no receiver can tell them apart. So each gun emits at most 4 distinct codes per team (4 teams × 4 damage = 16 total).
  • The audio is mislabelled on this unit: the spoken "Machine Gun" / "Sub Machine Gun" names are swapped relative to the damage actually sent. Identify shots by damage, not the voice line.
  • Carrier is ~38 kHz, confirmed because the (38 kHz) VS1838B demodulates the gun cleanly. No 56 kHz receiver needed.
  • Both bursts and gaps carry payload. An early fingerprint that used only the gap durations collided distinct shots (for some teams, damage 2 and damage 3 share gap patterns and differ only in the bursts). The working approach fingerprints/decodes the full frame.

The full derivation, the 16-code matrix, and the bit map are in docs/gun-protocol.md.


The Vatos library

lib/Vatos/ is a platform-independent decoder/encoder (no Arduino dependencies — carrier generation lives in the caller):

cpp
namespace Vatos {
  struct Shot { uint8_t team; uint8_t damage; };          // team 1-4, damage 1-4

  bool decode(const uint32_t *edgeDurationsUs, size_t count, Shot &out);
  bool encode(const Shot &shot, bool bits[FrameEdges]);    // FrameEdges == 41
  const char *teamName(uint8_t team);                      // "Blue".."White"
}
  • decode() quantises 41 edge durations, validates the constant framing and the checksum, and extracts team + damage.
  • encode() produces the 41-symbol pattern (even indices = bursts) for transmission, with the correct checksum from a 4×4 lookup table.

Both directions were verified by full TX → IR → RX → decode loopback across multiple team/damage codes, and decode() was verified against real gun fire.


RF (2.4 GHz) probe — CLOSED, no signal found

A side investigation into whether the Vatos kit also uses a 2.4 GHz link alongside its IR. Result: nothing was detected, and the leading explanation is that these gun-only units have no radio — see docs/rf-protocol.md for the evidence and the retractions.

The tooling works and is kept for if the question reopens. An nRF24L01+ wired to an ESP8266 (pio run -e esp8266-rfprobe) provides five serial commands:

CommandPurpose
selftestReads back written registers — proves the SPI wiring
scan [sweeps]Fast sweep of all 126 channels via the power detector
watch from= to= ms=Dwells per channel; far more sensitive to short bursts
dwell ch= secs=Camps on one channel, 100 ms buckets for event correlation
sniff ch= rate= secs=Promiscuous capture (2-byte address, CRC off)

Wiring: CE=GPIO4, CSN=GPIO5, SCK=GPIO14, MOSI=GPIO13, MISO=GPIO12, 3V3 with a 10 µF cap at the module. Captured RF … lines are analysed by the LaserTag.Rf library (CRC16 validation, bit realignment, address recovery); only a CRC-valid packet counts as a detection. Occupancy figures never do — that lesson cost an evening and is written up in the findings doc.

The C# signal trainer

tools/IrSignalTrainer/ is a .NET console app that reads the board's serial output and tags signals against named devices and buttons (see tools/README.md).

sh
dotnet run --project tools/IrSignalTrainer            # COM14, signatures.json
  • NEC signals match exactly on the decoded address:command code.
  • Other signals (e.g. the gun, before firmware decoding existed) match on a quantised full-frame bit signature.
  • t trains (device + button, 4 samples), l lists, q quits. The library is persisted to signatures.json.

signatures.json in the repo contains an example library trained on a TV remote (NEC) and the Vatos gun.


The journey (how we got here)

A condensed log of the build, because most of the value was in the process:

  1. Board bring-up. Adapted the manufacturer OLED example to PlatformIO and verified the display + serial. Discovered the panel is 128×32, not 128×64 — the cause of unreadable, interlaced text — using a config-finder sketch.
  2. Sensor selection. Started with a KEYES photodiode + LM393 comparator board. It is not a demodulating receiver, so its output fragmented/merged and needed constant potentiometer tuning. Swapped to a VS1838B, which produced clean, decode-grade frames with no tuning. (See docs/sensor-comparison.md.)
  3. NEC decoding. Added an NEC decoder to the firmware (OLED shows addr:cmd) and validated it against a TV remote (address 0x0707).
  4. Cracking the gun. The gun was not NEC. Captured frames, decoded the structure, and — crucially, with the owner's manual + the spotted audio/icon mismatch — realised the IR encodes team + damage, which explained an apparent "Shotgun = Machine Gun" collision (it is really Shotgun = SMG, both damage 2). Captured the full 4-team × 4-damage matrix and isolated the team, damage, and checksum bit-fields.
  5. Library + transmit. Factored the decode/encode into lib/Vatos/, added a 38 kHz IR transmitter, and verified the whole chain by loopback.

Repository layout

text
platformio.ini            PlatformIO config (lolin32 / displaytest / matrix + OTA + native)
src/
  main.cpp                Lolin32: RX + NEC/Vatos decode, OLED, IR transmit
  matrix_main.cpp         ESP32-S3-Matrix: 8x8 LED target + V2 control plane
  display_test.cpp        OLED driver/geometry config finder (separate env)
lib/
  Vatos/                  Platform-independent Vatos decode/encode
  IrFramer/               Shared IR edge-framing (ISR + frame assembly)
  TagNet/                 Shared WiFi (serial creds) + OTA + UDP + HTTP server
  ControlProto/           Protocol-agnostic control-plane wire codec + TagEvent
  Board/                  Compile-time BoardProfile per board (pins, capabilities)
  HitDisplay/             WS2812 matrix / RGB hit-feedback output HAL
  IrTx/                   IR transmit HAL
  Sound/                  Sound cues (piezo; I2S DAC stub)
  BoardNvs/               NVS override load + cfg serial command
test/
  test_board/             Native unit tests for Board/BoardNvs
  test_controlproto/      Native (host-compiled) unit tests for ControlProto
dotnet/                   .NET 10 host ecosystem (LaserTag.sln)
  LaserTag.Client/        Typed REST + UDP client library (parser, roster, client)
  LaserTag.Client.Tests/  xUnit tests
  LaserTag.Smoke/         Throwaway live smoke harness
  openapi/lasertag.yaml   OpenAPI description of the REST surface
tools/

The V2 control plane (REST + UDP config/control/telemetry, device-side health, the .NET client library) is in place. Next:

  • Firmware game-mode framework + "Team Colours" mode — pluggable modes on top of the activeMode/timings plumbing already wired through the control plane.
  • Host scoring & orchestration — tally match state from the EVT stream; multi-device game start/stop via CTL broadcasts.
  • .NET CLI + Claude skill — a runnable CLI over LaserTag.Client (the library exists; its CTL sender must target the subnet broadcast), wrapped as a Claude skill.
  • Real transmit range — a transistor driver + series resistor on the IR LED for gameplay distances rather than bench loopback.
  • Pin down the checksum formula — currently a verified 4×4 lookup; the exact algorithm (symmetric in team+damage) is not yet derived.

Parts

38
QtyValueFootprintReferences
4M3MountingHole_3.2mm_M3H1, H2, H3, H4
2100nFCapacitor_THT:C_Disc_D4.7mm_W2.5mm_P5.00mmC4, C9
210uF/10VCapacitor_THT:CP_Radial_D5.0mm_P2.50mmC2, C5
2220RResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P10.16mm_HorizontalR3, R9
11000uF/10VCapacitor_THT:CP_Radial_D10.0mm_P5.00mmC6
1100uF/10VCapacitor_THT:CP_Radial_D6.3mm_P2.50mmC1
12N2222APackage_TO_SOT_THT:TO-92_InlineQ1
1330RResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P10.16mm_HorizontalR7
133RResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P10.16mm_HorizontalR2
1470RResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P10.16mm_HorizontalR1
1470uF/10VCapacitor_THT:CP_Radial_D10.0mm_P5.00mmC3
1Activity LEDConnector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_VerticalJ14
1Ext buttonConnector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_VerticalJ12
1GAIN strapLocalJumper:SolderJumper-3JP2
1GP2-buttonLocalJumper:SolderJumper-2JP4
1GP2-touchLocalJumper:SolderJumper-2JP5
1IR LEDConnector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_VerticalJ7
1IR RXConnector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_VerticalJ6
1MAX98357AConnector_PinSocket_2.54mm:PinSocket_1x07_P2.54mm_VerticalJ3
1OLED I2CConnector_PinHeader_2.54mm:PinHeader_1x04_P2.54mm_VerticalJ9
1PWR switchSW_DIP_SPSTx01_Slide_9.78x4.72mm_W7.62mm_P2.54mmSW2
1Power LEDLED_THT:LED_D5.0mmD2
1Power in 2PTerminalBlock_Phoenix:TerminalBlock_Phoenix_MPT-0,5-2-2.54_1x02_P2.54mm_HorizontalJ0
1S3-Matrix LConnector_PinSocket_2.54mm:PinSocket_1x10_P2.54mm_VerticalJ1
1S3-Matrix RConnector_PinSocket_2.54mm:PinSocket_1x10_P2.54mm_VerticalJ2
1SD muteLocalJumper:SolderJumper-2JP1
1SD_VDD selLocalJumper:SolderJumper-2JP3
1SW_PUSH_6mmButton_Switch_THT:SW_PUSH_6mm_H5mmSW1
1Touch hdrConnector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_VerticalJ13
1WS2812 outConnector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_VerticalJ8
1always-onSolderJumper-2JP6
1microSDConnector_PinSocket_2.54mm:PinSocket_1x06_P2.54mm_VerticalJ5

Similar boards

  • 2 layers270 × 140 mmDigiSlatePlus, the printed circuit board in 3D

    DigiSlatePlus

    digislateplus/digislateplus.github.io

    DigiSlate+ is based on the DigiSlate and is inspired by the DIY timecode generator from mitkunz. The aim for the DigiSlate+ is not only to be able to read and display timecode, but also to generate it itself.

    130 parts
  • 2 layers162.6 × 147.3 mmEnAcess PCB, the printed circuit board in 3D

    EnAcess PCB

    jumarubea/OpenSmartMeter

    Low-cost & OEM agnostic GSM prepaid smart energy meter with a customizable API.

    153 partsMIT
  • 2 layers270 × 135.9 mmDigiSlatePlus, the printed circuit board in 3D

    DigiSlatePlus

    nibble-arts/DigiSlatePlus

    The project is in an early developement state.

    122 parts
  • 2 layers133 × 89.5 mmESP32-Simple Thermostat PCB, the printed circuit board in 3D

    ESP32-Simple Thermostat PCB

    jrtaylor71/ESP32-Simple-Thermostat

    Simple ESP32 Thermostat with touch screen

    52 parts2
  • 4 layersRectificador, the printed circuit board in 3D

    Rectificador

    rarias/puente_trifasico

    Rectificador trifásico 10kW

    134 parts2
  • 2 layers66 × 58.4 mmCC SSD 02, the printed circuit board in 3D

    CC SSD 02

    attachix/ukit-pcb

    The PCBs needed to create the U:Kit sensor kit.

    42 parts1
  • 2 layers140 × 100 mmCarrier board, the printed circuit board in 3D

    Carrier board

    doug7410/nowplaying-appliance

    Build guide, BOM and hardware for the Now-Playing desk appliance: HUB75 spectrum + touch now-playing display driven by room-mic song recognition

    31 parts1MIT

Questions

How big is the Layouts PCB?

It measures 100 × 80 mm and has 2 copper layers, 1.6 mm thick.

What parts does the Layouts use?

38 parts, built around the 100UF 10V 4*7, 2N2222A and 470UF 10V 6.3*7. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.

Where can I download the Layouts KiCad files?

From the jamesburton/Lolin32OLEDLaserTagTest repository on GitHub, at the commit this page was made from: the board is hardware/lasertag-carrier/layouts/default/default.kicad_pcb. PCBKit opens the board in the browser, with nothing to install.

Can I use the Layouts design in my own project?

Its repository states no licence, so its author keeps all rights: ask jamesburton before reusing the design.