mapv10 Architecture
mapv10 is a continent-scale 3D strategic-map system built as two
cooperating processes: a Rust generator that owns world truth and a
TypeScript + Three.js viewer that consumes typed artifacts. The renderer
core contains no React, no React Three Fiber, and no UI-framework
lifecycle. The contract version written into every run manifest is
mapv10-artifacts-v1 (generator/src/lib.rs,
viewer/src/data/types.ts).
The generator scales from province-slice (96 km x 64 km) up to the
canonical continent preset (2 400 km x 1 800 km, 6 000-16 000
strategic Locations) using one shared pipeline; the preset table is
authoritative in generator/src/config.rs. The viewer renders
Three.js ^0.181.2 on Vite ^7.1.12 (viewer/package.json).
Process docs live alongside this one: wave-protocol.md
(4-step wave dispatch contract), scenarios.md
(visual regression + perf budgets), and extending.md
(adding stages, map modes, layers, scenarios, sub-agents).
Documentation Source Of Truth
The committed Markdown docs are the portable architecture contract for mapv10. They must be complete enough to hand to another reviewer without the source tree and still answer:
- what mapv10 is and is not;
- what technologies it uses;
- which artifacts the generator emits and which subsystem owns each artifact;
- how the viewer loads, validates, streams, caches, and renders those artifacts;
- which failures must throw rather than fall back;
- which validation commands and scenarios prove behavior;
- what is implemented, what is deferred, and what is only proposed.
Source files, schemas, generated fixtures, and test results are implementation
evidence. They may be cited by file path plus symbol/function/schema/stage name,
but durable docs must not depend on source line numbers. If code changes an
architecture or artifact contract, the relevant doc in this packet is updated in
the same wave. If a reviewer finds code/doc drift, the drift is recorded in
next-work.md and fixed before using that behavior as a new source of truth.
The minimal portable packet is:
README.mdfor orientation and reading order.architecture.mdfor system boundaries and contracts.docs/generator.mdfor generator stages and product ownership.docs/viewer.mdfor the renderer, streaming, cache, shader, UI, and browser proof contracts.docs/export-contract.mdfor the Valenar projection lane.scenarios.mdfor visual/performance evidence.wave-protocol.mdfor the mandatory change process.roadmap.mdandnext-work.mdfor status, priority, deferrals, and open decisions.
Architecture Overview
The generator owns world truth as inspectable typed artifacts
(manifests, rasters, vector JSON, semantic ID rasters, meshes, previews,
validation reports). The viewer loads those artifacts through a typed
loader and renders them; the renderer core is framework-independent and
any UI shell talks through the typed Mapv10Renderer interface.
seed + preset -> generator (pipeline.rs::build_products)
-> typed artifacts (manifest.json + stages/ + tiles/ + meshes/)
-> viewer (manifestLoader -> Mapv10ThreeRenderer.loadRun)
-> Three.js render loop (7 named passes)
-> Mapv10Event union to any UI shell
Workspace Layout
The mapv10 root is examples/map/mapv10/. It holds the generator/
(Rust), viewer/ (TypeScript + Three.js), schema/ (shared
*.schema.json contracts), and the live governance docs at the root:
architecture.md, README.md, roadmap.md, next-work.md,
wave-protocol.md, scenarios.md, extending.md.
Generator runs land at viewer/public/<run-id>/ and are served at
/mapv10/runs/<run-id>/manifest.json by the dev (port 5443) and
preview (port 4279) servers via
viewer/server/mapv10ArtifactMiddleware.ts. The default fixture
run id is continent-lod6.
Scale Presets
A scale preset selects world bounds, source raster size, target Location
count, and the LOD ladder. The same pipeline runs for all four; only the
inputs differ (generator/src/config.rs).
| Preset | World km (W x H) | Raster (W x H) | Target Locations |
|---|---|---|---|
province-slice | 96 x 64 | 128 x 96 | 5-10 |
regional-slice | 250 x 180 | 384 x 288 | 90-180 |
realm-slice | 720 x 520 | 768 x 560 | 320-1 200 |
continent | 2 400 x 1 800 | 2 048 x 1 536 | 6 000-16 000 |
Generator Pipeline
build_products in generator/src/pipeline.rs orchestrates 16 stages
sequentially on a single thread. Each stage returns typed product
structs and a ValidationReport; the orchestrator writes products
through artifacts::write_* helpers, accumulates ProductRefs into the
run manifest, and threads each stage's contract() value into
stage-index.json.
| # | Key | Source | Primary Products |
|---|---|---|---|
| 0 | config | stages/config.rs | config.json |
| 1 | continent | stages/continent.rs | continentPolygons.json, coastlines.json, seaRegions.json |
| 2 | geography-graph | stages/geography_graph.rs | ridgeGraph.json, basins.json |
| 3 | heightfield | stages/heightfield/mod.rs | height.f32.bin, slope.f32.bin, normal.rg16.bin, sediment.f32.bin, flowAccumulation.f32.bin |
| 4 | water | stages/water.rs | riverGraph.json, riverCenterlines.json, lakePolygons.json, waterMask.u8.bin, riverWidth.f32.bin |
| 5 | biomes-materials | stages/biomes_materials.rs | biome.u8.bin, materialWeights.rgba8.bin, materialWeightsB.rgba8.bin, forestMask.u8.bin, wetlandMask.u8.bin |
| 6 | political | stages/political.rs | realmPolygons.json, provincePolygons.json, locationPolygons.json, provinceId.u32.bin, locationId.u32.bin, neighborGraph.json, provinceColorSeeds.json |
| 7 | routes | stages/routes.rs | locationConnections.json, roadNetwork.json, routeGraph.json, routeCenterlines.json, crossingAnchors.json |
| 8 | map-features | stages/map_features.rs | mapFeatureAnchors.json, mapFeatureFootprints.json, labelAnchors.json |
| 9 | influence | stages/influence.rs | influence/influenceTypes.json, influence/influenceSources.json, influence/influenceRules.json, influence/influenceMask.corruption.u8.bin, influence/influenceTypeMask.u8.bin, effective/effective*.bin |
| 10 | borders-sdf | stages/borders_sdf.rs | borderSdf.rgba8.bin (1+JFA SDF + nearest-province-id, RGBA8) |
| 11 | tile-pyramid | stages/tile_pyramid.rs | tiles/tile-pyramid.json, tiles/tile-coordinate-index.json, tiles/{raster,vector,semantic,mesh}-tiles/index.json, tiles/{raster,vector,semantic,mesh}-tiles/z*/y*.json |
| 12 | meshes | stages/meshes.rs | meshes/mesh-manifest.json, meshes/terrain-meshes.json, meshes/water-meshes.json, meshes/route-ribbons.json |
| 13 | readiness | stages/readiness.rs | readiness/readiness-report.json, readiness/continent-preset-draft.config.json |
| 14 | previews | stages/previews.rs | previews/*.png contact sheet |
| 15 | valenar-worlddata | stages/valenar_worlddata.rs | valenar/world-<seed>.json, valenar/world-<seed>.mesh.json |
Stage 9 is a generic influence stage. It registers influence types,
emits deterministic source anchors and rules, writes full-resolution
intensity/type masks, and derives effective visual/material products
from base biome/material truth. The base biome, materialWeights,
materialWeightsB, forestMask, and wetlandMask products remain
unchanged and inspectable; viewer rendering consumes the effective
products plus the tiled influence mask.
Coordinate System And Units
Horizontal distance is kilometres, elevation is metres, raster origin is
top-left, and Y runs north (row 0) to south (row N-1)
(generator/src/model.rs). The generator works in f64 km;
mesh-tile vertices are uploaded as f32 rebased through
MeshAssetRef.originKm. The decoder worker pre-transforms vertex
positions to scene-local coordinates so the main thread never runs the
vertex loop or computeVertexNormals
(viewer/src/data/meshLoader.ts). Semantic IDs (province
numericId, Location numericId, route edge ids, label entity ids)
are u32 integers carried in dedicated rasters and JSON; they are
never derived from float positions (viewer/src/data/types.ts).
Artifact Contract And Schema
Every run carries schemaVersion: "mapv10-artifacts-v1" at the manifest
top level; the same constant is asserted by the manifest loader
(viewer/src/data/manifestLoader.ts). The manifest enumerates
products and previews, each with a typed schema, productType,
mediaType, byteLength, and source-dependency list.
| Schema file | Role |
|---|---|
manifest.schema.json | top-level run manifest |
config.schema.json | seed + scale preset + bounds |
stage-index.schema.json | per-stage product/preview/validation index |
stage-manifest.schema.json | individual stage manifest shape |
raster-product.schema.json | typed binary raster header |
tile-product.schema.json | tile family manifests |
mesh-product.schema.json | mesh family manifests + asset refs |
readiness-report.schema.json | budget / precision / streaming report |
semantic-display-policy.schema.json | generated semantic zoom/content policy |
influence-types.schema.json, influence-sources.schema.json, influence-rules.schema.json | generated influence registry, source, and rule products |
valenar-world.schema.json | Valenar gameplay export |
valenar-world-mesh.schema.json | Valenar mesh manifest export |
Tile Pyramid And LOD
Stage 11 emits a generated physical tile-coordinate index plus four
z-row sharded tile-family manifest indexes — raster, vector, semantic,
and mesh (viewer/src/data/types.ts TileCoordinateIndex,
RasterTileManifest, VectorTileManifest, SemanticTileManifest,
MeshTileManifest). The coordinate index owns generated tile bounds,
hierarchy, cost, and error metadata. Family indexes own shard refs and
byte lengths; the viewer fetches only the z/y family shards needed for
requested runtime tiles. Borders are not a tile family; the
borderSdf.rgba8.bin raster from stage 10 is sliced per-tile and sampled
in the unified terrain shader.
Physical LOD is a generated data-residency ladder only: tile IDs,
source windows, raster/vector/semantic/mesh sidecars, mesh error,
raster sample/detail error, cache priority, underlay, preloads, and
resolver fallback. Strategic content visibility is owned by the
generated semanticDisplayPolicy product and consumed by the viewer;
labels, routes, borders, water visibility, markers, hover, selection,
and influence hooks must not be inferred from the physical lodBand
alone.
Tile-manifest packaging is part of the generated contract. Continent
z6/z7 scale makes a monolithic raster-tiles.json exceed Node/browser
single-file JSON limits, so stage 11 writes tiles/tile-coordinate-index.json
once and one required JSON shard per family per z/y row under
tiles/<family>-tiles/z<z>/y<y>.json. The run manifest points product
keys such as rasterTileManifest at tiles/raster-tiles/index.json;
the viewer's manifestLoader consumes only indexes at boot, and
RuntimeTileCache streams family shards on demand before fetching the
tile binaries/vector payloads.
The continent preset uses an eight-rung physical LOD pyramid totalling
65 533 tile slots, asserted by the canonical-pyramid test
(generator/src/config.rs):
| z | Physical band | Semantic band | Tiles X x Y | Sample step | Close detail scale |
|---|---|---|---|---|---|
| 0 | continent | continent | 1 x 1 | 32 | 1 |
| 1 | macro-region | realm | 4 x 3 | 16 | 1 |
| 2 | realm | realm | 8 x 6 | 8 | 1 |
| 3 | province-cluster | province | 16 x 12 | 4 | 1 |
| 4 | province | province | 32 x 24 | 2 | 1 |
| 5 | location-detail | location | 64 x 48 | 1 | 1 |
| 6 | close-detail | close | 128 x 96 | 1 | 8 |
| 7 | settlement-detail | close | 256 x 192 | 1 | 8 |
The z1 repair is intentional: z0 is now a true coarse continent rung
(sample_step = 32), while z1 is the first macro-region refinement
(sample_step = 16). z6/z7 are not just smaller windows over the same
truth; stage 11 emits a generated closeDetailNormal sidecar and
per-tile rasterSampleSpacingKm/closeDetailScale metadata so close
views consume finer authored detail instead of viewer-side procedural
noise.
For the full slicing algorithm, prefilter derivation, and per-tile output layout, see docs/generator.md § Stage 11 — tile-pyramid.
Viewer And Renderer
The viewer is a Three.js ^0.181.2 application built with Vite ^7.1.12
(viewer/package.json). The dev server listens on port 5443; preview on
4279. The renderer entry point is the Mapv10ThreeRenderer class in
viewer/src/renderer/Mapv10ThreeRenderer.ts. Lifecycle:
mount(canvas) -> loadRun(manifestUrl) -> per-frame tickFrame via rAF -> dispose.
The scene is split into seven explicit render-pass groups
(Mapv10ThreeRenderer.ts render-pass group construction):
mapv10-terrain-passmapv10-water-passmapv10-route-passmapv10-overlay-passmapv10-marker-passmapv10-label-passmapv10-picking-debug-pass
Seven map modes select the terrain shader's uModeId branch
(MaterialFade.ts): geography (0), height (1), slope (2),
normal (3), political (4), routes (5), influence (6).
The user-facing layer toggles are terrain, water, borders,
routes, influence, markers, labels, and wireframe
(Mapv10ThreeRenderer.ts).
For the full renderer lifecycle, streaming, cache architecture, and shader-mode reference, see docs/viewer.md.
Materials And Shading
Terrain uses a single unified THREE.ShaderMaterial; map-mode swap
flips the uModeId uniform without changing GL program. The twelve
samplers, splat composition for modes 0/5/6, the per-locationId LUT
path for mode 4, and the generated corruption-mask path for influence
mode live in MaterialFade.ts. Material factories
cover terrain, overlay, ocean, lake, river, route, crossing, marker,
label, and footprint surfaces; each fadeable surface is a
ShaderMaterial cloned from the matching ShaderLib entry with a
per-mesh uTileOpacity uniform injected (MaterialFade.ts), so
one GL program is shared across every tile mesh of a kind while fade
writes uTileOpacity from mesh.onBeforeRender. Non-terrain LOD fade
window is LOD_FADE_MS = 320 (MaterialFade.ts); terrain itself
does not alpha-crossfade.
Streaming, LOD Selection, And Cache
LOD selection runs in three stages every frame: VisibilitySet builds
a frame-coherent screen-space-error candidate set
(viewer/src/renderer/lod/VisibilitySet.ts), TileStreamingPlanner
schedules fetches (viewer/src/renderer/lod/TileStreamingPlanner.ts),
and RenderResolver chooses the visible LOD per tile with
ancestor-fallback when a deeper LOD is still in flight
(viewer/src/renderer/lod/RenderResolver.ts).
Tile requests carry a three-band priority — Urgent (0), Current
(1), Preload (2) — defined in
viewer/src/renderer/lod/TilePriority.ts; smaller band wins,
within a band larger priority wins. The planner caps in-flight
fetches at MAX_IN_FLIGHT_TILE_REQUESTS = 24
(Mapv10ThreeRenderer.ts). Eviction uses a spatial-LRU score
combining frame-recency, band penalty, and tile-grid distance to the
view centre (viewer/src/renderer/SpatialLruEviction.ts).
Fallback doctrine is intentionally split: required artifacts (manifest
products, tile binaries, mesh manifests, schema files) are not optional and
must fail loudly when missing or replaced by an app-shell response. Visual LOD
continuity is different: RenderResolver may keep a resident ancestor or the
previous frame's rendered tile visible while the required descendant streams,
because that preserves coverage without fabricating data or weakening artifact
validation. The resolver's structural-parent walk caps ancestor promotion at
MAX_ANCESTOR_Z_DISTANCE = 1 (viewer/src/renderer/lod/RenderResolver.ts),
and the isFullyResident AND-gate (RenderResolver.ts) requires both CPU and
GPU residency before any tile — primary, ancestor, or previous-rendered — is
treated as an acceptable visible source.
Underlay remains immediate-parent-only. The planner requests water auxiliary meshes for both primary and underlay runtime keys so a resident structural parent has matching generated water coverage, but the viewer still never substitutes bbox quads, planes, or billboard surfaces for missing generated water meshes.
Tile decode runs in a fixed-size dedicated worker pool sized at
min(6, hardwareConcurrency - 1) with a floor of 2
(viewer/src/workers/WorkerPool.ts). Workers fetch the byte
range, decode the typed array, transform vertices into scene-local
coordinates, compute per-vertex normals, build the worldKm sidecar,
and post zero-copy Transferable buffers back to the main thread.
Renderer/UI Boundary
The renderer core is React-free. Any UI shell talks to it through the
Mapv10Renderer interface (viewer/src/renderer/types.ts):
interface Mapv10Renderer {
mount(canvas: HTMLCanvasElement): void;
loadRun(manifestUrl: string): Promise<void>;
setMapMode(mode: MapModeId): void;
setOverlayState(state: MapOverlayState): void;
setLayerState(state: MapLayerState): void;
frameSelection(target: MapEntityRef): void;
frameWorld(pointKm: [number, number], distanceKm?: number): void;
resize(width: number, height: number): void;
inspectAt(screenX: number, screenY: number, state?: MapInteractionState): MapInteractionPayload;
hoverAt(screenX: number, screenY: number): MapInteractionPayload;
selectAt(screenX: number, screenY: number): MapInteractionPayload;
clearHover(reason: MapInteractionClearReason, screenPx?: [number, number] | null): MapInteractionPayload;
clearSelection(reason: MapInteractionClearReason, screenPx?: [number, number] | null): MapInteractionPayload;
findInteractionSample(kind: MapEntityRef["kind"], state?: MapInteractionState): MapInteractionPayload | null;
pick(screenX: number, screenY: number): MapPickResult | null;
getCameraState(): MapCameraState | null;
setTerrainVisualConfig(config: TerrainVisualConfig): void;
getTerrainVisualConfig(): TerrainVisualConfig;
startZoomTrace(options?: Mapv10ZoomTraceOptions): void;
markZoomTrace(label: string, detail?: Record<string, unknown>): void;
stopZoomTrace(): Mapv10ZoomTraceReport | null;
getZoomTraceReport(): Mapv10ZoomTraceReport | null;
raycastSurfaceAtCanvasPx(canvasX: number, canvasY: number):
{ worldKm: [number, number]; elevationM: number; hitTerrain: boolean } | null;
getTerrainElevationMetersAt(worldKm: [number, number]): number;
onEvent(handler: Mapv10EventHandler): () => void;
dispose(): void;
}
Outbound events use the Mapv10Event discriminated union
(viewer/src/events/types.ts): load-progress, run-loaded,
mode-changed, hover-changed, selection-changed, camera-changed,
tile-load-state, error. Interaction events carry a
MapInteractionPayload owned by the renderer; the UI passes canvas
coordinates in and renders the returned payload instead of sampling scene
internals itself.
Strategic Hierarchy
Generated semantic entities form a four-level hierarchy plus optional map features pinned inside Locations. Realms, provinces, and Locations come from stage 6; map features come from stage 8.
Continent -> Realm -> Province -> Location -> Map Feature
Locations are ~250-500 km^2 strategic territories; map features are anchors and footprints positioned inside a Location.
Mutable Viewer Overlays
The renderer accepts setOverlayState(state: MapOverlayState) to swap
mutable overlay state without reloading the run. Mutable overlays
render in the dedicated mapv10-overlay-pass group, are sourced from
generator overlay products, and do not require regenerating terrain,
political, or water truth.
Water Architecture
Stage 4 produces the river graph, river centerlines, lake polygons, the
water-class raster (waterMask.u8.bin: 0 dry, 1 sea, 2 lake, 3 river),
and riverWidth.f32.bin. Stage 12 triangulates sea meshes from each
seaRegions.json outer ring with land polygons as holes, and lake
meshes from lake outer rings with optional hole rings
(generator/src/stages/meshes.rs). Coastal terrain tiles emit
the full heightfield grid so terrain forms a continuous seabed under the
sea mesh; only fully deep-sea tiles emit no triangles. Route ribbons
live in a separate mesh family (meshes/route-ribbons.json) distinct
from the water family.
Water visibility is read through the semantic display policy and then resolved against generated mesh/raster products. The viewer may hide a water layer by policy or user toggle, but it may not invent water surfaces when the generated product is absent.
Interaction, Picking, And Selection
Interaction sources are typed semantic products: provinceId.u32.bin,
locationId.u32.bin, water sidecars/meshes, route centerlines, map
feature anchors/footprints, terrain tile sidecars, and the influence
products that Wave I added. inspectAt, hoverAt, and selectAt
raycast from canvas pixels to world-km, resolve the semantic target,
and return a typed MapInteractionPayload (viewer/src/events/types.ts).
The legacy pick(screenX, screenY) helper wraps this resolver for
debug callers.
type MapEntityRef =
| { kind: "continent"; id: string }
| { kind: "realm"; id: string }
| { kind: "province"; id: string }
| { kind: "location"; id: string }
| { kind: "water"; id: string }
| { kind: "route"; id: string }
| { kind: "feature"; id: string }
| { kind: "terrain-cell"; id: string };
interface MapInteractionPayload {
state: "hover" | "selected";
target: MapEntityRef | null;
worldKm: [number, number] | null;
screenPx: [number, number] | null;
displayName: string | null;
ancestry: MapInteractionAncestryNode[];
sourceProductKeys: string[];
inspector: MapInteractionInspector;
cursor: "default" | "pointer" | "crosshair";
clearReason?: MapInteractionClearReason;
}
Location and province selection update the persistent SelectionMask
texture. Routes, water, features, and labels receive renderer-owned hover
or selected emphasis without creating UI-owned scene state. Terrain-cell
inspection exposes tile coordinates, cell row/column/index, sampled
height and slope, water class, base/effective biome and material weights,
base/effective forest/wetland fields, and influence fields derived from
generated influence products. Base forestMask and wetlandMask remain
preserved generated products; the viewer samples their runtime tile sidecars
for inspection instead of fetching the full-resolution root rasters during
default load, while terrain rendering continues to consume the derived
effective tile products. Influence contributes effective
visual/material fields rather than mutating the source
biome/material/forest/wetland truth.
Label System
Stage 8 emits labelAnchors.json. Each label carries text,
entityKind, entityId, point, priority, minZoomBand,
maxZoomBand, and category (viewer/src/data/types.ts). Zoom
bands are continent, realm, province, location, and close. The viewer
owns screen-space collision avoidance, fade ramps, and stable per-frame
placement; the generator owns text and anchor positions.
Valenar WorldData Export
Stage 15 (stages/valenar_worlddata.rs) projects mapv10 truth into a
Valenar-shaped pair: valenar/world-<seed>.json (schema
valenar-world-v1, format: "valenar_world", format_version: 1) and
valenar/world-<seed>.mesh.json (schema valenar-world-mesh-v1,
format: "valenar_world_mesh", format_version: 1). A single
content_hash covers the gameplay JSON plus the mesh-manifest inputs
and is identical across both products
(generator/src/pipeline.rs).
The export carries regions, areas, provinces, Locations, anchors, normalized Location facts, and symmetric neighbour links derived from the political, neighbour, height, slope, water, biome, and route products. The native mapv10 artifact set (manifests, rasters, tile pyramid, meshes, previews, validation reports) stays intact; the Valenar export is a projection lane, not a replacement.
For the field-by-field schema, unit ranges, content-hash construction, and validator reference, see docs/export-contract.md.
Governance And Verification
Process is enforced separately from architecture: the four-step wave
protocol (Explore -> Implement -> Validate -> Tenets) lives in
wave-protocol.md, visual regression scenarios
and per-mode performance budgets in scenarios.md,
and extension procedures in extending.md.
The current local verification surface is:
- viewer package:
npm run typecheck,npm run build,npm run test,npm run scenarios,npm run verify:browser:continent,npm run verify:browser:baseline,npm run verify:browser:all, andnpm run validate:valenar-export; - generator crate:
cargo test --release; - workspace:
dotnet buildfor the C# solution.
npm run fixture:continent is the fresh-clone bootstrap for the ignored
canonical fixture. It regenerates viewer/public/continent-lod6, then
validates the run-local Valenar export. Browser MCP/manual screenshots remain
preferred wave evidence; when unavailable, verify:browser:continent is the
approved Playwright substitute only under the constraints in
wave-protocol.md.