destroyulator/game
Monster Robot Party e588508436 LANE10: the whole shop — four rooms, stitched by its own portal graph
The hip-hop room has stock (7 racks, 47 bins, 2,865 records); the entry hall and
back room are empty. Import all four anyway: the empty rooms are the connective
tissue, and they are the only floor in the building big enough to get an office
chair up to speed.

A portal pair is one doorway seen from both sides, so the importer walks the graph
and places each room by making its portal land exactly on its neighbour's. Every
pair here sits on parallel walls with opposite facings, so each join is a pure
translation — and when one isn't, it says so rather than quietly getting it wrong.
It then recentres the union, emits the interior walls with a gap at each doorway,
and puts the spawn in the entry hall, because that is the way in.

  hiphop-room <-> entry-hall <-> shop-floor <-> back-room
  12.96 x 15.41 m - 36 racks - 325 bins - 302 real genre labels - 24,024 records

Levels.record_store() now takes the walls and rooms from the import, hangs the
street glazing on the entry hall's outer wall, and lays a light grid across
whatever footprint the rooms came out as.

probe_store.gd also checks the new facts: five doorways exist, and the spawn has
nothing in it (excluding the player, who is supposed to be standing there — that
false positive was the probe's bug, not the level's).
All gates green: smoke clean, 7/7 sites 0.00 m/s, overlap CLEAN, all probes pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 02:48:39 +10:00
..
assets LANE10: the whole shop — four rooms, stitched by its own portal graph 2026-08-04 02:48:39 +10:00
dev LANE10: the whole shop — four rooms, stitched by its own portal graph 2026-08-04 02:48:39 +10:00
scripts LANE10: the whole shop — four rooms, stitched by its own portal graph 2026-08-04 02:48:39 +10:00
.gitignore Restructure into monorepo: game/ is the Godot lane 2026-07-14 15:33:13 +10:00
main.tscn Restructure into monorepo: game/ is the Godot lane 2026-07-14 15:33:13 +10:00
project.godot Restructure into monorepo: game/ is the Godot lane 2026-07-14 15:33:13 +10:00
README.md LANE10: the whole shop — four rooms, stitched by its own portal graph 2026-08-04 02:48:39 +10:00

Destroyulator — the game

Mac-first (Apple Silicon / Metal), Godot 4.7, Jolt physics.

Run it

Editor: open /Applications/Godot.app, import this folder (game/), press F5 / ▶.

CLI:

/Applications/Godot.app/Contents/MacOS/Godot --path /Users/m3ultra/Documents/Destroyulater/game

Controls

Input Does
WASD / Space move / jump
mouse look (Esc releases the cursor, click recaptures)
LMB swing the equipped weapon
16 · wheel · Q pick a weapon · cycle · swap to previous
H cycle HUD style (Arcade / Minimal / Work Order / Dev)
M cycle game mode (Cubicle Hell / Gauntlet / Total Destruction / Against the Clock)
L cycle site (Scranton / Pawnee / Incubator / Sub-level 4 / Greengrocer / Level 0 / Monster Robot Party)
G restart a Gauntlet run
F report the disc in hand (Find-the-Misfiled-Disc)
E work at the station you're standing at (locks you in place); else hop on / off an office chair under the crosshair; else pick up whatever's there
LMB · G with something in hand: throw it · put it down
drag ←→ with a record held: slide the disc out of the sleeve
digits · arrows · ENTER · SPACE while working: type · move cell · commit · the other thing
B · R rain 500 bodies (stress gate) · reset the office

The seven sites

scripts/Floorplan.gd builds a workplace from a data spec; scripts/Levels.gd holds seven of them. A site is a Dictionary — bounds, palette, walls with doors and glazing, window runs, ceiling style, light grid and style, slabs, props, desk clusters, chairs, smashables, gauntlet records-area and spawn — so a new one is authorable in minutes and a generator can later emit the same structure.

