Skip to content

Kat Racing — a kart racer on the real terrain of Moshav Amikam - #1

Draft
whadar wants to merge 106 commits into
masterfrom
claude/kat-racing-game-aaa-tuvduy
Draft

whadar wants to merge 106 commits into
masterfrom
claude/kat-racing-game-aaa-tuvduy

Conversation

@whadar

@whadar whadar commented Aug 20, 2026

Copy link
Copy Markdown
Owner

A Three.js kart racer whose default course is built on the genuine terrain and building
footprints of Moshav Amikam, Ramot Menashe. 68 commits, 45 modules, ~34k lines.

Playable now as a single self-contained HTML file — no server, no network, nothing to
install. Build it with npx vite build --config vite.config.single.js && node tools/pack-single.mjs,
then open game/kat-racing.html.

Controls: arrows or WASD to drive, Space to hop and hold to drift, Ctrl for items, Esc to pause.

The map is real data, not invented terrain

Overpass and OpenTopoData are both unreachable from the build environment, so the data comes
from AWS Open Data on S3 instead:

Layer Source
Elevation AWS Terrain Tiles (elevation-tiles-prod), SRTM 1-arcsec derived
Buildings, roads, land use, land cover, water Overture Maps Foundation 2026-08-19.0 (OSM-derived)

tools/ov_common.py reads the Overture GeoParquet by HTTP range request, using per-row-group
bbox statistics to pull only the few megabytes covering Amikam out of a 512-file dataset.

That yields: terrain 3072 m × 3072 m at 3 m post spacing (50–185 m ASL), 552 real building
footprints, 229 real road segments, plus land use, land cover and water.

The decoder was verified against points of known elevation before being trusted — Jerusalem
741 m vs ~760 published, Dead Sea −412 vs ~−420, Mount Carmel 512 vs 546, Amikam 83 m vs the
published 75–85 m. That check also caught a wrong starting coordinate.

What is in the branch

Terrain with chunked LOD and land-cover-driven splatting; atmospheric sky, cascaded shadows and
sky-derived IBL; 552 extruded buildings with pantile roofs and solar water heaters; vegetation
(olive, cypress, pine, prickly pear, vineyard); a circuit over the real moshav roads with
extruded kerbs and bilingual Hebrew/English trackside signage; raycast-suspension kart physics
with a servoed drift; eight procedural cat drivers and four kart chassis; items, AI racers, race
state machine, HUD and minimap; procedural WebAudio engine, surface, impact and music synthesis;
post-processing; menus, quality tiers and a photo mode.

How quality was driven, and where it got to

tools/shoot.mjs drives the built game in headless Chromium under SwiftShader and captures eight
canonical views. Independent critic agents then scored those frames against Mario Kart 8 Deluxe
on a 0–95 rubric where 88+ means genuinely shipped-AAA, and fix agents were dispatched per
defect.

Binding (minimum) score by round: 47 → 54 → 56 → 63 → 61 → 60 → 57.

The target was 88. It was not reached, and the loop stopped buying quality after round 4. This
section is the honest record of why, because the failure modes are more useful than the number.

Misattribution. Defects were consistently seen correctly and diagnosed wrongly. The cypress
trees were reported as "untextured cones in vegetation.js" by three consecutive rounds; they
were ConeGeometry primitives in track/furniture.js, so no amount of work on the named file
could ever have fixed them. The final round found the same pattern behind the missing kart
shadows: they are present in gridStart, itemChaos and hilltopVista and absent elsewhere,
making it a light-direction and fallback-strength problem that six rounds had been re-filing as
a shadow-map bug. Likewise the black crushing on thin geometry is half-res GTAO with
depth-reconstructed normals, not the vegetation code that kept being blamed.

Oscillation. Files that compose into the same pixel were edited by parallel agents on
separate briefs, who undid each other. The drift VFX swung between a blown white blob and
nothing at all four times; the asphalt went blue "choppy water", then "grey camouflage fabric",
then "curdled noise"; contact shadows were verified fixed and regressed four times. A final
attempt — one agent owning lighting, sky, post, post shaders, terrain material and track surface
together — produced what the critics called "a coherent palette, not a coherent lit world":
"one art direction applied to several different afternoons".

Frame-scoring is blind to motion, control and sound. A human playing the build for one
minute found inverted steering and an audio burst on first gesture. Neither was detectable from
still frames, and both had survived every round.

tools/guard.mjs measures the defects that kept regressing and exits non-zero on regression;
docs/CONTRACT.md carries both hazards for any future agent. Two checks were written and then
deleted for giving confident wrong answers — a contact-shadow test that measured the kart's own
bodywork, and a drift-blowout test that near-white kart liveries and kerb stripes tripped three
different ways.

Known defects

Kart shadows absent in roughly half the chase views; deep shade crushes to achromatic near-black
on thin geometry; aerial perspective does little under ~1 km; buildings are flat-box with decal
windows; vegetation reads emerald rather than the olive/silver/khaki of an Israeli July, and the
verge sits at a saturation the project's own art brief forbids; the course reads as a public
road rather than a designed circuit — a dashed centreline runs down the racing surface and road
width varies with the source OSM geometry; the title screen shows the race HUD through it with
overlapping text, and the settings screen prints the raw renderer driver string.

Notes for review

  • Build artefacts (dist/, dist-single/, shots/) and large regenerable intermediates are
    untracked; tools/README.md documents regenerating everything from source.
  • The legacy Express app at the repository root is untouched; the game lives entirely in game/.

Generated by Claude Code

claude added 30 commits August 20, 2026 15:09
Extracts genuine GIS data for Moshav Amikam and turns it into the game world:
- elevation from AWS Terrain Tiles (SRTM 1-arcsec derived), decoded and resampled
  to a 3072 m x 3072 m metric grid at 3 m post spacing (50-185 m ASL)
