Build the neon machine room,
then keep it running.
Everything you can author or change in Maschinenraum, from a room file to a drink's hangover, with the commands that prove you did not break it. The live panels write the files for you.
Build, run, measure
A Rust workspace: protocol, server, client (wasm), race, props, loadgen. The server serves the built client bundle.
Build and play
cd client && trunk build --release
cargo run -p server --release
# open http://localhost:8080/?room=lobby&nick=youThe first build compiles DuckDB's C++ and takes minutes. On Windows a running server.exe locks the binary: stop it before rebuilding or testing.
Check everything
cargo xtask all # lint test rooms client server golden smoke cargo xtask linux all # the same in a Debian container cargo xtask bench # benches + loadgen, history in .bench/ scripts/check.sh --quick # lint, tests without the release suites, rooms scripts/dev.sh # stop the server, build both, run on 0.0.0.0:8080
cargo xtask runs each step by hand, the same on Linux and Windows; there is no CI server. golden checks that the wasm builds compute the hashes the native tests pin, and smoke drives a headless browser through joining, sound and a server restart. check.sh runs clippy with warnings as errors for the native crates and the wasm client (app.rs and gfx.rs only build for wasm), every test suite, and every room in rooms/ through roomcheck.
Every look in every room
node scripts/looks.mjs --rooms lobby,bazaar --zoom 1
node .claude/skills/look-from-image/tools/snap.mjs \
--room market --look Off-World --out shot.pnglooks.mjs drives one headless Chrome through each room and each look and writes a labelled contact sheet per room to shots/looks/: the regression check for a look that only works where it was tuned. The server must be running.
Benches and load
cargo bench -p server --bench room cargo bench -p client --bench tessellate -- light_ cargo bench -p client --bench audio node client/benches/wasm_audio.mjs client/dist cargo run -p loadgen --release -- --rooms 10 --bots 50 --secs 20 cargo test -p client --release --lib machine -- --nocapture # the disco machine: x real time cargo test -p client --release --lib machine_wav -- --ignored # eight bars to target/disco.wav cargo test -p client --release --lib sid_wav -- --ignored # the SID's march to target/sid.wav
Server configuration
| Variable | Default | What it sets |
|---|---|---|
MR_BIND | 0.0.0.0:8080 | HTTP and WebSocket listen address |
MR_RTC_BIND | the HTTP port | UDP for the WebRTC data channels; off keeps everything on the WebSocket |
MR_RTC_PUBLIC | unset | IP advertised to browsers for UDP (behind NAT, in a container) |
MR_DB | maschinenraum.duckdb | The event log file |
MR_ARCHIVE | archive | Where history older than a day goes, as Parquet |
MR_ROOMS | rooms | Room files (<room>.ron, world.ron) |
MR_STATIC | client/dist | The built client bundle |
MR_DOCS | docs/html | These pages, served at /docs/ |
MR_ACCESS_KEY | unset (open) | The key needed to join; it buys an HttpOnly session cookie at POST /login |
MR_BUILDER_KEY | unset (everyone in may edit) | The key needed to edit rooms, checked per connection |
MR_SESSION_SECRET | <MR_DB>.session-secret | Signs the session cookies |
MR_TRUSTED_PROXY | unset | A proxy whose forwarded client address is believed |
MR_TLS_CERT, MR_TLS_KEY | unset | PEM files: the server speaks HTTPS itself (server/src/tls.rs) |
MR_ICE | none | Comma-separated STUN/TURN URLs voice links may use, served at /ice |
RUST_LOG | unset | Log filter, e.g. info,str0m=warn |
Rooms are files plus history
A room is its base file with every logged edit replayed on top. Change the file and restart: players' edits still apply, because they point at object ids. Hover a line to read about it.
hover a line
Each field of a room file, explained. Fields marked optional have a default.
Which way rot points
0= +x,1= +y2= −x,3= −y- Stairs climb along it; seats and sofas put their back on that side; screens face it.
Floors, voids and stairs
floorslists levels bottom up: aheightand theareasthat have floor, minusvoids.- Stairs climb two tiles along
rotand land one tile further, a floor up. The floor above needs a void over the ramp and floor at the landing. raisedlifts or sinks parts of a floor; steps up toMAX_STEP(0.5) are walked (FloorPlan::step_okin the server's A*), taller edges need steps between.protocol::FloorPlanis shared: server walking and client drawing read the same rasterised floors and heights.
The id rule
Base-file ids must stay below 1 << 20 (1,048,576). Ids made in-game start there, so edits logged against them keep pointing at the right object when you rewrite the file. Never reuse a base id for something different.
Connecting rooms
(
rooms: [
(name: "lobby", origin: (0, 0)),
(name: "street", origin: (24, 4)),
(name: "geode", origin: (40, 16)),
(name: "central", origin: (64, 0)),
],
)Rooms whose footprints touch on the world grid are neighbours: the room next door is blacked out but for its walls, doors, arches, windows and railings along the seam, and you walk through wherever both sides have floor. elevation: 120.0 puts a room up the tower, which keeps it from neighbouring the plaza. Rooms not in world.ron are reached by portals: to: "lobby", arrive: Some((11, 11, 0)).
style: and what comes with it
| Style | Feel | Ambience |
|---|---|---|
neon | The default club | mains hum, traffic, lamp buzz, sign crackle |
corporate | Offices | air conditioning, screen buzz |
slum | Street, rain | transformer, heavy traffic, crackle |
temple | Calm hall | drone, candle chimes |
industrial | Machinery | edgy hum, rumble, sparks |
crystal | The geode | wind, drone a fifth up, crystal chimes |
prairie | Oak, Roman brick, green carpet | soft air, a faint drone, rare chimes |
market | Night market: AC units, cables, hanging neon | crowd air, transformers, sign buzz |
lab | Machine room: ducts, rack lights, checkers | rack hum, low drone, screen buzz |
outdoor: true adds the city skyline, sky and a short, damp reverb.
Residents and the bartender
residents: 22fills the room with people who sit, stand about, wave and dance while someone is there.- The first presents on a
Stageif there is one. - The second works the room's longest
Barif there is one, from the open floor behind it that is not beside a stool. Keep that floor connected: a board placed behind the bar splits it into pockets.
Keyword billboards
#latency | Round trips between the players here, measured client to client |
#server | The server's CPU and memory, two minutes, smoothed |
#clients | Every player's main-thread load and memory |
#departures | The gates here and how many people are at the other end now |
#skate | The room's best half pipe runs, one per nick, with the airs each landed |
#transfer | The room's transfer winners, most wins first, with the droid each wears |
#score | The football score between the room's two goals |
#cycles | Light-cycle winners, most matches won first |
#breakout | The best breakout games |
#dizzy | The best Spindizzy runs: most gems, then quickest |
#bomber | Bomberman champions |
#commando | Commando high scores, one per nick |
#kobold | Kobold's histograms for the machine floor's job: cycles, size, activity |
#duel | Kobold's duel ladder for the machine floor's arena: Elo rating, wins, losses |
#relay | Kobold's best relay shifts on the machine floor's line, one per crew |
#show | Kobold's light-show set pieces: each nick's smallest solution per piece |
A Billboard whose text is the keyword draws the diagram instead. Keep them on floor 0.
Rooms that ship
- On the world grid: lobby, street, atrium (Chinatown), geode, central (a gate to every room and the departures board), tower (the skyscraper's plaza: a glass lift corkscrewing up to the skybar at 120 and the roof at 300, a paternoster in its foot), skatepark (a half pipe: step on the pad, K at the top of each air), deck (Deck 1: Paradroid's transfer game, X claims, ↑ ↓ Space fire), stadium (football between two goals,
#score), grid (light cycles for two, WASD or the arrows), court (breakout, one player or two taking turns, A/D and Space), spindizzy (a marble on a floating level, gems against the clock) and bomber (Bomberman for one to four, Space drops, E sets off remotes). - Behind portals: llm (the babbling model), lexicon (an 80-tile hall where every word said grows a tree, from the model room), prairie (a dining room), market (rainy night market), lab (machine room), bazaar (a market on three levels), disco (three stations, a DJ mixer, a bar), basement (Commando on a sunken sand table, from the lobby and the street) and boiler (Kobold, a coding game after EXAPUNKS with heists, rated duels, relay shifts and light shows on a rig of 32 lamps, down the basement's stairwell).
- Arcade cabinets in several rooms play a vector Scramble with a high-score table each.
- Specs for the image-made ones are in
.claude/skills/room-from-image/examples/.
Object kinds
| Kind | Key | What it is | Blocks |
|---|
Room sketcher
Pick a kind, click or drag on the grid to place, right-click or use Erase to remove, click a placed object again with the same kind to rotate it. The file updates as you draw.
A single-floor room
writes rooms/<name>.ronEditing a live room
Every edit is logged to DuckDB and replays on load. With MR_BUILDER_KEY set only people who logged in with it may edit; unset, everyone who got in may.
Place, turn, erase
- E toggles edit mode (placing walls).
- In edit mode only: 1…9 pick the first nine kinds, 0 portal; Shift+digit the next six; Alt+digit the ten after. The kinds past those are placed from the bar at the top.
- R rotates what you place, X erases, right-click removes.
- Ctrl+Z takes back your own last edits, one at a time (32 deep).
- Esc goes back to walking. ? shows every key.
Text, portals, orders
/screen TEXT | Sets the nearest screen, sign or billboard |
/board Title | line | line | Sets the nearest board |
/portal room [x y level] | Points the nearest portal somewhere |
/order 7 · /order vodka | Orders from the bar by number or name |
/wave /dance /point /sit | Emotes |
/group name · /g text | Joins a group (/group alone leaves it); says something to it, in whatever room each member is |
/letter nick text | Leaves a letter where you stand; only that nick can open it, by clicking it. It survives restarts |
/plane nick text | Throws a note to someone here; only they read it |
/follow nick · /unfollow | Walks after someone, across seams too; clicking a person offers it as well |
/map · N | The world map with how many are in each room |
/help | The commands, in the chat |
Talking and finding each other
- Click a person for a menu: walk to, follow, wave, throw a note, leave a letter. The cursor's hint says what a click does.
- Joining a room shows its last eight lines as fading bubbles where they were said. A room with
quiet: truecarries chat only five tiles and keeps no echoes. - Your nick in someone's line is marked, pings, and counts in the tab title while the tab is hidden.
- The mic button turns on proximity voice: people near you hear you, quieter with distance and duller through walls. It needs https or localhost.
- Click a screen, sign, billboard or board to read it up close; Esc goes back.
Your nick, kept
- Give a password when you join and the nick is yours: from then on it needs that password, and nobody else can join as it.
- Your outfit, group, display settings and where you stood go with it, to any browser, and survive a crash or restart.
- ⚙ → Account changes the password or logs out. Without a password you join as a guest under any free nick.
- The whole story, keys and cookies included:
SECURITY.md.
Text size, contrast, motion
- Text size scales names, chat and panels. High contrast puts them on solid backgrounds.
- Reduce motion (on if your system asks for it) drops trails, glitches, the teleport shimmer and interface animation.
- On a phone, buttons for what you are doing appear above the chat: trick and off on the half pipe, rows, fire, swap and give up in a transfer, close, unfollow.
What survives a restart
- Durable, replayed: placing, removing, rotating, screen text, portal targets, book box links.
- Durable, read at load: letters until opened, the boards' results, the lexicon's words, Kobold programs and champions, Kobold duels (replayed for the ratings).
- Kept across a crash (
room_state): open doors, the DJ deck and its mixer, matches under way (settled on load), notices owed; accounts with their outfit, settings and last tile. - History only: chat, joins, leaves, drinks served.
- Gone on restart: who sits where, guests' positions, props, a Commando run, the Kobold crew, the run on show, a live duel's clock, a relay shift under way and the show on the rig with its queue.
The bar's menu
One array in protocol/src/lib.rs. Each item has a name, a course (what it is served in), a glow colour and an Effect. Orders and props carry the index; the event log stores the name, so you may reorder freely.
One line
item("Heap Dump With Fries", Course::Food, [1.0, 0.6, 0.1], Effect { sway: -0.3, ..Effect::NONE }),
Bump the array length MENU: [MenuItem; N]. The DOM menu, /order, the prop's shape and colour all follow.
What it comes in
Coffee | a cup with a handle | no shake |
Bar | a glass with a straw | shaken 2.5 s |
Food | a plate, stacked rings | no shake |
Soft | a glass | no shake |
Effect fields
sway: drunk walk, swimming head, haze.jitter: trembling, twitchy view, grain.haze: blurred, dark edges.secs: how long one dose takes to wear off.- Doses are 0..1 and add up per sip; negative ones sober. Helpers:
booze(s),caffeine(s).
An order, start to finish
- Click a bar, sit at a stool beside it, or type
/order. - Greeting → walk to a station → shake (bar drinks) → serve → slide along the bar top.
- The customer picks it up within 2.5 tiles. Sips at 4 s, then every 15–25 s; three sips and it is gone.
- Lines live in
server/src/room/bartender.rs:GREETINGS,SERVES,TALK.
Effect tuner
Pick an item, take sips, and watch the figure stagger with the same formulas the client uses (client/src/buzz.rs). Tune the dose and copy the line back into MENU.
Sound is text
One sound per file, synthesised (FM, subtractive or drum voice) into a delay and a reverb; nothing is sampled. Two sources are code instead: the disco's machine and the SID. A field with two values goes from the softest to the hardest play: v is the event's strength over ref. Times are milliseconds; pitches and times interpolate geometrically.
Every key a .snd file knows
| Key | Values | Meaning |
|---|
Adding a sound the game can play
- In
client/src/sfx/mod.rs: a newEV_*constant beforeEV_COUNT(bump it), its name inEV_NAME, the file inSFX_SRC(bump its length). - The file's
eventline uses that name. - Play it from
app.rswithself.play(sfx::EV_…, key, v, spot, delay_ms); held sounds useSound::hold/steer/release. - Run
cargo test -p client --lib sfx, then re-pinVOICE_GOLDEN.
The disco's machine
- The disco's drums, its acid bass and the DJ's mixer are synthesised in the engine, not played from files: a kick swept in pitch with a click, a two-mode snare, 808 hats (the open one choked), a 303 bass through a saturating ladder at twice the rate.
- The mixer: a strip per channel (gain, three-band kill EQ, fader, send), the crossfader, the master, a limiter at 0.9; the send feeds the 80s chain: saturation, chorus, delay, reverb, each switched without a click.
- Notes are
EV_MACHINEevents; knobs are tweaks of it (never dropped), set fromDeckbyset_machine. cargo test -p client --release --lib machinechecks voices, choke, clicks, aliasing, switching latency and cost.
A SID
- After the C64's chip: three voices (pulse, saw, triangle with ring mod, noise), its ADSR and a resonant filter, and a 50 Hz player for the march.
- Effects take voice 3, the most urgent winning.
EV_SIDcommands it:keyaCMD_*orFX_*,va level. - Commando sounds through it.
The room's buses
delay_time 240 delay_feedback 0.32 delay_damp 2400 delay_spread 1.4 reverb_predelay 14 reverb_rt60 2300 reverb_damp 3600 reverb_width 1.0 master_gain 0.9
Each room overrides the reverb time from its size and style (Sabine), in app/ambience.rs.
Beds and details
beds(style): up to two held sounds on the ambience bus with their level, pitch and edge.details(style): one-shots at lamps, signs or glowing things with a mean interval.- The ambience ducks about 8 dB under the room's own sounds; players set its level under Ambience.
Patch sketcher
Shape a sound and copy the file. The preview is a rough Web Audio stand-in for the engine (no FM index sweep, no drive), good enough to judge length, pitch and brightness.
Looks, light and the frame
Six shader looks share one scene. Every look in the menu (52 of them) is a profile built on one of them, and choosing it sets every display setting, as far as the device's Quality allows. U steps to the next, Shift+U back.
Look, Quality, Fine-tune
- Look: a complete preset. Nothing lingers from the look before; what a profile leaves out comes from
Settings::DEFAULT. - Quality: Auto (Low on WebGL2 and phones, High on WebGPU), Low (no traced light, reflections, shafts or MSAA), Medium (traced light at most On, no shafts), High.
- Fine-tune: the raw switches; the vertex (10), edge (11, Fine among them) and face (12, Stone and Metal among them) themes; line weights for detail, edges and silhouettes. A change marks the look tuned until you choose a look again. Saved as
settings.v5.
Profiles
look Off-World base diorama shafts on # glow mirror shadows trails too lighting ultra # capped by Quality tone_dark #0a2a35 tone_light #ff9a3c ramp #000000 #06151c #1d4a55 #d77a2e #ffe0a8 streak 0.8 # anamorphic flares grain 0.35 # film: grain vignette letterbox letterbox 2.39 text 2 # text mode: the frame as characters
Shipped: the six shader looks; Off-World, Nouveau, Blockwork, Sunset, Noir, Character Soup; ten that set every property (Blueprint to Chalkboard); thirty traditional ones, from Daylight and Kodachrome through Architect's Model and Museum to Wireframe, Hidden Line, Vector Display and Cinema. From the twelfth on, every profile sets every key (a test checks). Add a file to look::LOOK_SRC; the look-from-image skill drafts one from a picture.
The six shader looks
| Diorama | Default. Low-poly blocks in pools of coloured light, neon spilling onto surfaces. |
| Lounge | Solid colour, soft edges. |
| Atmosphere | Glass walls, haze, shading modes Flat, Gouraud, Phong, Glass. |
| Classic vector | Pure beams, the original. |
| Graphic | Illustrated colour blocks on black. |
| Comic | Ink and hatching on a navy night. |
Shader entry points
diorama(),lounge(),atmo(): ranges ofcamera.look.x;graphic()is look ≥ 4,comic()5.- Illustrated looks return early in
fs_filland take derivatives before any branch (Chrome rejectsfwidthin non-uniform flow). pose_of()holds the walk cycle and gestures; add a gesture there, inscene::animand ingesture_len.post.wgsl: bloom, tone mapping, vignette, the grade (graded), text mode (as_text), the anamorphic streak, and photo mode's and the look's film.
The light volume
- Lamps, glowing objects and neon lines feed a 3D grid (
light.rs), two cells per tile. - Per kind: height, reach and colour in
emits(); neon spill inNEON_SPILL. - It covers the room you are in (
light::Patch): rooms next door are blacked out, neither drawn nor lit (1.4 ms to bake a 24×24 room). - An edit rebakes only within 9 tiles of itself, bit-identical to a full bake.
- Lighting tiers Off, On, Ultra add traced bounce light (WebGPU only).
Views
- Orbit: drag to turn, wheel to zoom, WASD or Shift-drag to pan, ←→ quarter turns.
- V first person (automatic when you sit).
- O photo mode: free flight, the six shader looks on 1–6, exposure, grain, letterbox, tilt-shift by clicking. Moving draws a lighter picture; held still it draws everything at its best, past Quality. All rooms of the world shows every room on the map at once. C waits for the light to settle and captures at 2×.
- B boss mode: flat, top-down, silent, every text coded.
- PgUp/PgDn show the floors above and below: in the bazaar, the decks and the rooftop.
Change one side, change the other
| Rust | WGSL | Size or rule |
|---|---|---|
gfx::CameraUniform | Camera in scene.wgsl | 304 bytes: vol, beat, then world (x elevation, yzw line weights) |
gfx::VERTEX_THEMES, EDGE_THEMES, FACE_THEMES | V_*, E_*, F_* | same order; look::THEME_MAX caps each |
look::Grade::lanes() | Grade in post.wgsl | ten vec4; w lanes: text, text_bg, streak |
gfx::GiParams | Params in gi.wgsl | four vec4, the volume's corner last |
scene::Line | beam vertex layout | 64 bytes |
scene::Instance | instance attributes | 80 bytes |
gfx::Photo::lanes() | Photo in post.wgsl | two vec4 |
scene::anim, scene::rig | G_*, bones, HOLDING | by value |
LIGHT_SCALE, MAX_LIGHTS | same names | 32 lights |
Get one wrong and the geometry arrives scrambled with no error. cargo test -p client --test shaders validates both shaders and their WebGL2 translation, not the layouts.
Things that move, people who live here
Deterministic props
props::stepruns on the server and every client from the same body.- Only
+ − × ÷,sqrt,min,max,abs: nosin,powf,mul_addor SIMD. - After any change, re-pin
GOLDENinprops/tests/golden.rsand check the wasm build prints the same hash.
Games are shared rules
- Each game's rules live in
protocoland run on the server and every client:skate,transfer,football,cycles,court,dizzy,bomber,commando,kobold,lift,paternoster. Change one and rebuild both sides together. commando::Gameis lockstep: the runner's browser, every onlooker's and the room step it from the same input bytes. Like props it may use only+ − × ÷,sqrt,min,max,abs.koboldis integer only, so a run is its job, its case and the programs. The server scores a submission's 100 cases off the room task;golden_scorespins the reference solutions. A duel is two games with the homes swapped: side 1 reads links through the arena's mirror, and the sides take turns going first within a cycle. Ratings are Elo per nick and arena, replayed fromkobold_duelrows at load. A relay shift is its line, its seed and its swaps: clients play it behind the room and replay from the start when a swap comes late, and the room replays it whole to score it. A light show is its program, its seed and its beat period, the period taken from the disco's tempo onDirectory::tempo. The room never steps a show, only browsers do; it checks a set piece withkobold::pieceon its own task. Jobs and arenas are append-only.- The lexicon's fold is the server's: it refits the room's principal axes and sends them; clients only apply them.
Tuning
| Rolling resistance | ROLL_DECEL 2.5 tiles/s² |
| Kick reach | KICK_REACH 2 tiles |
| Kick cooldown | 24 ticks (0.4 s) |
| Balls per room | MAX_BALLS 4, one per Ball spot |
Grab, carry, throw
- Click a glass to pick it up; Shift+click a ball.
- Holding: Shift+click a point to throw, G to put down.
- Drawn at the holder's right hand; the forearm is raised by
anim::HOLDINGin the idle lane.
Behaviour knobs
NICKSand timing inroom/residents.rs; they act every 0.5 s while a real player is in the room.- They use the same walk, sit and emote paths as players, so they cannot do anything a player cannot.
The event log is the contract
One DuckDB table, events, whose payload is JSON of eventlog::Event. Variants and fields are append-only: old rows must keep replaying.
What is logged
place remove rotate screen portal drop take | replayed onto the layout (from the latest snapshot) |
chat join leave served | history, archived to Parquet after 24 h |
skate transfer cycles court arcade dizzy bomber commando kobold | results, read at load for the boards; never archived |
kobold_text | each nick's program for a job or arena; the latest is read at load. A line's station programs are rows under job 160 + 4 × line + station, the latest whoever wrote it |
kobold_duel | a duel in an arena, both nicks and scores, live or a challenge; replayed in order at load for the ratings |
kobold_champion | each nick's champion for an arena; the latest counts |
kobold_shift | a relay shift on a line, its score and crew; read at load for the board, never archived |
kobold_piece | a light-show set piece a nick solved, and its size; read at load for the board, never archived |
letter letter_opened | letters lie there until opened |
lexicon | lines that grew the tree, replayed into it at load |
ts_us is UTC microseconds. A failed write ends the process rather than diverge from disk.
Read it with DuckDB
SELECT payload::json->>'item' AS item, count(*) FROM events WHERE kind = 'served' GROUP BY 1 ORDER BY 2 DESC;
Open a copy, or the file with the server stopped.
Load and census
server/src/load.rs: a thread samples the process's CPU and memory each second into awatch, two minutes kept.Directory::census: each room writes its headcount on join and leave.- A room passes either on only when one of its billboards asks (
#server,#departures).
Must not allocate
- The move and chat broadcast path;
tests/alloc.rschecks it, with and without a neighbour room observing. - A whole race;
race/tests/alloc.rs. - Room-to-room messages only
try_send; a blocking send between two full inboxes deadlocks.
Ship it
One process, one DuckDB file, UDP for WebRTC. Never more than one replica.
Four commands
IMAGE=ghcr.io/you/maschinenraum scripts/image.sh --push
scripts/deploy.sh kustomize prod
scripts/deploy.sh helm --set rtc.publicIP=203.0.113.10
scripts/compose.sh up # Caddy + TLS on this machine
scripts/lint.shWebRTC needs a reachable port
- Kubernetes: a hostPort on a pinned node (
maschinenraum/rtc=true) withMR_RTC_PUBLICset to its public IP. - Helm also offers
rtc.mode=loadBalancerandoff. - Without UDP everything still works over the WebSocket, with more race latency.
Keep it alive
- SIGTERM flushes the event log: give it 30 s.
- Back up the DuckDB file with the server stopped.
- Apply manifests server-side; client-side apply drops one of the two port-8080 entries.
I changed…
Tick what you touched. You get the checks to run, in order, the invariants at stake and the docs to keep in step. Your ticks stay in this browser.
Run
Watch out
Update
Key map
Hover or tap a lit key.