Implementation

Four workstreams and on-device spell gestures.

Implementation — four workstreams

Each card is the concrete next-step checklist. Dependency order: C (network, can start today, in parallel) → D (prerequisite gate) → AB. A and B both build on a working real fix from D; D gates all arena spend.

hw pendingGate D · 2–4 wk on hardware

D · Close the UWB loop

UART parsing + fake-data toggle implemented in firmware. Software ready — needs first real fix in the room.

  1. Done: real UART path in dwm3001c_protocol.c — issues range to the DWM3001C, parses RANGE 0x…: <m> and TIMEOUT lines, populates uwb_ranging_result_t (distance, rssi, quality).
  2. Done: WAND_UWB_USE_FAKE_DATA = false is the default in wand_config.h; fake mode stays available at runtime via the NVS key uwb_fake for Playground/demo. Caveat: real-mode discovery does no UWB scan yet — the anchor set comes from config / the gateway's assignment; local top-4-by-RSSI selection remains the fallback when no assignment arrives.
  3. Seed 4 anchor positions in the DB Anchor table so PositioningEngine.loadAnchors() picks them up.
  4. Bring-up rig: 1 wand + 4 anchors, static positions; confirm a stable PositionDTO arrives on the /game namespace.
Files: wand_sw/components/uwb_manager/uwb_manager.c, wand_sw/main/wand_config.h, gateway_server/src/positioning/PositioningEngine.ts
Gate D (blocks all arena spend): first real, stable fix in the room.
hw pendingafter D · software done

A · Gateway-driven anchor-set assignment

AnchorAssignment.ts + WS messages implemented. Validate reassignment with a moving wand in the arena.

  1. Done: AnchorAssignment.ts — inputs: anchor positions (PositioningEngine.getAnchors()), wand last position (getLastPosition(wandId)), anchor health/status.
  2. Algorithm: candidate = anchors in range of last position (all, on cold start) → greedily pick 4 that minimize GDOP (nearest first, then maximize angular spread; reject non-ONLINE).
  3. Hysteresis: only reassign when the wand moves > ~3 m or an assigned anchor goes stale — prevents thrashing.
  4. Done: UwbAnchorAssignmentDTO {anchors[], epoch} in contracts/v1.ts; sendAnchorAssignment(wandId, dto) (type:'uwb_assign_anchors') in WandWebSocketServer.ts.
  5. Done (firmware): uwb_manager_set_active_anchors(ids, count) handles uwb_assign_anchors on /wand. Fallback: local top-4-by-RSSI when no assignment / link lost.
Files: gateway_server/src/positioning/AnchorAssignment.ts, contracts/v1.ts, server/WandWebSocketServer.ts, uwb_manager.c/.h
Done when: a moving wand ranges the gateway-chosen 4 and reassigns cleanly as it moves.
hw pendingafter A · software done

B · UWB + TDMA slotting

TdmaScheduler.ts + firmware TDMA hooks implemented. Backpressure telemetry (uwb_report) still pending in firmware. Gate B: measure 16 wands @ ≥8 Hz on real hardware.

  1. Superframe model: T_sf = 100 ms ÷ 16 slots. Each wand owns a slot; in it, one TWR cycle to its 3–4 anchors (≈6 ms). Budget: 16 × 6 ms = 96 ms < 100 ms. ✓
  2. Sync, phase 1 — WiFi/gateway time: gateway pushes a periodic time reference (uwb_sync_ref); wand aligns slots with drift correction, ~3–5 ms guards (≈12–14 usable slots). Validates scheduler logic.
  3. Sync, phase 2 — UWB beacon (only if WiFi jitter forces it): a sync-anchor broadcasts a superframe beacon; ns-level, tight enough for a full 16 slots.
  4. Done: TdmaScheduler.ts — recomputes slot map on wand join/leave, pushes UwbTdmaConfigDTO via sendTdmaConfig (type:'uwb_tdma_config').
  5. Adaptive backpressure pending: gateway handler for uwb_report (successRate, timeouts) exists, but firmware does not send it yet; on congestion drop some wands to ½-rate or 4→3 anchors. Log the wand-rate-vs-count curve.
  6. Done (firmware): uwb_manager_set_tdma(slotIndex, slotCount, slotMs, superframeMs) fires a one-shot range at syncEpoch + slotIndex×slotMs each superframe (replaces fixed interval).
Files: gateway_server/src/positioning/TdmaScheduler.ts, contracts/v1.ts, server/WandWebSocketServer.ts, uwb_manager.c/.h
Gate B: 8 → 12 → 16 wands range concurrently at ≥8 Hz with <10% timeouts.
partially donesoftware · VLAN/AP still venue ops

C · VLAN + network boundary

