The only site whose contents are mostly SOFT, and that one material change rewrites the
level. `produce` is a new Smashable material that throws no shards at all — 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, both falling out of existing systems rather
than needing new ones:
* produce has a low brittle_speed, so anything heavy landing on it squashes it — which
means a pyramid crushes its OWN bottom layer as it collapses, with no special code.
Knock one off the top and the pile does the rest. Shove test: 257 items -> 45, and
42 litres of juice out of the collapse.
* puddles are slippery, cutting ACCELERATION rather than top speed so it reads as
sliding rather than as being slowed down. The more mess you make, the less the floor
cooperates.
Stacks are built bottom-up by Main._build_stack and nothing is glued or frozen — they
stand because they are stacked, so pulling one out of the bottom row does what you'd
hope. 258 produce items spawn and settle to 0.00 m/s with no spawn crush. The glass
bottles behind the juice bar are the deliberate exception: one shelf in the room that
still rewards a proper swing, and the contrast between litres and shards is the joke.
Round produce is authored CENTRED, not floor-centred like every other prop in this repo,
because a rolling body whose origin sits on the floor plane wobbles like a loaded die.
_glb_piece grew a `centred` flag for it.
REAL BUG FOUND, and it was costing hits in every level: _strike took the NEAREST collider
of any kind, so a static surface could eat a swing. Leaning over a display table, the
table edge is a few centimetres nearer than the fruit piled on it, so every swing hit the
table and nothing broke — 0/306 for the whole first take. It now prefers a Smashable and
only falls back to loose bodies if there isn't one. The offices were quietly losing hits
on desks and shelves the same way.
tools/gen_produce.py: apple, orange, tomato, watermelon, cabbage, banana hand, glass
juice bottle, display crate. The melon's stripes were radial boxes first time and it came
out looking like a sea mine; they're thin lenses through the middle now.
dev/probe_grocer.gd verifies stacks settle, that produce yields litres and NO debris,
that a collapse crushes, and that none of it leaks to the next site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
332 lines
17 KiB
Markdown
332 lines
17 KiB
Markdown
# 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 |
|
||
| **1–6 · 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 |
|
||
| **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** |
|
||
|
||
`dev/probe_levels.gd` builds every one and reports residual motion after a full second.
|
||
All five 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.
|
||
|
||
### 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 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 F–J 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.
|