- 552 building footprints, 229 road segments, land use, land cover and water from
  Overture Maps 2026-08-19.0, read straight out of S3 parquet by range request
- WorldData sampling API (height, normal, slope) shared by every system
- Vite + Three.js engine skeleton and a headless screenshot harness used by the
  visual review loop

Engineering contract for parallel module work in docs/CONTRACT.md.
preview/common.js boots the engine with the real Amikam world so each parallel
module agent can render and screenshot its own system in isolation, without
contending over main.js or a port.

Untracks dist/, shots/ and the large regenerable intermediates (the Overture
row-group index and the raw decoded DEM); public/data holds the built assets and
tools/README.md documents how to regenerate everything.
Parallel module work landing from the world and gameplay fan-outs:
- terrain: chunked LOD mesh over the real heightfield with multi-layer splatting
  driven by the Overture land-cover polygons, slope and elevation
- sky/lighting: atmospheric sky, cascaded shadows and a sky-derived environment
- characters: eight procedural cat drivers, kart bodies and the roster
- physics: raycast-suspension kart model with slip-curve tyres, plus collision
- per-module preview pages and the headless vehicle sim

Working checkpoint; the review loop has not run against these yet.
Second shoot-and-look pass from the module agents: fur shading and eye
definition on the cat drivers, denser kart bodywork, retuned vehicle and
collision behaviour, lighting and terrain-material refinements, plus a
headless characters sim.
- buildings: pitched-roof moshav architecture generated from the 552 real
  footprints, with facade and roof material sets
- items: item set, item meshes and boxes with place-weighted distribution
- ai/race: racing-line AI over the circuit and the race state machine
- headless sims for items, race and characters
Continued shoot-and-look passes: roof and facade detail on the moshav
buildings, sky and haze tuning, item meshes, and AI behaviour.
…the tree

Continued module passes on the moshav buildings, item meshes and behaviour,
AI driving and the race sim. Extends .gitignore to cover the probe scripts,
ad-hoc analysis files and one-off shoot wrappers the module agents generate
while iterating, and untracks the ones that had slipped in.
… build agents

The world and gameplay workflows were cut short by a session usage limit, but
these systems had already landed:

- src/world/vegetation.js, src/render/materials/vegetationMaterials.js
- src/world/roads.js and src/track/ (circuit over the real moshav roads)
- src/render/particles.js, vfxLibrary.js, decals.js
- src/ui/ (HUD), src/core/input.js

Post-processing, audio and the integration pass are still outstanding.
- src/render/post.js: SMAA, bloom, AO, DOF, motion blur, filmic grade, boost rush
- src/audio/audio.js: WebAudio synthesis for engine, drift, impacts, items, race cues
  and a procedurally sequenced Mediterranean race theme
All 41 modules load without error, so the integration pass will be fighting
runtime API mismatches rather than broken imports.
The integration agent has wired the modules into an actual game:
- src/main.js (1.7KB skeleton -> 41KB): boot, loading, menu and race flow
- src/game/camera.js: spring-damped drift-aware chase camera
- src/core/quality.js: renderer detection and quality tiers
- src/ui/menu.js: title, character select, course select, pause, results
plus surgical cross-module fixes surfaced by running the systems together.
Three independent critics scored the integrated build 52, 52 and 47 out of 95 against
Mario Kart 8 Deluxe: 17 critical, 26 major and 15 minor findings across 22 files.
docs/REVIEW-R1.md collects them, grouped by owning file, for the fix agents.
claude and others added 30 commits August 23, 2026 13:56
…ssion

17.1 lengthened shadows 2.3x but tools/guard.mjs measured the tarmac at blueBias -16.2 and
saturation 0.33, failing road-neutrality in the mirror image of the old blue 'choppy water'
regression: a warm-tinted road instead of a cold one. Same check, opposite direction, still a
road that is not neutral.

Measured across four angles on villageStreet:
  15.4  blueBias  -9.5  sat 0.17  pass  (but shadows only 0.74x caster height)
  16.0  blueBias -11.4  sat 0.22  pass
  16.5  blueBias -14.0  sat 0.28  on the threshold
  17.1  blueBias -16.2  sat 0.33  FAIL

16.0 keeps most of what the shadow pass was for — the sun drops about 7 deg and every shadow
grows roughly 30% — without trading a measured regression for it. Relaxing the threshold to
admit 17.1 was the other option and would have been moving the goalposts to pass my own change.
All five were reproduced before being touched, and the repros are now
permanent benches (tools/sim/grid.mjs, tools/restart.mjs, tools/touch.mjs).

1. The field reversed off the grid. The countdown hold was expressed as
   `brake: 1`, and vehicle.js reads a brake held at a standstill as a
   request to reverse — that is how you back out of a wall. So for the ten
   seconds the lights were red every kart was under rearward drive:
   measured at -1.04 m/s of backward creep with the rear wheels turning at
   -45 rad/s. Bleeding the velocity afterwards never helped, because the
   position had already integrated. The grid is now pinned to its slots and
   the wheels cannot turn backwards; height is left free so the suspension
   still settles.

2. Opponents did not move. `autopilot` has only ever meant "drive the
   PLAYER's kart for them" — attract mode, the demo, review shots — but it
   gated `ai.control` for the whole field, so the moment a human took the
   wheel every opponent fell through to zeroInput. Every existing bench ran
   with autopilot:true, which is why nothing caught it.

