Skip to main content

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 wave
  • scenarios.md — the visual regression scenario file format
  • architecture.md — the why (read this first if you've never touched mapv10)
  • README.md — what mapv10 is
  • roadmap.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 kindTouchpoints
Add a map modeGenerator stage (data) → manifest → loader → unified shader (MaterialFade.ts) → mode tab (shell.ts) → scenario JSON
Add a layer toggleMapLayerState type → renderer setLayerState → checkbox in shell.ts → optional scenario layer override
Add a generator artifactNew stage in generator/src/stages/ → manifest schema → validation → loader fetches → consumed by renderer
Add a scenarioAppend to viewer/src/scenarios/mapv10_scenarios.json. No code change.
Add a validator agentNew 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 as provinceColorSeeds.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 == N branch in the mode dispatch
  • Increment REQUIRED_FRAGMENT_SAMPLERS in SamplerProbe.ts if you added a new sampler

Step 5 — UI tab

examples/map/mapv10/viewer/src/ui/shell.ts:

  • Add { id: "trade", label: "Trade" } to the MODES array

examples/map/mapv10/viewer/src/events/types.ts:

  • Add "trade" to the MapModeId union

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

  • expectations is the human-readable rubric the visual validator follows; failConditions is now a typed gate list consumed by viewer/scripts/scenario-gates.mjs, not free-form prose. For example, a trade-network mode that must show several distinct ribbon colors should carry a labelTextMinDistinctCount-style gate:

    "failConditions": [
    { "type": "labelTextMinDistinctCount", "min": 5,
    "note": "Trade mode must show at least 5 distinct trade-network colors" }
    ]

    See scenarios.md § failConditions gate vocabulary for the canonical typed list. Archival prose from older scenarios lives in the optional notes field; the gate runner does not parse it.

Step 7 — Tests

examples/map/mapv10/viewer/src/renderer/__tests__/MaterialFade.test.ts:

  • Add an assertion that uTradeNetwork is 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: boolean to MapLayerState

Step 2 — Renderer respects the toggle

Mapv10ThreeRenderer.setLayerState:

  • Read state.cities and 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 setLayerState call 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.

  1. Stage in generator/src/stages/curvature.rs — read heightfield, compute curvature per cell, emit the raster.
  2. Register in pipeline.rs after the heightfield stage.
  3. Validate dims + byte count in validation.rs.
  4. Add to manifest schema and LoadedRun type.
  5. Loader decodes (or leaves as Uint8Array until a consumer needs it).
  6. Don't bind to any shader yet — that's a future wave.
  7. 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:

  1. Open examples/map/mapv10/viewer/src/scenarios/mapv10_scenarios.json
  2. Append a scenario object to the scenarios array (id starts with mapv10_)
  3. Save. Vite HMR picks it up — no rebuild needed
  4. window.__mapv10ListScenarios() confirms it's discovered
  5. await 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.

  1. Decide what evidence stream this agent owns (here: HUD percentiles after a stress sequence).
  2. 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 to wave-protocol.md Part 6's file map. wave-protocol.md intentionally does not carry an inline prompt-skeleton table.
  3. Add model: "sonnet" (or opus for any agent that makes prescriptive calls — see CLAUDE.md "UX audits that recommend = Opus" rule).
  4. Decide if it's standard (always runs) or per-wave opt-in (declared in the wave brief).
  5. 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):

  1. Add the rule to your project's Claude memory file (e.g. .claude/memory/feedback_<topic>.md) so it loads automatically across sessions.
  2. Cross-reference it in MEMORY.md (the index).
  3. If the rule is mapv10-specific and stable enough to ship in the repo, copy it into .claude/rules/mapv10.md so non-Claude readers see it too.
  4. Update the relevant Step in wave-protocol.md to 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

SituationDecision
Shader / material / mesh-geometry changeWave. Charter applies. Visual regression matters.
New map modeWave. Multi-file, charter-relevant.
New generator stage with downstream consumersWave. Cross-cutting.
New scenario in JSONBuild directly. No code change.
New validator agentBuild directly. Doc + prompt only.
New tenets memory ruleBuild directly. Doc only.
Renderer debug helpers / programmatic APIBuild directly with discipline (verify in browser, no autonomous PASS).
Tooling / scripts under scripts/Build directly.
Doc-only changeBuild directly.
Refactor that touches >10 files but is non-visualApply 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 (in SamplerProbe.ts). The 1×N LUT bug exists exactly because this was skipped.
  • GL / WebGL errors must surface to window.__mapv10Ready.errors. The console.error tee in shell.ts does 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.ts lies. 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.errors quoted. 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_* or mapv10_province_* scenario.
  • Refreshing visual baselines to mask a regression. The browser screenshot baseline under viewer/baselines/browser/<run-id>/manifest.json is a SHA-256 contract committed to git; the PNGs live under screenshots/ and are gitignored. Baselines are refreshed via npm run verify:browser:baseline:update only 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. See scenarios.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-ignore strings 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.errors is empty after the scenario settles
  • All 6 existing map modes still render (no mode-dispatch regressions)
  • getTerrainGeometryProbe().hasDisplacement === true for 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.