palette / light what makes it different
SCRANTON BRANCH magnolia, grey carpet, troffers the baseline office
PAWNEE CITY HALL civic beige, blue-grey a public counter, pinboards, over-partitioned
THE INCUBATOR timber, white, 3 m, pendants nobody has a desk; they work at a dining table
SUB-LEVEL 4 concrete, green strip lights no windows, exposed services, a wall of servers
THE GREENGROCER bright, pale, warm strips everything is soft except the bottles — see below
LEVEL 0 one colour, everywhere the Backrooms. Walls are generated, and everything you're here to destroy is invisible
MONSTER ROBOT PARTY timber, white, shop pink the real shop, imported from the live storefront — see below

dev/probe_levels.gd builds every one and reports residual motion after a full second. All seven must read 0.00 m/s.

THE GREENGROCER — soft things

The only site whose contents are mostly soft, and that one material change rewrites the level. produce is a new material in Smashable.PROFILES that doesn't throw shards at all — scripts/Splat.gd bursts it instead: coloured pulp, a puddle that stays, a squelch, and litres counted, because "how much juice did you make" is a far better score for a greengrocer than "how many objects did you destroy".

Two knock-ons make the level play itself:

  • Produce has a low brittle_speed, so anything heavy landing on it squashes it. A pyramid therefore crushes its own bottom layer as it collapses, with no special code — knock one off the top and the pile does the rest. A shove test turned 257 items into 45 and 42 litres.
  • Puddles are slippery. The more mess you make, the less the floor cooperates. It cuts your acceleration rather than your top speed, so it reads as sliding rather than as being slowed down.

Stacks are built bottom-up by Main._build_stack() and nothing is glued or frozen — they stand because they're stacked, so pulling one out of the bottom row does exactly what you'd hope. The glass bottles behind the juice bar are the deliberate exception: one shelf in the room that still rewards a proper swing.

Round produce is authored centred, not floor-centred like every other prop, because a rolling body whose origin sits on the floor plane wobbles like a loaded die. It also gets a sphere collider rather than the usual auto-boxed AABB — a box on an apple is why nothing used to roll, and why the lattice needed hand-tuned spacing to nest.

Three things make a pyramid of spheres actually stand up, and all three are needed:

  • Measured radii. Main._glb_radius() reads the asset instead of a table. The table said a cabbage was 84 mm; the model is 213 mm, so every row of that pyramid was driven a third of the way into the row beneath it.
  • Sphere lattice geometry. Each fruit sits in the dimple between four below, so the rise per row is sqrt(4r² step²/2), not a guessed multiple of r. Get it wrong and every row above the first spawns in mid-air.
  • A tray. A pyramid of frictionless spheres on a bare flat table cannot stand: nothing holds the bottom row in, so the weight above wedges it outward and the display walks itself apart in about a second. Main._stack_tray() frames each pile in four low timber walls sized to its base row — which is what every greengrocer on earth already does. Everything sleeps within 3 s.

Hanging scales. Brass shop scales hang down the aisles at chest height on real PinJoint3D pendulums — a static dial and rod, a scale-pan rigid body pinned at its own origin, so the pan can only rotate about the pivot. Walk into one and it swings; hit it with the bat and it swings hard, and the pin holds to within a millimetre. It's steel, so it never breaks: what you get instead is a heavy brass weight loose in a room full of stacked fruit.

Carrying and throwing. E picks up anything under 6 kg that isn't a record — an apple, a bottle, a stapler — and LMB throws it at 11 m/s, which is six times any brittle threshold in the game. Thrown fruit bursts wherever it lands via the same brittle_speed path as a collapse, so there's no separate "thrown object" code. The carried body is frozen kinematic with its collision layers zeroed while it's in the hand, and gets them back on release.