3. The game would not restart. Every restart path works on desktop; the
   fault was that the touch UI mounted itself inside the renderer's
   <canvas>, and children of a canvas are fallback content that the browser
   never lays out. Every on-screen control measured 0x0 px, so the game has
   never had working touch controls at all — and with no Escape key, no P
   and no gamepad Start, a phone could not reach the pause menu or the
   Restart Race in it. The overlay now mounts on the body, and there is an
   on-screen pause button.

4. Audio played before the game started. Attract mode runs a real race
   under the title card, and updateAudio ran in every mode, so the title
   and character screens played the autopilot kart's engine and tyres at
   full tilt. The driving bed is now gated to the racing modes. An explicit
   `vehicle: null` also fell through to the context object itself, so
   asking for silence got an engine reading garbage off a container.

5. The engine was a high-pitched drone. The grain buffer is authored at
   50 Hz and played back at firingHz/50 — 3.9x at the limiter — and it had
   its body resonances baked in, so they rode the pitch: res1 at 240-410 Hz
   arrived at 940-1600 Hz on the redline. The engine climbed into a
   mosquito instead of opening up. The grain now carries only what should
   track pitch (the fundamental, its harmonics, a broadband blowdown bang)
   and the fixed formant filters supply the body. Measured: the peak band
   at full revs moves off 1-2 kHz onto the fundamental and the centroid
   drops 3384 -> 1804 Hz. The engine bus comes down 3 dB to hold the mix
   balance, since the point was a bigger engine, not a louder one.

Also fixes testkit's fundamental(): the octave guard read corr[lagMin - 1],
which is never filled, so the floor lag always looked like a rising peak and
any bass-heavy signal pinned to sr/lagMin. Two different engines both
reported exactly 5333 Hz = 48000/9. It now reads 119 and 125 Hz.

main.js gains stepFrame(), a real frame including the input poll: simulate()
only steps the race, so nothing reached through tick() — pause, respawn, the
touch controls, a human's steering — was testable, and headless Chromium does
not schedule requestAnimationFrame for a harness to wait on.

Audio bench still 228/228.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
Every one of these let a real defect ship past a green suite:

- Every headless bench builds its race with autopilot:true. A real race is
  built with autopilot:false, so the configuration a player actually gets was
  never exercised, and a wrong AI gate froze the whole field while four
  benches stayed green.
- Existence is not layout. A node inside a <canvas> is fallback content: it
  parses, querySelector finds it, it has computed styles, and it is never
  rendered. The entire touch control set was 0x0 px and a presence assertion
  would have passed throughout.
- A passing audio suite is not a good-sounding game. 228/228 green while the
  engine was a mosquito, because "fatiguing" is a question about balance
  between bands that no per-voice check asks — and green again while every
  voice arrived at full level on resume, because the offline path bypasses
  the fade.
- Nothing reached through tick() was testable at all: simulate() only steps
  the race and headless Chromium never schedules rAF, so pause, respawn,
  touch and human steering had no coverage.

Also notes in the defect list that the game has no controls screen anywhere,
and that R is bound under the name "reset" but performs a respawn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
Claude Code on the web hands a rebuilt container a workspace restored from a
cached image, not a fresh clone, and for this repo that image is badly stale.
Measured today: the container booted at 12:13 with HEAD 60 commits behind
origin, a dirty working tree, and a .git whose reflog ended on 21 Aug — three
days of pushed work looked missing, and a recovery performed in the previous
turn had left no trace at all because .git itself was rolled back.

The dangerous part is not the missing commits, it is the debris. The restored
tree carries a src/render/particles.js of 57,837 bytes where origin has
109,246; building on top of that would have silently reverted ~50 KB of VFX
work with no conflict and no warning.

So on every session start, fast-forward to whatever origin already has. Three
rules keep it safe:

  - Never discard work. A dirty tree is stashed with a timestamped label, so
    anything real is recoverable from `git stash list`.
  - Only ever fast-forward. If HEAD holds commits origin does not have, the
    hook does nothing — an unpushed commit is real work and not its to move.
  - Never fail the session. Every step is advisory and it always exits 0.

Also installs game/ dependencies, skipping the repo root, which is an
unrelated legacy Express app pinned to node 0.6 that must not be installed.

Verified against a clone reproducing the exact failure: 60 commits behind with
a stale particles.js and an untracked scratch dir. The hook fast-forwarded to
the tip, restored particles.js to 109,246 bytes, and preserved the debris in a
stash. With an unpushed local commit present it correctly refused to sync and
left both the commit and the edit intact. Idempotent across repeated runs, and
a no-op outside a remote session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
Every motion capture died on frame 0 with `page.screenshot: Timeout 30000ms
exceeded` and wrote nothing at all, so the sequences this harness exists to
produce have never once been captured. The screenshot call inherited
Playwright's 30 s default, and SwiftShader needs longer than that to draw a
1280x720 frame of this scene at q=high — software rasterisation being the
entire premise of the harness.

Measured after the fix: boot plus three frames of driftCorner in 2m47s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
The sequence capture had never actually produced a frame (previous commit),
so nothing that only exists over time had ever been reviewed. Running it:

A rival kart rides between the chase camera and the player and smears across
the bottom of the frame. popcheck flagged 8 pops in 13 pairs of oliveGrove
while the telemetry over those same frames was perfectly smooth — yaw
-0.10..+0.16 rad/s, speed 60->72 km/h, camera 3.0-3.7 m a frame. Smooth
physics with popping pixels is not instability, so the fault had to be in
front of the lens. Projecting every kart into screen space found it: the
player sits rock steady at 8.7-9.6 m and screen (638-654, 383-391) — the
chase camera tracks it almost perfectly — while P2 rides 2.6-3.2 m out, a
third of the player's distance, projecting to y=681, 817 and 864 on a 720 px
frame. There is no rival avoidance and no near-plane push.

