rally-maps
Create or edit Gravel Rally maps (road layout, terrain, jumps, scatter, road-side props, environment) and verify them. Use for any request about rally stages, tracks, terrain, forests, crowds, start/f
SKILL.md.claude/skills/rally-maps/SKILL.md
GitHubRally maps
Every stage needs tyre (StageDef, required TyreId: the recommended compound for its road surfaces - tarmac, mixed or gravel, see physics/tyres.ts / src/games/rally/PHYSICS.md; the recommended suspension follows from it); integration-tests/rally/tyres.test.ts lists the maps it drives (add the new id).
A map is pure data: one folder per map, src/games/rally/maps/<id>/map.ts exporting a MapDef (the world) with its
stages (stages.ts: the events on it, rally DETAILS.md "Maps")
(format in maps/shared/types.ts; helpers shared by maps in maps/shared/), registered in maps/index.ts.
Map-specific files (baked data.json, TODO.md, later textures / props) live in the map's folder.
Every map folder has a DETAILS.md tracking ALL external links / files used (route links, data sources +
licences / citations, downloaded files, reference screenshots) - add one short row whenever you use a new source (no prose; AGENTS.md "Write short"). Public-facing
sources (data sets) also go in MapDef.sources (label / url / note; a stage's own route link in StageDef.sources) - listed on the main menu About screen. Everything (heightfield, road carving, trees, colliders) is
generated deterministically from it. There is no map editor on purpose — edit the data.
Coordinate system
+X east, +Z south (north = -Z), +Y up, metres (three.js is right-handed: +Z north would mirror real maps).
bounds = streamed/playable area; terrain outside rises (edgeRise).
Editing the road
road.points: Catmull-Rom control points[x, z], or{ x, z, dy, width }.- Keep consecutive points 30-80 m apart; tight hairpins need 3-4 points ~25-35 m apart.
- The road must not cross itself or come within ~50 m of another part (terrain carving blends to the nearest road).
- Crest / jump: three close points (~15 m apart) with
dy0 -> 1.5..2 -> 0 (see test map #16-18).
- Height: the road follows terrain smoothed over
smoothingm, clamped tomaxGrade(0.11 = 11%), the grade changes rounded overgradeSmoothingm (default 12; fast stages need ~50 or a +7/-7 % kink launches the car), thendyis added. - Cross-section:
width,shoulder(loose verge),ditch,crown. - Surface changes along the stage:
road.sections: [{ from: <metres along>, surface, texture }](applies until the next entry;road.surface/textureare used before the first). E.g. asphalt valley road -> gravel climb (maps/petralica). Real maps: check Street View for where the surface really changes. stage.start/stage.finishFromEnd/stage.splitsdefine timing. They also place the start / finish gantries, the lines and the split boards automatically (world/stage-signs.ts; the gantry header showsname+year) - do not add arch props. Every map needsyear.
Terrain
terrain.layers noise (scale = feature size m, amplitude m, ridged for crests).
flatAreas = pads (service park / test pad); name makes them spawnable (?spawn=pad) and shown in the viewer.
Scatter & props
scatterrules per asset:densityper 1000 m²,minRoadDist(from road EDGE),maxSlope,mask(noise clustering -> forests/clearings),scale,tilt,alignToGround,sink,detail(grass: only near camera).roadsiderules:everym orat: [metres],side(outside= outside of the bend),offsetfrom edge,maxRadius(only in tight bends),count/spread/depth(crowds),faceRoad.- Real maps: grow the area by widening
detail/landcover/outerextents and ADDING the new strips toosmBoxes(each box is downloaded / cached on its own, the first wins: the old area's route, roads and building ids stay identical - diff the old and newdata.jsonto prove it).railways.dropleaves out tracks of given services inside a box (a freight yard the track bed cannot level). Far backdrop:python scripts/realmap/horizon.py <config>(a grid takesradiusor a worldextent: reach far where the view goes, little behind a ridge - Vodno);buildings.dropWays= OSM ways a landmark asset draws instead;paths.dropWays/dropKindsleave roads / kinds out (a junction ring the engine cannot level, gully streams that hold no water),paths.mergeJointsjoins ways that meet end to end at a plain two-way node (short ways = a height step at every joint);MapDef.aerialways= a cable car line (pylons, ropes, cabins). cornerFans: {}: tape + spectators + flags on the inside of tight corners over the whole stage (every,maxRadius,chance,fans,flagChance,tapeOffset;world/corner-fans.ts). Use it on rural stages; hand-placed crowds (roadsideat) stay for the big spots.servicePark: team bays at the start on aServiceLot(maps/shared/service-lot.ts); add aterrain.padsentry withservicePadfor the same rectangle (seemaps/ajvatovci/service-park.ts).props: explicit, eitherx/zoralong(+lateral, negativealong= from the end).- Asset ids must exist in
assets/catalog.ts(see the rally-content skill).
Verify (always)
npm run test -- tests/rally/world.test.ts— prints road length, max grade, control-point distances (use these to placeat:props), checks the road is flat/gravel and no solid scatter is on the road.npm run test:integration -- integration-tests/rally/stage.test.ts— the autopilot must finish the stage with every car without rolling. If it fails, trace the failing distance (lowermaxGrade, widen a hairpin, move a crest out of a corner).- Look:
/games/rally/map-viewer.html?map=<id>&stage=<id>(one stage of the map, default the first; oblique overview,grid=1chunk grid,splat=1surfaces, click terrain -> "Drive from here"). - Drive:
/games/rally/?stage=<id>&spawn=<metres along the stage>&mute=1to test a section directly. - No sound while testing: add
mute=1to every game URL you open (see AGENTS.md "Sound while testing"). - Stage card (every stage, stage select; a stage without one is built live in the menu, dev warning): NEVER re-bake on your own - only when the user asks. Then
/games/rally/?bakecard=<stage id>&season=all&mute=1(or?bakecards=1&season=all;season=one of spring / summer / autumn bakes just that picture, none = the map's own season; one<stage id>-<season>.jpgper season inenvironment.groundTints) onnpm run dev:noreload(launch configportal-dev-noreload; on the normal server each saved card - or another session's edit - reloads the page and restarts / cuts off the bake), wait for "saved", and the changedmaps/<map>/preview/<stage>.json+<stage>-<season>.jpggo in the commit. An out-of-date card only warns in the dev console - mention it to the user.stageCardHashcovers only what the card draws: text (names, descriptions, tyre, links) andpreviewRelief(e.g. 1.8 on a flat map) need no re-bake. Code:tools/stage-card*.ts.
Real-world maps (OSM + elevation + land cover)
Example: maps/ajvatovci/ (map.ts, baked data.json, task list TODO.md, sources DETAILS.md);
maps/shared/real-map.ts helpers.
- Config
scripts/realmap/<id>.json:origin(lat, lon = world 0,0),waypoints(route start / end / via, e.g. from a Google Maps directions URL), world-space extents for thedetail(10 m) /outer(50 m) height grids and thelandcover(5 m) raster,canopyheights subtracted from the surface model,minCornerRadius. Optional:dem(file= local GeoTIFF or the OpenTopography.tar.gzinsources/maps/<id>/,credit) instead of Terrarium tiles - the detail grid must lie inside the file, the horizon grid falls back to Terrarium beyond it;landcover.highTrees/osmWood="trees"where the woods are broadleaf (default: pine plantations);landcover.plainOrchards = falsekeeps big lowland tree patches as woods (default: they becomeorchard, which only suits farmland maps - a city forest turns empty),landcover.plainTreesAs = "shrub"makes them scrub (a valley of bushes);landcover.leafType= split the woods into evergreen / mixed / broadleaf covers from a leafless winter and a summer Sentinel-2 scene (scripts/realmap/leaf_type.py, run afterbake.py; worked example Vodno);landcover.manualCover= hand-traced lat / lon rings painted over the cover (orchards WorldCover reads as crops: fit the screenshot to the baked route line by ICP, see ajvatovci DETAILS.md "Traced land cover");landcover.belts= cover by altitude ([{from: [covers], to, min?, max?}], DEM metres, e.g. oak -> beech -> pasture on a mountain; coversbeech,pasture);lakes.levels= fixed water level by lake name (big multipolygon lakes are stitched and clipped to the map,LakeDef.level);paths.keepNear=[[lat, lon, radius]]keeps every road round the stage ends beyondpaths.corridor;buildings.box: true= one Overpass building query for the box (long winding routes); Mountain roads:barriers: hairpinRails(data.route, { maxRadius: 30, lead: 20 })rails the outside of every hairpin.buildings.worldcover: truegenerates houses on WorldCover built-up ground where no footprint data set covers the area (sourcewc- say so in the map DETAILS.md);buildings.manual= known buildings by lat / lon;msRoads: { country, clearance, minLength }adds roads OSM lacks from Microsoft ML Road Detections (ODbL,msroads.py, dirt tracks, needs pyarrow >= 21) - it finds side roads, not forest tracks under trees. Check the route against the user's link (overlay it on their screenshot): the shortest OSM path can take another road, and OSM may not have the road at all. Missing parts:python scripts/realmap/trace_route.py scripts/realmap/<id>.json(configtrace: screenshot, start / end marker pixels, line colour) printsextraWaysfor the config; add a via waypoint on each. Traced parts are only as good as the screenshot - list them in the map DETAILS.md + TODO and ask for close-up crops. - Bake:
python scripts/realmap/bake.py scripts/realmap/<id>.json --preview(needs numpy, scipy, Pillow, opencv-python; downloads are cached inscripts/realmap/.cache/). It prints route length, control point spacing, min corner radius, self-proximity;--previewwrites a land cover + roads PNG (north up). - Map file:
terrain.heightmap(+offsetso the start is near y = 0, small noiselayersfor detail,edgeRise: 0),landcover(+splatweights per cover,fieldspatchwork),paths,road.pointsfromdata.route(widths per section),geo,credits: data.meta.sources. - Scatter by cover class:
cover: ['pine']; orchardsrows: { spacing, rowSpacing }(row angle per zone);minPathDistkeeps trees off other roads / canals (default 1.5 m solid, 0.4 m grass). Other roads are carved flat on their own smoothed height line (PATH_PROFILEper OSM kind inworld/terrain-gen.ts); a new road kind that should be smoother / steeper gets a row there.tests/rally/side-roads.test.tschecks them. Water ways (canal / drain / river / stream / ditch / brook, depth per kind inCHANNEL_DEPTH; abrook- 1 m, 0.2 m deep, bake configwaterwayskind - is crossed on culverts: roads fill over it) are carved and filled with water automatically (world/water-mesh.ts);tests/rally/water.test.tschecks every channel holds water. A street along a drain bank stops its embankment at the water (only its own surface crosses a channel, as a culvert); a drain ending in another drops until its water meets that channel's over its last 60 m. A stream OSM lacks (a lowered valley with no water):python scripts/realmap/dem_stream.py <config> --at=<x>,<z>traces the DEM flow line through that point down to the next baked water way and prints a configwaterwaysentry (lat / lon); the baker appends it as astreampath. To add it without the other drift of a full re-bake, bake to a tempoutand copy only the new path (+ the buildings it drops); worked examplemaps/petralica/DETAILS.md"Valley creek". Lakes / ponds / reservoirs come from the baker (data.lakes): setlakes: data.lakesinmap.ts. To add them to an already baked map without renumbering its buildings, bake to a tempoutand copy onlylakesinto its data.json. RoadsideminRadiuskeeps solid props out of hairpins. Junctions: side roads are joined to the stage road automatically (world/junctions.ts); add stageclosures: { asset: 'road_barrier', length: 2.2, setback: 4 }(+stageMarginon a map with several stages) to close their mouths like a real stage. Check them in game (/games/rally/?stage=<id>&spawn=<along>;__rally.world.gen.junctionslists every one). Season month:environment.months({ spring: 4, summer: 7, autumn: 10 }, the default) says which month each season is driven in; shown on the stage screen, and each stage'stemps([air, road]per season and time-of-day slot in itsstages.ts; DETAILS.md "Temperatures", figures in PHYSICS.md "Climate") are researched for it, at the stage start (higher up the car is colder, 6.5 K per km). A new stage needs a full table. Season look:environment.groundTint(the map's own season,environment.season;groundTints= the other two, same fields; a stage opens in another one withStageDef.season;grass+amount,crop= ripe crop colour,moisture= wet hollows / dry ridges strength, default 1); afields.paletteentry whose weights sum to < 1 leaves the rest as ripe crop ([0, 0, 0, 0]= wheat field, drives like grass). Match the detail grass cards to the ground:spring_grass(bright green),dry_grass(golden),grass_tuft(olive, untinted ground). Example:ajvatovciis spring (mostly green palette, a few ploughed / wheat parcels). - Verify as below. Map viewer "Picked point" shows lat/lon, cover class and Google Maps / Street View links -
use it to match screenshots. Keep
maps/<id>/TODO.mdupdated (missing assets, screenshots needed); a new map (even a planned one) gets a row in the "Map task lists" table ofsrc/games/rally/TASKS.md.
Widening a real map (free-drive background, worked example maps/ajvatovci/DETAILS.md "Free-drive background"): grow the
config's detail / outer / landcover extents (keep their origins on the cell grid) and bounds in map.ts; bake to a
temp out dir holding a copy of the current data.json (stable building ids) and check before copying it in: identical
route, unchanged heights near the road, old building rows unchanged. A newer bake also carries building kinds - strip
them (kinds: undefined) to keep box buildings. Then run the water / side-road / junction tests: towns bring streets along
drain banks and small bridges that rural stages never had.
Buildings (real maps): the baker merges OSM + Microsoft ML footprints (scripts/realmap/buildings.py) into
data.buildings (oriented boxes, type house / flat, wall height, floors) + <map>/buildings.csv; World places
them as per-axis scaled instances of house_pitched / building_flat with box colliders and keeps scatter out.
They are referred to as stage <stageNumber> - building <id> - lat, lon; ids are stable across re-bakes
(matched by position). Map viewer: click a building or ?building=<id>.
Junctions (rule for every real-world map): the world must not end at the stage road. Every other road / track
that meets it is joined to it (world/junctions.ts extends the baked side roads to the stage road edge) and its
mouth is closed like on a real rally stage - set the stage's closures: { asset: 'road_barrier', length: 2.2, setback: 4 } in map.ts. The baker marks ways that only pass over / under the stage road (bridge, no shared
OSM node) with junction: false - those get a bridge TODO, not barriers. tests/rally/junctions.test.ts prints
the junction / barrier count per map and checks the barriers are off the road; look at a few in game
(?spawn=<along> from the test output) and note the count in the map DETAILS.md.
City maps (example maps/jackie/, config scripts/realmap/jackie.json) - extra baker options and engine features:
osm: { source: "overpass" }(scripts/realmap/overpass.py): the plain OSM API refuses a city-sized box. Base query for the whole box, then buildings only withinbuildings.corridorm of the route (default 220: a wide map must not model every building). Needscurl(TLS).paths: { corridor, highwayCorridor }limit the other roads the same way.dem: { source: "3dep", cell: 5 }: USGS 3DEP lidar bare earth (US only, public domain) - nocanopyto subtract.route: { highways: true, offHighwayCost: 5 }: the stage may follow motorway / parkway carriageways (one-way respected); the bake prints the OSM roads the route follows. The other carriageway, ramps and cross streets becomepathswithbridge/layer/lanes/oneway/name.- Bridges: the baker writes
routeSpans(route onbridge=yes/layer<0) ->routeSpans(data.routeSpans)inRoadDef.spans; the stage road is then a deck over lowered ground (street / rail beneath). OSMbridge=yespaths are elevated decks (lifted over what crosses them, piers, parapets;bridgeStyle:stoneparkway look by default (arch soffit on street overpasses, on stage-road spans >= 12 m and their twin carriageway decks),concrete= modern overpass with an open railing - use it for anything that is not an old stone bridge; concrete decks keep parallel railings end to end unless the map setsbridgeCorners: true, and their wing walls splay outward - the ends open up, never close in) - see the rally DETAILS.md "City maps". Check withtests/rally/bridges.test.tsand the map viewer (?map=<id>&along=<m>&zoom=0.5). - Buildings with
kind(bakerclassify_kind: house, row, apartment, commercial, industrial, garage, church, tomb) are mesh buildings (world/building-mesh.ts: real outline, OSMheight, facade textures inengine/facade-textures.ts). - More city options (see the rally DETAILS.md "City maps"):
pathBarriers(Jersey / guard rail along the opposite carriageway),road.cutWalls(sheer cuts with a stone retaining wall;concreteFrom= concrete + chain-link),goreAreas(hatched wedge between the road and a shallow ramp),junctionPlazas(outline of a big junction on a portal slab: one plain asphalt box, no crossing ribbons),junctionAreas+plazaIslands/plazaCrosswalks/plazaSignals/plazaTrees(the whole street space around a junction from open surface data: height field, islands, markings, furniture; worked examplemaps/jackie/DETAILS.md"Kew Gardens junction"),parkwayLanes(streets OSM draws beside the carriageways that really run down in the cut: treated as carriageways),cityStreets(city asphalt, kerbs, sidewalks, crosswalks, lamps),streetDressing(parked cars, crowds, police / ambulance / fire vehicles),overheadSigns(green "EXIT n" gantries from the ramps + hand-placed boards),road.trenchFills(pad the land where the lidar has a void beside a sunken stretch). Streets crossing over a tunnelled parkway (underspans) become bridge decks automatically (world/under-bridges.ts) and the span becomes a portal structure (world/portals.ts: slab, headwalls with name / clearance plates, railings, median piers; the street grid on top is padded to the slab top). Overpass decks and junction plazas are drivable in free roam. Bridges / underpasses: see "Bridges and underpasses (city maps)" below. - Looking at a map in the preview pane (it renders one frame every few seconds):
window.__mapViewer.look(cx, cy, cz, lx, ly, lz, fov)parks the camera and streams all terrain + instances around it; wait ~10-30 s, then screenshot. Helpers to build camera positions along the road:world.road.at(d)(x, y, z, tx, tz), lateralx + tz * l, z - tx * l. - Power lines:
MapDef.pylons/powerLines(baked from OSM,data.pylons/data.powerLines) becomepower_pylon/power_poleinstances + cables (world/power-lines.ts). Scatter optionchannelDist: [min, max]= only within that distance of a canal / drain edge (willows, reeds). - Railways:
MapDef.railways=data.railways(bakerrailway_lines: OSMrailway=rail, electrified flag). Tracks becomerailpaths (300 m smoothing, 2.5 % grade limit, never eased to a road - a road crossing at grade meets the rail level; drivable ballast hump carved, decks over them lifted byRAIL_CLEARANCE, placeholder buildings on the bed dropped), drawn byworld/rail-mesh.ts, masts every ~58 m on the electrified ones. To add them to an already baked map: bake to a tempout, copy onlyrailwaysinto its data.json (as for lakes). Checktests/rally/railways.test.tsand look under the road bridges in the map viewer. Stations and platforms are landmarks (examplemaps/ajvatovci/station/: platforms placed from the baked tracks insite.ts). - Road texture
road_parkway(lane lines), structuresjersey_barrier/guard_rail/concrete_block, roadside rule optionsonBridge/skipJunctions,terrain.rockSlope(grassed cut slopes instead of bare rock). - Browser checks: the preview pane throttles rendering hard; stream terrain by hand from the console
(
__rally.terrain.update(pos, 300)in a loop,__rally.streamer.updateNow(pos)) and park the camera with__rally.rig.update = () => {}+__rally.camera.position.set(...). - README shots in game (
?stage=<id>&car=<car>&spawn=<along>&mute=1, no HUD): step the game by hand (__rally.frame(1 / 60)x 90, whatbenchmarkuses) and read__rally.renderer.domElementin the same task -renderer.renderoutside the loop draws sky only (the frame sets up the camera-relative scene). A hidden pane stalls loading:tabs_selectthe tab first.
Gotchas: +Z is SOUTH (the baker handles it); uneven control-point spacing makes Catmull-Rom loop (the baker evens
it out, world.test.ts "no loops or kinks" catches it); the elevation is a surface model (trees / roofs) - tune
canopy if roads climb walls of forest.
Bridges and underpasses (city maps)
Owner rules - a review fails on any of these:
- Approved bridge tops are never touched; bridge walls run parallel unless the bridge curves; always check the bridge START and END.
- Retaining walls are concrete, straight, continuous (also at OSM way joints), never on a road / shoulder, never through the deck above, never higher than the parkway beside them; no fence on underpass / approach walls; no tunnels in underpasses.
- The street under a bridge is flat and unbroken (no ground through it); sidewalks continue under bridges; lamps under a deck go on the
wall (
wall_lamp), never a pole; nothing scattered in a trench; no barrier / wall / sign post on another road; streets never overlap a carriageway. - Fences / railings never cross a road: a rim fence keeps a street off the drop to the parkway, it never stands on a street, plaza or
bridge deck, and never bridges a gap (one railing per run of fenced wall rows,
cut-wall-mesh.ts). Where OSM puts a street's lane on the wall line, move the street off the cut (MOVED_NODESinmaps/jackie/map.ts); a junction plaza stays between the headwalls. Check every new wall / plaza / street change at the bridge start and end for a railing across the road.
Where it lives: path pipeline in TerrainGen (world/under-bridges.ts: separateStreets, alignParallelDecks, ...), trench and deck
terrain in terrain-gen.ts (dipUnderBridges, pathCutWeight, capUnderDecks), walls in cut-wall-mesh.ts, span fitting in
maps/shared/real-map.ts. World.analytic is the TOP surface (includes the stage deck): anything on a street samples the ground
(GroundTerrain / gen.height). No terrain through streets: a street ribbon holds its own height line where the land under it
falls away (cut beside it, trench under a portal slab; not on / under the stage road, pathMeshesJob), the ground under a deck
end is not pitted inside a junction plaza (capUnderDecks), a sidewalk is lifted clear of a bank, and decks on a portal slab
carry sidewalks (street-detail.ts deck). Motorway / trunk ways are carriageways, never dipped - except a not-connected one
crossing beneath a stage-road span on a country map (TerrainGen.highwayUnder, worked example maps/petralica/, E-871).
Approaches (TerrainGen.raiseApproaches): the ways a street deck continues into climb to the deck end at DECK_GRADE, relative to
their own land line (a hillside street keeps following the hill), smoothed over 24 m; motorway / trunk and portal decks are left
out. Two raised approach streets within 10 m share one fill (no notch in the gore). A road that only passes beside a deck end (no
node there) is never anchored to it (meetsEnd).
Most bugs come from OSM way joints: one street is several ways, and every per-way step (2 m sampling, square ends, a profile smoothed alone, "another road here" checks) breaks at the joint - sample to the exact end, mitre to the mean direction, treat the next way of the same street as itself.
Fixing one: open the owner's camera link in your own preview (__mapViewer.look(...)), hide scene groups (terrain, road children
bridges / cut-walls / paths / street-detail / barriers) + shell.renderer.render(...) to name the culprit mesh, probe heights in
the page (gen.height vs world.analytic.height, pathHeight, path ends near the point - indices shift, find ways by position), fix the
rule (never a per-bridge offset), re-check start / end and the other underpasses, run city-maps / bridges / side-roads tests.
Landmarks (hand-modelled streets / lots) and pads
When a stretch of a real map needs more than placeholder boxes (the Ilinden street at the start of ajvatovci):
make a landmark folder maps/<id>/<name>/ (worked example maps/ajvatovci/start-row/, read its DETAILS.md first) with a pure
layout (layout.ts, node-testable), a Landmark (index.ts: replaces = placeholder buildings to hide, occupies =
keep scatter out, colliders, instances = rally assets as fixed scatter, build = geometry job), register it in
world/landmarks.ts and list its id in MapDef.landmarks. Geometry: build/kit.ts (soups per tile + material with the paint
in vertex colours, metre UVs, Frame = site -> world + shear onto a lot's plane), build/wall.ts (walls with real
window / door openings), materials in build/materials.ts, textures engine/lot-textures.ts. Level the ground with
terrain.pads (polygon + frontage knots x, z, ... on the stage road; the pad is level across the street and follows
the road height along it). Place lots from a satellite screenshot: trace pixels with a px -> site transform fitted to the
road (start-row/trace.ts), share one fence between neighbouring lots (layout.ts sharedFences), and make fences /
poles colliders and kerbs groundOverride ground so the car feels them (start-row/index.ts, streetwork.ts).
Parked vehicles are instanced (build/vehicles.ts): keep a car < ~500 triangles. Check: tests/rally/start-row.test.ts style
tests + the stage test (colliders must stay off the road), look at it in /games/rally/?stage=<id>&spawn=<along>.
Single buildings away from the stage road (example maps/ajvatovci/hilltop/: church, bell tower, court): positions from
OSM ways reprojected with the baker's Proj, one Local frame per structure (hilltop/build/shapes.ts: the kit's
Frame with its own origin, walls with real openings, tiled roof planes), ground levelled with named flatAreas
(pads need a stage-road frontage) - keep their blend rim off the road (tests/rally/hilltop.test.ts).
New map checklist
Copy maps/test/ to maps/<id>/ (fill in its DETAILS.md sources), change id, name, seed, bounds, road.points and
its stages (one or more StageDef: id, name, tyre, timing, reverse for a stage driven the other way along the road,
closures, cornerFans); add to ENTRIES in maps/index.ts (a real map with baked data: put the menu fields + stages.ts in
<id>/info.ts, load map.ts with a dynamic import, see rally DETAILS.md "Lazy loading"); the stage test picks every stage up
automatically. Several stages on one road: measure each stage end's distance along the road in node (bake meta.waypointsAlong
is along the baked polyline, the game road differs by metres) and hard-code timing. Add every stage to STAGES_LIST with
hideInProd: true in src/games/rally/release.ts (tests/rally/release.test.ts fails for an unlisted stage): it is then
dev server only (TEST badge) and out of the published build. Remove the flag to release it (or set it again to pull it).
Source files
Files / links the user provides (models, reference photos, data downloads) go to sources/ (git-ignored) and are
listed in the car / map folder DETAILS.md - follow .claude/skills/source-files/SKILL.md.
Bump the times version (last step)
A new stage adds its id to STAGE_TIMES_VERSIONS in game/stage.ts (start at 1). Changing a released stage's road, layout, length or surfaces bumps its entry (a map's road change bumps every stage on it), but only when the user says we are making a build;
until then say in the PR which stage changed (AGENTS.md "Rally times versions"). A hideInProd stage stays at 1 until released (a test checks). Old times stay below the new ones.