destroyulator/game/README.md
Monster Robot Party 7c282c298e LANE7: LEVEL 0 — the Backrooms, generated walls, and the dust mechanic
"stuff is invisible have to release dust or gas or something to coat items to see them
to destroy them? and you inhale some of it too"

THE SITE (Levels.backrooms)
Mono-yellow, 2.55 m ceiling, damp carpet, a dense grid of buzzing troffers, no windows
and no exit. THE WALLS ARE GENERATED — this is the site that proves the Floorplan spec
can be emitted rather than authored: a seeded RNG lays partial wall runs on a 4.6 m
lattice with random gaps, plus 22 freestanding pillars. It follows the reference photos
rather than a maze algorithm on purpose — the Backrooms aren't corridors, they're big
offset slabs in an open floor, and a proper maze would be legible. Legible is wrong.

THE MECHANIC (Dust.gd) — it argues with itself, which is the whole point.
Everything smashable here is invisible but SOLID; you find things by walking into them.
RMB with the extinguisher throws a cloud of powder. Anything it touches is coated,
glows faintly out of all that yellow for 7 seconds, then settles back into nothing.

And you breathe it. Every cloud puts a little in your lungs, and the haze that builds is
on YOUR side of the glass. So: spray more to see individual objects, and see the room
less. Past 72% you cough — camera jolt, screen bloom, and a rage tick, because of course
it does. Intended rhythm is spray -> switch to something heavy -> break it before the
coating settles -> spray again, coughing. Pairs with TOTAL DESTRUCTION, since the level
IS a search.

Tuning that mattered: the first cloud was a wall of big blocky quads that hid the very
thing it was revealing (150 finer, shorter-lived particles now), and a coated object has
to POP out of a room that is entirely one colour, so the coat is near-white and faintly
self-lit rather than a subtle tint. 78 objects, not 34 — a search with nothing in it is
just a walk.

Bug found while testing: `x as Smashable` on a freed object is itself an error in Godot
4, so the validity check has to come BEFORE the cast — destroying anything mid-level was
spewing "trying to cast a freed object" every frame.

dev/probe_backrooms.gd verifies the whole loop headlessly, including that the mechanic
does NOT leak into the other four sites when you leave.

All five sites still settle to 0.00 m/s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:05:56 +10:00

228 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:**
```sh
/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 / The Incubator / Sub-level 4) |
| **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); otherwise grab a record |
| **drag ←→ · LMB · G** | with a record held: slide the disc out · throw it · drop |
| **digits · arrows · ENTER** | while working: type · move cell · commit |
| **B · R** | rain 500 bodies (stress gate) · reset the office |
## The four sites
`scripts/Floorplan.gd` builds a workplace from a **data spec**; `scripts/Levels.gd` holds
four 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 |
| **LEVEL 0** | one colour, everywhere | the Backrooms. Walls are **generated**, and everything you're here to destroy is **invisible** |
`dev/probe_levels.gd` builds every one and reports residual motion after a full second.
All five must read `0.00 m/s`.
### 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.
## The four modes
`scripts/Modes.gd`, cycled with M.
- **CUBICLE HELL** — the main one, below.
- **THE GAUNTLET** — `scripts/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`](../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*.
## 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:
```sh
/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**:
```sh
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
```sh
/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:
```bash
/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.