tools/camcheck.mjs makes that measurable: it projects every kart into screen
space and fails if anything is nearer the camera than 5 m, or if the player
leaves the middle of the frame. It currently fails 3/3 samples on oliveGrove.
The second check is for camera lag, which is a different defect and is not
present — worth keeping so a fix for one cannot silently introduce the other.

Also recorded, unresolved: driftCorner shows an impact signature at frame
3->4 — yaw rate flips +0.345 -> -2.736 rad/s in one 0.12 s step while speed
drops 70 -> 45 km/h and the drift readout snaps to zero for good. The
scenario is scripted, so this may just be driving into the scenery; telling
that apart from real instability needs a constant-input capture and this run
does not settle it. Claiming either way would be guessing.

villageStreet is clean across all 13 pairs. itemChaos has still never been
captured to completion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
A street circuit through Dogpatch, San Francisco, built the same way Amikam
was: real SRTM elevation and real Overture road and building geometry, not a
neighbourhood-shaped invention.

The data pipeline stopped being about one place. tools/places.mjs now holds
each place's origin, DEM tile centre and extents; fetch-dem and build-world
take a slug. A place's tile centre is deliberately separate from its origin,
because Amikam's tiles were fetched before its coordinates were corrected, and
keeping them apart is what lets Amikam rebuild byte-identical — verified: the
heightfield hashes the same and all 552 buildings, 229 roads, 52 land-use, 136
land-cover and 65 water features are unchanged, with only two new meta keys.

tools/ov_place.py replaces ov_extract/ov_theme, which hard-coded Amikam's
centre and — the real bug — chose Overture row groups by asking whether the
group's bbox *contained the centre point*. A row group covering only the north
half of the box would be skipped, silently losing everything in it. Dogpatch
needs 4 row groups for buildings and 4 for roads; a point test finds at most
one. The test is now a bbox intersection.

Dogpatch is a city, not a village: the raw extract is 8.0 MB against Amikam's
0.33 MB. Culling what is off-map or invisible — 2,141 buildings outside the
playable box, 2,985 mapped sidewalks away from the circuit — brings it to
3.2 MB. The cull is configured per place and Amikam has none, so its tuned
output is untouched.

The circuit is generated from measured intersections of the real grid
(Illinois, 20th, Indiana, 22nd, Tennessee, 23rd) with proper corner arcs,
then rotated so the start/finish sits on the long Pier 70 straight instead of
into a corner. 1719 m, six sectors, five lefts and one right.

Two things had to be understood rather than guessed at:

  - The first draft reported a tightest racing-line radius of 0.7 m because
    the control points turned 90 degrees in a single step, so the spline
    collapsed at every junction. Corner arcs fixed the geometry.
  - It then still reported 1.5 m, and widening the corners from 21 m to 40 m
    made it WORSE — which ruled out radius as the cause. snapToRoads pulls
    each control point onto the nearest real centreline, displacing alternate
    arc points by up to 5.1 m and turning a smooth radius into a zigzag. That
    is radius-independent, which is exactly why widening did not help. A
    polyline already generated from the road network gains nothing from
    re-snapping, so Dogpatch sets snap:false.

After that the two courses are strikingly comparable: tightest line radius
11.4 m against Amikam's 12.0, average pace 78.9 km/h against 79.3. The AI
race is clean — 12/12 finishers, 0 respawns, 0 wrong-way flags, longest stall
0.40 s against Amikam's 5.90, and off-track excursions down from 5.89 to 1.17
per kart per lap.

Known gap: the AI records 0 drifts and 0 mini-turbos here, where Amikam gets
15 and 1. Its drift trigger does not fire on 90-degree street corners taken at
42-52 km/h. A human can still drift; the AI simply does not, and on a kart
racer that is a real loss of character.

track.js keeps the course in src/track/courses.js and takes opts.course; the
jump and the Orchard Cut are gated on it, since a flat street circuit has
nowhere to launch from and no orchard. The world is a multi-megabyte asset
that every mesh is built against, so the course is chosen at boot via
?course=<slug> and the select screen navigates rather than rebuilding live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
The 25 terrarium DEM tiles and the five raw Overture extracts, 12 MB in all.
Amikam's equivalents are already tracked, and for the same reason: the world
asset is derived from them, so without them in the tree a rebuild depends on
S3 reachability and on Overture still serving release 2026-08-19.0. The
sandbox that built this could not reach Overpass or OpenTopoData at all, which
is a good argument for keeping the inputs rather than the ability to re-fetch
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
The first Dogpatch render came back with terracotta pantile roofs over
Jerusalem stone, a Menashe Hills Winery hoarding, and a start gantry reading
"AMIKAM VILLAGE CIRCUIT" in Hebrew — on the San Francisco waterfront.

That is not a polish problem, it is a data problem. Across the 365 Dogpatch
buildings nearest the circuit, Overture supplies a roof shape for zero of them
and a facade material for zero of them, and 95% carry no class at all; only
height is well populated. Every surface therefore falls through to a default,
and the defaults were Amikam's.

src/world/themes.js now holds the material identity of a place — wall, roof
and sheeting palettes, how often a roof is flat, how often a wall is sheeted
or stone, and the trackside sign pack. Each world asset names its own theme in
meta, so a renderer asks the world what it is made of rather than being told
by the boot sequence, and an older asset with no theme still renders as Amikam.

Dogpatch reads as red brick, painted clapboard, grey industrial panel and
corrugated shed, under tar, gravel and galvanised steel — with flatBias 0.55,
because a pitched terracotta roof is the single most out-of-place thing that
can be put on this shore. Its signs name real neighbours: Pier 70, the old
Union Iron Works yard the Illinois straight runs past, and the American
Industrial Center on 3rd that Tennessee Run goes behind.

