9.1 KiB
HARD YARDS — dev manual & cookbooks
Written at Sprint 13 integration, distilled from THREADS. The canon docs (DESIGN.md, PLAN3D.md) say what the game IS; this says HOW to work on it. When this file and a lane's THREADS entry disagree, THREADS wins — fix this file.
The tool map — what answers what
| question | tool | notes |
|---|---|---|
| does everything still work? | web/world/selftest.html |
must be N/0/0; the count is the per-lane sum |
| is this yard rig-able by mouse? | web/world/dev_rigging.html |
N switches yards; __laneB.switchSite(i) |
| what does the sky/storm look like at t=X? | web/world/dev_skyfx.html |
scrubbable clock; flies debris; warning: it aims you SOUTH |
| player anims/movement | web/world/dev_player.html |
|
| is this site winnable, at what price? | tools/site_audit/ (audit.html browser, audit.mjs node) |
funnel state printed in the header — check it |
| does rigging save the garden here? | tools/garden_bench/ + tools/site_audit/gardenfly.js |
garden outcomes, separation targets, margin rule |
| per-anchor wind exposure over a storm | tools/storm_envelope/ |
the storm-side second harness |
| regenerate all canon GLBs | blender -b -P tools/blender/build_yard_assets.py |
deterministic; --only <name> for one asset |
| job-sheet/invoice design preview | tools/jobsheet/index.html?v=... |
E's design-ahead handover pattern |
| deploy to partly.party/hardyards | sh tools/deploy_hardyards.sh |
ships web/ ONLY; self-verifies |
Adding an asset (a prop, a structure, a thing in the yard)
House rules first (PLAN3D §0): the runtime asset is always .glb, committed
in web/world/models/. Raw .blend/.fbx never enter the repo (gitignored).
Two legitimate routes in:
Route 1 — the factory (canon assets). Add a builder to
tools/blender/build_yard_assets.py, re-run, commit the GLBs. Nothing is
hand-edited after export; the factory must produce byte-identical output across
runs (this is asserted — three full runs, same hashes). This is Lane E's lane.
Route 2 — vendored external (John's hand-built / MeshGod / library assets).
Precedent exists: models/debris/* is copied verbatim from the 3D-STORE
library. Drop the GLB in web/world/models/, then wire it (below). If it's a
one-off, say in THREADS where it came from; if it'll be regenerated, it belongs
in the factory instead.
Wiring a prop — the gnome pattern (~5 lines + data, one commit). There is
deliberately no generic props: [] — every prop is a named top-level key in
the site JSON, wired explicitly, so a site says what it contains. The bike
(Sprint 12) is the worked example — copy its diff:
- Put
bike_kid_01_v1.glbinweb/world/models/. - Site JSON gains a key:
"bike": { "x": -7, "z": 9.69, "rotYDeg": 180 }. world.jsloader: one entry in thePromise.all+ a placement block (position viaheightAt(x,z), rotation from the JSON).- Optional collateral: bake
collateral/collateral_valueextras in the GLB as the proposal; the site JSON's number is canonical (the carport'scollateralValue: 180pattern). Nothing is billed untilcollateralFor()can resolve it — an unpriced collateral returns null and reads as "not scored", never as free… and never as billed either (the gutter was a free failure for two sprints). - Wrecks: same origin, same footprint, mesh-for-mesh swap on
world.wreckStructure(id)— build the wreck variant from a shared helper so intact/wrecked can't drift (E's_house_facade()pattern). - An assert that can actually fail, negative-control-proven (E rebuilt the bike at lean 0° to watch the suite go red before trusting it).
Conventions the loader reads (bake as GLB extras / node names):
rating_hint(float) on anchor nodes — effective failure =hw.rating × ratingHint. Unset ⇒ 1 at adoption; the sim reads the anchor, live.collateral(string) on anchor nodes — what breaks when this anchor's corner blows.collateral_keyon a structure whose priced thing has a different name than the structure (house → "gutter").- Named nodes matter:
pickup_anchor(where carried items sit), thefascia_anchor_*family, etc. Checkworld.jsadoptAnchor before renaming anything.
⚠ THE AXIS TRAP (bitten every sprint since 3): the exporter maps Blender
(x, y, z) → three.js (x, z, −y). A lean toward Blender +Y arrives leaning
toward three.js −Z. Never write a docstring about orientation without
measuring the exported GLB in three.js coords — E's bike comment lied about its
own geometry and only a browser-coords assert caught the flip. Dims/tri-count
verifies CANNOT catch a wrong shape or a flipped lean; look at the render.
Authoring a site (a "level")
Sites are data: one JSON in web/world/data/sites/. Top-level shape (see
site_02_corner_block.json): id, name, blurb, yard, sun, gardenBed, structures, trees, posts, fence, wind, [props…], [separation], _design/_why.
Anchors are not listed directly — they come from structures/trees/posts (and
GLB nodes), typed against the checked ANCHOR_TYPE enum in contracts.js.
The authoring loop that works (Sprint 10–13 distilled):
- Copy the nearest existing site JSON; keep
_design/_whynotes in-file — they're the level's design doc and the repo expects them. validateSiteruns at load and fails loud (types, wind block, enum). Boot withboot({site:'your_site', bank:N})to jump straight to it.- Wind personality lives in the SITE (venturi axis/gain/throat, tree shelters), storm personality in the storm def. The venturi axis is site GEOMETRY (a line, mod π), not a wind heading — reconciled twice in THREADS before that sentence earned its place.
- Every trap must be: visible before commit (the enum gives it its pre-rig read), priced (collateral), and mechanically real (rating_hint) — "the price is the trap" vs "the steel is the trap" is a ruling, not an accident.
- Run the gauntlet before calling it a level:
site_audit(winnable lines at $80, funnel ON — check the header),gardenfly/garden_bench(does rigging separate held-vs-bare? pin aseparationblock if this site makes that promise),storm_envelope(per-anchor exposure), then a cold playthrough by someone who didn't author it (Lane D's job) — Sprint 11's soft-lock shipped behind 296 green tests and was found in one minute of play. - The blurb and brief carry real design load (night 3's "plenty to tie off
to" is the trap's cover story). Client/brief/pay live in week.js NIGHTS;
a site without a night is only half real (
boot({site})seeks the night).
The earned gotchas (each cost a real sprint-day; receipts in THREADS)
- Measure through the real chain. Any garden/load number that didn't go
through
commit → attach → skyfxis suspect: session-levelcommit()bypasses attach and scores every rig as bare (D, S13); a 12 s pre-settle skews SailRig's internal storm clock (B catching A, S13). - Site wind is built in ONE place:
windForSite()in weather.js. Three harnesses independently rebuilt it wrong (funnel off ×2, frozen tree sway). Import the helper; never hand-wire venturi/shelters in a tool again. - Margin rule: any bench corner within ~15% of its rating breaks in the real game (residual bench-vs-UI under-read, cause unfound). Marginal ≠ PASS.
world.dress()needs the importmap — without it the bare-specifier throw is swallowed by callers' try/catch and every ratingHint silently becomes 1.0 (no carport trap, invincible house).setHardware()refuses over-budget quietly — check.okor fly withbudget: 9999in tools (this ate four of C's measurements).- Documentation cannot fail: an unchecked enum, a JSDoc contract, a comment about geometry — if nothing asserts it, expect it to be wrong someday.
- Two harnesses, one number. When they disagree, find the variable before tuning anything; the variable is usually the harness.
- One clone per lane; ports 8823 (integration) / 8824–8828 (A–E) via
untracked
.claude/launch.json(copy the example). - Hidden tabs throttle rAF dead: drive with
SHADES.step(dt)/.render(); hidden-canvas boot needs one resize before projections are sane.
Design canon quick-reference (ruled, don't relitigate casually)
- Business: HARD YARDS. ABN stays sequential (never make it realistic).
- Carport $180 / gnome $25 / gutter $90; bike deliberately unpriced until the sim can knock it over (tripwire assert enforces this).
- The wild night sells no clean line — the pyrrhic win is intended. Night 5's garden is beyond saving BY DESIGN and the brief says so. GARDEN_DRAIN does not move. 18–45 m² is an availability floor, not a ceiling.
- Hail is why sails matter (steep, blockable); rain walks under cloth (don't reopen the rain-angle argument).
Where knowledge actually lives
READ ORDER for anyone new: README → DESIGN.md → PLAN3D.md → THREADS from the
last [I] entry backwards until you have context → the current SPRINTn.md.
THREADS is append-only memory, not a reference — when you learn something from
it the hard way, promote it into THIS file so the next person reads one page
instead of 300 entries.