Isolated VLAN for wands + anchors + gateway; traffic stays app-layer-unsecured without exposing ports — and without a real perf hit. No code dependency on D, so it can run in parallel from day one.

  1. VLAN: dedicated VLAN (e.g. 20) on a managed switch + AP; SSID → that VLAN; only wands/anchors/gateway NIC on it; no internet uplink (or firewalled).
  2. WiFi (2.4 GHz only): clean channel (1/6/11), 20 MHz, minimize competing SSIDs; disable power-save on wands; enable WMM/QoS, mark wand UDP as voice (AC_VO); wired backbone AP↔gateway. Dual-AP on different channels if airtime saturates near 16 wands.
  3. Keep WPA2 on (AES is hardware/near-free; an open SSID invites airtime theft). Skip app-layer TLS on the firehose — that's the real cost.
  4. Boundary hardening: no public ports (WireGuard if remote needed); lock device HTTP APIs (/api/uart/command, /api/ota, /api/reboot, /api/config) behind gateway-IP allowlist + shared secret; close wildcard CORS. Done in firmware + gateway.
  5. Creds: remove default admin creds; move WiFi creds out of committed headers into NVS via config_manager / anchor NVS. Device creds done. Change admin password before venue.
  6. Field provisioning (wand + anchor): shared setup web UI at GET / on device :80. If STA fails (or after factory reset), device starts LaserWands-Setup-XXXX → laptop joins setup AP → http://192.168.4.1 → WiFi form (no secret in setup mode). Done. See docs/DEVICE_MANAGEMENT.md.
Touches: switch/AP config, gateway HTTP API middleware, common_components/device_web_ui/, wand_sw + anchor_sw web_server / wifi_manager
Done (software): X-Device-Secret, NVS creds, hidden-SSID STA, setup AP + shared web UI, factory reset → setup mode. Remaining: VLAN/AP wiring at venue, rotate admin password.
donewand + anchor · same UI

Device provisioning flow

No chicken-and-egg: you never need arena WiFi to reach a device for first-time setup. Wand and anchor share one web UI (common_components/device_web_ui/).

Path A — Flash-time

  1. idf.py menuconfig → WiFi SSID/password + DEVICE_API_SECRET
  2. Flash firmware — device joins arena WiFi on first boot

Path B — Same LAN

  1. Device already on arena WiFi — note IP (serial / DHCP / admin UI)
  2. Open http://<device-ip>/ — enter X-Device-Secret once
  3. Save WiFi or gateway URL in the shared web form
Path C — Setup AP (field install)
  1. Power device — if STA fails (or after Factory Reset), it starts LaserWands-Setup-XXXX (password laserwands-setup by default)
  2. Laptop joins that WiFi (not arena WiFi)
  3. Open http://192.168.4.1 — WiFi form works without secret in setup mode
  4. Save → device reconnects → reboots on success → joins arena WiFi
  [Factory reset] ──► clear NVS WiFi ──► reboot ──► Setup AP again
  Arena AP (hidden SSID OK) ◄── STA ◄── wand / anchor ESP32 :80 ──► gateway .209:4000
  Game plane (open): UDP :12346 firehose + WS /wand · Admin plane: X-Device-Secret on POST
Canonical doc: docs/DEVICE_MANAGEMENT.md#wifi-and-device-provisioning
API: POST /api/factory-reset · POST /api/wifi (wand) · POST /api/config (anchor + gateway URL)

Source-agnostic pose pipeline (player look stays identical)

  UWB multilateration ─┐
  IMU dead-reckoning  ─┼─►  PoseProvider ─► PoseFusion ─► POSITION event ─► SpellEngine · Unity · HUD · lighting
  (retro optical, v2) ─┘     {x,y,z,heading,conf,source}        (unchanged contract — nothing above changes)

On-device spell gestures software

Each wand runs a tiny neural net (TFLite Micro) that recognizes the cast gesture locally from 6-DOF motion (accelerometer + gyroscope), then sends only the recognized gesture to the gateway. The gateway — not the wand — decides legality (mana, cooldown, bans, tier, ownership) before the spell fires. Gestures are trained off-device in wand_tinyml, compiled into wand_sw, and named/assigned to spells in the admin panel. The pipeline is fully implemented in firmware, but the repository ships no pre-trained model (GESTURE_MODEL_AVAILABLE 0) — gestures are recorded and trained per deployment, and casting stays disabled until a model is exported and flashed.

6-DOF
Accel + gyro · magnetometer excluded (heading-independent)
50
Model input samples — capture 0.15–3 s @ 100 Hz, resampled to 50
≥70%
On-device confidence gate before sending
32 KB
TFLite Micro tensor arena (fits ESP32-C6)

Pipeline — train once, assign freely

  1. Collect on a wand running wand_tinyml: name a gesture on the PC and record ~50–80 reps (plus one shared idle reject class). Up to ~15 gestures per model is comfortable.
  2. Train + export → an int8 model compiled into wand_sw.
  3. Flash wand_sw (USB or OTA) — needed only when the model changes.
  4. Import the manifest into the gateway, then rename + assign each gesture to a spell in the admin panel.

No reflash to rename a gesture or point it at a different spell — that is a server-side change.

Representative gesture traces

Training emits a 2-D trajectory per class so admins can see each learned gesture (start → end ) and assign it confidently.

Circle
Swipe
Stab
Why this matters: spell selection moves from "one button = one spell" to expressive motion, while the server keeps full authority over balance and anti-cheat. Re-skinning or re-balancing a gesture→spell mapping is instant and needs no firmware update.

This is the differentiator — player accounts with levels, avatars and unlocks already exist as sold features of competing laser-tag platforms (Laserforce memberships, Zone/O-Zone). What they don't have: an unlock that is a physically trained motor skill — casting a spell is something you learn to do and the wand recognizes it, not a cosmetic or a point balance.