ShitboxInfinity/README.md
m3ultra aac4384c05 Per-junction medal targets, measured; fix the last unplayable junction
Medal targets are no longer one uniform 15/35/60k guess across every event.
tools/tune_junctions.gd plays each junction 12 times with varied launches and
sets bronze/silver/gold at the 35th/65th/88th percentile of actual damage, so
they now range from $4k at Cliffs Corner to $73k gold at Melbourne x Merivale.
The old numbers made half the events impossible and the other half trivial.

Grey x Glenelg scored $0 in 12/12 runs and took three fixes, each found by
measuring rather than reading:
- The launch sat on the street centreline, so the car drove cleanly BETWEEN the
  two convoy lanes -- closest pass 2.9 m, almost exactly the 2.7 m lane offset.
  It now launches in a lane. (The first attempt offset the wrong way and widened
  the pass to 5.5 m, which is how the sign error announced itself.)
- int(L / headway) floors to one car on a short clipped stream, so that junction
  fielded 9 convoy cars against the usual 20-25. Minimum 3 per stream, evenly
  spaced to fit.
Result: $0 -> $91k, convoy 9 -> 18, and 0 of 9 junctions now unplayable.

The tuner has turned out to be the event validator as much as a balance tool --
"scores nothing in every run" is an unplayable event, not a tuning problem.

Also from the review: the deformer no longer crumples det_* panels after they've
been shed onto their own body, and notes that rebuilding an ArrayMesh drops any
imported LODs.

Honest gap: Cliffs Corner and Bridge Approach show median == max at ~$4.7k --
winnable and correctly tuned, but the run ends on scenery before the convoy is
reached. Both are curved approaches; they want better crossings picked by hand.

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

269 lines
14 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.