Amikam's palettes and Hebrew sign pack are carried over unchanged, so its
frames are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
The first themed render fixed the signage and the brickwork but left the
nearest, most prominent building wearing a pitched red roof. The reason is
that roof tints are multiplicative against the pantile texture: the greys in
the bayfront palette can only ever make a clay roof dark RED, never grey. No
tint can fix that. Flat roofs do not use the pantile texture at all, so
flatBias goes to 0.88 — which is also simply true of the neighbourhood.

Themes can now switch off trackside dressing that belongs to one place. The
olive grove, the village bunting, the hay bales and the olive crates are
Amikam's; an industrial waterfront gets none of them. Explicit opts still
override, so nothing that passes its own flags is affected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
The chase view of Dogpatch had a row of narrow columnar cypresses running down
a San Francisco street, which reads as Tuscany. The species name was not
actually the problem — Monterey CYPRESS is genuinely Californian — but
buildCypress draws the Italian form, and that shape is Mediterranean wherever
it is planted. The pine's spreading crown is what grows on this coast.

Themes now name the tree that lines a place's streets, and furniture asks the
world rather than hard-coding it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
The packer embedded a single hard-coded world, so the standalone playable
could only ever be Amikam. It now walks every world in the build and the fetch
shim keys off the requested filename, decoding each heightfield lazily —
loading Amikam never pays to expand Dogpatch's terrain.

Each world's JSON is inserted with its own JSON.stringify rather than one
stringify over the whole map. Building the map in one pass would escape every
quote in every vector file a second time, which on a dense city costs
megabytes for nothing.

10.61 MB for both courses. Verified booting from file:// with Google Fonts
blocked, on desktop and an emulated Pixel 5: both circuits load at their real
lengths (3065 m and 1719 m), races run, no page errors.

`node tools/pack-single.mjs amikam` still builds a one-course file for anyone
who wants the smaller download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
Three things reported from actually playing it.

1. Names were still Amikam's, still in Hebrew. The trackside sign atlas had
   text baked into it: FINAL LAP, the hairpin warning, the street sign and
   three sponsor boards were Hebrew string literals in furniture.js, so no
   amount of theming reached them. Every slot now comes from the place. Item
   names were the other half — the HUD called things Hummus Slick and Triple
   Jaffa while you raced past Pier 70 — so items take their display names from
   the theme too. Same twelve items, same mechanics, colours, speeds and hit
   types; only what they are called. The title card named Moshav Amikam on
   every course, and the loading tips advised a shortcut through an olive
   grove that does not exist in San Francisco.

2. The ads are now San Francisco tech, and they are INVENTED. Dogpatch really
   is full of robotics and AI shops, and Overture even names some of them —
   those real names are used on the buildings themselves, which is map
   fidelity. A trackside hoarding is a different claim: it says a company paid
   to sponsor this race. So the boards are plausible SF firms that do not
   exist.

3. "Is this the real map?" It is, and it was throwing away a quarter of it.
   Building height was storeys x clamp(b.h / storeys, 2.4, 9) with storeys
   capped at 3, so every building over 9 m was squashed to 9 m. Overture
   carries a real height for 5,329 of Dogpatch's 5,527 buildings, and 1,213 of
   them (23%) are taller than that clamp — 115 beside the circuit, including a
   91 m tower rendered as a 9 m shed. The neighbourhood had real massing in
   the data and the renderer flattened it.

   That clamp was tuned on Amikam, which carries a height tag on ZERO of its
   552 buildings, so the branch never runs there and the village is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
…Amikam's

The standalone file's start plate still said the game was built on Moshav
Amikam, and a loading tip claimed every roof in "the village" was an Amikam
footprint — shown while racing San Francisco. The plate now names both
courses; the tip says what is actually true of either, which is that the roofs
are real footprints at their real heights.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
Two things reported from playing it.

The sand. The six ground layers are procedurally authored Levantine surfaces
— limestone, nari soil, terra rossa, wheat stubble, tilled farm soil, scree —
and the splat that chooses between them is driven by farm / crop / grass /
forest land cover. Dogpatch is a city and has almost none of any of those, so
every pixel falls through to the base limestone: a sandy verge along a San
Francisco street. Re-authoring six surfaces is a bigger job than this
deserves, but the terrain shader already does `diffuseColor.rgb *= kAlbedo`
starting from the material colour, so a place can multiply the whole ground
for free. A cool grey turns warm ochre into something that reads as concrete
and worn asphalt. Amikam multiplies by white — its layers were authored for
exactly that place — so the village cannot move.

Picking the cat twice. Choosing a different course is a page load, because
every mesh in the scene is built against the world, and the character choice
did not survive it: you picked a cat, picked Dogpatch, and landed back on the
character screen to pick the same cat again. The cat and an intent to race now
travel in the URL, and a boot that carries them goes straight to the grid.
Still one load — that part is real work — but no second choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
The first grey attempt came back greyer but still visibly tan, and the reason
is arithmetic: a colour multiply only scales channels. The base layer is warm
limestone around (0.75, 0.68, 0.50), so any multiplier capped at 1 — which is
every hex colour — can darken it but can never neutralise it.

A float triple can. [0.80, 0.92, 1.30] lands that ochre at (0.60, 0.63, 0.65):
neutral, and still bright enough to read as sunlit concrete rather than wet
slate. Amikam stays [1, 1, 1].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
Three renders of tinting the ground and the pixels did not move. Measured
rather than eyeballed, the patch was rgb(209,184,137) before the tint and
rgb(209,184,137) after it — byte-identical — and the terrain material really
did carry the new colour. Both facts were true, which meant the surface being
looked at was not the terrain.

