The Backrooms: No Exit
This world didn't start here.
It needs WebGPU or WebGL2: try a recent Chrome, Edge or Safari, or open it on its own.
Open on its own
Goal & tips
Goal Follow your camcorder's beeping through the endless yellow rooms: three lost tapes, batteries, the breakers, a TV, then the EXIT. There is no exit.
Try this
- The SIGNAL under your task points the way: turn until it shows ▲, the beeping speeds up as you get close
- Walk over tapes and batteries to take them, press E on breakers, the TV and the door
- Every tape takes you a level deeper. From level 2, when the lights cut out, look where it was standing
- Jump scares can be turned off in the pause menu (Esc). H hides the HUD for recording
Keys WASD walkShift runMouse lookE useH recording mode (REC + task only)C cinema (camcorder only)F full screenM muteEsc pause
Secret Go through the EXIT. Then keep going.
Show hint (spoiler)
After the exit you wake up on the carpet again. The second loop's tapes are recorded in your own voice, and it no longer waits until level 2.
The prompt
Build **"The Backrooms: No Exit"**: an infinite first-person horror walk through endless yellow office rooms, as ONE self-contained HTML file with **synthesised sound**. You noclipped out of reality. There is no exit, and nothing can hurt you; the fear comes from the hum, the emptiness and the thing that keeps standing a little closer each time.
It should feel like **found footage on a 1996 camcorder tape**: the damp, buzzing, too-ordinary office yellow, a picture that is soft, noisy and slightly wrong, and **dread first, then a few earned jump scares**. No gore, no chase, no game over. There is always a clear next task.
## Tech
- three.js r170 with `WebGPURenderer` (automatic WebGL2 fallback). Load it with an importmap from jsDelivr: map "three", "three/webgpu" and "three/tsl" to `https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.webgpu.min.js`, and "three/addons/" to `https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/`. Use TSL node materials and `THREE.PostProcessing` with TSL passes. Movement, collision and generation are your own code (no physics libraries).
- The only other network request is the **VT323** font from Google Fonts for the camcorder overlay and HUD; if it fails, fall back to `ui-monospace, monospace`. No other libraries, fonts, images, models, textures or audio files: every texture is drawn on a canvas or generated as data at start-up, every sound is synthesized.
- Start with `<!doctype html>`, `<meta charset="utf-8">` and a viewport meta tag.
- It must run inside `<iframe sandbox="allow-scripts allow-pointer-lock">` (no `allow-same-origin`): `localStorage` and cookies throw there, so wrap every storage access in `try/catch` and keep values in memory. No `alert`, pop-ups or top-level navigation.
- **Keys:** read `KeyboardEvent.code` (`KeyW`, `ShiftLeft`, `Space`...), never `.key`: on a Ukrainian, Russian or AZERTY layout `.key` for W is `ц`/`z` and you could not walk. `preventDefault` on Space and the arrows, so the page around the game never scrolls.
- **Pointer lock and focus:** clicking the canvas requests pointer lock (it is allowed in this sandbox); losing it (`Esc`, or the visitor clicks the page around the iframe) pauses and shows the pause screen. Handle `pointerlockerror` and browsers without pointer lock: then drag to look. After any on-screen button is clicked, hand focus back to the canvas. Clamp mouse deltas (some browsers send a huge jump on lock).
- Fixed 60 Hz simulation timestep with bounded substeps, decoupled from frame rate; on a slow frame drop simulation time instead of speeding up. Draw the camera between its last two fixed-step states (interpolate by the leftover time), so turning and walking never stutter. Clamp the first frame's delta at 0 (the first `requestAnimationFrame` timestamp can be earlier than `performance.now()`).
- Preallocate every buffer, geometry and material at start-up; nothing is created while walking (see Performance).
- **Performance is a feature here, not polish:** a locked 60 fps on a laptop with integrated graphics and on a mid-range phone, no hitches ever. Follow the `## Performance` section to the letter (budgets, dynamic resolution, shader warm-up, zero allocations). Respect `prefers-reduced-motion` (much less sway, no tracking glitches, gentler flicker).
- Known three r170 pitfalls, avoid them:
- the scene background is drawn after transparent objects: draw everything as meshes, the fog does the rest;
- never `.clone()` a `CanvasTexture` (the clone uploads black);
- create every `InstancedMesh` at its full capacity and never draw one with fewer than 2 instances (wrong-size uniform buffer, black screen); hide unused instances by scaling them to zero, never by lowering `count`;
- never let a helper overwrite a shared temp vector its caller still uses;
- a vertex shader that reads a texture must sample it at an explicit level (`.level(0)` in TSL).
## Priorities
Build it in this order, and finish each tier properly before starting the next. A polished tier 1 beats a broken everything.
1. **Must be excellent:** the rooms (the exact yellow, the light pools, the fog, the hum): one screenshot must say "Backrooms" instantly; endless seamless generation with no visible popping and no getting stuck; the camcorder look; a locked 60 fps with no hitches (chunk rebuilds, first sight of every prop and of the silhouette included) and smooth mouse look and walking; the demo mode.
2. **Must work:** the task chain with its beeping signal, the jump scares, levels and the counter, the silhouette, flickering and dead panels that really darken their surroundings, the odd details, footsteps, breathing and knocks, the wake-up intro, the pause screen, phone controls.
3. **Polish:** the event director's rarer events, the CRT TV, the shifting layout, the camcorder lamp and zoom, the second loop.
Never ship a feature half-working: if something from tier 3 can't be done well, leave it out cleanly.
## The rooms
- **Scale (metres):** eye height 1.62, ceiling **2.5** (low; the silhouette has to stoop under it). The world is a grid of **3 m cells**; walls stand on cell edges, 0.15 m thick, so rooms, corridors and pillars all snap to it.
- **Wallpaper:** classic mono-yellow, #c9b558, with a faint repeating diamond-and-chevron print (one motif every 25 cm, lines just darker than the paper, so it reads at 480 px but never shouts) and soft vertical bands, drawn on a canvas (1 m tile) and mapped in world space (from world position, never per wall) so it never seams or stretches. Seams every 0.5 m, some **peeling**: the strip's edge lifts on one side as a paler curl with a thin shadow, more often near the floor and ceiling. **Water stains near the floor**: rising damp from the wet carpet, darker and browner, with a brown tide mark and an older fainter line below it; only faint leaks under the ceiling. A darker 8 cm baseboard band. Vary all of it per wall with world-position noise in the shader, so no two walls repeat.
- **No shimmer:** every texture gets a full mip chain, trilinear filtering and the GPU's maximum anisotropy (`renderer.getMaxAnisotropy()`). Never hide a pattern to fight aliasing: the wallpaper print and stains stay **fully visible up to ~8 m** and only drift toward the paper's average colour on far walls (9-16 m from the camera, at most 70 %). Thin procedural lines are box-filtered by screen-space footprint (`fwidth`): ceiling grid lines average out, the diffuser pattern flattens, the carpet's finest fibres fade where a pixel can't resolve them.
- **Carpet:** old damp beige carpet, never concrete: low-contrast beige (#b9a46a to #a08a58), a soft fibrous pile (centimetre tufts, streaky fibres and 2 cm matted clumps, faded out where a pixel can't resolve them), a velvety brightening at grazing angles where it is dry, and **darker wet patches** (darker, browner, matted flat with no sheen) with a faint tide line.
- **Ceiling:** a drop ceiling of 0.6 m tiles, off-white #d9d1a6 yellowed toward the stains, thin grey grid lines, a few sagging or water-stained tiles.
- **Light panels:** recessed 1.2 x 0.6 m fluorescent panels, **two per cell** (0.6 m either side of its centre, on the 0.6 m tile grid), behind a prismatic diffuser pattern. They are **not meshes**: the ceiling shader draws them (frame, diffuser, tubes) and reads each panel's brightness from the light grid, so thousands of panels cost zero draw calls. **Uneven**: every panel has its own brightness (0.66-1.1) and its tube drifts warm (#ffecbd) or cold (#eef8ff), some with greying ends. States: **on**, **soft flicker** (irregular stutters, a buzz and ticks; never regular blinking), **hard flicker** (a dying tube that cuts fully to black, sometimes stays off) and **dead** (grey, darker tube shapes visible). At LEVEL 0 about 80 % on, 11 % flickering, 9 % dead.
- **Light pools:** no real lights. Light the walls, carpet, ceiling and props from a **light grid**: a small `DataTexture` with one texel per panel (32 cells x 64 panel rows around the player, RGBA8: brightness, wall flags), re-uploaded only on frames where a value changed, scrolled with the player in whole-cell steps, and sampled with bilinear filtering in world XZ by every material, clamped at walls so light never leaks through them. Bright **pools** under each panel, **dark zones** between and beyond them (walls 1.5 m from a panel row are clearly darker), black-ish holes under dead tubes, and a flickering panel visibly flickers its half of the room. Add a cheap height gradient (darker near the floor and the ceiling corners) as baked occlusion.
- **Fog:** thick, warm, slightly greenish yellow (#8f7f45 at level 0, darker and browner deeper), exponential squared, so every corridor fades into nothing. Things in the fog lose saturation first, then contrast.
- **Layout:** rooms from 1 x 1 to 3 x 4 cells, connected by doorless openings, short corridors and occasionally a **corridor that is too long** (8-20 cells straight, one panel lit in the middle). Pillars at some grid corners in bigger rooms. Never a dead end you can't turn around in, never a sealed area.
## Generation (infinite, never the same twice)
- A random session seed each load; every chunk's layout is a pure function of `(seed, chunkX, chunkZ, level band)`, so walking back shows the same rooms (except the shifting rooms below).
- **Chunks of 6 x 6 cells (18 m).** Keep a 5 x 5 window around the player; when they cross a chunk edge, rebuild the far row into the freed chunk, spread over a few frames, never more than one chunk per frame. Everything rebuilt is out of sight inside the fog.
- **Seams:** the openings on a chunk's border come from a hash of that border edge, so both neighbours agree and chunks always connect.
- **Connectivity:** inside a chunk, carve a random spanning tree over the cells (guarantees everything is reachable), then knock out extra walls by the level's openness. The cell where you wake up and its neighbours are always open.
- **Counting rooms:** every cell you step into for the first time adds 1 to ROOMS WALKED (kept in a hashed set of visited cells). The counter never counts the same cell twice.
## Odd details
Rare, placed by the chunk seed, never two in the same room, about one per chunk. Each one is a small instanced or pooled prop, nothing created at runtime.
- A lonely office chair (dark grey fabric, five-star base), slightly turned. If you look away and back from more than 10 m, it faces you now.
- A wall outlet in the middle of a bare wall at the wrong height (1.6 m), or one in the middle of the floor.
- A door with a frame and a brass handle; `E` (or tap) opens it slowly with a creak onto **more wallpaper**.
- A door only 1 m tall.
- A light switch that does nothing (clicks; the hum dips for a second).
- A wet patch with a slow drip from a stained ceiling tile (a drop falls, a ring spreads).
- A green EXIT sign over an opening that leads into a one-cell room with no other way out.
- A stack of damp cardboard boxes; a single folding table.
## Levels
Top-left HUD: `LEVEL 0` and `ROOMS WALKED: N`. **Every 100 rooms the level goes up**, endlessly. On a level change the picture breaks up for half a second (tape glitch), the overlay flashes `LEVEL N`, and over 4 s the lights dim, the hum shifts and new chunks use the new level's layout. Interpolate every value between levels; past level 4 keep stepping toward the last row's limits.
| | panels on / flicker / dead | rooms | fog (density) | hum |
| --- | --- | --- | --- | --- |
| LEVEL 0 | 85 / 10 / 5 % | small, cosy, cluttered | light | 60 Hz, steady |
| LEVEL 1 | 72 / 13 / 15 % | bigger | thicker | louder, -10 cents |
| LEVEL 2 | 58 / 17 / 25 % | big, emptier, pillars | thick | -20 cents, a beating second hum |
| LEVEL 3 | 45 / 20 / 35 % | halls, almost no walls | thicker, browner | -30 cents, uneven |
| LEVEL 4+ | down to 30 / 20 / 50 % | vast and empty | heaviest | down to -50 cents |
Odd details get rarer and the rooms quieter as you go deeper. The deeper it is, the emptier it feels.
## Goal: the task chain
The visitor must always know what to do next. A line under ROOMS WALKED names the current task (`▸ Find the tape 1/3`), and under it a **signal** from the camcorder: an arrow (▲ ahead, ◀ ▶ turn, ▼ behind, ● here) and five bars for distance. The camcorder also **beeps** from the target's position (positional audio), faster as you get closer. After 45 s without progress a hint explains the signal; after 2 minutes, that the rooms connect and Shift runs.
Tasks, in order (each target placed 5-13 cells away in a loaded chunk; walking over tapes and batteries picks them up, `E` for the rest):
1. **Find the tape.** It plays as typed text on screen (`▶ PLAY TAPE 1 / 3`), two short lines from a previous lost person. Each tape also takes you one level deeper.
2. **Batteries.** The camcorder battery (top right) drops to 20 %: the picture darkens and gets noisier as it drains (about 2 minutes to empty). Find a battery pack.
3. **Second tape.**
4. **Restore the power.** POWER FAILURE: every light drops to 40 % and stutters. Find the **three breaker boxes** set into walls (grey box, lever, red LED that turns green); each one brings some light back.
5. **Third tape.**
6. **Play the tapes on a TV** (a CRT on a folding table). The TV shows live footage of you from behind; after 1.6 s the silhouette is standing right behind you on the screen and the hum cuts out. Turn around (or wait 6 s): jump scare. Then: "The exit is real."
7. **Follow the green light** to a freestanding door with a lit EXIT sign in the middle of a room. Walking through it: the door swings open, the picture burns to white, an end card (rooms walked, deepest level, time, how many times it came closer, "...or did you?"), then you wake up on the carpet again: "There is no exit." The next loop has different tape texts (in your own voice) and the silhouette from the start.
Tape texts (loop 1): "Day 1. I fell through the floor of the copy room. / Follow the beeping. Keep your batteries charged." · "Day 6. When the power fails, the lights die. All of them. / There are breakers in the walls. Something lives in the dark." · "Day ??. Play the tapes on a TV. They show the way out. / Whatever you hear while it plays, don't turn around."
## Jump scares
A switch in the pause screen turns them off (they become a glitch and a thud). At most one every 75 s, except the scripted ones.
- **The face:** a pale, too-long face with sunken black eyes and a stretched mouth fills the screen for 0.6 s through the camcorder pass (glitch, tear, camera shake) with a synthesized scream (detuned saws sweeping up, noise, a low hit). Drawn on a canvas at start-up, never an image file.
- When: the TV task (always); turning around when it breathes behind you (the silhouette's last step); sometimes (20 %) a door you open from LEVEL 1.
- **The approach** (from LEVEL 2, the director's rarest event, also usable without the face): it stands at the end of a straight hall (needs ~15 m), then the lights around you cut to black (0.3 s, the fog goes dark too) at 1.3, 2.5, 3.5 and 4.4 s, and **every time they come back it is closer**: 14, 10, 7, 4.5, 2.4 m. A last, longer blackout (0.6 s) at 5.2 s, then it is gone (a glitch) or it is the face. Each blackout has a click and a low hit that gets heavier. It is the best clip of the game: it reads with the sound off.
- **The figure in the dark:** during a lights-out event from LEVEL 2 (and after the second breaker), the silhouette stands 1.5-2.6 m in front of you for 0.65 s with a dissonant sting, then a glitch takes it.
## Writing on the walls
Marker writing on some walls (about one per chunk), shaded with the wallpaper underneath: "IT'S BEHIND YOU", "DON'T TRUST THE EXIT", "I COUNTED 1000 ROOMS", "THE HUM IS LOWER HERE", "STOP LOOKING AT IT", "NO EXIT", "WHERE IS EVERYONE", "WAKE UP WAKE UP".
## The silhouette (from LEVEL 2)
- A tall, thin, **pure black** figure, about 2.7 m, so it stands **stooped**, head bent forward under the 2.5 m ceiling. No face, no detail, slightly soft edges, motionless. It never moves while you watch.
- **It appears only at the far end of a long straight sightline** (found by marching the cell grid from the player along the view direction), deep inside the fog (60-80% fogged), and it is placed only while that spot is out of view or fully fogged: it never pops in on screen.
- **It is gone when you look again:** it vanishes when you stare at it for about 1.5 s, walk toward it past a threshold, or look away and back. The vanish is always hidden by a short tracking glitch (a band rolls through, a frame tears), never a visible pop.
- **It gets closer each time:** distances step down 42, 34, 27, 21, 16, 12, 9, 7 m; it appears roughly every 60-120 s, never during another event.
- **The last step:** after 7 m it stops appearing for a while. Then the hum cuts out completely, you hear one slow breath that is not yours, close and behind you (positional audio). When you turn around: nothing, the hum comes back, and its next appearances start far away again. It never touches you, never chases, never ends the game.
- When it is visible the hum dips a little and a very low swell (about 30 Hz) rises; your breathing holds.
## Event director
One small system paces the scares like a film: a tension value that rises slowly while walking and drops after each event; events fire only above a threshold, **never two within 20 s**, and some minutes stay quiet. Events, from common to rare:
- one panel stutters and buzzes;
- a distant knock: three dull knocks from inside a wall, somewhere to the side (positional, HRTF);
- the hum stops for 3 s, then snaps back;
- footsteps that echo yours a fraction of a second too late, for a few steps;
- the lights die one by one down a corridor toward you, then come back in the same order;
- (from LEVEL 2) **the approach** (see Jump scares), instead of about half the lights-out events;
- (tier 3) **the CRT TV:** an old TV on the carpet in an empty room shows static, then live footage of **you, from behind** (a second camera rendered at low resolution into a texture, 3 m behind the player); it switches back to static when you step in front of it.
- (tier 3) **the shifting rooms:** from LEVEL 1, rarely, a chunk that is completely out of sight is rebuilt with a new sub-seed. The room you just walked through is different when you turn back.
## You
- WASD / arrows walk at **1.4 m/s**, Shift runs at **3.4 m/s**, accelerating and stopping over about 0.2 s. Running lasts about 8 s, then you slow to a jog and your breathing gets heavy until you recover.
- Collision: a 0.3 m circle against the wall and pillar segments of the 3 x 3 chunks around you, sliding along walls, never through corners, never stuck.
- **Handheld camera:** a slight sway that never stops (breathing, small micro-jitter), a soft head bob in step with the footsteps while walking, stronger and rougher while running, a tiny roll when turning. All damped springs; nothing snaps. The camera drifts back to level when you stop.
- Mouse look with pointer lock, pitch limited to ±80 degrees, sensitivity and invert Y in the pause screen.
- **Phones:** left half of the screen is a floating thumb joystick (pushed to the edge = run), right half drags to look; an `E` button appears near usable things. No pointer lock on touch.
## The wake-up (intro)
- **Loading:** a camcorder blue screen with `PLAY ▶` in the top left until the first frame is drawn.
- **Before the first click** (needed for sound and pointer lock): the camera lies on the carpet at 0.2 m, the picture blurred, a small pill "Click to wake up" (phones: "Tap to wake up").
- **On the click:** the eyes blink open twice (soft black lids), the picture focuses, the camera rolls up from the carpet to standing over about 3 s with a stagger, the hum fades in from silence, and the text fades in centre screen: **"You noclipped out of reality."**, then fades out. Then a one-line hint that fades after the first steps: "WASD to walk · Shift to run · mouse to look" (phones: "Left thumb to walk · drag to look").
## Sound
- WebAudio only; create the `AudioContext` on the first click and never before. A master gain into a compressor, a short damp office reverb (a convolver whose impulse is generated noise), at most about 16 voices, every audio call in `try/catch`. Mute with `M` and a speaker pill.
- **Fluorescent hum:** mains hum (60 Hz with 120, 180 and 240 Hz harmonics, slow amplitude flutter) plus a thin high ballast buzz (filtered noise, 3-6 kHz, very quiet). Its loudness follows how many lit panels are near you; it detunes and grows louder level by level (see the table) and gets a beating second hum from LEVEL 2. A flickering panel adds positional ticks and buzz bursts in time with its flicker.
- **Footsteps:** soft, damp carpet: low-passed noise bursts with a muffled thump, timed exactly to the head bob, slightly squelchy on wet patches, heavier and faster when running.
- **Breathing:** filtered noise shaped into in / out breaths. Calm and barely audible at LEVEL 0-1; **faster from LEVEL 2** (and faster still each level), heavy after running, held while the silhouette is visible.
- **Knocks, doors, the switch, the drip, TV static:** all synthesized, positional where they come from somewhere.
- Silence is a sound: never fill every second.
## Look: 1996 camcorder found footage
Aim for a real tape someone found, not a game filter. This world overrides the site's usual "no heavy grain or chromatic aberration" rule: the camcorder is the subject. Keep it readable: the rooms must still be clearly visible.
- **Colour:** sRGB output, Neutral tone mapping, exposure about 1.1, then a VHS grade: lifted blacks, soft rolled-off highlights (panels bloom into the ceiling), a slight yellow-green cast.
- **Render small, then degrade:** render the scene into a target about **480 px tall** (it is both authentic and fast), then one fullscreen camcorder pass upscales it with:
- **chroma bleed:** colour blurred horizontally (in YIQ / YUV) more than brightness, plus 1-2 px red/blue channel offset growing toward the edges;
- **soft blur** and a gentle lens vignette;
- **scanlines** and fine luma noise (animated, never a static pattern), a few white dropout specks;
- slight horizontal line wobble (tape jitter), stronger for a frame now and then;
- a **tracking glitch** every 6-15 s: a band of torn, offset, noisy lines rolls up the frame for 0.3-0.6 s; rarely a full-frame wobble with a colour shift;
- a cheap glow on the panels (strength about 0.4, threshold above 1): one bright-pass downsample of the scene target to 1/8 size with a small blur, added in the same camcorder pass. Not the stock multi-mip bloom node.
- The whole camcorder pass is **one** fullscreen shader with at most about 12 texture reads per pixel, and the canvas it draws into is capped at about **900 px tall** (the browser scales it up; the picture is soft anyway), so a 4K screen costs the same as a laptop.
- **Camcorder overlay** (drawn crisp, as camcorders burn it in, in VT323, white with a 1 px dark shadow): bottom right a blinking red **"● REC"** (1 s cycle) and the timestamp **"NOV 08 1996 03:17:42 AM"**, ticking in real seconds from 03:17:00 AM; top right a small battery icon and a tape counter `0:00:00` that counts play time.
- **Photosensitivity:** never flash the whole screen more than 3 times a second; flicker affects rooms, not the full frame.
- **Framing:** horizontal field of view about 70 degrees (camcorder wide end), the picture fills the window, no letterboxing. Previews are square crops of the centre: keep the action centred.
- **Motion:** everything eases and settles; doors swing with weight, flickering panels stutter irregularly; nothing teleports on screen.
## Interface
A camcorder viewfinder, not a game HUD. All text in VT323, white or pale yellow, uppercase, with a dark shadow. No boxes, no icons except the battery and speaker.
- Top left: `LEVEL 0` (large) and `ROOMS WALKED: 0` under it. The counter ticks with a tiny blink.
- Top right: battery, tape counter, a status line `WEBGPU · LIVE` or `WEBGL2 · LIVE`, the speaker pill and a full screen pill (`F`; `document.documentElement.requestFullscreen()`, hidden where unsupported).
- Bottom right: ● REC and the timestamp.
- Near usable things (doors, the switch, the tape): a small `E` hint (a tap target on phones).
- **Pause screen** (`Esc`, or pointer lock lost; the world and the sound stop): the overlay switches to `PAUSE ‖`, the picture freezes with a slight tape wobble; a list: Resume, Sound, Mouse sensitivity, Invert Y, Camcorder effect strength (0-100 %), Field of view, Wake up again (new seed, LEVEL 0), and the controls.
- If neither WebGPU nor WebGL2 works, a designed message: a blue camcorder screen with "NO SIGNAL".
## Camera and keys
- `WASD` / arrows walk, `Shift` run, mouse look, `E` use, `H` recording mode, `Esc` pause, `F` full screen, `M` mute.
- `C` **Cinema**: hides the HUD (LEVEL / ROOMS / hints) but keeps the camcorder overlay, for recording clips.
- `H` **Recording mode**: hides everything except ● REC, the timestamp, LEVEL and the task line (no WEBGPU/WEBGL2 · LIVE, SOUND, FULL, rooms, signal, battery, counter, controls hint or [E] prompts). Press again to bring it all back.
- (tier 3) `L` the camcorder's tiny lamp: one narrow warm spotlight from the camera (no shadows), useful in the dark deep levels, with a flicker when the battery icon drops. Mouse wheel / `Z` camcorder zoom (1x-3x) with a short autofocus hunt.
## Performance
The bar is a **locked 60 fps with no single frame over 25 ms**, including chunk rebuilds, level changes, the first time any prop or the silhouette is seen, and the TV. Reference devices: a laptop with Intel Iris Xe / Apple M1 graphics in a 1080p window, and a mid-range phone (Pixel 6a, iPhone 12). The site's build guard paces frames to 60 fps and caps `devicePixelRatio` at 1.5: don't fight it.
**Budgets (every frame, in normal play):**
| | budget |
| --- | --- |
| draw calls (`renderer.info.render.calls`) | 40 or fewer, typically about 20 |
| triangles | 100k or fewer |
| JS per frame (simulation + generation slice + audio + HUD) | 3 ms or less on the laptop |
| GPU frame | 8 ms or less on the laptop at the default resolution |
| allocations in the frame loop | none (no GC pauses while walking for 2 minutes) |
| texture uploads per frame | only the light grid when it changed (a few KB) |
**Geometry and draw calls:**
- **Walls and pillars:** one `InstancedMesh` per chunk slot (25 slots, created once at full capacity for the worst-case chunk, reused forever) of a box with **no top and bottom faces** (8 triangles). Each slot gets a correct bounding sphere after a rebuild, so three frustum-culls whole chunks; slots entirely beyond fog-far are hidden. Unused instances scaled to zero, never a lower `count`. Draw opaque slots front to back (set each slot's `renderOrder` by distance whenever the player changes chunk) so depth testing rejects hidden walls early.
- **Floor and ceiling:** one large plane each that follows the player in whole-cell steps, textured from world position, so they never end. The ceiling draws the panels (see The rooms).
- **Props:** all props share one material and one small canvas atlas; each prop type is one `InstancedMesh` (capacity 8-16, at least 2) placed by the chunks. The silhouette is one mesh, created at start, hidden when not used.
- **Everything is opaque** except the drip ring; no transparency sorting, no shadow maps, no real lights except an ambient term and the optional lamp. The light grid and baked occlusion do that job.
- Low-poly is fine: at 480 px tall nobody sees triangles. Spend the budget on shading, not vertices.
**Shading:**
- Rendering at about 480 px tall keeps fill rate tiny: keep it that way. Wall, floor and ceiling shaders read at most about 6 textures per pixel; take noise from a small tiling noise texture made at start-up (2-3 reads), **never multi-octave procedural noise per pixel**. Fog, the light grid, stains and occlusion are all computed in the same material pass.
- Few materials (walls, floor, ceiling, props, silhouette, camcorder pass): fewer pipelines, faster start.
- **Shader warm-up:** before the wake-up, make every material and the post graph visible once (props and silhouette included, off to the side) and `await renderer.compileAsync(scene, camera)`, so no pipeline is ever compiled mid-game (that is the classic WebGPU hitch). The blurred "Click to wake up" screen hides it.
**Dynamic resolution:**
- Measure the average frame time over the last second. Above 17 ms, step the internal height down (540, 480, 420, 360 px); below 12 ms for 3 s, step it back up. Hysteresis, so it never oscillates. After that, lower the camcorder pass's blur taps, then fog-far (the fog hides it) and the number of live chunk slots; never the frame rate or the feel.
- Pick the starting tier from a 1 s micro-benchmark that runs during the blurred wake-up screen (plus touch screen / backend hints), so the first steps are already smooth.
**CPU and generation:**
- Chunk layouts in typed arrays (`Uint8Array` edge flags per chunk), the spanning tree on typed arrays with a seeded integer PRNG; no objects per cell and **no string keys** like `"x,z"`: visited cells live in a numeric open-addressing hash set (`Int32Array`).
- A chunk rebuild is time-sliced: generate in one frame, write instances in the next, update its bounding sphere in the next; at most one slice per frame, at most about 1 ms each. Pre-generate the next row while you walk toward it.
- Collision looks up only the wall segments of the 3 x 3 chunks around the player from the edge flags (O(1) per cell), no raycasts against meshes.
- The silhouette's sightline search marches the cell grid (integers), a few times per second, not every frame.
- Mouse movement is accumulated in the event handler and applied once per frame.
**Everything else:**
- **HUD:** DOM text, written only when it changes (the room counter, the timestamp once a second); no CSS `filter`, `backdrop-filter` or animated box-shadows (expensive compositing over a full-screen canvas). The REC blink is a CSS opacity animation.
- **Audio:** all noise buffers and the reverb impulse generated once at start-up; the hum oscillators run all the time and are only re-tuned; one-shots reuse pooled gains; at most 4 positional panners alive.
- **The TV (tier 3):** its camera renders 160 x 120 every other frame, only while the TV is on screen and within 12 m, and shares the scene's materials.
- **Paused or hidden:** when paused, stop rendering the scene and redraw only the camcorder pass over the frozen frame at 30 fps; when `document.hidden`, stop the loop entirely and suspend the `AudioContext`.
- **Start-up:** first frame within about 1.5 s after three.js has loaded: canvas textures at 512-1024 px with mipmaps, nothing generated that the first view doesn't need.
- **Perf overlay:** the backquote key (`Backquote`) toggles a small corner readout: fps, frame ms (average / worst of the last second), draw calls, triangles, internal resolution, live chunks.
## Test hook
If the URL has `?test=1`, expose `window.__toy` with no visible change:
- `setSeed(n)`, `teleport(x, z)`, `setLevel(n)`, `addRooms(n)`;
- `showSilhouette(step)`, `hideSilhouette()`, `event(name)` (`"approach" | "flicker" | "knock" | "humstop" | "echo" | "lightsout" | "tv" | "shift"`);
- tasks and scares: `stage(i)`, `nextGoal()`, `openExit()`, `scream()`, `approach(scare)`;
- recordings (trailers): `skipIntro()`, `drive(x, z, yaw, pitch, run, snap)` (a scripted camera path that still bobs and steps), `quiet()` (no random events, glitches or intro lines), `quality(internalHeight, canvasCap)`, `corridor()` (the demo corridor with uneven, dying tubes), `setLevelNow(n)`, `figure(x, z, on)`, `tvAt(x, z, yaw)`, `playTV()`, `say(text)`, `glitch(d, p)`, `lightsOut()`, `cut(snow)`; on WebGL2 the canvas keeps its drawing buffer so tool screenshots never catch an empty frame;
- `getState()` returning `{ renderer, fps, seed, level, rooms, pos: [x, z], yaw, chunksLive, panels: { on, flicker, dead }, silhouette: { visible, step, dist }, paused, perf: { frameMs, worstMs, calls, triangles, internalHeight, tier } }`.
## Demo mode (required)
If the URL has `?demo=1`, auto-play a 4-second loop with no user input: the camera walks down a corridor, a light flickers, the silhouette is at the end for a moment, a tracking glitch, and it's gone.
For this world that means:
- Hide the HUD, hints and cursor; **keep the camcorder overlay** (● REC and the timestamp, frozen at "NOV 08 1996 03:17 AM", REC lit for 0-2 s and off for 2-4 s so it loops). Never create an `AudioContext`, never ask for pointer lock.
- One fixed quality preset, fixed seed, LEVEL 2 lighting, and a hand-made demo corridor 3 m wide: panels every 3 m, wall details repeating every 4.5 m, so walking **4.5 m in 4.0 s** (1.125 m/s, with the handheld sway on a 4 s cycle) loops seamlessly. Camcorder noise is seeded by demo time (identical frames each loop) and runs at about 60 % strength so the preview survives video compression.
- A deterministic loop restarting from the identical state every 4.0 s of real time:
- **0.0 to 1.0 s:** walking down the corridor; the second panel ahead stutters twice.
- **1.0 to 2.2 s:** the stooped silhouette stands at the far end, in the fog, centred in the frame.
- **2.2 to 2.6 s:** a tracking glitch band rolls up the frame; behind it the silhouette is gone.
- **2.6 to 4.0 s:** the empty corridor; the stuttering panel steadies, back to the state at 0.0 s.
- Start moving within 1 s of load; the action must read clearly at 15 fps (the demo is recorded in a slow software renderer).
- Support `?demo=1&t=<seconds>` to freeze the demo at that time.
## Before you finish
Self-check against this list and fix anything that fails:
- One screenshot reads as the Backrooms at once: the yellow, the carpet, the drop ceiling, the panels, the fog.
- Walk and run for ten minutes in one direction and in circles: no seams, no holes, no chunk popping in view, no getting stuck, no walking through walls or corners, no frame hitches when chunks rebuild. Walking back shows the same rooms.
- ROOMS WALKED never double-counts; LEVEL changes at 100, 200, 300 with the glitch, the dimming and the new hum; every level row of the table is visible in the picture.
- Flickering and dead panels darken their surroundings, not just themselves.
- The silhouette never pops in or out on screen, always hides behind a glitch, steps closer each time, and the behind-you moment works. It never attacks.
- Every key (also with a non-Latin keyboard layout), the pause screen and all its settings, Cinema, full screen, pointer lock lost and regained, the phone joystick and drag look.
- Sound: hum, footsteps in step, breathing faster from LEVEL 2, knocks; nothing plays before the first click; mute works.
- It works inside `<iframe sandbox="allow-scripts allow-pointer-lock">` with storage blocked, and takes keyboard focus on click.
- `?demo=1` matches the timeline, loops cleanly, shows only the camcorder overlay and plays no sound; `?test=1` exposes `window.__toy`.
- Performance, with the perf overlay on: run in one direction for 2 minutes, through a level change, past the TV and the silhouette. Every budget in the Performance table holds, the worst frame stays under 25 ms, the JS heap stays flat (no GC sawtooth), and nothing compiles a shader after the wake-up. Repeat at phone width with touch and on the WebGL2 fallback.
- No console errors or WebGPU validation errors on WebGPU **and** on WebGL2 (force the fallback to check).
Put the rooms, the hum and a locked 60 fps first: the place itself is the horror, and a stutter breaks it. The moment the silhouette stands stooped at the end of a corridor, and the glitch takes it away, should be the best part.