# Burnout Infinity: Shitbox
Burnout 3-style arcade racer, Aussie shitboxes, Woolies carparks. Godot 4.6 + Blender, native Apple Silicon.
## Run
```
/Applications/Godot.app/Contents/MacOS/Godot --path .
```
Controls: WASD/arrows drive, Shift drift, Space boost, R reset (or retry after Crash Mode), Esc menu.
## Modes
- **Cruise** — free roam with traffic, plus two rivals lapping the circuit.
Pull alongside one at speed and it's an **Outrun** (NFSU2 rules, our code):
first to gap the other by 250 m wins. Straight-line distance decides *when*
it ends — so ducking down a side street is a real escape — but path order
decides *who* won, so reversing away from the circuit is a loss. Winning
pays takedown-tier boost. R mid-duel is a forfeit.
- **Crash Mode** — 60 seconds, rack up damage dollars ramming traffic and parked cars.
- **Crash Junctions** — pick a "CRASH JCT" entry in the level list: launch into scripted
cross-traffic convoys at a real intersection, then Aftertouch-steer your wreck (arrows)
and detonate the Crashbreaker (space). Damage tally + bronze/silver/gold. Events live in
`levels/<id>/junctions.json`, regenerated in one go by
[tools/build_all_junctions.sh](tools/build_all_junctions.sh) — which is also the only
record of *which two streets each event is*, so keep the spec list in it current.
Medal targets come from [tools/tune_junctions.gd](tools/tune_junctions.gd), which plays
every event headless with varied launches and sets bronze/silver/gold at the
35th/65th/88th percentile of what was actually achieved. They were previously one
uniform 15/35/60k guess across every event, which measurement showed was nonsense —
achievable damage ranges from a couple of thousand to nearly $100k depending on the
intersection. Percentiles rather than the max, so gold is a good run and not a freak one.
```
Godot --headless --path . --script tools/tune_junctions.gd -- runs=14
```
Two traps worth knowing. It reloads the level for every run, so a full pass is slow —
budget tens of minutes. And **nothing else may touch Godot or the levels while it
runs**: a second Godot instance contends for the `.godot` import lock, and a level
rebuild mid-flight silently feeds it half-written levels, which reads as "this event
scores zero" rather than as an error.
The tuner is also the event *validator*. An event that scores nothing across every
run isn't badly tuned, it's unplayable, and it prints `!!` for exactly that. Three
separate causes turned up that way, none visible by reading the generator:
convoy streams clipped so wide that you thread the intersection through a 20 m gap;
a launch point on the street centreline threading cleanly *between* the two convoy
lanes; and `int(L / headway)` flooring to one car on a short stream, leaving nine
convoy cars where the working events have twenty. Re-run it after any change to the
generator, the launch geometry, or convoy spacing — all three feed the numbers.
Read the spread it prints, not just the targets. **Cliffs Corner** and **Bridge
Approach** both show median == max at a low ceiling (~$4.7k), which means the run
reliably ends on the first thing you hit and the convoy is never reached — they're
winnable and honestly tuned, but they're scenery crashes rather than pile-ups. Both
sit on curved approaches where the launch street bends away from the intersection.
Worth hand-picking better crossings for those two.
- **Race** — 3 laps vs 3 rubber-banded rivals on the level's `RacePath`.
- **Drag** — NFSU2's event, our code: a straight-line run down the level's longest
genuinely straight stretch of wide road (the builder finds it and drops
`DragStart`/`DragEnd` markers, so no level needs hand-authored coordinates).
The gearbox *is* the game — revs climb, **space shifts**, and shifting inside
the window pays while an early shift or sitting on the limiter bogs you. One
opponent alongside.
- **Derby** — Destruction Derby in the Woolies carpark: five opponents, last one
running wins. They use the pursuit AI, each hunting a different car so it's a
brawl rather than a mob, and leaving the ring for 8 s forfeits.
- **Pursuit** — not a menu mode; it happens *to* you in Cruise. Smashing things
fills a heat meter, and at full heat the police turn up and hunt you. Open
250 m for 6 s to lose them, or get pinned for 4 s and you're busted. Heat
decays if you behave, so picking a fight is a choice rather than a timer.
The boost loop is Burnout 3's: earn boost by drifting, near-missing traffic, and smashing
things. Wrecking a rival near you = TAKEDOWN — refills the meter and grows it (up to 4x).
Hard crashes shed body panels, crumple the bodywork and trigger Impact Time slow-mo.
## Crash deformation and damage
[src/deform.gd](src/deform.gd) implements the control-hull model from the design
PDF: a 3×3×5 lattice of control points encloses the car, an impact shoves the
nearby ones inward, and every mesh vertex follows by an inverse-distance-weighted
blend (`W_i = 1/‖p_j c_i‖^α`, α = 2).
The trick that makes it free: those weights depend only on *original* positions,
so they're precomputed once per car and each impact is a plain weighted sum —
**0.3 ms**, no distance maths, no per-frame cost. Panel travel is clamped
(`MAX_PULL`) per the PDF's bounding-box safety limits so bodywork crumples
instead of folding through itself. Only the player and rivals carry a deformer;
traffic and parked cars are plain bodies.
Paint damage rides the same numbers: total control-point travel drives the albedo
dulling toward dirty grey, the roughness climbing, and (past 35% damage) a scratch
detail layer switching on — so the scuffs and the crumple can never disagree.
Meshes *and* materials are deep-copied per car; share either and denting one
Kingswood dents every Kingswood in the level.
## Cars
A car is a folder: `cars/<id>/stats.tres` + optional `car.glb`. Models are built
procedurally — specs live in [tools/build_cars.py](tools/build_cars.py), one dict per car
(silhouette profile, dimensions, paint). Rebuild all GLBs with:
```
/Applications/Blender.app/Contents/MacOS/Blender -b -P tools/build_cars.py
```
Model convention (any GLB works, hand-modelled or generated):
- Nose faces **+Y** in Blender (imports as Godot -Z forward).
- Node origin 0.75 m above ground level (the controller's ride height).
- Objects named `det_*` are detachable — the game rips them off on impact
(doors, mirrors, bumpers, bonnet, boot).
- Current fleet: 30 shitboxes, from the Datto 120Y to the FLOORED LAZZA 1.5 ('82
Laser hatch, rear louvres that shed slat-by-slat — John's actual first car,
built from his photos). Piss-take names on purpose: real model names and
badges are trademarks.
To improve a silhouette: supply a side-on photo + front three-quarter photo, tweak the
profile points in the spec, rebuild, screenshot.
**Stats are low-key reversed**: the shitboxier the car was in real life, the better it
performs here. The Excel X3 is the fastest car in the game; the muscle cars are all bark.
Downloaded models (from `shitboxes/`, gitignored + .gdignore'd) convert via:
```
/Applications/Blender.app/Contents/MacOS/Blender -b -P tools/import_shitbox.py -- "<file>" <car_id> <length_m> [yaw_deg]
```
Imported cars don't shed det_* panels until someone cuts their doors out in Blender.
## Real-world levels from OpenStreetMap
Real street grids, our art style, clean collision, ODbL-licensed. Fetch a bbox from the
OSM API, convert, build (queen_st_mall was made exactly like this):
```
curl -A 'BurnoutShitbox/1.0' 'https://api.openstreetmap.org/api/0.6/map?bbox=<minlon>,<minlat>,<maxlon>,<maxlat>' -o area.xml
python3 tools/osm_xml_to_json.py area.xml area.json # see git history / osm_level_args.py
ARGS=$(python3 tools/osm_level_args.py area.json "<spawn way name>" "<loop street 1,2,3,4>")
Blender -b -P tools/build_level_osm.py -- area.json <level_id> <origin_lat> <origin_lon> $ARGS
```
Don't reconstruct from Street View imagery (ToS + melted collision). Use photos as
reference to hand-detail hero landmarks on the OSM base.
Levels so far: woolies_carpark, queen_st_mall, southbank, albert_parklands,
story_bridge (procedural steel truss along Bradfield Highway), kangaroo_point.
Area JSONs are cached in `~/.cache/burnout_osm` (override with `$OSM_CACHE`);
`tools/build_all_levels.sh` rebuilds every level from them.
### What the builder makes of the OSM data
Beyond the street grid and building footprints, it uses the furniture OSM
already carries — street lamps, benches, bins, bollards, bus stops and the
whole footway network — so most of the detail is real Brisbane, not invented:
- **Buildings** extrude with a glazed shopfront band at street level, a
cantilevered awning over the footpath (three canvas colours, varied heights,
~70% of buildings — a whole street at one height in one colour reads as a
painted stripe), a roof parapet, and a plant room on anything tall.
- **Footpaths** come from the OSM footway ways, raised 14 cm with a chamfered
kerb. The chamfer is deliberate: a hard vertical edge at 120 km/h reads as a
crash to the jolt detector in car.gd.
- **Roads** get dashed centre lines; `footway=crossing` becomes zebra bars laid
flat on the road instead of a raised strip you'd launch off.
- **Trees** are a trunk plus three offset welded spheres (figs) or radiating
fronds (palms), scattered along kerbs on top of the ones OSM maps — Brisbane
has far more street trees than are tagged.
- **Smashables**: the builder drops marker empties and game.gd fills them at
load with RigidBodies — `PP_` kerbside/carpark parking spots become shuntable
fleet cars, and `SL_`/`BO_`/`WB_` (OSM street lamps, bollards, bins) become
topple-able street furniture with a low centre of mass so a clipped lamp post
goes over rather than helicoptering. All asleep until rammed, and skipped
entirely in junction mode to keep those puzzles deterministic. Lamps and
bollards are deliberately *not* baked into the level mesh — a static pole you
drive through is worse than no pole.
- **Brisbane colour**: red busway asphalt, jacaranda-purple canopy on ~1 in 5
street trees, Queenslander weatherboard + corrugated iron on `building=house`
footprints, fictional shop-fascia signs above awnings and rooftop billboards
in three designs (FLUX gibberish lettering on purpose — no real brands).
- **Traffic signals** from `highway=traffic_signals` — pole, mast arm and two
lantern heads, with emissive lenses so they read as lit in a shaded canyon.
- **Power poles + sagging wires** down one side of residential streets, the
single most Australian thing a suburban street can have.
- **Southbank landmarks** (hardcoded per level id): the Wheel of Brisbane
placed off its mapped ticket office, and the Grand Arbour's curling steel
tendrils + bougainvillea following the actual mapped footway.
- **Queen Street Mall's winged canopies** grow along pedestrian ways whose name
starts with the `canopy` argument. Note OSM splits the mall into a 15 m stub
named "Queen Street Mall" plus the 438 m pedestrian way named just
"Queen Street", hence prefix matching.
Variation is seeded off OSM ids, so rebuilds are reproducible.
### Textures
`assets/textures/` is FLUX-generated. Regenerate the whole set with
`tools/gen_textures.sh` or one with `tools/gen_textures.sh <name>`; creds are
read from `.env`, never printed.
Cloudflare Workers AI first, then the **local MODELBEAST farm** as an automatic
fallback ([flux_local.py](tools/flux_local.py), operator `flux_local`). This is
not optional nicety: Cloudflare's free tier is 10,000 neurons/day and one full
set regen exhausts it, after which every call 429s. The fallback is transparent
`gen_textures.sh` output looks identical either way.
Two things worth knowing before you re-prompt:
- FLUX returns a **near-white blank sheet** for anything described as pale
("off white metal panel" came back at 240/255 however it was worded), and a
blown-out albedo renders as a featureless void in game. The spec table's last
column is a target mean luma that clamps this deterministically.
- `tools/check_textures.py` reports mean/contrast for the set and flags the
duds. Run it after regenerating.
The builder box-projects UVs at a per-material metres-per-tile scale; facades
pick glass/brick/concrete/heritage/sandstone by height, building tag and id hash.
`woolies_carpark` is the one level still built in GDScript rather than as a GLB
([carpark.gd](levels/woolies_carpark/carpark.gd)) — its parked shitboxes have to
stay shuntable RigidBodies, which a static GLB can't carry. It picks its parked
cars at random from the real fleet. Its Trolley Run junction runs convoys along
the aisles at `z = 15/12` and `z = -21/-24`, so keep those lanes clear.
## Add a level
Make a folder `levels/<id>/` containing `level.glb` (or `level.tscn`). Blender convention:
- Solid objects: suffix the name with `-col` (Godot generates collision on import).
- An Empty named `Spawn` where the car starts.
- A curve/path named `RacePath` (loop) enables Race mode and traffic.
- Export glTF Binary to `levels/<id>/level.glb`. It appears in the menu.
## Tests
```
/Applications/Godot.app/Contents/MacOS/Godot --headless --path . --import
/Applications/Godot.app/Contents/MacOS/Godot --headless --path . --script tests/smoke.gd
```
Covers all seven modes (cruise, crash, junction, outrun, pursuit, drag, derby,
race) in about 30 s, printing a `.. stage` line as it enters each one.
Failures go through `_check()`, never bare `assert()`. A failed assert in a
headless `SceneTree` script halts the script but leaves the tree running, so the
process spins forever at 100% CPU instead of failing — it looks exactly like a
slow test. That cost 53 minutes of wall clock to spot once; the stage lines and
`_check()` exist so it can't happen twice.
Only one Godot instance may touch the project at a time: a second contends for
the `.godot` import lock and can kill the first mid-run.
Visual check (opens a window, saves a PNG):
```
/Applications/Godot.app/Contents/MacOS/Godot --path . --script tests/screenshot.gd -- car=datto_120y mode=crash frames=280 out=shot.png
```
## Design doc
`docs/BurnoutInfinityShitbox.pdf` — research report on the original game. Still to come:
crash deformation (control-hull vertex displacement, equations in the PDF), oncoming-lane
boost, Road Rage mode, sound, real level GLBs from Blender.