A raycast through the offending pixels named it in one shot: circuit:runoff,
the strip either side of the racing surface, built by trackMesh.js from
vergeTextures() — "sun-bleached grit, dry grass and scattered stones". A
Levantine farm-track run-off, drawn along a San Francisco street.

This is the misattribution pattern in docs/CONTRACT.md, committed by the
person who wrote it down. The lesson holds: find out which system emits the
pixels before theorising about the one you assume does. A raycast costs
seconds and settles it; staring at frames does not.

The verge now takes a tint from the place, alongside the terrain tint from the
previous commit, which was correct in itself and simply aimed elsewhere.
Amikam's verge tint is null, so the village is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
Keep the geodata pipeline, the measurement harnesses and the failure-mode
contract; rebuild the game; cap the critic loop at three rounds.

The pipeline is the part worth carrying: not the code so much as four things
that each cost a day to find — that AWS Open Data was reachable where Overpass
and OpenTopoData were blocked, that a GeoParquet footer lets you read 500 files
without downloading them, that row groups must be selected by bbox
intersection and never by point containment, and that decoded elevations have
to be checked against known ones because a wrong tile centre still produces a
perfectly plausible heightfield.

The argument against re-running the loop is the score curve — 47 to a peak of
63 at round four, then 58 — set against where the defects actually came from,
which was a human playing the build and mechanical measurement, and never a
critic round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
A second build following this project's own reuse notes: keep the geodata
pipeline, the measurement harnesses and the failure-mode contract; rebuild the
game; make it playable before anything cosmetic.

One course — the Dogpatch Waterfront circuit on the real San Francisco
waterfront — with no theme system, no bilingual signage, no multi-course
plumbing, no jump and no shortcut, because a flat street circuit has neither.
Nothing named Amikam, nothing in Hebrew, and eight drivers with names that
belong to the place.

The reused half is the part that took discovery rather than typing: terrarium
elevation off AWS, Overture row groups selected by bbox intersection, and a
build that refuses to write if the decoded elevation does not look like the
place.

The rebuilt half is deliberately smaller. Terrain is a vertex-coloured
heightfield rather than six procedurally authored surface layers and a splat
map; buildings are extruded footprints at their real heights rather than an LOD
system; audio is a handful of oscillators through fixed formants rather than
granular playback; items are six rather than twelve.

Every defect the last build shipped is prevented by construction and checked by
tools/play.mjs, which boots the built game on desktop and an emulated phone:
the grid does not creep under the red lights (0.000 m), all 7 opponents race
(84.8 km/h, 7/7 moving), the touch stick lays out at 132 px on the body rather
than 0x0 inside the canvas, and no page errors on either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
Looking at the render did its job.

The ground came back near-white, like snow. Vertex colours are consumed as
LINEAR, and these were authored the way you would pick them from a colour
picker, i.e. sRGB — so 0.53 rendered as near-white. Converted once, at the
point they are written.

The particles were half-metre white squares over the whole frame.
PointsMaterial takes ONE size for every point: the per-point `size` attribute
this was carefully writing is ignored unless a shader reads it. Additive
blending on top made them read as sheets of paper rather than sparks.

The HUD showed the item's id (`latte`) where it should show its name.

And the screenshot harness never drove the kart, so every shot was a parked
kart off the racing line. It hands the player to the AI now, which is the one
legitimate use of the autopilot flag.

Also adds a .gitignore and untracks dist/ and shots/, which the first commit
swept in with `git add -A`. Build output does not belong in the repository.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
The ribbon builder wound every triangle (p, p+2, p+1), which points the
face normal DOWN. With the default FrontSide material that back-face
culls the entire circuit — road, shoulder, edge lines, dashed centreline
and kerbs, all built by the same strip() — so the game rendered as eight
karts crossing a bare tan field. A downward raycast at the centreline hit
only `terrain-24-22`; the meshes were present, visible and correctly
coloured, but face-down. The start line survived because it is a
PlaneGeometry, not a strip, which is why anything was painted at all.

With the road back, the second defect showed: the ribbon is flat across
its width at the centreline height while the ground keeps its natural
cross-slope, so terrain stood up to 22 cm proud of the asphalt and ate
the verge in ragged wedges. World.carve() now grades the heightfield to
the circuit's own draped height out to 10.5 m, smoothstepped back to
natural ground over the next 7 m — the ground conforms to the road, not
the other way round. Runs after buildTrack (which reads these heights)
and before the terrain mesh is built from them.

Also scrubbed the last two "Amikam" strings, both in the extract tools:
a stale comment and an HTTP User-Agent.

Measured after: road present across the full +/-7 m at every offset
sampled, terrain below the surface everywhere in the corridor, and
tools/play.mjs green on desktop and Pixel 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
Three independent critics scored six gameplay frames blind against Mario
Kart 8 Deluxe on the project's 0-95 rubric: 41, 36, 36 — binding 36. The
previous build's eight rounds ran 47 -> 63 -> 58, so this starts below
that first round, which is the expected trade for 2,310 lines against
34,952: the difference was almost entirely art depth.

All three called the sponsor hoardings critical and the only outright
broken thing in the set — every board rendered mirror-reversed. A plane's
front face looks down its local +Z, so rotating by the track heading
pointed the printed side down the road, away from oncoming traffic; with
DoubleSide on you read the back of the board. Now faces oncoming traffic
and cants slightly toward the road. Same family as the strip winding that
made the whole circuit invisible: a face pointing the wrong way.

shoot.mjs now captures a fixed six-moment review set (grid, launch,
street, corner, pack, waterfront) so rounds are comparable.

