▶ PLAY NOW: https://backrooms-beryl.vercel.app — host a session, send the
code to friends. (Game server: backrooms-mp.fly.dev, sleeps when idle; the
first connect of the day may take a few seconds to wake it.)
A browser-based co-op horror game in three descending acts. You and up to 7 friends have noclipped into an infinite, procedurally generated labyrinth. Each level: find each other, pull the three breaker panels to power the dead exit, and step through together — but the door doesn't go out, it goes down. LEVEL 0 (the mono-yellow lobby) → LEVEL 1 (a concrete garage under sodium light) → THE POOLROOMS (glossy tile and cold water light). The thing in the walls gets faster and hungrier with every level — only escaping the poolrooms sets you free. It hunts whoever wanders off alone, and the only thing it hates is your flashlight.
Everything — textures, audio, the maze itself — is generated procedurally at runtime. There are no asset downloads; the whole game is ~150 kB gzipped.
npm install
npm run devOpen http://localhost:5314 in two browser tabs. In tab one, hit
HOST A DESCENT and note the code (e.g. MOIST-7431). In tab two, enter the
code and JOIN. You'll spawn scattered in the same maze — go find yourself.
Tip: browsers only allow microphone + audio autoplay on
localhostand HTTPS, so voice chat works locally and on any deployed HTTPS URL.
| Thing | What it means |
|---|---|
| The code | Seeds the world. Everyone with the same code generates the identical infinite maze, deterministically. |
| The entity | Driven by an AI Director: calm → build → peak → guaranteed breathing room, with a hunger that shortens the quiet as the round goes on. It prefers the most isolated player, remembers its last victim and picks on someone else next, and its first appearance each round is only a warning — it closes in, stares, and leaves. Lights die around it; the hum goes silent near it. |
| Going down | Getting caught doesn't kill you — you're downed: crawling, bleeding out (60s). A teammate holding E next to you for 4s brings you back. Downed alone with nobody left? You have 12 seconds to make peace. It always retreats after a takedown — the rescue window is real. |
| Blackouts | Sometimes, when it commits, every light in the world dies with it. |
| Objectives per level | L0: pull 3 breakers (sparking, beaconed). L1: find 3 fuel canisters and carry them to THE GENERATOR — the fuel sloshes, and it hears carriers; G sets it down. THE POOLROOMS: two drain valves held simultaneously, far apart, on the level where it's fastest. Power up → the exit opens → it stops being patient. |
| Noise | Sprinting, wading, carrying fuel — it can hear all of it and prefers loud targets. Walking quietly is a real tactic. |
| The bodies | Die, and your corpse — name scrawled beside it — is still findable next round, in the new maze. The backrooms keep what they take. |
| The light | Hold your flashlight beam on the entity: it slows. Keep it lit up during a hunt for a few seconds: it flees shrieking. Your only weapon. |
| Glimpses | Sometimes it's just… standing there, far down a hall. Sometimes it isn't there at all. Low sanity blurs the difference. |
Chalk (hold C, flick mouse, release) |
Draw arrows/symbols on walls and floors. Synced to everyone, persistent for the session. Navigate — or gaslight. |
Mental map (hold TAB) |
A chalk sketch of everywhere you have personally been — scroll to zoom. Shows the crew's chalk marks, landmarks you've found, breakers, the exit once seen, where people died, and edge arrows toward everything off-screen. |
| Almond water | Carry up to two bottles; Q to drink. Restores sanity and gives an 8-second pulse toward the nearest objective. Drinking while bleeding out stabilises you — once. |
| Sanity | Drains alone, in the dark, near the entity. Low sanity means whispers, visual decay, and things that aren't there. Company heals it. |
| Echoes | The dead become spectators. They can't speak — but every 25s they can press F to stutter the lights near them. Near the entity, that flicker stuns it. Guide the living. Or troll them. |
| Audio mimicry | Late in a round, the entity borrows footsteps and voices and plays them from the wrong direction. Trust nothing you can't see. |
| Voice chat | Proximity-based WebRTC. Voices fade with distance, muffle through walls, and corrupt when the entity is near. Push V to talk. |
Controls: WASD move · SHIFT sprint (stamina) · F flashlight (echo: light-flicker) ·
C hold = chalk · TAB hold = map · V hold = talk · ENTER chat · ESC menu.
It's already deployed (see the top). The current production setup:
- Game server (rooms + entity + a fallback copy of the client) on Fly.io:
app
backrooms-mp, regionlhr, scale-to-zero. Update with:fly deploy
- Client on Vercel (project
backrooms→ backrooms-beryl.vercel.app), built against the Fly WebSocket URL. Update with:(https://backrooms-mp.fly.dev also serves the game directly if Vercel is ever down.)cd client && VITE_WS_URL=wss://backrooms-mp.fly.dev/ws npx vite build rm -rf ../.deploy-vercel && mkdir ../.deploy-vercel && cp -r dist/* ../.deploy-vercel/ cd ../.deploy-vercel && vercel deploy --prod --yes
If you'd rather run it elsewhere, the whole game — static client and WebSocket rooms — is one Node process, so you deploy exactly one thing. No database, no required environment variables.
- Push this folder to a GitHub repo.
- Go to render.com → New → Web Service → pick the repo.
- Set:
- Build command:
npm install && npm run build - Start command:
npm start
- Build command:
- Create the service. That's it — share
https://<your-app>.onrender.comand a room code.
Free-tier note: the instance sleeps after ~15 min idle, so the first person to open it may wait ~40s for a cold start. Everyone after that is instant.
Both work with zero config changes: they run npm install && npm run build
then npm start, and inject PORT, which the server respects.
- Railway: New Project → Deploy from GitHub repo, set the build/start commands above under Settings if not auto-detected.
- Fly:
fly launch(Node builder), setinternal_portto matchPORT.
Vercel/Netlify alone won't work — they don't host long-lived WebSocket
servers. If you must use Vercel for the client, deploy client/dist there and
run the server on Render, then point the client at it (change the WS URL in
client/src/net.ts).
Plain ws WebSockets with a server-authoritative room model. Considered:
- PeerJS / WebRTC-P2P host-authoritative — free hosting (no server), but NAT traversal fails for a meaningful slice of home networks without a TURN server (which isn't free), and the host leaving kills the session or forces hairy host migration. Rejected for reliability.
- Colyseus / PartyKit — excellent, but they add a framework and (for
PartyKit) a platform dependency for what is here ~300 lines of room logic.
Rejected for deployability-simplicity: one
npm startprocess wins. - Socket.IO — adds fallbacks (long-polling) we don't need in 2026 and a
fatter wire format. Plain
wsis smaller and does the job.
Consequences of server-authority: there is no privileged "host" client — the code creator is just player #1 — so anyone can disconnect (including the host) and the session lives on. No host migration needed by design. The entity AI runs on the server, so all players share one coherent monster. WebRTC is used, but only for voice, with the WebSocket as signalling.
Sync details: clients send position at 12.5 Hz; the server rebroadcasts room state at 10 Hz; remote players render 150 ms in the past with snapshot interpolation (no teleporting, no jitter). The world needs no sync at all — it's a pure function of the seed.
shared/src/ worldgen.ts deterministic infinite maze (walls, archetypes,
exit, pickups, spawns, collision, line-of-sight)
protocol.ts the complete wire protocol (typed)
rng.ts hashing — the universe derives from this
server/src/ index.ts http static host + ws upgrade + room registry
room.ts sessions: join/leave, relay, win/wipe, rounds
entity.ts the hunter: A* pathing, isolation targeting
testbot.ts headless test player (see below)
client/src/ game.ts the conductor
world.ts chunked renderer, instanced meshes, light pool
player.ts movement, stamina, head-bob, flashlight
avatars.ts remote players, headlamps, occlusion nametags
entityView.ts the thing, client-side
audio.ts 100% synthesized: hum, footsteps, dread
voice.ts proximity WebRTC voice + entity interference
fx.ts bloom + grain/grade/aberration/VHS shader
chalk.ts synced wall marks
map.ts the TAB mental map
ui.ts title/HUD/overlays
Level 0 is an infinite grid of 4 m cells in 32 m blocks: always-open hall lattice every 8 cells (so the maze is provably connected), block interiors filled per-archetype — open plains, random-walled rooms, pillar halls, cubicle farms (low walls the entity steps over), tiled pool blocks, and rare landmark blocks (a lone chair, filing cabinets, scrawled writing, almond-water shrines, and somewhere out there: the exit).
Performance: instanced meshes per chunk, a pool of 10 real point lights assigned to the nearest fixtures each frame, chunk load/unload around the player, fog-bounded draw distance. Targets 60 fps at 8 players.
# with `npm run dev` running and a hosted room:
npx tsx server/src/testbot.ts MOIST-7431 60A headless player joins your room for 60s, walks in circles, drops a chalk mark, says hello (proximity chat — you'll only see it if you're close), and logs everything the server tells it, including entity activity.
Win (everyone through the exit) or wipe (everyone taken), then GO BACK IN:
same crew, same code, a new maze (the seed is code:round). Chalk resets;
grudges persist.