Wavegrid is a modular, configuration-driven laser controller for arrays of Laser Space Cannons. It includes a grid state server, an artist-facing creative canvas, and OSC output adapters for BEYOND and FB4 hardware.
Layouts are configuration, not code: grid-7x7, grid-7x2, ring-6, ring-25-hollow, or any custom shape. Everything β projects, config, secrets, users, state, logs β lives in one centralized store (~/.wavegrid), managed entirely through the CLI.
npm i -g @wavegrid/cli
wavegrid projects create ring-demo # pick a layout preset + run mode; secrets generated once
wavegrid projects users add admin # UI login (scrypt-hashed, stored centrally)
wavegrid start # server + UI + API + WebSocket + receiver, one process, LAN-only
wavegrid doctor # diagnose anything β local checks + whole-installation viewFor a distributed show, run the brain and receivers separately: wavegrid server (server + UI + API + WebSocket, no receiver) on the host, and wavegrid receiver --server ws://<host>:<port> --shard <a-b> on each receiver laptop.
Bare wavegrid (or any command group) opens an interactive menu β every layer prompts. Full operator walkthroughs live in the agent skills (see Agent Skills):
- Simple show (one laptop, up to ~40 cannons):
.agents/skills/wavegrid-simple-show - Distributed show (multiple laptops, sharded receivers):
.agents/skills/wavegrid-distributed-show
pnpm install
pnpm test
pnpm build- Node.js 18+
- pnpm
| Package | Name | Description |
|---|---|---|
packages/server |
@wavegrid/server |
Grid state engine and master controller UI |
packages/ui |
@wavegrid/ui |
Artist UI β Paint, Gradient, Drops, Motion, Scenes, Animations, Flags, Brightness, Audio |
packages/layout |
@wavegrid/layout |
Layout model β presets, fixture generators (grid/ring/rings/filledRing), config resolution |
packages/settings |
@wavegrid/settings |
Centralized appstash store β projects, secrets, users, state, logs |
packages/doctor |
@wavegrid/doctor |
Diagnostics as data β the checks behind wavegrid doctor and the desktop Status screen |
packages/cli |
@wavegrid/cli |
wavegrid CLI β projects, settings, start, doctor |
packages/receiver |
@wavegrid/receiver |
Receiver brain β LP filter, sine fallback, pluggable adapter pattern |
packages/osc |
@wavegrid/osc |
OSC output adapters for BEYOND and FB4 laser hardware |
packages/webgl |
@wavegrid/webgl |
Three.js 3D Civic Center viewer β volumetric laser beams, bloom, camera presets |
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β UI β ββwsβββΆ β Server β ββwsβββΆ β Receiver β
β (artist UI) β βββwsββ β (state + LP) β β (brain) β
β :3003 β β :3000 β β own LP β
ββββββββββββββββ ββββββββββββββββ β sine fbk β
β β hardware β
ββββββββββββββββ
β
ββββββββββββββββ
β @wavegrid/ β
β osc β
β β BEYOND β
β β FB4 β
ββββββββββββββββ
- Server β grid state engine with exponential low-pass filtering. Scenes, animations, ambient presets, idle timeout. Runs at 60fps, broadcasts only on change. Layout-driven: resolves the active project's layout preset from the store, and serves the UI + API + WebSocket on one port.
- UI β a static Vite/React artist-facing creative instrument served by the server on the same origin. Paint, Gradient, Drops, Motion, Scenes, Animations, Flags, Brightness, Audio. iPad-optimized touch UI. Renders whatever layout the server resolves β grids, rings, filled rings.
- Receiver β the "brain" that controls physical hardware. Runs its own independent LP filter so output never jolts. On signal loss, smoothly transitions into ambient 3D sine waves. Pluggable input/output adapters.
- OSC β output adapters for Pangolin BEYOND and FB4 laser hardware. HSB-to-RGB color conversion, per-cannon routing via JSON config.
# Start the stack (each in its own terminal)
pnpm dev:server # Server at :3000 β also serves the UI + API + WebSocket
pnpm dev:receiver # Receiver (brain)
# The UI in watch mode (Vite dev server; proxies /api to :3000)
pnpm dev:ui # http://localhost:3003
# Optional
pnpm dev:webgl # 3D Civic Center viewer at :3004Operators don't run these β they use wavegrid start (see Running a Show).
The physical arrangement is a layout stored in the project β never code. Built-in presets: grid-7x7, grid-7x2, ring-6, ring-25-filled, ring-25-hollow, disc-25. Pick one at wavegrid projects create, or change it later:
wavegrid projects config set layout grid-7x7 # a built-in preset
wavegrid projects config set layout grid:9x4 # cols Γ rows
wavegrid projects config set layout ring:6 # one ring
wavegrid projects config set layout annulus:25@0.5 # rings with a hole in the middle
wavegrid projects config set layout rings:12,8,4,1 # explicit rings, outermost firstRound rigs are concentric rings: one ring is a ring, a ring plus smaller ones inside it is a ring with a hollow centre, and rings all the way in to a centre fixture is a symmetric disc. annulus picks the rings for you from a cannon count and the size of the hole (0 = solid disc).
The server resolves the layout once and broadcasts it; the UI and receiver render from it β no per-process NUM_CANNONS/GRID_COLUMNS to keep in sync. The project name is just a label β the preset controls the shape.
Split the cannons across multiple receiver laptops when hardware limits apply β each receiver drives its shard range, all connecting to the same server, the UI stays unified. wavegrid doctor shows shard coverage with gaps/overlaps across the whole installation. See the distributed show skill for the full multi-laptop walkthrough.
The UI never sends OSC β only the Receiver talks to laser hardware:
ββββββββββββ HSB grid (WebSocket) ββββββββββββ HSB grid (WebSocket) ββββββββββββ OSC/UDP ββββββββββββ
β UI β βββββββββββββββββββββββββΊ β Server β βββββββββββββββββββββββββΊ β Receiver β βββββββββββΊ β BEYOND β
β (browser)β β :3000 β β (brain) β β (laser) β
ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ
Paints colors Broadcasts state Smooths + converts Drives
& scenes to all clients HSB β RGB/OSC hardware
- UI sends high-level grid state (HSB colors per cell) over WebSocket
- Server broadcasts that state to all connected WebSocket clients
- Receiver applies LP smoothing, converts HSB to the configured color format, and sends OSC messages over UDP to BEYOND
- The UI has no knowledge of OSC, projectors, or zones
For a live event where everything runs on a single machine at the venue, use the CLI (see the simple show skill):
wavegrid start # server + receiver, one process; iPads connect to the UI on the LANWhen the UI/Server run on a cloud server and the laser hardware is on-site:
βββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
β Cloud Server β β On-Site (Pangolin PC) β
β β WebSocket β β
β Server (:3000) βββββββββββββββΌβββββββββββββββΌββ Receiver β
β UI (:3003) β β β β
β β β βΌ OSC/UDP (localhost) β
β Artists connect via browser β β BEYOND (:7001) β
βββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
On the cloud server (e.g. DigitalOcean):
# Server β grid state engine + UI + API + WebSocket, all on one port
pnpm dev:serverOpen http://203.0.113.50:3000 in the browser (replace 203.0.113.50 with your server's public IP). The browser derives its WebSocket URL from the page origin, so there is no UI URL to configure. Ensure port 3000 is open in the firewall.
On the Pangolin PC (on-site, Windows β same network as BEYOND):
PowerShell:
$env:SIMULATOR_URL = "ws://203.0.113.50:3000"
$env:BEYOND_HOST = "127.0.0.1"
$env:BEYOND_PORT = "7001"
$env:SHARD_START = "0"
$env:SHARD_END = "23"
$env:DEBUG_OSC = "1"
pnpm dev:receiverBash (Linux/macOS):
SIMULATOR_URL=ws://203.0.113.50:3000 \
BEYOND_HOST=127.0.0.1 \
BEYOND_PORT=7001 \
SHARD_START=0 \
SHARD_END=23 \
DEBUG_OSC=1 \
pnpm dev:receiverThe receiver connects outward to the cloud server and sends OSC locally to BEYOND. BEYOND_HOST=127.0.0.1 when BEYOND runs on the same machine; use the LAN IP if BEYOND is on a different box.
When a single BEYOND PC can't handle all 49 zones, split the grid across multiple machines using a routing config JSON file. One receiver dispatches OSC to multiple BEYOND targets over the LAN β no extra Node.js installs needed on the other machines.
ββββββββββββββββββββββββββββββββ
β Receiver (one machine) β
β β
β reads routing.json β
β ββββββββββ ββββββββββ β
β β grid ββββΊβ routed β β
β β state β β output β β
β ββββββββββ βββββ¬βββββ β
β β β
βββββββββββββββββββββΌββββββββββ
βββββββββββΌββββββββββ
βΌ βΌ
ββββββββββββββββ ββββββββββββββββ
β BEYOND A β β BEYOND B β
β .1.68:7001 β β .1.69:7001 β
β zones 0β23 β β zones 0β24 β
ββββββββββββββββ ββββββββββββββββ
Create a routing.json file (see examples/routing-two-beyond.json for a full 49-cannon example):
{
"targets": {
"beyond-a": { "type": "beyond", "host": "192.168.1.68", "port": 7001 },
"beyond-b": { "type": "beyond", "host": "192.168.1.69", "port": 7001 }
},
"flushHz": 30,
"cannons": [
{ "logical": 0, "target": "beyond-a", "projectorIndex": 0, "label": "row0 col0" },
{ "logical": 1, "target": "beyond-a", "projectorIndex": 1, "label": "row0 col1" },
...
{ "logical": 24, "target": "beyond-b", "projectorIndex": 0, "label": "row3 col3" },
{ "logical": 25, "target": "beyond-b", "projectorIndex": 1, "label": "row3 col4" },
...
]
}Each cannon entry maps a logical grid index to a target and zone index:
logicalβ grid cell index (0β48 for a 7Γ7 grid)targetβ name of a target defined intargetsprojectorIndexβ the BEYOND zone index on that target (resets to 0 for each target)labelβ optional human-readable name for debuggingsafeDisabledβ settrueto disable a cannon in software
Run with:
PowerShell (Windows):
$env:ROUTING_CONFIG = "routing.json"
$env:SIMULATOR_URL = "ws://203.0.113.50:3000"
$env:DEBUG_OSC = "1"
pnpm dev:receiverBash:
ROUTING_CONFIG=routing.json SIMULATOR_URL=ws://203.0.113.50:3000 DEBUG_OSC=1 pnpm dev:receiverThe startup banner will show: Routed OSC β [beyond-a, beyond-b]
Note: When using
ROUTING_CONFIG, do not setBEYOND_HOSTβ they are mutually exclusive.
The receiver sends 5 OSC messages per changed cannon: alpha (255 = full override) + red + green + blue (0β255) + Brightness (0β100). This requires BEYOND's RGBA panel to be enabled: Settings β Configuration β Live Control β Extra Controls β "Show R-G-B-A panel".
The UI has a login screen backed by the centralized store β users are scrypt-hashed, per project, managed entirely through the CLI:
wavegrid projects users add admin # prompts for a password
wavegrid projects users list
wavegrid projects users rm <name>Alongside real accounts there are access keys β named passphrases minted at runtime, one per person or one shared with a crowd:
wavegrid projects keys new dan-ipad # a personal key
wavegrid projects keys new friday-guests # a shared "guest passphrase"
wavegrid projects keys ls # role, state, last use
wavegrid projects keys disable friday-guests
wavegrid projects keys rm friday-guests # --all revokes every keyThe passphrase is generated and printed once (only a salted scrypt hash is stored); re-mint the same name to replace a forgotten one. Keys default to the operator role β drive the show, no access management β with --admin for a deliberate admin key. Each key is independently enabled, disabled or revoked, and revoking one drops the sessions opened with it. The shared receiver key is unrelated and never grants admin.
When the active project has users or enabled keys, the UI requires login; with none, login is unavailable (503) β add a user or mint a key first. Session tokens are JWTs signed with the project's store-held jwtSecret (generated once at project creation; the store is authoritative on both UI and server, so they can never desync).
Project config lives in the store (wavegrid projects config) β env vars are explicit overrides only. Config hijacking via generic vars is disabled: only namespaced WAVEGRID_* values are honored.
| Variable | Default | Description |
|---|---|---|
WAVEGRID_PORT / WAVEGRID_HOST |
store value | Server bind override |
WAVEGRID_PROJECT |
store active project | Project selection override |
APPSTASH_BASE_DIR |
~/.wavegrid |
Relocate the entire store |
WG_RECEIVER_KEY |
store value | Receiver auth key (override to share across laptops) |
SIMULATOR_URL |
ws://localhost:3000 |
WebSocket upstream for the receiver |
BEYOND_HOST |
β | BEYOND PC IP (enables OSC output) |
BEYOND_PORT |
7001 |
BEYOND OSC receive port |
BEYOND_GRID_ORDER |
row |
Grid-to-zone mapping: row or column |
SHARD_START / SHARD_END |
β | Cannon index range for this receiver |
DEBUG_OSC |
β | Set to 1 to log every OSC message |
RECEIVER_ALPHA |
0.06 |
LP filter smoothing factor |
FALLBACK_DELAY |
3000 |
Ms before sine fallback on signal loss |
FB4_HOST / FB4_PORT |
β | FB4 device IP and port (default port 8000) |
ROUTING_CONFIG |
β | Path to JSON routing config file |
Repository skills in .agents/skills/ document the canonical workflows for humans and AI agents:
| Skill | What it covers |
|---|---|
wavegrid-simple-show |
The one-laptop golden path: install, create a project, add users, start, doctor, troubleshooting |
wavegrid-distributed-show |
Multi-laptop shows: brain + sharded receivers, device identity & discovery, provisioning, export/import, config sync |
testing-wavegrid-command-mode |
Testing command-mode changes end-to-end (unit + WS integration) |
wavegrid-js-patterns |
Writing dynamic JS patterns for receivers via evalPattern |
The umbrella roadmap (one brain service, device identity/discovery, provisioning, export/import, sync) is tracked in constructive-planning#1465.
Built by the Constructive team β creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on GitHub.