The three things the greengrocer was still missing, plus the physics bug that
finding them uncovered.
HANGING SCALES. Real PinJoint3D pendulums, not animations: a static dial and
rod, and a scale-pan rigid body pinned at its own origin so it can only rotate
about the pivot. They hang at chest height down the aisles — dial at eye level,
dish below — where a real shop scale hangs and where you keep walking into it.
The dish swings 0.40 m off a light knock and the pin holds the pivot to 1.1 mm.
The pan is steel so it never breaks; what you get is a heavy brass weight loose
in a room full of stacked fruit.
THE BAG YOU CANNOT OPEN. Tearing one off the roll is the easy half — that's the
setup. Then you have to open it, and the bag has two ends, only one of which
opens, and nothing tells you which. Rubbing alternates arrow keys, because
mashing one key is not rubbing. Either the meter climbs or it doesn't, and the
only way to learn which end you're holding is to have already lost several
seconds to the other one. SPACE turns it over. That is the whole solution and
the game never says so.
CARRY AND THROW. E picks up anything under 6 kg that isn't a record; LMB throws
it at 11 m/s, six times any brittle threshold in the game. Thrown fruit bursts
on landing through the same brittle_speed path a collapse uses — no separate
thrown-object code at all.
Then the part that took the longest. Round produce got sphere colliders (a box
on an apple is why nothing ever rolled) and every display in the shop instantly
fell over. Three separate things were wrong:
- the stack radii were TYPED, not measured. The table said a cabbage was
84 mm; the model is 213 mm, so that pyramid was built with every row driven
a third of the way into the row below it. Main._glb_radius() reads the asset.
- the lattice was box geometry. For spheres the rise per row is
sqrt(4r^2 - step^2/2) — the height at which a fruit touches all four
beneath it. Anything else spawns every row above the first in mid-air.
- there was no tray. A pyramid of 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 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.
All 310 bodies asleep within 3 s.
While chasing that, the spawn guard fired on a cardboard box in the OFFICE. The
guard is a net, not a test: it only catches a pair Jolt happens to resolve
violently on the frames it's watching, and this one had been interpenetrating
in four levels for weeks. dev/probe_overlap.gd now finds them by measurement,
comparing every pair of dynamic colliders across all six sites. It found 15,
including a row of filing cabinets 0.70 m apart that are 0.80 m wide, and a
stapler inside a monitor. The box asset is 1.35 m across, so boxes are now
stacked into piles rather than dotted about, and the Backrooms scatter uses
rejection sampling with a 1.6 m minimum. All six sites read CLEAN.
Also: the rage veins were drawing every branch from its own start point
regardless of how far the trunk had grown, so at low rage you got disconnected
fragments floating mid-screen that read as biro scribble rather than blood. A
branch now can't appear before the trunk carrying it, and trunks are thick and
dark where capillaries are fine and pale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
341 lines
18 KiB
Markdown
341 lines
18 KiB
Markdown
# LANE 7 — CUBICLE HELL
|
||
|
||
*The game stopped being a smash sandbox and became a game about a man having a bad day.*
|
||
|
||
## The pitch
|
||
|
||
You are at work. The work is designed to provoke you. A meter fills. When it fills you
|
||
**snap**, and for half a minute you physically cannot go back to your desk until you've
|
||
broken enough things. Then you sit back down and try to earn the rest of your shift.
|
||
|
||
Falling Down, but the payslip is the scoreboard.
|
||
|
||
## The score IS your payslip
|
||
|
||
```
|
||
NET = WAGES (tasks completed) − DAMAGES (everything you broke)
|
||
```
|
||
|
||
The load-bearing detail: **rage drains a flat amount per object destroyed, but each
|
||
object bills at its own value.** During a meltdown the optimal play is therefore to smash
|
||
a lot of cheap rubbish very fast — cartons, paperwork, monitors — rather than put the
|
||
sledgehammer through a filing cabinet. You don't choose whether you lose your temper. You
|
||
choose how expensive losing your temper is.
|
||
|
||
`FURY` (objects broken while actually melting down) is tracked separately and is
|
||
deliberately **not** money, so "I ended the shift owing them $2,400" stays a brag.
|
||
|
||
---
|
||
|
||
## BUILT (this lane, shipped)
|
||
|
||
| Piece | File | What it does |
|
||
|---|---|---|
|
||
| Tilt meter | `game/scripts/Rage.gd` | WORKING → MELTDOWN → COOLDOWN. Provocations fill it, clean work drains it slightly, ambient office drains your patience passively. During a meltdown the clock **stalls if you stop destroying** — you MUST break things. |
|
||
| The blood vessel | `game/scripts/RageOverlay.gd` | Red vignette that closes in with rage and jumps inward on every heartbeat; procedural **veins** that creep further into frame the angrier you get; a synthesized lub-dub whose BPM goes 62 → 172. Zero assets: shader built in code, veins are `draw_polyline`, heartbeat is a generated WAV. |
|
||
| The job | `game/scripts/Tasks.gd` | Stations you walk to and are **locked in place at** (you're at your desk — that's the joke). Two tasks so far, below. |
|
||
| The shift | `game/scripts/CubicleHell.gd` | 5-minute shift, wages vs damages, meltdown count, end-of-shift payslip with a verdict line. |
|
||
|
||
### The two tasks that exist
|
||
|
||
- **SPREADSHEET** — enter a figure in a named cell. Partway through, the selection
|
||
**silently drifts one cell** — no sound, no animation. Commit without noticing and the
|
||
figure lands in the wrong cell, you eat a big provoke, and you start again. This one
|
||
task is the whole thesis of the game in about forty lines.
|
||
- **PHOTOCOPIER** — run a copy job. It jams. Clearing it needs the **right tray**, and
|
||
the trays are labelled `1, 2, 2A, 3, 3B` because that's what head office decided.
|
||
|
||
---
|
||
|
||
## BUILT — more ways to be provoked
|
||
|
||
STAPLES and GUILLOTINE shipped (see `Tasks.gd`), plus `Ambience.gd` for the passive
|
||
attrition. The table below is what's still open.
|
||
|
||
### Still to build
|
||
|
||
Each is a `Tasks.Kind` + a `panel_state()` case. The framework takes them without
|
||
changes; the design constraint is that each must have **one specific, identifiable way of
|
||
wasting your time**, not just "it's slow".
|
||
|
||
| Task | The provocation |
|
||
|---|---|
|
||
| **Staple removal** | A stack with N staples. Click each. Too slow and the page tears — redo the sheet. The remover slips and takes a chunk out of the page. |
|
||
| **Guillotine / paper slicer** | Align the sheet to a line, pull the handle. Misalign and the cut is crooked → redo. The alignment guide is 2mm off and always has been. |
|
||
| **The phone** | Rings mid-task. You must answer it. It's hold music. Not answering keeps it ringing and ringing raises rage faster than answering does. |
|
||
| **Printer driver** | The printer is "offline". Toggling it takes four clicks in a dialog that moves its buttons. |
|
||
| **Reply-all** | An email thread with 40 recipients. You must reply to the sender only. The default is Reply All. |
|
||
| **Toner** | Replace the cartridge. It's the wrong model number by one digit. |
|
||
| **Timesheet** | Enter hours. It won't accept decimals. It also won't accept fractions. |
|
||
|
||
### Passive ambient provocations (no interaction, just attrition)
|
||
Someone microwaves fish. The air-con drips. A colleague's ringtone. The fluorescent tube
|
||
above your desk flickers at a frequency chosen to be maximally irritating. Each is a
|
||
small periodic `rage.provoke()` — cheap to add, and they're what makes the room feel hostile
|
||
rather than neutral.
|
||
|
||
---
|
||
|
||
## BUILT — modes
|
||
|
||
All four ship in `Modes.gd`; M cycles. The Gauntlet is `Gauntlet.gd` + `Cabinet.gd`
|
||
(drawers slide out as their own bodies, folders inside go live, and the shredder eats a
|
||
whole open drawer). Versus is still the open one.
|
||
|
||
### The original design notes
|
||
|
||
1. **CUBICLE HELL** *(built — the core)*. Shift timer, work, snap, wreck, repeat, payslip.
|
||
2. **TOTAL DESTRUCTION** — no job, no pay. Destroy 100% of the level. Scored on time.
|
||
3. **AGAINST THE CLOCK** — fixed timer, maximise damage value. The inverse of Cubicle Hell:
|
||
here expensive things are the *goal*.
|
||
4. **THE GAUNTLET** *(designed below — the most interesting one)*.
|
||
5. **VERSUS** — two players, one office. Explore later; the interesting version is
|
||
asymmetric (one working, one wrecking) rather than both wrecking.
|
||
|
||
### THE GAUNTLET (design)
|
||
|
||
> *"you need to destroy every file in a specific cabinet but which one, heaps cabinets,
|
||
> real subtle hints, time limit, and you have to actually pull each drawer out, empty
|
||
> files out, destroy them individually or in small lots unless you have a specific tool"*
|
||
|
||
- A room of **many** identical filing cabinets. Exactly one holds the target files.
|
||
- **Subtle hints only**: a memo on a desk names a client; a labelled folder on the copier;
|
||
a sticky note with half a reference number. No waypoint, no glow.
|
||
- Destruction is **granular**, not one-hit: pull a drawer → drawer slides out as its own
|
||
body → files inside become individual smashables → destroy singly or in small lots.
|
||
- **The perfect tool.** Each level has ONE tool that makes its gauntlet tractable — an
|
||
industrial shredder for files, a crowbar for the drawers, a lighter for a wastepaper
|
||
bin. It is somewhere in the level, and **the game does not tell you what it is.**
|
||
Working out "what would actually make this fast" IS the puzzle. With the wrong tool the
|
||
gauntlet is possible but you will not beat the clock.
|
||
- Needs: `Drawer.gd` (pull-out sub-body with its own contents), a `Container` concept on
|
||
Smashable, and a `Tool` flag on Weapon marking it as a level's key tool.
|
||
|
||
---
|
||
|
||
## BUILT — the other three workplaces
|
||
|
||
Shipped as `Levels.gd` specs consumed by `Floorplan.gd`. The notes below are the design
|
||
they were built from.
|
||
|
||
### Design notes
|
||
|
||
Reference floor plans provided: **Parks & Rec** (municipal, partitioned, outdoor patio),
|
||
**Silicon Valley hacker house** (domestic-open-plan, kitchen + pool), **The IT Crowd**
|
||
(basement, two rooms, glorious junk), plus the generic open-plan bullpen.
|
||
|
||
Each is a new script alongside `Office.gd` implementing the same interface:
|
||
|
||
```gdscript
|
||
build() # shell, fittings, lighting
|
||
configure_environment(env)
|
||
desk_spots() / chair_spots() / copier_spot()
|
||
spawn_point() / spawn_yaw()
|
||
```
|
||
|
||
They differ in **materials and hazards**, which is what stops them being reskins:
|
||
|
||
| Level | Signature material | Signature provocation |
|
||
|---|---|---|
|
||
| Municipal (Parks & Rec) | laminate, pinboard, civic beige | the public counter; forms in triplicate |
|
||
| Hacker house (Silicon Valley) | glass, whiteboard, drywall, a pool | standup meetings; someone's monitor wall |
|
||
| Basement IT (IT Crowd) | server steel, cardboard mountains, CRTs | "have you tried turning it off and on again" |
|
||
|
||
### On procedurally generating floor plans
|
||
|
||
The repo you found — `doratracyer/floor_plan` — **won't help**. It's OpenSCAD that turns
|
||
hand-drawn SVG wall paths into 3D-printable STLs. It doesn't generate layouts, doesn't
|
||
parse plan images, and produces no room polygons or graph you could build a level from.
|
||
It's also GPL-3.0, which we would not want to link against.
|
||
|
||
**Better path, and we're most of the way there already:** `Office.gd` is a parametric
|
||
floor-plan builder that just happens to have its numbers hard-coded. Lift those into a
|
||
data spec —
|
||
|
||
```gdscript
|
||
{ rooms: [{name, x0, x1, z0, z1, walls, door_at}],
|
||
fittings: [{kind, pos, yaw}],
|
||
lights: {grid_x, grid_z},
|
||
spawn: {pos, yaw} }
|
||
```
|
||
|
||
— and a generic `Floorplan.gd` builds any of them. Then each workplace is a **data file**,
|
||
new levels are authorable in minutes, and a *generator* becomes possible later (rooms as a
|
||
BSP split of the footprint, doors on shared walls, fittings by room type) because the
|
||
consumer is already data-driven. Do the spec first, generation second.
|
||
|
||
---
|
||
|
||
## Asset needs (MODELBEAST / `tools/gen_office_props.py`)
|
||
|
||
Already generated procedurally: office chair, vending machine, microwave, potted plant.
|
||
Needed next, in rough priority order:
|
||
|
||
`filing cabinet with pullable drawers` (split mesh per drawer) · `desk phone` ·
|
||
`guillotine / paper slicer` · `stapler + staple remover` · `wall clock` · `whiteboard` ·
|
||
`server rack` · `pinboard` · `water cooler bottle (separate from base)` · `wastepaper bin` ·
|
||
`hole punch` · `shredder` (the gauntlet's perfect tool)
|
||
|
||
Rule of thumb established this lane: **if it's a box, build it in code**; if its silhouette
|
||
can't be faked by a box, generate it.
|
||
|
||
---
|
||
|
||
## LEVEL 0 — THE BACKROOMS *(built)*
|
||
|
||
> *"special back rooms level can get weird with it? 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"*
|
||
|
||
`Levels.backrooms()` + `Dust.gd`. Mono-yellow, 2.55 m ceiling, damp carpet, a dense grid
|
||
of buzzing troffers, no windows and no way out.
|
||
|
||
**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 photographs
|
||
rather than a maze algorithm on purpose: the Backrooms aren't corridors, they're big
|
||
offset slabs in an open floor. A proper maze would be legible, and legible is wrong.
|
||
|
||
**The mechanic argues with itself.** Everything smashable is invisible but solid — you
|
||
find things by walking into them. RMB with the extinguisher throws powder; anything in
|
||
the cloud is coated, glows faintly out of all that yellow for 7 s, then settles back to
|
||
nothing. Every cloud also goes in your lungs, and that haze is on YOUR side of the glass.
|
||
Spray more to see individual objects; see the room less. Past 72% you cough: camera jolt,
|
||
screen bloom, and a rage tick.
|
||
|
||
Rhythm: spray → switch to something heavy → break it before the coating settles → spray
|
||
again, coughing. Pairs naturally with TOTAL DESTRUCTION, because the level *is* a search.
|
||
|
||
Still open here: a second gas with a different trade (something that reveals further but
|
||
costs more lungs), and the entity. There is currently nothing in here with you.
|
||
|
||
### LEVEL 0's horror layer *(built)*
|
||
|
||
`Entity.gd` · `Dread.gd` · `Backrooms.gd` · `tools/gen_horror_props.py`
|
||
|
||
Three borrowings, each doing a different job. **Slenderman**: frozen while watched,
|
||
closer every time you look back, and staring is what builds the static — so the safe
|
||
play kills you slowly and the fast play lets it catch up. **Xenomorph**: it hunts sound,
|
||
and the objective is to make sound, so playing well feeds it. **Blair Witch**: the level
|
||
wraps, and a wrap is when it repositions, so "I've been here" and "it's already here"
|
||
arrive together.
|
||
|
||
The entity is deliberately unresolved — 2.5 m, faceless, arms past the knees, cranium
|
||
swept backwards. Slenderman from the front, something else in profile. A silhouette your
|
||
brain finishes beats a monster it recognises.
|
||
|
||
Two bugs worth remembering:
|
||
- `seen_now` was a pure view-cone test with **no line of sight**, so it built static
|
||
through walls. In a maze that's most of the time.
|
||
- Then the fix exposed the real one: the generated maze was so dense there was **no
|
||
sightline longer than a few metres anywhere**, which kills the entity outright — a
|
||
stalker you can never see can never be stared at. Thinned the generator (skip a third
|
||
of the lattice lines, much bigger gaps), which also matches the reference photos far
|
||
better; they're mostly open floor with occasional slabs.
|
||
|
||
**DREAD and the three deaths** *(built)* — one meter, not a health bar. Nothing else in
|
||
this game does damage, so hit points would be a survival-horror UI bolted onto a
|
||
demolition game. DREAD is fed by being stared at, by proximity while unseen, by each
|
||
loop, and by your own coughing; it decays when nothing is happening to you, and every
|
||
loop makes it grip harder. At 100% you die, and whichever source pushed it over decides
|
||
which death you get, so the three memes stay legible: STARE (Slender), REACH (gory, the
|
||
xenomorph sprays), LOOP (the room simply keeps you at eleven). `Gore.gd` does blood on
|
||
the lens with drips, a wet burst, and the camera going down onto the carpet — slow on
|
||
purpose, because a jumpscare is a spike but a death should take its time.
|
||
|
||
**THE OTHER ONE** *(built)* — after three loops there are two, and they share a
|
||
silhouette. Its rules are the first one's inverted: it advances ONLY while watched, is
|
||
inert when you look away, and is completely deaf. Every instinct the hunter trains into
|
||
you is how the other one kills you. Tells are close-range only and all wrong the same
|
||
way: head thrown back at the ceiling, arms up, feet that never reach the carpet.
|
||
|
||
**ONE THING PER LOOP** *(built)* — `Backrooms._disturb()`. Exactly one change per wrap,
|
||
never two, because that constraint IS the effect: nothing and a loop is just a teleport,
|
||
several and it reads as a new room. One means you're never sure whether you noticed or
|
||
imagined it. Escalates from moving a prop half a metre or killing a light bank, to
|
||
hanging a totem that wasn't there, to permanently revealing something nobody dusted, to
|
||
putting the hunter exactly where you were standing.
|
||
|
||
Still open in LEVEL 0: the two entities can't yet be told apart by anything except
|
||
getting close, which is correct but means a first-time player has no way to learn the
|
||
difference except dying. A tell at medium range — a sound only one of them makes, or one
|
||
that never appears in the same frame as the other — would give skill somewhere to live.
|
||
|
||
---
|
||
|
||
## THE GREENGROCER *(built)*
|
||
|
||
> *"stack and fall physics, can squash to make juice, throw it, smash glass bottles of
|
||
> juice etc"*
|
||
|
||
`Levels.grocer()` + `Splat.gd` + `tools/gen_produce.py`.
|
||
|
||
The only site whose stock is mostly **soft**, and that single material change rewrites
|
||
everything. `produce` throws no shards; it bursts — coloured pulp, a puddle that stays, a
|
||
squelch, and LITRES, which is a much better score for a greengrocer than a body count.
|
||
|
||
Two knock-ons make the level play itself:
|
||
- Low `brittle_speed` means anything heavy landing on produce squashes it, so a pyramid
|
||
**crushes its own bottom layer as it collapses** with no special code. Shove test: 257
|
||
items became 45, and 42 litres.
|
||
- Puddles are slippery, cutting acceleration rather than top speed so it reads as sliding.
|
||
The more fun you have, the worse the footing.
|
||
|
||
Stacks are built bottom-up and nothing is glued or frozen — they stand because they're
|
||
stacked. 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 that still wants a
|
||
proper swing, and the contrast between litres and shards is the whole joke.
|
||
|
||
Round produce is authored CENTRED rather than floor-centred like every other prop in the
|
||
repo, because a rolling body whose origin sits on the floor plane wobbles like a loaded
|
||
die — and it now carries a SPHERE collider rather than the auto-boxed AABB everything
|
||
else gets, which is what finally made it roll.
|
||
|
||
### The pyramid took three fixes, not one
|
||
|
||
Swapping to sphere colliders broke every display instantly (28 m/s of residual motion,
|
||
fruit on the floor before you were through the door). Three things were wrong, and all
|
||
three had to be right:
|
||
|
||
1. **The radii were typed, not measured.** `Main._glb_radius()` now reads the asset. The
|
||
table said a cabbage was 84 mm; the model is 213 mm.
|
||
2. **The lattice was box geometry.** For spheres the rise per row is
|
||
`sqrt(4r² − step²/2)` — the height at which a fruit touches all four beneath it.
|
||
Anything else spawns every row above the first in mid-air.
|
||
3. **There was no tray.** A pyramid of spheres on a bare flat table *cannot* stand;
|
||
nothing holds the bottom row in. `Main._stack_tray()` frames each pile in four low
|
||
timber walls sized to its base row, which is exactly what a real shop does. All 310
|
||
bodies are asleep within 3 s.
|
||
|
||
### Hanging scales *(built)*
|
||
|
||
> *"feel like hanging scales would be involved somehow too"*
|
||
|
||
Real `PinJoint3D` pendulums, not animations. A static dial + rod, and a `scale-pan` rigid
|
||
body pinned at its own origin so it can only rotate about the pivot. They hang at chest
|
||
height down the aisles — dial at eye level, dish below it, where a real one hangs and
|
||
where you keep walking into it. Measured: the dish swings 0.40 m off a light knock and
|
||
the pin holds the pivot to 1.1 mm. The pan is `steel`, so it never breaks — you get a
|
||
heavy brass weight loose in a room full of stacked fruit instead.
|
||
|
||
### Carry and throw *(built)*
|
||
|
||
E picks up anything under 6 kg that isn't a record; LMB throws it at 11 m/s, six times
|
||
any brittle threshold in the game. Thrown fruit bursts on landing through the same
|
||
`brittle_speed` path a collapse uses, so there is no separate "thrown object" code at
|
||
all. The carried body rides frozen-kinematic with its collision layers zeroed and gets
|
||
them back on release.
|
||
|
||
### The bag you cannot open *(built)*
|
||
|
||
> *"and those rolls of plastic bags you cant open haha"*
|
||
|
||
`Tasks.Kind.BAGS`, and it may be the purest thing in the game. Tearing one off the roll
|
||
is easy — that's the setup. Then you have to open it. The bag has two ends, only one of
|
||
which opens, and **nothing tells you which**. You rub, alternating ← and → because
|
||
mashing one key is not rubbing, and either the meter moves or it doesn't. The only way to
|
||
learn which end you're holding is to have already lost several seconds to the other one.
|
||
SPACE turns it over. That is the entire solution and the game never mentions it. After
|
||
about five seconds on the sealed end it takes pity and says so — but only after the
|
||
provoke has already landed on the meter.
|