The bag roll. The station at the bag stand is Tasks.Kind.BAGS, and it is the purest distillation of what this game is about. Tearing one off the roll is easy. Then you have to open it, and the bag has two ends, only one of which opens, and nothing anywhere tells you which. You rub — alternating ← and →, because mashing one key is not rubbing — and either the meter climbs or it doesn't, and the only way to find out is to have already wasted several seconds on the wrong one. SPACE turns it over. That's the answer. The game never says so.

LEVEL 0 — the Backrooms, and the dust

The only site whose walls are generated rather than authored, which is both the honest test of the spec and the right call for the fiction: the place is meant to feel endless and samey, and a seeded RNG does that better than a human placing slabs. It emits partial wall runs on a coarse lattice plus freestanding pillars — the Backrooms aren't corridors, they're big offset slabs in an open floor, and a proper maze would be legible.

scripts/Dust.gd is its mechanic. Everything smashable here is invisible but solid — you find things by walking into them. Right-click with the fire extinguisher throws a cloud of powder; anything it touches is coated, glows faintly out of all that yellow for seven seconds, and then settles back into nothing.

And you breathe it. Every cloud puts a little in your lungs, and the haze it builds is on your side of the glass. So the mechanic argues with itself: spray more to see individual objects, and see the room less. Past 72% you start coughing — the camera jolts, the screen blooms, and it feeds the rage meter, because of course it does.

The intended rhythm is spray → switch to something heavy → break it before the coating settles → spray again, coughing.

…and there is something in there with you

scripts/Entity.gd borrows from three places, and each borrowing does a different job:

  • Slenderman — it does not move while you are looking at it, and it has closed the distance every time you look back. Looking at it is also what builds the static, so the safe option (keep it in view) is the one that kills you slowly and the fast option (run) is the one that lets it catch up. That's the trap, and it's why Slender worked.
  • Xenomorph — it hunts by sound, and the thing you came here to do is smash furniture. Every break and every puff of powder drops a marker it walks toward. Playing well is what feeds it; playing quietly means not playing. A loud enough noise overrides the freeze entirely.
  • Blair Witch — walk far enough in any direction and you come out of the opposite wall into the same room, because every room here is the same room. And a wrap is when the entity gets to reposition, so "I've been here before" and "it's already here" land in the same second.

It can't be fought — weapons pass through. The outs are distance, silence, and not looking, three things the rest of the game actively discourages.

And after three loops there are two of them. THE OTHER ONE shares the silhouette — same height, same suit, same dark — so at range you cannot tell which you're looking at. Its rules are the first one's rules inverted:

the hunter the other one
while you look at it frozen it comes
while you don't it closes inert
noise hunts it deaf
correct play keep it in view, stay quiet look away; noise is irrelevant

Every instinct the first one trains into you is the way the second one kills you. The tells are close-range only, and all wrong in the same direction: its head is thrown back so it's staring at the ceiling rather than at you, its arms are raised, and its feet never reach the carpet.

The room changes one thing

Every loop, exactly one thing is different. Never two — that constraint is the whole effect. Change nothing and a loop is just a teleport; change several and it reads as a new room, which is the opposite of what this place is for. One change means you're never sure whether you noticed something or imagined it, and since the room is otherwise identical, when you are sure it's worse.

It escalates: early loops move or turn a prop, or kill a bank of lights. Later ones hang a totem that wasn't there, permanently reveal an object nobody dusted, or put the entity where you were standing a moment ago.

DREAD, and dying

No health bar. Nothing else in this game does damage, so hit points would be a survival-horror UI bolted onto a demolition game. Instead there is one meter — DREAD — fed by every way this place gets at you: being stared at, something close behind you, each loop, and your own coughing. It falls when nothing is happening to you, and every loop makes it harder to shake.

At 100% you die, and which death you get is decided by whichever source pushed it over — so the three memes stay legible in the moment it matters:

how the card
Slenderman you looked too long YOU LOOKED TOO LONG — it was still. it was always still. that was never the safe part.
the xenomorph it reached you, and it sprays IT WAS ALREADY IN THE ROOM — you were making so much noise.
Blair Witch eleven loops of the same room YOU ARE STILL IN THE ROOM — you walked it eleven times. you will walk it again.

