Skip to main content

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.md for orientation and reading order.
  • architecture.md for system boundaries and contracts.
  • docs/generator.md for generator stages and product ownership.
  • docs/viewer.md for the renderer, streaming, cache, shader, UI, and browser proof contracts.
  • docs/export-contract.md for the Valenar projection lane.
  • scenarios.md for visual/performance evidence.
  • wave-protocol.md for the mandatory change process.
  • roadmap.md and next-work.md for 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).

PresetWorld km (W x H)Raster (W x H)Target Locations
province-slice96 x 64128 x 965-10
regional-slice250 x 180384 x 28890-180
realm-slice720 x 520768 x 560320-1 200
continent2 400 x 1 8002 048 x 1 5366 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.

#KeySourcePrimary Products
0configstages/config.rsconfig.json
1continentstages/continent.rscontinentPolygons.json, coastlines.json, seaRegions.json
2geography-graphstages/geography_graph.rsridgeGraph.json, basins.json
3heightfieldstages/heightfield/mod.rsheight.f32.bin, slope.f32.bin, normal.rg16.bin, sediment.f32.bin, flowAccumulation.f32.bin
4waterstages/water.rsriverGraph.json, riverCenterlines.json, lakePolygons.json, waterMask.u8.bin, riverWidth.f32.bin
5biomes-materialsstages/biomes_materials.rsbiome.u8.bin, materialWeights.rgba8.bin, materialWeightsB.rgba8.bin, forestMask.u8.bin, wetlandMask.u8.bin
6politicalstages/political.rsrealmPolygons.json, provincePolygons.json, locationPolygons.json, provinceId.u32.bin, locationId.u32.bin, neighborGraph.json, provinceColorSeeds.json
7routesstages/routes.rslocationConnections.json, roadNetwork.json, routeGraph.json, routeCenterlines.json, crossingAnchors.json
8map-featuresstages/map_features.rsmapFeatureAnchors.json, mapFeatureFootprints.json, labelAnchors.json
9influencestages/influence.rsinfluence/influenceTypes.json, influence/influenceSources.json, influence/influenceRules.json, influence/influenceMask.corruption.u8.bin, influence/influenceTypeMask.u8.bin, effective/effective*.bin
10borders-sdfstages/borders_sdf.rsborderSdf.rgba8.bin (1+JFA SDF + nearest-province-id, RGBA8)
11tile-pyramidstages/tile_pyramid.rstiles/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
12meshesstages/meshes.rsmeshes/mesh-manifest.json, meshes/terrain-meshes.json, meshes/water-meshes.json, meshes/route-ribbons.json
13readinessstages/readiness.rsreadiness/readiness-report.json, readiness/continent-preset-draft.config.json
14previewsstages/previews.rspreviews/*.png contact sheet
15valenar-worlddatastages/valenar_worlddata.rsvalenar/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 fileRole
manifest.schema.jsontop-level run manifest
config.schema.jsonseed + scale preset + bounds
stage-index.schema.jsonper-stage product/preview/validation index
stage-manifest.schema.jsonindividual stage manifest shape
raster-product.schema.jsontyped binary raster header
tile-product.schema.jsontile family manifests
mesh-product.schema.jsonmesh family manifests + asset refs
readiness-report.schema.jsonbudget / precision / streaming report
semantic-display-policy.schema.jsongenerated semantic zoom/content policy
influence-types.schema.json, influence-sources.schema.json, influence-rules.schema.jsongenerated influence registry, source, and rule products
valenar-world.schema.jsonValenar gameplay export
valenar-world-mesh.schema.jsonValenar 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):

zPhysical bandSemantic bandTiles X x YSample stepClose detail scale
0continentcontinent1 x 1321
1macro-regionrealm4 x 3161
2realmrealm8 x 681
3province-clusterprovince16 x 1241
4provinceprovince32 x 2421
5location-detaillocation64 x 4811
6close-detailclose128 x 9618
7settlement-detailclose256 x 19218

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-pass
  • mapv10-water-pass
  • mapv10-route-pass
  • mapv10-overlay-pass
  • mapv10-marker-pass
  • mapv10-label-pass
  • mapv10-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, and npm run validate:valenar-export;
  • generator crate: cargo test --release;
  • workspace: dotnet build for 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.