docs/REVIEW-R1.md records the scores and the findings the critics agreed
on: no texture layer at all, no VFX, shadow sides crushing to black,
flat gradient sky, undecorated building boxes, featureless drivers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
tools/pack-single.mjs inlines the bundle, the styles, the shell and the
world itself, so the game is a single file with nothing to fetch.

The payload carries what the game reads rather than what the extractor
produced. Only world.buildings is touched at runtime, and only its first
ring, height and storey count, so roads, water, landuse and landcover
come out — 1.3 MB of the source JSON — along with a UUID and eleven
all-null attribute columns per building. The heightfield goes from
Float32 metres to Int16 decimetres: half the bytes, and the only thing
that would notice 10 cm is the road, which carve() regrades to exact
heights after loading. 7.5 MB of source data becomes a 3.87 MB payload.

World.load now prefers an embedded payload over the network, and
World.unpack decodes it; the fetch path is unchanged for dev.

The output deliberately carries no doctype, html, head or body of its
own so it can be published as an artifact, which supplies that skeleton.
Browsers wrap a bare fragment the same way, so the same file also opens
off disk — which is why it needs its own charset meta ahead of the first
non-ASCII byte. Without it the hoardings render "·" for the middots;
found by looking at a frame from the packed file, not from the bench.

Verified as the packed file rather than the dev build: play.mjs green on
desktop and Pixel 5, and a rendered frame checked. Quantisation costs 2 m
of field progress over 20 s, 440 against 442.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
The three critics agreed on what round one lacked, and this is their top
two, which compound: every surface was a flat constant colour, and the
shadow sides crushed to black.

Materials are procedural canvas textures built at boot — asphalt with
aggregate and patching, concrete with slab joints, a facade of windows
tiled at one bay per 5.5 m and one storey per 3.4 m so openings stay
window-sized on a cottage and on a warehouse, and a ground grain. Drawn
rather than shipped, because 4.65 MB of the single-file build is already
the neighbourhood and image files would not survive packing.

Two of them are greyscale and sit near white: they MULTIPLY the colour
already there, so seven building materials and the terrain's own
height-and-slope ramp all gain detail without losing their palette. The
first cut had them at mid grey and took a third of the brightness out of
every surface in the game — measured, 9.0% to 15.5% near-black, which is
why they are near white now and why the meshes using a colour-carrying
map set no tint at all.

Hemisphere fill 1.05 -> 1.9 with a warm ground bounce, so a wall facing
away from the sun shows its own colour instead of reading as an unlit
black polygon. The sky dome gains an fbm cloud deck and a sun disc.

Two regressions this introduced and then fixed: crisp 2 px slab joints
turned the pavement into a moire fence at grazing angles (now faint and
wide, slabs every 8 m instead of 4), and coarse ground clumping banded
the verge at distance.

tools/frame-stats.mjs measures this instead of trusting the eye — `dark`
is the share below 6% luminance, `detail` the mean neighbour difference,
which is near zero for a flat surface however pretty its colour.

           lum    dark   detail
  round 1  0.296   9.0%  0.0061
  round 2  0.363   5.0%  0.0105     clipping stayed at 0.0%

play.mjs still green on desktop and Pixel 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
…cles

Round two scored 34/34/37 against round one's 41/36/36 — the loop went
backwards, and the critics said why, unanimously: no shadows anywhere,
lighting reads as flat ambient. That was a regression I introduced.

The shadow map was on the whole time — 189 casters, 1956 receivers,
measured. Raising hemisphere fill to 1.9 to stop round one's crushed
blacks had flattened key-to-fill to 1.3:1, which washed the shadows out
until they were invisible. Fill back to 1.15, sun 2.5 -> 3.2, about
2.8:1, keeping round two's warm bright bounce that fixed the crushing.
That is the same effect failing in opposite directions across two rounds,
which CONTRACT.md names as one problem rather than two.

Contact shadows: every critic in both rounds said the karts float. Drawn,
not cast — a shadow-mapped blob is at the mercy of the fill level, this
one is always under the kart. It lives outside the kart group so body
roll cannot tip it off the ground.

VFX that appear during ordinary racing. The pool worked, but everything
in it fired only while drifting, boosting or off-track — a kart at 93 km/h
down a straight, which is most of a lap and all of most screenshots, emitted
nothing. Exhaust and tyre-slip smoke now fire while simply racing, on a
soft radial sprite rather than bare squares.

Camera stays in the street. Nothing stops a kart driving into a building,
and when one did the chase camera followed it inside and the frame became
the interior of a warehouse wall (waterfront measured 0.169 luminance,
17.5% near-black). Clamping the camera's lateral offset to the centreline
is O(1) and keeps it outdoors; it recovered that frame to 0.338 / 7.3%.

Known and not fixed: buildings have no collision, so a kart can still
drive inside one — the camera clamp treats the symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
The circuit is a hand-authored polyline over real Dogpatch streets, and
where it does not follow a street centreline exactly a real footprint
overlaps the road. Nothing removed those, so a warehouse wall could stand
at the kerb with the racing line running through it: the chase camera
ends up hard against an untextured elevation filling a third of the
frame, and a kart leaving the road drives inside the building. Two rounds
of critics called that mass the single most damaging thing in the set and
read it as a backface or a broken shadow volume. It is neither — it is
architecture the road was drawn through. Footprints within the road,
shoulder and a metre of daylight are now skipped at build time.

Measured on the same six frames: street 5.9% -> 2.6% near-black,
waterfront 7.3% -> 3.6%, corner luminance 0.348 -> 0.401.