scripts/Gore.gd does the rest, procedurally: seeded blobs that swell on the lens and then bleed downward in streaks, a wet burst at the camera (physical deaths only — being taken by the static shouldn't spray), and the camera going down to land on its side on the carpet. The sequence is deliberately slow — a jumpscare is a spike, but a death should take its time, because the seconds you spend watching the lens settle are where it actually lands. R to wake up.

scripts/Dread.gd is the tape: grain, scanlines, chroma split, a wandering tracking tear, and Slender's interference driven straight off the entity's stare — the HUD element that isn't a HUD element.

The four modes

scripts/Modes.gd, cycled with M.

  • CUBICLE HELL — the main one, below.
  • THE GAUNTLETscripts/Gauntlet.gd + scripts/Cabinet.gd. Five identical cabinets, each labelled with an alphabetical range. The brief names a client, never a cabinet, so working out that HENDERSON lives in FJ is the puzzle — nothing glows. Destruction is granular: pull a drawer, it slides out and goes live, and the folders inside become individual smashables. Doing that by hand inside the clock isn't really possible — every site hides a shredder that eats a whole open drawer at once, and the game never says so. Noticing it is the level.
  • TOTAL DESTRUCTION — no job, no pay. Clear the whole site, scored on time.
  • AGAINST THE CLOCK — fixed timer, maximise damage value. The exact inverse of Cubicle Hell: here the expensive things are the goal rather than the mistake.

CUBICLE HELL

The mode the game is about. See ../LANES/LANE7-cubicle-hell.md for the full design.

Rage.gd is the tilt meter: work provokes it, clean work drains it slightly, and at 100% you SNAP into a forced destruction window that stalls if you stop destroying. RageOverlay.gd is the blood vessel — a vignette that closes in, procedural veins that creep further into frame, and a heartbeat that goes 62 → 172 BPM. All of it generated in code; no textures, no shader files, no audio assets.

Tasks.gd is the job — four stations, each with one designed way of wasting your time:

  • SPREADSHEET — the selected cell silently drifts one cell partway through entry. No sound, no animation. Commit without noticing and the figure is in the wrong cell.
  • PHOTOCOPIER — jams, and wants the right tray out of 1, 2, 2A, 3, 3B.
  • STAPLES — SPACE lifts a staple, then it must be pulled the way it's bent. Dither and the sheet tears and the bundle reprints.
  • GUILLOTINE — align to a mark and cut. The guide has a permanent offset and nothing on screen admits it; you have to learn to distrust the equipment.

Ambience.gd is the attrition you can't play around: the tube above your desk flickers (and actually flickers, on the real lights), someone puts fish in the microwave, a phone rings four desks away, reply-all to forty-one recipients. No interaction, just a periodic tick on the meter — it's what makes the room hostile rather than merely beige.

CubicleHell.gd scores it as a payslip: wages damages. Rage drains a flat amount per object but each object bills at its own value, so the meltdown decision is smash a lot of cheap things fast versus one expensive swing.

MONSTER ROBOT PARTY — the only site that isn't authored

The shop the whole game started as, and the only one imported rather than written. The live storefront at robotmonster.party/store/ is assembled procedurally from Postgres (the wowplatter virtual_* schema, served by recordgod), and this game's sites are already data — so tools/import_store.py reads the real shop and emits assets/store/shop_floor.json:

rooms four, stitched by the shop's own portal graph — 12.96 m × 15.41 m all up
racks 36, across 21 archetypes
bins 325, each carrying its real genre label (HARD TRANCE, $12-$15 EURO/ITALO HOUSE)
stock 24,024 records

The shop is hiphop-room ↔ entry-hall ↔ shop-floor ↔ back-room. Only two of those hold stock; the entry hall and the back room are empty, and that is exactly why they are worth importing — they are the connective tissue, the run-up, and the only floor in the building big enough to get an office chair up to speed. Each portal pair is the same doorway seen from both sides, so the importer places every room by making its portal land on its neighbour's, then emits the interior walls with a gap at each doorway.

The shop's schema is the cascade. virtual_crate.rack_id already records which rack holds which bin, so every bin arrives frozen and supported_by its rack — knock a rack over and the actual bins that actually sit on it actually go. None of it is authored; it comes off the shop floor. dev/probe_store.gd proves it: smashing the rack that carries 22 bins releases exactly those 22 and leaves the other 256 frozen.

Two things the importer has to reconcile, because a web storefront is a renderer:

  • The mesh sizes are a lie. 21 archetypes share a handful of stand-in GLBs, so each piece carries a fit — the archetype's real width/height/depth from the shop's own table — and the game scales the visual to it and builds the collider from it.
  • Nothing collides in a renderer. A rack can sit a few centimetres inside its neighbour and look fine for years; Jolt disagrees violently. The importer separates them (weighted, so racks barely move and the aisles keep their shape — 3 m of total correction across 35 racks), insets rack footprints by 1.5 cm a side, and then audits its own output with the same test dev/probe_overlap.gd runs.

Re-run it when the real shop is rearranged and the level follows:

python3 tools/import_store.py

The fun layer (LANE9)

Three antagonists to the tidy impulse, all riding systems that already exist:

  • The Roomba (scripts/Roomba.gd) — a robot vacuum that deploys the moment the first thing breaks and patrols eating your debris, docking points as it goes. It's company property (steel), so the weapon matrix already knows it: bare hands clank, a crowbar dents it (it panics and flees), a sledge kills it — and then the payslip bills you for it. Not in LEVEL 0; nothing cute lives in the Backrooms.
  • The incident report (scripts/Receipt.gd) — when a meltdown ends, HR prints a dot-matrix receipt itemizing what you broke during it: object · unit $ · ×N · DEDUCTED. A meltdown where you broke nothing prints nothing.
  • The office chairE on one to ride it. WASD shoves it (strong) and steers it (badly); momentum is real and ramming breaks things through the same brittle_speed path as any collision. Sledge your own vehicle and the ride ends where you're sitting.

The weapon-vs-material matrix

This is the decision the game is built on. A swing deals Weapon.power × Weapon.vs[material] against that material's hp, and there are three outcomes: it breaks, it dents (knockback + a chip of damage), or it's futile — a dead clank, no score, no combo, and a HUD nudge to switch tools.

cardboard paper vinyl glass wood steel
Bare Hands ok good ok ok poor useless
Box Cutter shreds shreds good poor useless useless
Cricket Bat good poor great great ok poor
Crowbar ok poor ok good good great
Sledgehammer ok poor ok great great great
Fire Extinguisher ok poor ok great good good

Tuned in one place: scripts/Weapon.gd (vs tables) and scripts/Smashable.gd (PROFILES[kind].hp). The office printer is the level boss — steel at toughness_scale = 5.0, so it's a real fight with anything but the sledge.

Weapons also differ in when they connect, not just how hard: the hit lands at Weapon.contact through the swing animation, so the sledgehammer commits late and heavy while the box cutter is near-instant.

The viewmodel

scripts/ViewModel.gd. Real rigged hands (mixamorig, all 20 finger bones per hand) holding real weapon meshes.

The arms rig is instanced once per hand and each instance is placed so its own hand bone lands on the grip — no IK solver. That works because in first person you only ever see forearm and hand. Placement solves two aims at once: the fist's bore lines up with the weapon shaft, and the forearm runs back toward where that shoulder would be. Both directions are measured off the rig at load (pinky-knuckle → index-knuckle for the bore, elbow → hand for the forearm), so the grip survives re-tuning grip_rest_rot instead of needing a fresh set of hand-authored euler angles.

Motion is layered on one node: look-sway with spring-back, walk bob scaled by speed and weapon heft, idle breathing, landing dip, and a keyframed swing arc per archetype (overhead / horizontal / jab / thrust).

Assets

Path Source
assets/viewmodel/fps_arms.glb tools/gen_fps_arms.py — cut from the GODVERSE modular character kit
assets/viewmodel/{bat,sledge,crowbar,cutter,extinguisher}.glb tools/gen_viewmodel.py — procedural, grip at origin, shaft +Y
assets/store/*.glb + *.fractured.glb Lane 2 prop batch; the fractured sibling drives real chunk destruction
assets/art/sleeves_*.jpg 3×3 sheets of generated cover art, windowed per record via uv1 offset

Regenerate either set with:

/Applications/Blender.app/Contents/MacOS/Blender --background --python tools/gen_viewmodel.py -- --render

Dev harness

dev/ is not shipped. Because macOS screen-recording permission isn't available to the CLI, the game records itself:

Godot --path game --resolution 1280x720 --write-movie /tmp/cap.avi --quit-after 2250 dev/demo.tscn

dev/DemoDriver.gd drives the player through a scripted tour (every weapon, the material matrix, the boss, all four HUDs) so a change can be eyeballed frame by frame without a human at the keyboard. dev/probe_*.gd print rig/scale/placement numbers — renders are too coarse to tell a 5 cm error from a 50 cm one.

Smoke test before committing

/Applications/Godot.app/Contents/MacOS/Godot --headless --path game --quit-after 300

Zero script errors required. After adding any class_name script, run Godot --path game --editor --quit first — headless Godot won't rescan a stale class cache.

The level

scripts/Office.gd builds LEVEL 01: an open-plan office after the Dunder Mifflin floor plan — bullpen of facing desk pairs behind cubicle partitions, reception, a glass-walled manager's office, conference room, break room with vending machines, a copier alcove for the printer boss, and a warehouse roller door. Drop ceiling, fluorescent troffers (which are the actual light sources), magnolia walls, grey carpet, a window wall with blinds.

Office owns the shell and static fittings; Main._populate() owns everything smashable, and asks Office where things go (desk_spots(), chair_spots(), copier_spot()). Walls, desks and counters are boxes built in code because they are boxes; chairs, vending machines, the microwave and the plant come from tools/gen_office_props.py, because a box reads as obviously wrong for those.

The spawn guard

Placing ~40 props by formula means something will eventually spawn a few centimetres inside something else, and Jolt's depenetration is not gentle — a chair overlapping a desk left the building at 500 m/s on frame one, knocking over the furniture on its way.

Main._physics_process() speed-limits dynamic bodies for the first 0.75 s: enough for a real overlap to push apart calmly, far too little to launch anything. Every offender is named once in a warning with the position it was placed at, so the underlying overlap stays visible and fixable rather than silently papered over.

Verify a level is genuinely at rest with:

/Applications/Godot.app/Contents/MacOS/Godot --headless --path game --quit-after 300 dev/demo.tscn

The demo's first act touches nothing and prints total body speed each second. It must read 0.00 m/s, and no [spawn] warnings should appear.

The guard is a net, not a test: it only fires when Jolt happens to resolve a pair violently on the frames it's watching, and a symmetric overlap can sit quietly for a whole run and then launch a filing cabinet the day you change something unrelated. That is exactly what happened — a cardboard-box pair had been interpenetrating in four levels for weeks and only surfaced when an unrelated change shifted the RNG stream. dev/probe_overlap.gd finds them by measurement instead of by luck, comparing every pair of dynamic colliders across all six sites:

/Applications/Godot.app/Contents/MacOS/Godot --headless --path game --script dev/probe_overlap.gd

It must print CLEAN. The box asset is 1.35 m wide, which is why the specs now stack boxes into piles instead of dotting them about, and why the Backrooms scatter uses rejection sampling with a 1.6 m minimum.