How to Add Features to mapv10
This document is the practical recipe book for the most common kinds of mapv10 changes. It is companion to:
wave-protocol.md— the 4-step process for any feature wavescenarios.md— the visual regression scenario file formatarchitecture.md— the why (read this first if you've never touched mapv10)README.md— what mapv10 isroadmap.md— what's planned.claude/rules/mapv10.md— the path-scoped rules every Claude agent reads when touching mapv10/CLAUDE.md(root) — workspace-wide tenets
If a recipe below contradicts the charter docs, the charter wins.
Quick file map — where things live
| Change kind | Touchpoints |
|---|---|
| Add a map mode | Generator stage (data) → manifest → loader → unified shader (MaterialFade.ts) → mode tab (shell.ts) → scenario JSON |
| Add a layer toggle | MapLayerState type → renderer setLayerState → checkbox in shell.ts → optional scenario layer override |
| Add a generator artifact | New stage in generator/src/stages/ → manifest schema → validation → loader fetches → consumed by renderer |
| Add a scenario | Append to viewer/src/scenarios/mapv10_scenarios.json. No code change. |
| Add a validator agent | New committed agent prompt → wave-protocol.md Part 6 file map → orchestrator dispatch list |
| Add a tenet / rule | /CLAUDE.md (root) or .claude/rules/mapv10.md (path-scoped) → reference in wave-protocol.md Step 4 |
Recipe 1 — Add a new map mode
Example: add a "Trade" mode that colors locations by trade-good network.
Step 1 — Define the data
Decide where the data comes from:
- Per-location scalar (e.g. trade-good id) → emit a
provinceTradeSeeds.json-style artifact in a generator stage. Same shape asprovinceColorSeeds.json. - Per-pixel field (e.g. trade-network density) → emit a continent-wide raster (
tradeDensity.r8.bin) similar to the existing splat textures. - Mode-specific LUT swap (no new data, just re-color the LUT) → no generator change; the renderer rebuilds the LUT in
setMapMode.
Step 2 — Generator (if new artifact)
examples/map/mapv10/generator/src/stages/:
trade.rs (new stage)
mod.rs (register the module)
pipeline.rs (run the stage in order; emit the artifact)
validation.rs (assert artifact dims/byte size)
Add the artifact to model.rs (the run product struct) so downstream stages can read it. Update pipeline.rs to register the artifact in the manifest as required: true (no fallback per the no-fallbacks rule).
Step 3 — Manifest schema + loader
examples/map/mapv10/viewer/src/data/types.ts:
- Add the artifact type (e.g.
TradeNetworkArtifact) - Add it as a field on
LoadedRun
examples/map/mapv10/viewer/src/data/manifestLoader.ts:
- Fetch the artifact in
loadMapv10Run. If missing, throw — no fallback path.
Step 4 — Renderer wiring
examples/map/mapv10/viewer/src/renderer/Mapv10ThreeRenderer.ts:
- Build the new texture in
prepareStrategicMapResources - Call
assertTextureFitsBudget(samplerProbe, "trade-network", w, h)before binding - Bind the new sampler uniform on
materials.terrain
examples/map/mapv10/viewer/src/renderer/MaterialFade.ts:
- Add
uniform sampler2D uTradeNetwork;to the unified shader's pars block - Add a new
uModeId == Nbranch in the mode dispatch - Increment
REQUIRED_FRAGMENT_SAMPLERSinSamplerProbe.tsif you added a new sampler
Step 5 — UI tab
examples/map/mapv10/viewer/src/ui/shell.ts:
- Add
{ id: "trade", label: "Trade" }to theMODESarray
examples/map/mapv10/viewer/src/events/types.ts:
- Add
"trade"to theMapModeIdunion
Step 6 — Scenario
examples/map/mapv10/viewer/src/scenarios/mapv10_scenarios.json:
-
Add at minimum a top-down scenario:
mapv10_continent_trade_topdown -
Add an oblique scenario if the mode reads displaced terrain:
mapv10_continent_trade_oblique -
expectationsis the human-readable rubric the visual validator follows;failConditionsis now a typed gate list consumed byviewer/scripts/scenario-gates.mjs, not free-form prose. For example, a trade-network mode that must show several distinct ribbon colors should carry alabelTextMinDistinctCount-style gate:"failConditions": [{ "type": "labelTextMinDistinctCount", "min": 5,"note": "Trade mode must show at least 5 distinct trade-network colors" }]See
scenarios.md§failConditionsgate vocabulary for the canonical typed list. Archival prose from older scenarios lives in the optionalnotesfield; the gate runner does not parse it.
Step 7 — Tests
examples/map/mapv10/viewer/src/renderer/__tests__/MaterialFade.test.ts:
- Add an assertion that
uTradeNetworkis declared in the unified shader source - Add a
setTerrainMode(material, TERRAIN_MODE_TRADE)test
Step 8 — Wave or build directly?
A new mode is shader-touching, charter-relevant work — wave it. Use wave-protocol.md start to finish. Step 1 Explore audits where the existing modes live; Step 2 Implement does steps 1–7 above; Step 3 Validate runs every scenario including the new ones; Step 4 Tenets Check confirms no fallbacks / no partial migrations.
Recipe 2 — Add a new layer toggle
Example: add a "Cities" layer (markers for major settlements).
Step 1 — Type
examples/map/mapv10/viewer/src/events/types.ts:
- Add
cities: booleantoMapLayerState
Step 2 — Renderer respects the toggle
Mapv10ThreeRenderer.setLayerState:
- Read
state.citiesand toggle visibility of the relevant scene group
Step 3 — UI checkbox
examples/map/mapv10/viewer/src/ui/shell.ts:
- Add
<input type="checkbox" data-layer="cities" checked />in the layer panel - Wire it into the
setLayerStatecall alongside the other toggles
Step 4 — Scenario layer overrides
If you want scenarios to set this on/off per scenario, the existing Mapv10ScenarioLayers is already Partial<MapLayerState> — adding the field to MapLayerState makes it usable in JSON scenarios automatically.
Step 5 — Test
Add a unit test asserting the new key flows through the renderer's setLayerState path. Build directly — this is a small change, a wave is overkill.
Recipe 3 — Add a new generator artifact (no new mode)
Example: add a terrainCurvature.r8.bin raster that future modes might consume.
- Stage in
generator/src/stages/curvature.rs— read heightfield, compute curvature per cell, emit the raster. - Register in
pipeline.rsafter the heightfield stage. - Validate dims + byte count in
validation.rs. - Add to manifest schema and
LoadedRuntype. - Loader decodes (or leaves as
Uint8Arrayuntil a consumer needs it). - Don't bind to any shader yet — that's a future wave.
- Test the generator stage with a fixture continent-lod6 run.
This is a generator-only change. No renderer wave needed. Build directly with discipline (verify the stage runs end-to-end on the canonical fixture; check validation-summary.json shows the new stage passes).
Recipe 4 — Add a new scenario
See scenarios.md for the full reference. One-line summary:
- Open
examples/map/mapv10/viewer/src/scenarios/mapv10_scenarios.json - Append a scenario object to the
scenariosarray (id starts withmapv10_) - Save. Vite HMR picks it up — no rebuild needed
window.__mapv10ListScenarios()confirms it's discoveredawait window.__mapv10RunScenario("mapv10_your_id")runs it
Recipe 5 — Add a new validator agent
Example: add a mapv10-validate-perf-stress agent that drives rapid mode swaps and re-measures HUD over 60 frames.
- Decide what evidence stream this agent owns (here: HUD percentiles after a stress sequence).
- Write the prompt as a committed agent file in the active agent surface
(
.claude/agents/,.codex/agents/, or.github/agents/as appropriate) and add it towave-protocol.mdPart 6's file map.wave-protocol.mdintentionally does not carry an inline prompt-skeleton table. - Add
model: "sonnet"(oropusfor any agent that makes prescriptive calls — see CLAUDE.md "UX audits that recommend = Opus" rule). - Decide if it's standard (always runs) or per-wave opt-in (declared in the wave brief).
- Update wave-protocol.md's "Step 3 may run as N parallel Sonnets" line to include the new validator in the count.
The validator only runs when the orchestrator dispatches it. There's no auto-discovery.
Recipe 6 — Update charter rules / tenets
When you discover a failure mode the protocol didn't catch (e.g. the LUT-too-big bug Codex found):
- Add the rule to your project's Claude memory file (e.g.
.claude/memory/feedback_<topic>.md) so it loads automatically across sessions. - Cross-reference it in
MEMORY.md(the index). - If the rule is mapv10-specific and stable enough to ship in the repo, copy it into
.claude/rules/mapv10.mdso non-Claude readers see it too. - Update the relevant Step in
wave-protocol.mdto gate on the new rule (e.g. add a new anti-pattern scan in Step 4).
The wave protocol is meta-versioned — every time it fails, the protocol is at fault, not the wave. Update the protocol; rerun the wave.
When to wave, when to build directly
| Situation | Decision |
|---|---|
| Shader / material / mesh-geometry change | Wave. Charter applies. Visual regression matters. |
| New map mode | Wave. Multi-file, charter-relevant. |
| New generator stage with downstream consumers | Wave. Cross-cutting. |
| New scenario in JSON | Build directly. No code change. |
| New validator agent | Build directly. Doc + prompt only. |
| New tenets memory rule | Build directly. Doc only. |
| Renderer debug helpers / programmatic API | Build directly with discipline (verify in browser, no autonomous PASS). |
Tooling / scripts under scripts/ | Build directly. |
| Doc-only change | Build directly. |
| Refactor that touches >10 files but is non-visual | Apply judgment. Often a wave's Step 1 + Step 2 + Step 4 is right; Step 3's browser gates skip with reason. |
The non-negotiables (regardless of wave or direct)
These are the rules that apply to EVERY change touching mapv10 — copy them into any prompt you give an agent:
- No fallbacks / graceful-degradation / try-or-skip. Required asset missing = fail loud, not synthesize a placeholder.
- No partial migrations. If you replace a system, the old one is GONE in the same change.
- No backwards-compat shims.
- Artifact metadata is byte-length/schema only. Do not add file-fingerprint fields or file-fingerprint verification.
- Read existing files end-to-end before touching them.
- Texture sizing must use
assertTextureFitsBudget(inSamplerProbe.ts). The 1×N LUT bug exists exactly because this was skipped. - GL / WebGL errors must surface to
window.__mapv10Ready.errors. Theconsole.errortee inshell.tsdoes this for THREE / WebGL strings; verify your new failure modes match its regex or extend it. - Every new texture-creation site needs a scenario that exercises it. Otherwise the visual validator can't see if the bind worked.
- Never use Haiku for any sub-agent. Sonnet for explore/validate; Opus for any decision/implementation.
- Every Agent dispatch specifies
model:explicitly. Never omit. - Sub-agents NEVER use
mcp__ccd_session__spawn_task,ScheduleWakeup,CronCreate,mcp__scheduled-tasks__*. Hard prohibition.
Common pitfalls
- Forgetting to update the sampler probe constant when adding a sampler. The probe will still pass (typical GPUs have plenty of slots) but the file-level comment in
SamplerProbe.tslies. Update both. - Adding a feature without a scenario. The visual validator only walks scenarios — anything not in the JSON is invisible to regression. New shader uniform, new mode, new layer = new scenario.
- Hard-coding a magic number in a shader. If it's a tunable, expose it as a uniform. If it's an architecturally-derived value, comment WHY (cite the spec / paper).
- Test count drift. When you add tests, the implement agent's report must say the actual new count, not the old + 1 inflated. The validation step will re-run vitest and catch inflation.
- Implement agent self-reports "browser confirmed." That phrase is meaningless without a screenshot referenced +
window.__mapv10Ready.errorsquoted. The validator should reject self-confirmation that isn't backed by structured-state evidence. - Skipping detail-zoom scenarios. Continent-overview screenshots miss the per-tile uniform binding bugs that only fire when streaming kicks in. Every wave that touches the render path should include a
mapv10_location_*ormapv10_province_*scenario. - Refreshing visual baselines to mask a regression. The browser screenshot
baseline under
viewer/baselines/browser/<run-id>/manifest.jsonis a SHA-256 contract committed to git; the PNGs live underscreenshots/and are gitignored. Baselines are refreshed vianpm run verify:browser:baseline:updateonly after the new visual state has been accepted. Never refresh the baseline mid-wave to make the diff "go green" — that is a falsified proof. Seescenarios.md§ Browser visual baselines for the full policy.
Checklist before declaring a feature done
- Code changes pass
dotnet build,npx tsc --noEmit,npx vitest run,cargo test --release - No new TODO / FIXME / HACK / XXX /
as any/@ts-ignorestrings in the diff - No new fallback paths, no graceful-degradation
- No partial migrations — old code is gone, not commented out
- Every new texture-creation site goes through
assertTextureFitsBudget - At least one scenario exercises the new feature; runs cleanly via
__mapv10RunScenario -
window.__mapv10Ready.errorsis empty after the scenario settles - All 6 existing map modes still render (no mode-dispatch regressions)
-
getTerrainGeometryProbe().hasDisplacement === truefor any wave that touched terrain mesh - HUD p95 ≤ 50ms idle, fps ≥ 60 (regression budget)
- Charter docs (
AGENTS.md,architecture.md,mapv10.md,README.md,roadmap.md,docs/generator.md,docs/viewer.md,docs/export-contract.md) are not contradicted by the change
If any item is unchecked, the feature is not done. The wave protocol's job is to enforce this list mechanically.