docs/REVIEW-R1.md now carries all three rounds. Binding scores 36 -> 34
-> 36 against a target of 88; means 37.7 -> 35.0 -> 43.0. Round 2 was a
real regression from over-correcting round 1's complaint, and the binding
score ends where it started because one critic held at 36 while the other
two moved 41 -> 46 and 36 -> 47.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
Both were scratch tools for a single measurement and were committed by
accident; the durable measures are tools/frame-stats.mjs and
tools/play.mjs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
The first piece of the art layer from game/src/render/lighting.js, which
had solved this properly and was left behind in the rebuild.

Shadow fitting. The rig here was a fixed 320 m half-box over a 2048 px
map — 0.31 m per texel, so a 2 m kart spanned six of them and its shadow
could only ever be a smudge, which is why every critic in every round
said the karts float. Fitted to the camera at 92 m it is 0.09 m per texel
and about 22 texels across the same kart. Three details come with it and
all three matter: the focus is pushed down the view axis so the box is
spent on what is in frame; the focus is snapped to whole texels in light
space, or the map crawls with sub-texel camera motion and every edge
shimmers; and bias and penumbra are expressed in metres and converted per
frame, because shadow.radius is in texels and a texel is a different size
in every view.

Sky IBL, prefiltered from the dome we already draw. A hemisphere light
gives shade one colour from above and one from below; real ambient
carries the sky's whole distribution, which is what makes a shadowed
elevation read as a lit surface in shade rather than a darker copy of
itself. One render at boot, nothing per frame. The hemisphere light drops
from 1.15 to 0.42 — doubling the fill up is exactly what flattened the
key in round two.

Haze reaching the far side of the box: 420-2100 m over a circuit whose
furthest visible block is 1.5 km out cost distance almost nothing, and
every critic read the result as collapsed depth. Now 160-1500, tinted to
the horizon.

Measured over the six review frames:

           lum    dark   detail   sat
  round 3  0.357   6.9%  0.0101  0.280
  this     0.405   0.8%  0.0109  0.308     clipping still 0.0%

play.mjs green on desktop and Pixel 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
game/src/render/decals.js and vfxLibrary.js, 745 lines, across unchanged
apart from one import path. An instanced decal system with its own shader
that lays a ribbon of quads along each rear wheel and drapes them on the
ground normal. It needed only `scene`, `heightAt` and `normalAt`, all of
which this build already had — which is the argument for porting rather
than rewriting: the interface was already small.

Two measurements shaped the wiring, neither of which a screenshot would
have shown. At the smoke's 4-degree slip threshold the pool reported 46
stamps and ZERO alive after twenty seconds: the field slips off the line,
then drives clean enough to leave nothing, so the circuit stayed bare for
the rest of the lap. Dropping to 2.2 degrees then went the other way —
4490 stamps into 1200 slots, saturated, recycling the whole circuit's
rubber every few seconds so nothing survived to read as a racing line.
Segment 0.42 -> 0.9 m and capacity 1200 -> 2400 settles it: 1946 stamped,
1783 alive, pool not full.

`window.__game.vfxStats()` exposes the pool, because a skidmark system
that stamps nothing looks exactly like one that works.

Six review frames after the shadow rig, IBL, haze and decals together:

           lum    dark   detail   sat
  round 3  0.357   6.9%  0.0101  0.280
  now      0.399   1.0%  0.0108  0.322    clipping still 0.0%

src is 3556 lines, of which 745 are ported. play.mjs green both devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
…ound

Street names are derived, not invented. The Overture extract carries road
names and the circuit was drawn over real streets, so tools/streets.mjs
matches each track sample to the nearest named roadway and collapses the
runs. The lap is Illinois -> 20th -> Indiana -> 22nd -> Tennessee -> 23rd,
which is a real loop of Dogpatch blocks. Generated into
src/track/streets.js.

One ordering bug worth recording: filtering short runs AFTER merging left
a spurious run naming the CROSS street sitting between two halves of one
leg, blocking the join, so the first pass reported six streets as
thirteen legs. Filter before merging.

Signs are San Francisco mast arms: MUTCD green, white border, and
MIXED CASE — the city adopted the federal manual in 2009 and has been
fitting mixed-case retroreflective faces since about 2012, so "22nd
Street" is right and "22ND STREET" is a sign overdue for replacement. The
arm reaches over the roadway, not away from it; the first cut hung the
blade over the pavement behind its own pole where no driver could read
it. Text auto-shrinks to fit, because "Tennessee Street" is a lot wider
than "20th Street".

Buildings follow Dogpatch's typology instead of a dice roll. The district
is about a hundred workers' cottages and flats from 1870-1910 standing
among brick warehouses and shipyard sheds, and which one a footprint is
can be read off its own geometry: a 60 m2 plan at 7 m is a cottage, a
900 m2 plan at 11 m is a warehouse. Three facade maps now — clapboard
with tall sash windows for the cottages, brick for the warehouses,
corrugated sheeting for the Pier 70 vocabulary that sits on the Illinois
leg — with the cottages on a tighter 3.2 m bay.

The ground is paved. Dogpatch has almost no grass; rendering the flats as
meadow is what made every frame read as a road through open country
rather than nine blocks of the Central Waterfront. Green now survives
only on Potrero Hill rising to the west. Exposure 1.05 -> 0.94, because
the sun and the new sky IBL together were washing the paving to desert
beige.

Particles are on wall-clock intervals. Eight karts emitting once per
FRAME is 480 a second, which laid a bright dotted line down every
straight and read as screen noise.

           lum    dark   detail   sat
  before  0.399   1.0%  0.0108  0.322
  now     0.390   1.5%  0.0111  0.304    clipping still 0.0%

play.mjs green on desktop and Pixel 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MDYxGtEF9r5y82S8F2Hxgp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants