4. JS client — modules & exported APIs

client/ (ES modules, no bundler; index.html imports src/main.js as a module). PixiJS v8.19.0 is pinned to its CDN ESM build and imported only by the Pixi render module worker. The worker uses the native async Application.init({preference:"webgl"}) lifecycle after receiving the sole visible transferred canvas. The production backend bundle creates the existing orthographic semantic camera and asynchronous Pixi worker host for live play, Lab, spectators, and replays. Match owns the only animation-frame loop.

index.html        # PINNED — #app + module entry + screens markup; no main-thread Pixi script
styles.css        # HUD, lobby, menus, command card
lobby_map_selector.css # Custom lobby map picker layout and responsive preview popover
live_pause.css    # live-match pause overlay and actions
assets/decals/    # SVG decal source art plus generated worker-decodable PNG mask atlas
src/
  protocol.js     # PINNED — message tag constants + builder helpers (mirror of §2)
  config.js       # PINNED — stable public facade for render/UI constants and balance mirrors
  config/         # timing.js, presentation.js, rules_mirror.js, factions.js
  net.js          # Net: WebSocket wrapper, typed send helpers, event emitter
  snapshot_stream_net.js # Offline static snapshot-frame clock using Net's normal decoder/events
  stress_test.js # App-owned shareable Hellhole benchmark lifecycle and result UI
  stress_test_profile.js # Pure JS Self-Profiling trace analysis and SVG flame graph rendering
  stress_test_launch.js # Bounded /stress-test route/query parser
  report_window_aggregate.js # bounded rolling-window aggregation helper for telemetry reports
  prediction_controller.js # PredictionController: local command sequence/buffer bookkeeping
  prediction_runtime_startup.js # WASM runtime readiness and latest-snapshot catch-up
  prediction_compatibility.js # server/client prediction-build compatibility guard
  prediction_frame.js # allowlisted composition of sparse owned pose/progress prediction patches
  prediction_settings.js # localStorage-backed prediction toggle
  unit_range_settings.js # localStorage-backed selected-unit range overlay toggle
  health_bar_settings.js # localStorage-backed always-show HP-bar toggle (damaged-only by default)
  sim_wasm_adapter.js # optional WASM prediction adapter
  state.js        # GameState: holds prev+current snapshot, selection, control groups, display overlays
  state_runtime_reset.js # shared clearing of state derived from one authoritative timeline
  state_auto_build.js # authoritative Auto-Build snapshot normalization and command acknowledgement
  state_ground_decals.js # authoritative ground-decal revision/cache, normalization, repaint queue, building-footprint sizing
  match_ground_decal_sync.js # snapshot-revision repair requests, retry coalescing, seek/view reset
  client_intent.js # ClientIntent: browser-local placement, command targeting, lab tools, previews, feedback
  command_interaction.js # shared Input/HUD/Minimap command issue-and-planned-order recording
  command_budget.js # client mirror of command-supply selection admission and outgoing command guard
  camera.js       # Camera: pan/zoom, world<->screen transforms, edge/keyboard/pointer-lock scroll
  auto_spectator.js # spectator/replay battle director: tick-paced combat clustering and camera framing
  spectator_controls_panel.js # floating live-spectator/replay camera and all-unit range controls
  match_auto_spectator.js # Match availability, camera-limit, and director-construction wiring
  renderer/       # Pixi app facade plus layers, terrain, entities, units, buildings,
                  # decals, resources, fog overlay, feedback, rig schema/import, and renderer-local palette helpers
  renderer/pixi_worker_host.js # sole main-thread Pixi surface: bounded queue, lifecycle, capture, diagnostics
  renderer/pixi_render_worker.js # sole Pixi runtime/Application owner and WebGL presentation task
  renderer/worker_environment.js # minimal OffscreenCanvas Pixi DOM adapter; no event/accessibility systems
  renderer/worker_rehydration.js # revisioned map/grid/decal worker-owned staging
  renderer/map_editor_worker_renderer.js # Map Editor Pixi display objects behind the same worker
  renderer/decals.js # GroundDecalLayer permanent decal texture, stamping, diagnostics, teardown
  renderer/tank_tread_layer.js # approximate authoritative history plus precise visible-tank tracks
  renderer/decals/ # SVG source manifest, generated PNG atlas metadata, worker-safe loader, deterministic selection
  renderer/trenches.js # Authoritative trench terrain pass and deterministic nearby-trench connectors
  renderer/feedback_view_model.js # Builder for renderer feedback's narrow per-frame read model
  renderer/lab_tool_preview.js # Armed Lab unit/remove-tool cursor ghosts
  renderer/lab_ruler_overlay.js # Persistent Lab tile ruler geometry and screen-readable labels
  renderer/observer_map_analysis.js # Observer-only static AI map-analysis world overlay drawer
  fog.js          # Fog overlay: apply authoritative visible/explored grids; local stamping fallback
  input/          # lifecycle facade plus selection, commands, placement, shared camera navigation, UI input routing
  audio.js        # Audio: Web Audio context, buses, one-shots
  audio_spatial.js # renderer-neutral distance, pan, low-pass, and priority profiles
  sound_manifest.js # Stable sound ids and asset URLs
  hud.js          # HUD: resources/supply bar, minimap status row, selected panel, command card
  hud_command_card.js # Command-card descriptors, faction command ids, and grid hotkeys
  hud_train_card_helpers.js # Train/research command-card slotting, affordability, and one-unit limits
  hud_selection_panel.js # Selected-unit strip/details panel
  hud_unit_commands.js # Unit tactical command descriptors
  hotkey_profiles.js # Local hotkey presets, custom profile storage, import/export
  hotkey_editor.js # Settings Hotkeys tab editor
  tab_menu.js # hold-Tab Auto-Build controls with optimistic state through authoritative acknowledgement
  chat_overlay.js # App-owned docked lobby chat plus game direct-text overlay
  resource_icons.js # Shared DOM resource icon helpers for HUD and observer analysis
  minimap.js      # Minimap: draw terrain+entities+viewport; click to move camera/command
  minimap_road_layer.js # Cached post-fog dotted road-marking overlay
  minimap_forest_layer.js # Cached pre-fog foliage-centered tree-symbol overlay
  lobby.js        # Lobby screen controller: browser polling, joins, ready/start, host controls
  lobby_map_selector.js # Host map picker: minimap previews, creator credits, keyboard navigation
  lobby_browser_view.js # Pre-join lobby browser rows, state rendering, and age/status formatting
  lobby_view.js   # Lobby roster renderer: stacked teams, compact seat rows, spectators
  match_history.js # Lobby match-history table and replay launch affordance
  scoreboard.js   # Shared score/result formatting helpers
  status_badge.js # Compact network/frame status badge
  ai_diagnostics_panel.js # dedicated live/replay AI decision diagnostics panel
  observer_analysis_army_value.js # viewport combat-unit value calculation
  observer_analysis_overlay.js # replay/live spectator analysis overlay
  observer_analysis_preferences.js # persisted observer analysis tab/visibility/window preferences
  observer_analysis_research.js # completed-research tab renderer for observer analysis
  observer_analysis_resources.js # resources tab renderer and wire normalization for observer analysis
  observer_analysis_rows.js # observer analysis player row metadata joiner
  floating_panel_positioner.js # shared app-shell move-only panel interaction and placement
  observer_analysis_signatures.js # dirty-body signatures for observer analysis DOM updates
  match_observer_diagnostics.js # Match-owned observer/AI diagnostics surface composer
  client_perf_report.js # bounded client frame-profiler upload field shaping
  match_health.js # match network/render health reporter
  frame_profiler.js # bounded client frame phase profiler and debug summary API
  live_pause_overlay.js # live-match pause state overlay and unpause affordance
  branch_staging.js # replay branch staging panel
  lab_catalog.js # LabCatalogScreen: app-owned `/lab` setup/blank selector
  interact_bridge.js # InteractBridge: launch-gated narrow local automation facade
  interact_game_bridge.js # Isolated normal-match inspection/move/surrender/tab-menu automation facade
  clean_presentation.js # app-shell reversible DOM chrome mode for Interact capture
  lab_client.js  # LabClient: lab request ids, pending results, state/result subscriptions
  lab_scenario_authoring.js # pure lab setup metadata defaults, slugging, and local validation
  lab_scenario_authoring_flow.js # LabPanel setup validation and JSON authoring orchestration
  lab_panel.js   # LabPanel: app-owned lab controls/status UI mounted around Match
  lab_tool_detail.js # Pure armed-tool instruction text for LabPanel status
  lab_panel_window.js # draggable/resizable chrome helper for the app-owned LabPanel
  lab_control_policy.js # Lab control collaborator placeholder injected into Match
  control_policy_projection.js # frozen read-only ownership/command-surface projection
  visual_profiles.js # Lab-scoped visual experimentation profile registry and resolver
  settings_container.js # Reusable settings shell: opener, tabs, focus, teardown
  settings_panels.js # Portable settings tab panel descriptors
  main.js         # Entry point: starts App
  app.js          # Lobby/app shell lifecycle and persistent Net/Audio ownership
  launch_url.js   # Namespaced rtsLaunch URL parsing and pure lobby automation decisions
  map_editor_app.js # Dedicated `/map-editor` lifecycle; never constructs Net, Match, or GameState
  map_editor_launch.js # Bounded editor route/handoff query parsing
  map_editor_handoff.js # Short-lived HTTP map handoff create/consume client
  map_preview_app.js # Capture-only authored-map route composed around existing renderers
  map_preview_bridge.js # Bounded minimap-only PNG capture surface
  map_editor_session.js # Flat authored-map state, undo/redo, and stroke transactions
  map_authoring/ # Pure browser/Node geometry, symmetry, and serializable map operations shared by the editor and CLI
  map_editor_panel.js # Dedicated editor controls for maps, terrain, start/base locations, JSON files, and Lab launch
  map_editor_panel_workflow.js # Pure editor category/operation availability and current-tool labels
  map_editor_viewport.js # detached editor-presentation assembly plus editor-only pointer/keyboard input
  map_editor_presentation.js # cloneable terrain/overlay/camera record consumed by the Pixi owner
  match.js        # Match lifecycle, module dependency wiring, render loop, transient events
  match_startup_inbox.js # semantic buffering while asynchronous Match construction completes
  match_combat_audio.js # Match-owned combat sound routing and machine-gunner sound cleanup
  match_notice_presenter.js # Match-owned existing-notice fanout and under-attack incident admission
  match_live_pause.js # live pause state actions and prediction visual suspension
  match_net_reporter.js # Match ping cadence and client net-report upload collaborator
  match_settings_context.js # Match settings action/tab context builder
  frame_recovery.js # Frame-loop soft-failure logging and rescheduling diagnostics
  visual_clock.js # Render-only normal/capture clocks; never used for networking, health, input, or timeouts
  frame_entity_views.js # One-RAF entity view builder shared by render, fog, HUD, minimap, analysis
  presentation/    # Frozen semantic layers, cloneable revisioned grids/projection data, static map, and frame assembly
  replay_controls.js # Capability-driven RoomTimeControls plus replay-only vision/branch controls
  replay_seek_notice.js # Shared replay-seek direction/duration notice formatting
  replay_seek_overlay.js # Dismissible center-screen authoritative seek progress notice
  room_time_panel.js # Floating, draggable chrome around shared room-time controls
  room_capabilities.js # Client-side room capability parser for controls/diagnostics affordances
  alerts.js       # Notice/toast alert ids and viewport alert behavior constants
  bootstrap.js    # DOM lookup, ws/dev-watch/lab launch config, startup helpers

/map-editor is a separate frozen client session. main.js constructs MapEditorApp for that route, so it never opens a WebSocket or constructs App, Match, GameState, Lab controls, fog, resources, orders, replay controls, or a simulation clock. It reuses the normal worker-owned Pixi Renderer, terrain cache, Camera, map schema, and player palette, but MapEditorViewport never constructs Pixi or reaches into renderer layers/application state. Input, hit math, session edits, and camera stay on the main thread; MapEditorPresentationV2 carries revisioned terrain replacement/patch data, complete authoring-layer visibility, and detached grid, symmetry, start/base, selection, label, and paint-preview records. The main-thread adapter owns the stable transferred HTML canvas while the same Pixi module worker owns display objects, ordered resize, present, and teardown. The removed map-editor.html implementation and Lab-embedded editor are not compatibility routes.

4.1 Module export contracts

net.js

export class Net {
  constructor(url, diagnostics?)         // ws url; auto-derived from location in main.js
  connect(): Promise<void>
  on(type, handler)                      // type ∈ ServerMessage tags + "open"/"close"
  off(type, handler)
  join(name, room, spectator?, replayOk?)
  setName(name)                          // update own display name while waiting in the lobby
  ready(isReady)
  start()
  setTeamPreset(preset)                  // deprecated compatibility command; server ignores it
  setTeam(id, teamId)                    // host-only scripted lobby team assignment
  setFaction(factionId)
  addAi(teamId?, aiProfileId?)           // AI profile id
  setAiProfile(id, aiProfileId)          // AI profile id
  removeAi(id)
  setSpectator(spectator, id?)
  chatSend(channel, text)
  command(cmd, clientSeq)                // lower-level sequenced gameplay command envelope
  giveUp()
  pauseGame()
  unpauseGame()
  returnToLobby()
  ping()
  netReport(report) -> bool                // true only when WebSocket.send accepted the upload
  createSnapshotReportStats()
  consumeSnapshotReportStats()
  noteSnapshotFrame({bytes, parseMs, decodeMs, snapshotCodec, snapshotCodecVersion, frameKind})
  setRoomTimeSpeed(speed)                // room-controlled replay/speed-only live/dev-scenario/lab time
  stepRoomTime()                         // paused dev-scenario/lab room time
  seekRoomTime(ticksBack)                // room-controlled replay/lab time; pass huge N for full reset
  seekRoomTimeTo(tick)
  setVisionSelection(selection)
  lab(requestId, op)                     // lab rooms only; request id allocated by LabClient
  requestBranchFromTick()
  claimBranchSeat(playerId)
  releaseBranchSeat(playerId)
  startBranch()
  selectMap(map)                         // host map selection; capacity limits Add AI, spectator return, and the optional empty team target while preserving reassignment for same-team two-player lobbies
  get playerId()
  get bufferedAmount()
}

Net isolates each subscriber during dispatch. The first occurrence of a stable failure signature (event type truncated to 64 characters, local weakly-held handler id, and Error or NonError thrown-value class) is reported with console.error("[rts-net] subscriber failure", detail) and is optionally mirrored to diagnostics. Each instance retains at most 32 signatures; the next distinct failure emits one fixed saturation report and all later new signatures are suppressed. Reports never inspect thrown-value properties or message payloads, survive reconnects for the lifetime of the Net, and cannot prevent delivery to later subscribers even if console or diagnostics throws.

App owns one ChatOverlay for the lifetime of the socket and moves it between the joined-lobby chat column and game overlay context. Lobby chat keeps an all-chat composer visible, retains at most 50 messages only in the current browser while it remains in that room, and clears on room change; the server neither persists nor includes lobby messages in replays. Replay branch staging keeps lobby-scope chat in the floating, bounded, fading presentation because the ordinary lobby screen and its chat dock are hidden. In game, Enter opens a transparent single-line composer over the current screen; Enter sends and closes it, Escape cancels, and Tab cycles team/all only when the local active player has at least one teammate. Singleton-team 1v1/FFA players and spectators use all-chat. While the composer is focused, its capture listener consumes those keys and marks the match input surface interactive so gameplay hotkeys, the hold-Tab menu, camera input, and desktop cursor recapture do not compete with typing. Reliable game deliveries render with textContent, keep at most six visible lines, and fade after eight wall-clock seconds. Replay context is read-only; replay seeks clear the transient lines before tick-timed messages resume.

prediction_controller.js

export class PredictionController {
  constructor({sendCommand, predictor?, enabled, now?, commandTimeoutMs?, uiConfirmationSnapshots?})
  issueCommand(cmd)                      // allocates clientSeq, records pending, calls sendCommand(cmd, seq)
  applyAuthoritativeSnapshot(snapshot, {allowStale?}?)
                                         // consumes snapshot.netStatus sim-consumption ack metadata
  applySimAcknowledgement(clientSeq, serverTick?)
  recordSocketReceipt(clientSeq, detail?)// diagnostic only; idempotent for duplicate receipts; does not reconcile
  recordCommandRejection(clientSeq, reason?)
  recordAckSnapshotApplied(clientSeq, snapshotReceivedAt)
  enterPredicting(), beginResync(correction?), finishResync()
  predictionDisplayOverlay()             // view data for optimistic production/rally display only
  reset({enabled?, preserveClientSeq?, reason?})
  debugSummary()                         // pending count/seqs, latest authoritative tick, ack/correction metrics
  consumeCommandReportStats(now?)
  peekCommandReportStats(now?)
  get pendingCommandCount()
}

Match composes one CommandInteraction from its command issuer, ClientIntent, and current selected-entity snapshot. Input, HUD, and Minimap receive the same interaction and call issueCommand(cmd, options?); it issues once and records a synchronous accepted result once for planned-order feedback. Promise-returning Lab issue-as commands stay non-optimistic and record no planned order. The controller owns browser-local clientSeq allocation and passes the sequenced envelope to Net.command(cmd, clientSeq). Replay viewers, spectators, and dev-watch passive viewers keep prediction disabled and do not allocate gameplay command sequence ids. GameState.applySnapshot remains authoritative. The partial WASM predictor returns a sparse PredictionFrame whose entity patches may identify an existing locally owned entity and claim only predicted position, body facing, and an optional explicit motion presentation. It never returns an EntityView, cannot create or replace entity identity, and cannot claim health, gameplay activity, weapon facing, gathering, combat, ability, or future snapshot fields. A missing motion claim preserves the authoritative gameplay state; a motion claim is admitted only when a modeled pending local command owns that presentation transition. The compositor maps that claim onto the frame-local display entity’s move/idle state for existing renderer and HUD consumers; it never mutates the buffered authoritative entity, and no baseline-derived activity may produce the claim.

The same frame carries a separate progress array whose patches contain only an existing owned building id, construction/production kind, active-item identity, and a normalized fraction. The WASM runtime derives those claims from the latest authoritative owner baseline and Rust-authoritative durations, samples the render clock at a fractional tick for smooth bars, and caps claims below completion until the server confirms completion. The client admits a patch only while the current authoritative building still has the same active identity and activity signal; a patch cannot create an entity or alter queue, health, state, economy, or supply.

prediction_frame.js is the sole allowlisted compositor for both patch types. Rendering, current-state UI reads, and entityById() start from the authoritative entity and apply the same compositor; they never spread or substitute a predicted entity record. Prediction display writes go through GameState.applyPredictionDisplayOverlay({optimisticCommands?, predictionFrame?, diagnostics?, smoothCorrections?, includePose?}), so controller bookkeeping and prediction patches stay outside broad snapshot mutation. Progress is composed before train/rally optimism. Live pause clears pose but retains the last frozen progress frame until the first post-unpause authoritative snapshot rebases the WASM clock. The progress runtime is available in compatible active live-player matches independently of the Movement Prediction preference and supported-pose faction list; replay and spectator views do not predict progress. If WASM is loading or unavailable, authoritative progress is the fail-safe rather than a second JavaScript calculator.

renderer/rigs/schema.js

export const RIG_SCHEMA_VERSION = 1
export const REQUIRED_ANCHORS = ["origin", "selection", "hp"]
export const TINT_SLOTS = [
  "team", "team-light", "team-light-soft", "team-light-strong", "team-light-08",
  "team-light-10", "team-light-14", "team-light-24", "team-stroke",
  "team-fill-stroke", "neutral", "fixed",
]
export const GEOMETRY_TYPES = ["rect", "circle", "ellipse", "line", "polygon", "polyline", "path"]
export const ANIMATION_INPUTS = [
  "now", "teamColor", "recoilProgress", "recoilPx", "recoilKickX", "recoilKickY",
  "setupVisual", "vehicleMotion", "selected", "damaged", "shotRevealAlpha",
  "visibility", "mapTileSize", "facing", "weaponFacing", "weaponFacingCos",
  "weaponFacingSin", "weaponVisualFacing", "carriageVisualFacing",
  "weaponVisualDoubleCos", "weaponVisualDoubleSin", "weaponRecoilX", "weaponRecoilY",
  "scoutGunnerX", "scoutGunnerY", "scoutMountX", "scoutMountY", "setupVisible",
  "setupMostlyDeployed", "setupBarrelVisible", "busy", "breakthroughTicks",
  "lowOil", "oilStarved", "fuelCueVisible",
]
export const ANIMATION_PROPERTIES = [
  "transform.x", "transform.y", "transform.rotation", "transform.scaleX", "transform.scaleY",
  "transform.localX", "transform.localY", "geometry.scaleX", "geometry.scaleY",
  "alpha", "visible", "tintSlot",
]
export function validateRigDefinition(definition, options?)
  // Pure validator. Returns { ok: true, definition, errors: [] } or { ok: false, errors }.
  // options.expectedKind rejects rigs whose kind does not match the importer/runtime caller.

Normalized rig definitions are plain objects with id, kind, schemaVersion, ordered parts, semantic anchors, semantic bounds, optional animations, and requiredRuntimeInputs. Parts use stable ids, integer drawOrder, normalized primitive geometry, local transform, pivot, one tint slot, and optional normalized paint {fill, stroke, strokeWidth, opacity} for SVG-authored literal colors. The validator is independent of Pixi and SVG DOM APIs; it fails closed with path-addressed structured errors for missing required anchors, duplicate part ids, unsupported geometry or transforms, non-finite coordinates, invalid tint slots or paint, invalid animation bindings, and unit-kind mismatches.

renderer/rigs/svg_importer.js

export function compileSvgRig(svgText, metadata?)
  // Pure SVG authoring importer. Returns validated normalized rig data or structured errors.
  // metadata.id/kind may override the authored id/kind; metadata.expectedKind enforces callers.

The SVG importer accepts only the Phase 3 authored rig subset: root <svg> with viewBox, data-rts-rig-kind, data-rts-rig-version="1", and data-rts-origin="center"; geometry elements g, path, polygon, polyline, rect, circle, ellipse, line, and metadata; direct hex fill/stroke, numeric stroke-width/opacity, data-rts-tint, data-rts-pivot, and semicolon-separated data-rts-animation bindings. Part ids use part.*, anchors use anchor.*, and bounds use bounds.*; required anchors remain origin, selection, and hp, with weapon fixtures adding semantic anchors such as muzzle, bipod, or turret. The importer rejects scripts, foreign objects, images/use/external hrefs, filters, masks, clip paths, gradients, patterns, CSS classes or style attributes, percentage units, duplicate ids, lowercase or unsupported path commands, non-finite values, and transforms that cannot decompose into translate/rotate/scale.

renderer/rigs/animation.js

export function createRigRenderContext(entity, options?)
export function sampleRigAnimation(definition, entity, renderContext?)

Rig animation sampling is pure data math: it derives a narrow render context from existing client entity state and renderer-local visual state, then applies normalized animation bindings to part transforms, alpha, visibility, and tint slots without creating Pixi objects. The sampler accepts only the schema-approved runtime inputs such as facing, weaponFacing, recoilProgress, setupVisual, vehicleMotion, Scout Car gunner/mount offsets, selected/damaged flags, shot-reveal alpha, map tile size, worker busy state, breakthrough ticks, and oil cue flags.

renderer/rigs/runtime.js

export function createDefaultPixiFactory(pixi?)
export function createUnitRigInstance(kind, definition, pixiFactory?)
export function renderLiveUnitRig(renderer, entity, colorByOwner, state, definition, options?)
export class UnitRigInstance {
  update(entity, renderContext)
  destroy()
}

UnitRigInstance owns one Pixi container and one graphics child per normalized rig part, redraws primitive geometry with sampled transforms and tint slots, and tears down all owned children through destroy(). Live rig routing is per-kind through _liveRigDefinitionsByKind. Worker, Golem, Command Car, and Ekat still compile authored SVG sources as raster metadata and fallback layers. Command Car renders its body from a production PNG atlas while preserving SVG effects; Rifleman, loaded Panzerfaust, Machine Gunner, Anti-Tank Gun, Mortar Team, Artillery, Scout Car, Scout Plane, and Tank use raster-native normalized metadata plus their production PNG frame strip or atlas. Missing rig definitions and missing production PNG textures fail closed; raster units never fall back to a duplicate SVG depiction. Shadow and body parts route through separate live pools so normal unit and shot-reveal layer ordering stays intact.

Local lab visual profiles may supply per-entity unit rig overrides to Renderer.render through visualUnitOverrides. The renderer resolves those rules against the current frame’s real unit entities with local-only selectors, validates candidate ids through the checked-in renderer/rigs/visual_override_rigs.js registry, and then passes the candidate rig definition through the same renderLiveUnitRig runtime path. The checked-in registry is currently empty; the generic compiler remains available for local experiments on SVG-authored units. Overrides never change entity.kind, snapshots, selection ids, command targeting, HP bars, fog, minimap inputs, or scenario authoring data; broken selectors or candidate rigs publish local diagnostics and fall back to the normal live rig for that unit.

SVG unit art workflow:

Raster rig workflow:

renderer/feedback_view_model.js

export function buildRendererFeedbackView(state, options?)
  // Per-frame read model builder. Returns placement, command feedback,
  // selected entities, resource mining previews, support-weapon previews,
  // ability target previews, ability objects, smokes, transient projectile/
  // target markers, relationship helpers, and entity lookup for renderer
  // feedback drawing without exposing the full mutable GameState. `options`
  // may inject frame-local entities and selectedEntities arrays.

state_ground_decals.js

export class GroundDecalBuffer {
  applyAuthoritativeBatch(message, context)
  reconcilePending()                    // stage shared pre-assembly batch used by Match
  acknowledgeReconciled()               // release it after a successful backend frame
  consumePending()
  requeueAuthoritative()                // repaint retained records after renderer reset
  get pendingCount()
  clear()
}
export function normalizeAuthoritativeGroundDecal(record, context?)

Snapshots advertise the requesting perspective’s groundDecalRevision and repeat a fog-safe, 64-revision groundDecalDelta { afterRevision, decals } tail. GroundDecalSync applies a contiguous or overlapping tail immediately; stable ids deduplicate repeats, and the buffer retains pending rows across skipped or failed presentation frames. A forward gap may queue the entitled rows for immediate display but cannot advance the complete cache cursor. In that case GroundDecalSync coalesces the mismatch into one reliable requestGroundDecals { requestId, afterRevision } repair and retries with bounded backoff. A later covering snapshot tail cancels an obsolete outstanding repair. The echoed repair request id rejects delayed responses from a prior replay tick or vision perspective. Replay seeks and observer-view changes clear both the cache and painted presentation, block possibly pre-reset inline tails until a correlated repair establishes the replacement authority, then resynchronize from revision zero. Transient combat events still drive short-lived effects and audio, but are not used to create permanent decals.

renderer/decals.js

export const GROUND_DECAL_TEXTURE_WORLD_SCALE
export class GroundDecalLayer {
  resetForMap(map)
  stampBatch(decals, options?)
  displayObjectCount()
  diagnostics()
  destroy()
}

GroundDecalLayer owns one downsampled OffscreenCanvas-backed Pixi texture and one sprite on the decals world layer. SVG files remain source art; scripts/generate-ground-decal-atlas.mjs creates the checked-in ground-decals-v1.png plus deterministic rect metadata, and runtime loads that one PNG through fetch/createImageBitmap. New visible death and impact decals stamp from those rects; an unavailable or failed atlas remains a reported blocking asset error and never silently chooses a procedural replacement. Historical decals are retained as normalized records in GameState for rehydration but are not retained display objects or iterated during ordinary frames. diagnostics() exposes total stamped decals, queued decals, texture update count, texture dimensions/downsample, child count, and asset-load status for stress checks. The renderer tears down the decal sprite, texture, canvas, tint scratch canvas, loaded atlas masks, and late async asset loads through Renderer.destroy() / rematch cleanup.

renderer/tank_tread_layer.js

export class TankTreadLayer {
  resetForMap(map)
  stampVisibleTankPoses(entities)
  stampAuthoritativeTrails(trails)
  revealAuthoritativeTrails(fog)
  diagnostics()
  destroy()
}

TankTreadLayer paints approximate checkpointed trail history learned through the normal durable ground-mark discovery cursor. It derives precise live tracks for every tank currently present in the client’s fog-filtered entity snapshots, using its x/y/facing poses; those client-retained pixels are presentation state rather than historical server authority. Reconnects, replay seeks, and later visits to previously fogged ground rebuild a coarser but directionally representative version from server trail chunks. Received chunks are cached as geometry first: each render pass permanently stamps only the 32-world-pixel map tiles that current authoritative fog marks visible, using a hard canvas clip so the reveal ends exactly at tile boundaries. A chunk can therefore be present in client memory before all of its pixels are eligible to appear. The exact set of client-stamped tiles is presentation state and is not separately checkpointed by the server. Pending historical geometry is reconsidered only when fog visibility changes or a new chunk arrives, and each stamped tile bucket is discarded immediately instead of being rescanned for the rest of the match. Pixels persist in lazily allocated 512-world-pixel tiles (256×256 texels); only touched tiles upload, and all tile canvases/textures/sprites are destroyed with the ground-decal layer. Preview contact poses are accumulated between bounded 10 Hz uploads; the swept-contact raster fills the complete intervening motion. Tread pigment is deliberately faint, and every live or later-discovered trail excludes road-tile pixels so paved surfaces never retain tank tracks. Historical road-only tile buckets are discarded before rasterization; live cross-boundary stamps clear their road area.

renderer/trenches.js

export function normalizedTrenches(trenches, tileSize?)
export function _drawTrenches(state)

_drawTrenches renders the latest authoritative state.trenches into one persistent Pixi Graphics object on the trenches world layer. It clears and redraws that object each frame, skips malformed records, and draws deterministic connectors between nearby trench footprints so clustered neutral trenches read as continuous ground without allocating per-trench display objects. Renderer.destroy() removes and destroys the trench graphics object during rematch teardown.

branch_staging.js

export class BranchStaging {
  constructor(rootEl, net)
  show()
  hide()
  destroy()
  render(msg)                            // branchStaging payload
}

observer_analysis_overlay.js

export const OBSERVER_ANALYSIS_TABS
shouldMountObserverAnalysisOverlay({ capabilities })
createObserverAnalysisOverlayPreferences(storage?)
export class ObserverAnalysisOverlay {
  constructor({ root, preferences, getEntities, getCameraBounds, getPlayers, stats })
  applyObserverAnalysis(payload)            // renders server-backed production, research, unit, and losses tabs
  update(frameViews?)                     // refreshes viewport army value from camera/snapshot state
  destroy()
}

App owns one observer analysis preference object and passes it through replay and live spectator Match lifetimes so selected tab, visible state, and collapsed state survive in-place replay seeks and spectator rematches. Preferences are stored under rts.observerAnalysisOverlay; clients still read the old rts.replayAnalysisOverlay key for compatibility. Its titlebar is draggable, keyboard-nudgeable, viewport-clamped, and retains its desktop placement through replay seeks and spectator rematches; Home restores the default placement. Coarse-pointer layouts also support viewport-clamped titlebar dragging without writing their temporary placement over the saved desktop position. Analysis buttons activate directly on touch release and suppress the synthesized compatibility click. The overlay owns its generated DOM and is read-only. The Army Value tab is client-side and viewport-specific, excludes economy workers (Engineer and Golem), and counts only combat units. Production, Research, Units, Resources, Alive Resources, Units Lost, and Resources Lost render the latest server-authored observerAnalysis payload. Alive Resources subtracts dead-unit steel/oil value from lifetime mined resources; because starting resources are not lifetime mined income, the derived value can be negative. Research groups completed permanent upgrades by player, retaining an explicit empty row when a player has completed none. Resources Lost follows the protocol’s narrow definition: spent steel/oil value of units that died, excluding buildings, stockpile changes, harvesting, refunds, and cancelled queues.

ai_diagnostics_panel.js

shouldMountAiDiagnosticsPanel({ capabilities, players })
createAiDiagnosticsPanelPreferences(storage?)
export class AiDiagnosticsPanel {
  constructor({ root, preferences, getPlayers, onMapLayerVisibilityChange })
  applyObserverAnalysis(payload)          // renders optional per-player aiDiagnostics trace rows
  mapLayerVisibility()                    // current map-analysis overlay layer switches
  destroy()
}

Match mounts the AI diagnostics panel beside the observer analysis overlay only when the room advertises observer-analysis diagnostics and the start roster contains at least one isAi participant. The panel consumes the same server-authored observerAnalysis payload, but normalizes and renders aiDiagnostics separately so high-churn AI trace lines do not dirty-update the general replay/spectator analysis tabs. It owns its generated DOM, persists visible/collapsed/selected-AI state plus map-analysis layer toggles under rts.aiDiagnosticsPanel, uses the shared lab-panel window chrome for drag, resize, collapse, keyboard nudge, and viewport clamping, and renders one tab per AI diagnostics row with profile id, trace tick, status metrics, and bounded decision trace lines for AI-vs-AI debugging. When observerAnalysis.mapAnalysis is present, the panel also shows region/choke/base/resource/label switches that drive the passive world overlay without writing to GameState, command targeting, selection, prediction, or fog. match_observer_diagnostics.js composes both observer surfaces for Match, forwards observerAnalysis messages to each mounted panel, retains the latest optional map-analysis payload for renderer consumption, updates the viewport-dependent observer analysis frame surface, and centralizes teardown.

renderer/observer_map_analysis.js

_drawObserverMapAnalysisOverlay(model, { camera })

The renderer draws server-provided observer map-analysis primitives on a dedicated Graphics/Text pair mounted below ordinary command feedback but above fog. It supports tile-rect region fills, choke segment bands, base/resource/approach markers, labels, and per-layer visibility from the AI diagnostics panel. The overlay is observer-only visual state and does not contribute to hit testing, entity pools, minimap blips, local fog sources, or game state.

frame_entity_views.js

buildFrameEntityViews(state, { alpha }) // frozen SharedFrameContextV1 outer record with frame-local entity arrays
state.entityVariants(alpha)             // render/current/authoritative arrays in one source traversal

frame_recovery.js builds this object once per requestAnimationFrame after prediction display has advanced and before fog, renderer, HUD, minimap, and observer analysis run. The object is not authoritative state and must not be retained after the frame; it exists only to share common entity variants and selectedEntities() results across frame consumers. Production GameState builds the render-alpha predicted view, alpha-1 predicted view, and alpha-1 authoritative view in one _cur.entities traversal; GameState-compatible test doubles retain the truthful entitiesInterpolated() fallback. interpolatedEntities uses the render alpha and prediction display for the Pixi renderer, currentEntities uses the latest predicted display positions for minimap blips and HUD tech checks, authoritativeEntities uses latest no-prediction positions for local fog-source filtering and observer Army Value rows, and fogSourceEntities removes shot-reveal/vision-only entries plus non-vision neutral resources.

presentation/entity_snapshot.js prepares an aligned, frame-local entry for each interpolated entity. Each entry contains the complete detached/frozen selection interaction and a narrower admitted presentation sidecar certified against the renderer schema during the same graph walk. Presentation and selection consume entries by array position, never by id, so duplicate order is preserved. The sidecar and source reference die with the frame; only the interaction may outlive it through the last successfully published selection scene. Standalone callers and remembered buildings keep their legacy detachment fallback.

presentation/layers.js, presentation/grid_snapshot.js, and presentation/frame.js

PRESENTATION_LAYER_DESCRIPTORS       // exact frozen back-to-front semantic descriptors
createGridSnapshot(input)            // source-detached cloneable revisioned Uint8Array record
new PresentationFrameAssembler({map, generation?, entityStats?})
assembler.staticMap                  // StaticMapPresentationV2, rebuilt only on map revision/reset
assembler.assemble(inputs)           // one structured-cloneable frozen PresentationFrameV2
assembler.reset({map, generation?})  // replay/Lab/rematch generation reset seam

frame_recovery.js updates authoritative fog before it assembles this sidecar. It samples one projection, one visual time, one renderer feedback view, and one observer/screen-overlay model for the frame; the same projection drives SelectionSceneV1. Match then makes exactly one renderer.render(presentationFrame) call. PixiWorkerPresentationAdapter copies and transfers the revisioned map/grid/durable lifetimes and keeps one in-flight plus one latest pending frame. The worker-owned PixiPresentationAdapter reconstructs only its ratcheted frame-local compatibility facade, updates Pixi-owned staging, and times that work separately from exactly one actual app.render(). The outer main-thread match.renderer phase covers submission only; worker update and present timings are returned in the acknowledgment and exposed in worker diagnostics. The Match-owned network reporter samples those lifecycle diagnostics, uploads new terminal failures immediately, and includes in-flight age plus bounded WebGL/Pixi/error context in periodic reports so deployed black-screen incidents are recoverable from server logs. The Match-owned PresentationCoordinator keeps pending selection/decal metadata keyed by generation and frame id. The worker reports durable decal retention independently, and only an asynchronous matching presented outcome advances the displayed counter or publishes selection; failed/superseded frames preserve the prior scene. The Pixi worker does not own an animation loop. The sidecar contains no mutable state/intent, selection proxy, mutable typed array, Pixi object, or transport record. Static terrain/resource locations are separately revisioned; visible/explored grids reuse opaque snapshots by revision. settings_container.js

export class SettingsContainer {
  constructor({ button, menu, title?, onOpenChange? })
  setContext({ kind, spectator, replay, actions, tabs }) // mounts context-specific tabs/actions
  setTabs(tabs)                         // [{id,label,visible,render(panel, context)}]
  open({ focus }), close({ restoreFocus }), toggle()
  isOpen()
  activateTab(id)
  destroy()
}

settings_panels.js

buildSettingsTabs({ audio, hotkeyProfiles, game, debug })
buildGiveUpAction({ visible, onOpen })
buildPauseAction({ visible, disabled, label, title, onPause })

match_cursor_capture.js owns the focused policy that pauses aggressive desktop cursor capture while Settings or the hold-Tab menu is interactive and schedules recapture after the last menu closes. Match remains the lifecycle owner and thin delegator.

live_pause_overlay.js

export class LivePauseOverlay {
  constructor({ root, settingsRoot?, onUnpause, onOpenSettings?, playerNameForId? })
  applyLivePauseState(state)
  destroy()
}

replay_controls.js

export class RoomTimeControls {
  constructor({ net, state, replayViewer?, capabilities, label? })
  applyRoomTimeState(state)
  noteSnapshotTick(tick)
  destroy()
}
export class ReplayControls extends RoomTimeControls

RoomTimeControls renders pause/resume, speed, step, relative seek, absolute timeline seek, tick status, and keyframe marks only from capabilities.roomTime. The AI-only live route advertises the speed-only room-time profile, so the same component renders no seek, step, or timeline affordance for those rooms. Paused replay viewers can advance exactly one simulation tick with the shared step control. Replay viewers also get a local Hide end time checkbox that removes the total tick count and absolute timeline for spoiler-free commentary while retaining the current tick and playback speed. Replay fog-perspective controls and the replay-branch button remain gated by replay-specific visibility/action capabilities, not by lab or URL identity.

The app shell listens for reliable roomTimeSeekStarted broadcasts throughout replay playback, clears replay timeline-derived state in place without reconstructing Match or its renderer, and shows every viewer a Seeking forward/backward X seconds… notice. Authoritative roomTimeState.seek metadata owns the continuing progress lifecycle. During that lifecycle, a dismissible center-screen Seeking notice in the title typeface distinguishes reconstruction from ordinary fast playback; each new seek shows it again. Sampled snapshots advance the existing timeline and growing keyframe marks until a state at the target omits seek.

Async Match.create startup uses semantic latest-only inbox slots for snapshots, room-time state, live-pause state, and observer analysis, plus a bounded ordered command-receipt queue. High-rate snapshot or observer-analysis traffic therefore cannot evict one-shot control authority while the renderer initializes. Room-time pending/awaiting state disables only clock actions; replay vision selection remains independent and usable.

The shared control surface is the dom.roomTimeControls root (#room-time-controls). Static pause/step controls use .room-time-pause-btn and .room-time-step-btn; generated room-time status and timeline markup use .room-time-tick-status and .room-time-timeline* selectors. The exported ReplayControls alias remains only for existing replay imports while composition should construct RoomTimeControls for any room that advertises room-time capabilities.

room_capabilities.js

createRoomCapabilities({ startPayload })

Match and app-shell controls consume this parsed startPayload.capabilities and startPayload.diagnostics record for room-time controls, diagnostic settings, observer analysis, vision-selection controls, live pause controls, replay branch actions, and read-only/gameplay command affordances. Product shells may still use product metadata for launch/routing and owned controls such as lab setup tools, but shared affordances must not be inferred from replay/dev/lab identity.

lab_client.js

export class LabClient {
  constructor(net, options?)
  setInitialState(state)
  subscribeState(handler)                // returns unsubscribe
  subscribeResult(handler)               // returns unsubscribe
  setVision(vision)                      // sends {op:"setVision", vision} for this operator only
  setPlayerGodMode(playerId, enabled)    // sends {op:"setPlayerGodMode", playerId, enabled}
  exportMap()                            // authoritative map-only editor transition payload
  exportScenario(name?)                  // compatibility wire name for checkpoint setup export
  importScenario(scenario)               // compatibility wire name for checkpoint/legacy setup import
  validateScenario(metadata)             // sends {op:"validateScenario", metadata}
  resetScenario()                        // seeks lab room time to the current setup baseline
  request(op, options?)                  // allocates requestId, resolves with labResult/timeout
  destroy()
}
export function labVisionLabel(vision)
export const labVision                   // all(), team(teamId)

lab_catalog.js

export function normalizeLabScenarioEntry(entry)
export class LabCatalogScreen {
  constructor({ root, fetchImpl?, initialRoom?, onStart })
  mount()                                // fetches GET /api/lab-scenarios and renders choices
  setConnected(connected)
  setStatus(status, options?)
}

LabCatalogScreen is app-owned and used only for the bare /lab route. It renders a blank lab row plus bundled checkpoint setup metadata from GET /api/lab-scenarios; clicking a row builds the existing hidden __lab__:<room>:map=<map>:scenario=<id> join room, replaces the catalog URL with the corresponding direct scenario URL, and lets App start the normal lab flow. Keeping the scenario in the visible URL makes catalog-launched blank and bundled Labs reloadable with Lab-scoped options such as visualProfile. Direct /lab?scenario=lategame, /lab?scenario=blank, map, and seed URLs still bypass the selector and auto-join for compatibility.

interact_bridge.js

export const INTERACT_BRIDGE_KEY = "__rtsInteract"
export class InteractBridge {
  status()                            // readiness or launch error; no internal references
  call(method, input)                 // status/catalog/spawn/update/remove/order/time/inspect/select/camera/reset/presentation/captureReadiness
  destroy()
}
export function interactLaunchEnabled(locationLike?)

App composes this bridge only when the /lab URL includes interact=lab. Its global surface is a frozen {version, status, call} object; it never returns App, Match, Net, Renderer, or GameState. Before readiness, server launch errors are projected as bounded status text so the local driver fails immediately instead of waiting for its startup deadline. Calls delegate through existing LabClient, normal issueCommandAs, room-time, semantic camera, and GameState projection seams. Catalog includes the mirrored command and ability ids; inspection can restrict results to the current camera viewport, while camera focus accepts bounded padding, defaults to a close 32-world-pixel frame for readable single-unit captures (and retains 48 world pixels for multi-subject and non-unit framing), and returns CameraSnapshotV1 plus semantic CSS viewport and ground bounds. Camera set accepts only CameraSnapshotV1; status, readiness, screenshot manifest v2, recording manifest v2, and fixed-capture manifest v2 carry the same versioned shape. The Lab bridge surface version is 6. select resolves up to 400 visible entities into browser-local GameState selection, accepts an empty list to clear it, and waits for two render frames without sending a gameplay or Lab mutation. Setup mutations wait for the server’s immediate authoritative snapshot without advancing paused simulation. Order calls also wait for a new snapshot and request one bounded tick when paused so the queued command is consumed before success. presentation calls the app-owned CleanPresentation helper, which hides only DOM chrome and never hides Pixi world layers; it is removed on capture completion, rematch, and app teardown. captureReadiness reports bounded live PNG/frame-strip/profile/decal asset status, font status, render frames, frame-loop errors, renderer errors, and subject missing-texture fallbacks without exposing renderer references. The local scripts/interact/driver.ts owns the selected-worktree server, headless browser, logs, clean viewport clipping, readiness wait, PNG/JSON artifacts, and profile cleanup. The bounded command service owns aliases and exact input contracts. Its per-worktree daemon preserves that state across machine-readable CLI calls, expires after 30 idle minutes, and returns screenshot paths and metadata without embedding image content. Portable setup export/import uses the bridge’s narrow exportSetup/importSetup methods and keeps checkpoint bytes out of CLI results. Replay bytes bypass the browser and normal WebSocket result through the capability-gated private-server handoff; the daemon writes only bounded artifacts and alias sidecars under target/interact/lab/. Media commands retain the session’s CSS viewport dimensions but temporarily raise the default capture density: DPR 4 for PNG screenshots and DPR 2 for real-time recordings, fixed-step videos, and sampled time lapses. An explicit media viewport DPR overrides that default, and every capture restores the prior session viewport when it settles. Visual delivery is deliberately not owned by that per-worktree lifecycle. Before returning a Tailnet URL, the daemon validates the artifact and copies it into the machine-level tailnet-preview service on stable port 8091. The preview server has no idle timeout, and each copied artifact has at least 24 hours of retention, so Lab close/shutdown, idle expiry, and worktree removal do not invalidate issued links. A later publisher can restart the service and continue serving unexpired copies from its OS-temporary root. Operational aliases, inspection, selection, camera focus, screenshot subjects, and corresponding bridge entity-id inputs accept at most 400 references. Screenshot readiness validates the complete requested subject set, but returned and persisted subject detail is capped at 24 rows with total and truncated metadata; recording and fixed-capture alias detail is similarly capped at 40 rows. Successful bulk spawn and artifact-import responses default to counts, a truncated flag, and at most 12 ordered detail rows. Callers may explicitly request details: true when they need every spawned entity/raw outcome or every restored and stale alias row; rejection details remain complete and actionable regardless of that success-response option. The daemon publishes its startup checkout commit as optional IPC v1 state/probe metadata. The CLI refreshes a mismatched daemon only through an atomic idle-only shutdown request; active scenes are preserved behind daemonCheckoutMismatch, while status and shutdown remain usable. Real-time recording uses raw Chrome DevTools screencast events as its composition cadence and takes non-overlapping physical-resolution PNG source frames from the persistent page. It assigns the newest source to cumulative 30 FPS monotonic-wall-clock slots before streaming H.264. Its manifest records raw event/timestamp gaps and exact source-frame reuse; it warns below 80% source-slot coverage. record-start can also resume authoritative time atomically after the initial physical frame through its bounded resumeSpeed. Fixed capture likewise streams up to 1,800 rendered PNG buffers into H.264, retains at most six representative PNGs, and keeps per-frame ticks/hashes in the manifest instead of the CLI response.

interact_game_bridge.js

export class InteractGameBridge {
  status()                            // isolated-match readiness and bounded semantic UI state
  call(method, input)                 // status/inspect/select/move/giveUp/tabMenu/time/camera/presentation/captureReadiness
  destroy()
}
export function interactGameLaunchEnabled(locationLike?)

App composes this bridge only for a root rtsLaunch=match URL whose room begins interact-game-, role is player or spectator, and interact=game. The CLI creates either one local human plus one AI or exactly two AI seats through ordinary lobby automation. The bridge observes only the recipient’s normal fog-filtered GameState, projects a fixed semantic UI schema (HUD resources, timer, selection, command-card labels, give-up dialog, and score screen), and never returns internal object references. Its only gameplay command is move, which validates 1–100 unique visible locally owned unit ids plus an in-map destination and delegates to the normal Match.commandIssuer. giveUp delegates to Match.requestGiveUp and waits for the ordinary score screen. It exposes no arbitrary protocol command, DOM selector, browser evaluation, attack, production, economy, or ability surface. Player sessions may hold/release and exercise the authoritative Auto-Build tab menu for deterministic UI inspection; pause and resource-floor changes use the normal gameplay command path and synchronize from acknowledged snapshots. Spectators cannot call move, give-up, or tab-menu controls; only AI-only room speed control is exposed for sampled time-lapse capture. Camera overview disables the automatic spectator director and fits authoritative map bounds. Game screenshots and recordings default to normal presentation and accept full-viewport, live-minimap, or bounded custom regions; clean presentation is an explicit opt-in. The bridge surface version is 4. Its shared game/dev-scenario select method replaces browser-local selection with up to 400 entities from the recipient’s normal fog-filtered snapshot and waits for two render frames. It is available to players, AI-vs-AI spectators, and dev-scenario observers, including an empty selection for clearing, and never changes authoritative simulation state.

lab_scenario_authoring.js

export const LAB_SCENARIO_AUTHORING_LIMITS
export function createLabScenarioAuthoringState(options?)
export function slugifyLabScenario(value)
export function validateLabScenarioAuthoringState(state)
export function labScenarioPreviewLabel(preview)

lab_scenario_authoring.js is a pure UI helper for the app-owned lab panel. It owns checkpoint setup authoring field defaults, slug generation, client-side metadata limits that mirror the server catalog limits, comma-separated tag parsing, and local blocking errors before the panel sends a server dry-run validation request.

lab_scenario_authoring_flow.js

export function updateLabScenarioTitle(panel, value)
export function captureLabScenarioAuthoringFields(panel)
export function renderLabScenarioOptions(panel)
export function validateLabScenario(panel)

lab_scenario_authoring_flow.js is the LabPanel-owned UI helper for checkpoint setup dry-run validation, JSON preview rendering, and local setup import/export controls.

lab_panel.js

export function labSpawnFactionOptions()
export function labSpawnUnitKindsForFaction(factionId)
export function labBuildingSpawnFactionOptions()
export function labSpawnBuildingKindsForFaction(factionId)
export class LabPanel {
  constructor({ root, labClient, launch, startPayload, match?, onEditMap? })
  applyLabToolChange(change)             // syncs active/cancelled tool status from Match callbacks
  armSpawnPaletteTool(kind?)             // arms a Match-owned completed spawnEntity world-click tool
  armBuildingSpawnPaletteTool(kind?)     // arms a Match-owned completed building spawnEntity tool
  cancelActiveTool()
  validateScenario(), exportScenario(), importScenario()
  saveLabReplay(), openLabReplay()        // distinct replay affordances; not legacy scenario ops
  destroy()
}

map_editor_session.js

export const MAP_EDITOR_HISTORY_LIMIT    // 25
export class MapEditorSession {
  initializeBlank(options?)
  initializeFromScenario(scenario, options?)
  loadAuthoredMap(source, options?)
  mutate(label, mutation), undo(), redo()
  beginTerrainStroke(label?), paintTerrainTiles(tiles, terrain), commitTerrainStroke()
  beginOverlayStroke(label?), paintOverlayTiles(tiles, edit), commitOverlayStroke()
  materialized(), exportMap()
  mapOverlay()
}

map_authoring/ is the single implementation of authoring geometry, symmetry, advisory symmetry checking, authored-map limits, layer vocabulary/classification, and recipe materialization. Browser pointer gestures and CLI recipes are separate adapters over its pure ESM operations; the package does not access the DOM, Pixi, Node filesystem APIs, or simulation state. UI-only input/history and CLI-only file/argument handling stay outside it.

LabPanel renders separate floating, collapsible Options and Tools windows. Options owns room status, lab vision, command-limit policy, setup authoring metadata, validation, setup import/export/reset, and result status; Tools owns target player, player state, spawn palettes, active tool status, and the remove setup tool. Vision presents one Full button plus one button per team; Full requests the authoritative union of every current team’s fog, and Lab exposes no omniscient/no-fog control. The supported author workflow starts at /lab: choose a bundled catalog setup or blank setup, edit authoritative state with lab tools, fill in setup name/title/slug/description/tags, run validation, and export the setup JSON locally. Bundled setups remain build-time assets; the browser has no server-persistent setup or map write API. The lab replay controls are visually separate from setup checkpoint JSON controls; replay save/open uses the bounded lab replay artifact path instead of the legacy exportScenario and importScenario lab operations. Lab now exposes one Edit map action. It requests the current authoritative map-only payload, creates a server-validated editor handoff, and navigates away; no Lab entity, resource, order, timeline, or replay state crosses that boundary.

MapEditorApp owns the dedicated editor. Its persistent document bar owns map identity, undo/redo, the Map settings and Layers toggles, camera framing/zoom, local authored-map JSON import/export, authoritative check, preview, and Lab handoff. The document bar reserves the top strip: movable editor panels are constrained below it when dragged, restored, resized, or viewport-clamped. Map settings is an independently movable, collapsible, and resizable sheet for map source, details, resizing, and the advanced route report; opening it temporarily replaces the editing palette and operation rail. Layers remains a separate visibility-only floating panel. The palette is independently movable, collapsible, and resizable, with pinned Terrain, Objects, Zones, and Locations tabs above the sole scrolling content region and pinned current-tool/symmetry context below it. A separate operation rail exposes Brush, Box, Path, Place, Spray, Erase, Add, Move, and Remove; operations that cannot apply to the selected content remain visible but disabled with explanatory titles. The percentage in the document bar stays synchronized with wheel zoom. Map settings loads bundled JSON from /maps/catalog and /maps/<file>, creates configurable 16–256-tile-per-axis blank maps with a 126 × 126 default and separate width/height fields that follow the active draft, edits name/description plus flat start and base locations, and provides centered resize; the document bar owns undo/redo and local map JSON import/export. The editor accepts authored-map JSON up to 8 MiB and only materialized authored maps containing terrain; agent-authored recipes remain a scripts/map-author.mjs build CLI input and are not a Map Editor document type. Import normalization preserves authored terrain verbatim, including impassable terrain in a protected base footprint, so the advisory and authoritative checks can report the author’s actual input. Interactive rock/water painting is still rejected in protected footprints, and moving or adding a location makes its footprint passable. Resize preserves the existing tile cells without scaling them, fills newly exposed edges with grass, and shifts start/base locations with the centered source map. Authored v8 maps and materialized Lab handoffs carry explicit width and height; older authored schemas are rejected. Start locations set map player capacity; every base location is permanent and its authored resource counts spawn even when no player starts there. The selected starting or neutral base exposes integer Steel (0–36) and Oil (0–9) patch controls; new and migrated bases default to 12 Steel and 3 Oil. Editor drafts may temporarily contain zero start locations so authors can clear and rebuild the player layout. Editor-directed handoffs preserve that in-progress zero-start state without trying to create a simulation; Lab-directed handoffs still require a playable start/base layout. Adding symmetric starts reuses any base sites already present at the target locations. There is no active layout, player slot, or per-player natural assignment. The viewport draws blue start markers and neutral base markers over the shared Pixi terrain and owns editor-only pan/zoom/paint/site input. Terrain content supports brush and inclusive drag-box fills, plus none, horizontal, vertical, half-turn, four-way radial, or either single-diagonal symmetry; Grass remains an ordinary selectable material, while the separate Erase operation applies grass internally. Rectangular maps retain axis reflection and half-turn symmetry, while three-way, four-way, and diagonal transforms are disabled because they would rotate or transpose the map into a different shape. Symmetry expands every terrain tile before it is painted, moves existing matching start or base locations together, and adds all symmetric locations. The editor viewport also draws non-authoritative Steel and Oil stand-ins for every base using the same count, centre-facing field geometry, tile snapping, passability search, and Oil spacing rules as match setup and the production resource-node drawing primitives; editing either patch count updates those stand-ins immediately. The selected neutral base has a pale map ring. The viewport draws the selected centre axis, a centre marker for half-turn symmetry, a cross for radial symmetry, or the selected diagonal. Grass, Gravel A/B/C, Dirt A/B/C, Mud A/B/C, Frosted Ground, bare road, and the four marked road orientations are passable paint materials; all may cross protected start/base areas while rock and water remain rejected there. Authored map rows encode bare, horizontal-marked, vertical-marked, NW-SE diagonal-marked, and NE-SW diagonal-marked roads with =, -, |, \, and /, respectively. The ten visual Open-terrain variants use 0 through 9 in protocol-code order: Gravel A/B/C, Dirt A/B/C, Mud A/B/C, then Frosted Ground. The dedicated road tool drags a configurable-width road snapped to the eight cardinal/diagonal directions, paints bare-road shoulders, and chooses the yellow centre-mark orientation automatically. The same five road tile variants remain in the ordinary terrain palette for brush and box detail work. The selected symmetry runs the shared advisory checker against the current draft and renders any terrain, start/base, overlay, resource, or doodad mismatch directly below the selector. Three-way checks compare complete generated square-grid orbits, including rounded copies clipped by an edge; marked-road checks still require the transformed orientation. Editor status uses a separate bottom dock outside the scrolling palette; failures use a high-contrast alert treatment. A terrain pointer stroke clones once for undo, mutates rows in place, records dirty tiles, and commits once. The renderer patches those tiles plus their edge-sharing neighbours into the existing canvas texture and calls texture.source.update(); it does not recreate the canvas, fingerprint/serialize the map, or replace a Pixi texture per tile.

The Terrain palette exposes Forest as compound content with a configurable 1–31-tile brush; the separate operation rail applies it with Brush or removes it with Erase. Forest painting is the single source for its tree scatter and all five gameplay effects. The draft stores the exact tile area as compact inclusive [y, xStart, xEnd] row spans; generated trees use reserved deterministic ids, while ordinary doodads remain independently authored. Symmetry expands the forest stroke before the span mask is updated. Erasing removes both the semantic area and only the forest-owned trees. Authored schemas before v9 are rejected rather than migrated.

Generated-tree placement uses the visible foliage bounds shared with the doodad renderer, including the renderer’s deterministic size variation. Interior canopies must remain substantially within the painted mask. Perimeter trees are placed first so their foliage joins along exposed sides; bottom-edge tree roots sit in the immediately adjacent tile below the forest while their foliage terminates on the semantic boundary. The grounded root location is therefore not itself evidence that a tile is a forest tile.

The Gameplay overlays palette can select any combination of Concealment, No vehicles, No buildings, Damage reduction, and Slowed movement, then paint or erase the selected layers in one brush or box stroke. Forest is a separate composite authoring tile: materialization unions its spans into all five runtime layers, while the five effect tiles remain independently paintable. The viewport uses green, red, amber, blue, and purple respectively, with a closed eye, no-entry sign, crossed building, half shield, and mired boot on every affected tile. A single effect uses the full tile; overlapping effects subdivide into stable 2x2 or 3x2 icon cells so all five remain legible without hiding one another. Damage reduction and slowed movement each reduce their affected value by 25%; overlay strokes use the same brush/box, symmetry, undo/redo, resize, local JSON import/export, and Lab handoff paths as terrain. Sparse coordinate pairs remain authoritative.

The editor’s compact floating Layers panel independently toggles ten presentation-only authoring layers in a two-column grid: Terrain & bases, Forest, Concealment, No vehicles, No buildings, Damage reduction, Slowed movement, Trees, Gameplay doodads, and Decorative doodads. Full labels and descriptions remain available through accessible checkbox names and hover tooltips when narrow panel geometry truncates visible text. Tank Traps are gameplay doodads; wildflowers are decorative doodads. Visibility never mutates the draft, export, undo history, or handoff. The shared pure map_authoring/layers.js vocabulary also drives map-author.mjs preview --layers <csv>, whose SVG preview shows every layer by default and can isolate any comma-separated subset.

The doodad palette exposes oak, pine, spruce, alder, and Tank Traps. Tree species are independently toggleable visual variants. Place adds one selected doodad; spray continuously adds selected trees or chosen-tint wildflowers at 1–256 density, without a per-location tree-density limit. Repeating a spray adds more in-bounds doodads until the authored-map cap of 4,096 is reached. Erase continuously removes doodads in its brush. All trees share one mechanical tree semantic with a tiny authoritative trunk; wildflowers can be placed singly or sprayed with a chosen tint. Tank Traps snap to tile centres and materialize at match setup as completed owner-0 Tank Trap entities, so they use the live rendering, fog, combat, deconstruction, and vehicle-pathing behavior. Authored doodads cannot be picked up or moved; the erase brush removes them continuously. Symmetry applies when placing and erasing doodads, while undo/redo apply to all authored doodads. Individually placed trees retain only their tiny trunk collision; forest-owned trees receive their shared effects from the compact forest span mask. Trees do not change line of sight, cover, or combat damage, and wildflowers remain mechanically inert.

Open in Lab posts the authored map plus its flat materialized locations to /api/map-handoffs. The bounded server record expires after two minutes and is consumed once. Lab consumption creates a private Lab whose first start payload already contains the edited map at tick zero; returning through Edit map transfers only an authoritative exported map. The handoff itself carries the current map in either direction; the editor does not maintain a separate browser-storage workspace.

/map-preview is a launch-gated consumer of the same one-use, two-minute Lab handoff used by Open in Lab. It accepts no map data through query parameters or browser-evaluation calls. The server validates the authored and materialized maps, creates a private Lab, and sends an ordinary authoritative start payload and snapshot. The route then runs the normal App/Match and live Minimap, including server-materialized starting resource depots, neutral bases, resources, and doodads. Its visible page presents that captured production minimap; the world viewport is not a preview surface.

Its versioned bridge exposes only bounded square 64–4096-pixel minimap PNG captures, with at most 16,777,216 output pixels. It enables reveal-all vision and suppresses app chrome. Captures call Minimap.capturePng, which temporarily resizes the production minimap and omits only transient camera, ping, and artillery markers; terrain, resources, and authoritative entities remain. scripts/map-preview.mjs is the local Node/Chrome adapter: it validates and materializes an authored map with MapEditorSession, creates a bounded Lab handoff on a loopback RTS server, calls the narrow bridge under a browser-side deadline, validates the returned PNG dimensions, and writes the requested artifact. Its output extension may be PNG or JPEG; JPEG output is transcoded in the already-running preview browser after validating the authoritative PNG, with bounded --jpeg-quality. A 512×512 minimap export displayed at 256 CSS pixels is the lobby’s 2×/high-DPR preview convention. The Map Editor’s Preview action uses the same handoff and route, whose visible controls call the same bridge. The page owns no authoring operations or recipe semantics.

The editor’s Authoritative check and Route report actions post the current exported map to /api/map-authoring/check and /api/map-authoring/report. The panel summarizes validity and base counts or route/unreachable counts, while one collapsed <details> element exposes the complete JSON response as a single text node. scripts/map-author.mjs check|report <map.json> is the matching thin Node adapter over the authored-map Rust binary; neither adapter reimplements materialization or pathing. Each browser request is bound to the exact exported-map fingerprint, has a total 20-second deadline, and is aborted and cleared when the draft changes or the panel is destroyed; late responses cannot replace the result for a newer draft.

lab_panel_window.js owns local drag, resize, collapse/expand, reset, keyboard nudge, viewport-clamping, and localStorage geometry hints for those app-owned lab windows. It has no transport or match authority.

lab_control_policy.js

export function createLabControlPolicy({ labClient, metadata })
export function createDefaultControlPolicy()

App owns LabCatalogScreen before joining a lab and owns LabClient, LabPanel, and lab control policy lifetimes when a start payload carries lab metadata. Match receives labMetadata, labClient, and labControlPolicy through constructor options only; renderer, HUD, input, and minimap do not import lab modules. The shipped MVP exposes catalog/blank lab selection, per-operator lab vision, per-player asset god mode, setup mutations, issue-as commands, and setup checkpoint import/export through those collaborators while keeping the normal match screen authentic. Lab operator starts are still spectator-shaped for projection and prediction, and LabClient treats start.lab.vision plus labState.vision as the recipient’s server-authoritative choice; start.lab.godModePlayers plus labState.godModePlayers mirror room-scoped player god mode. Match wraps the injected policy in one frozen read-only projection used by selection, control groups, command budget, HUD/cards, Input, Minimap, renderer feedback/entities, combat audio, LabPanel research display, room-time controls, and shell visibility. GameState neither publishes nor discovers the policy. App separately injects LabPanel’s narrow mutable command-limit settings collaborator (ignoreCommandLimitsEnabled()/setIgnoreCommandLimits(enabled)), so mutable operator settings never enter the read-only projection. The projection exposes canUseCommandSurface(state) so Match and HUD can keep selection plus the real command card available for operators while read-only lab viewers, replay viewers, and normal spectators remain passive. Lab selection itself is not toggled by this control. Operator gameplay commands still flow through the shared CommandInteraction; its underlying command issuer delegates to LabControlPolicy, which wraps them as lab issueCommandAs requests for the single controllable selected owner and includes whether that command should bypass the normal command-supply limit. Mixed-owner lab selections remain command-blocked, but renderer inspection treats every selected operator-controllable owner as a feedback owner so all-team Lab overlays such as rally/order markers and selected support-weapon field-of-fire wedges can be compared across players. Every client surface that needs ownership semantics must read through the injected read-only policy projection: command-card resources/faction/upgrades, right-click enemy classification, control groups, renderer feedback ownership, rally/order overlays, range/setup previews, minimap commands, and combat audio categories. Raw state.playerId remains the local viewer id and is not the lab command owner.

Lab setup tools use ClientIntent.activeLabTool for browser-local armed tool state. LabPanel may ask Match.armLabTool(tool, { onWorldClick, onBoxSelection }) to arm a tool, and normal Input consumes a completed left world click before selection, command targeting, or placement. Tools that opt into paintOnDrag sample and interpolate each crossed map tile, delivering repeated world-click callbacks without disarming the tool or falling through to selection; unit spawning, building spawning, and draft terrain painting use that path. Other left drags normally promote to box selection and cancel the active lab tool, while tools that opt into consumeBoxSelection receive the selectable ids intersecting the real screen drag box instead. Box callbacks also receive the screen rectangle and, for diagnostics only, any available ground polygon plus conservative bounds. World-click callbacks receive the active tool payload, an exact nullable-ground world position (or the selected proxy anchor for an entity-only hit), and any selectable hit entity id. ClientIntent.labToolPreview tracks the armed tool at the world cursor for the renderer: unit and building spawn ghosts, the chosen terrain tile, and a large removal X make the pending action visible before a click. Match.cancelLabTool(reason) clears the tool and preview for Esc, right-click, teardown, ordinary box selection, or panel-driven cancellation. Window blur releases camera/input transient state but does not cancel the active lab tool. Input routes those cancellations through the injected lab tool controller so Match can publish an active/cancelled change back to the app-owned LabPanel, keeping the panel status and cancel affordance synchronized with keyboard, pointer, world-click, box-selection, and teardown paths. Starting ordinary placement, command targeting, or command-card build menus cancels the active lab tool so setup tools do not share state with gameplay command modes. Unit and building spawning are lab panel palettes backed by the client faction catalog mirror and playable faction labels; the unit palette excludes ability-created units such as the Command Car’s Scout Plane. Each palette arms a persistent completed spawnEntity lab tool and clicking or dragging sends the chosen world positions through LabClient until cancelled. Changing the target player while a spawn tool is armed immediately retargets that tool, including its cursor preview and subsequent placements, without requiring the operator to select the unit or building again. Target-player changes re-render the complete Tools surface from that player’s authoritative resources, god mode, and completed research instead of incrementally patching only the player colors. The lab does not expose a secondary advanced spawn fallback; the panel spawn affordance is limited to playable faction unit and building palettes. The visible map-editing surface is limited to the remove tool: it arms a persistent removeSelectableUnits setup tool; clicking deletes the selectable unit or building under the cursor, and dragging deletes selectable units and buildings in the box without changing the current selection.

hotkey_profiles.js

export class HotkeyProfileService {
  constructor({storage?, catalog?, profilesKey?, activeKey?})
  allProfiles()
  getActiveProfile()
  hasProfile(id)
  profileById(id)
  setActiveProfile(id)
  createCustomFromPreset(presetId, metadata?)
  saveCustomProfile(profile)
  validateDraftProfile(profile)
  runtimeDiagnostics(profile?)
  importProfile(payload, {targetId?, activate?}?)
  exportProfile(id?)
  exportProfileJson(id?)
  parseImportText(text, options?)
  resolveCard(card, profile?)
  resolveSlot(slot, profile?)
  hotkeyForCommand(commandId, profile?)
  hotkeyCodeForCommand(commandId, profile?)
  storedProfilePayload(profile)
}

buildHotkeyCommandCatalog(cards)
normalizeHotkeyCode(value)
hotkeyLabelForCode(value)
profileBindingForCommand(profile, commandId)
setProfileBindingForCommand(profile, commandId, code)

hotkey_editor.js

export function renderHotkeyEditor(root, hotkeyProfiles, context?)
export class HotkeyEditor {
  constructor(root, hotkeyProfiles, context?)
  render()
  destroy()
}

Hotkey profile schema v2 is physical-only: every binding value is a DOM KeyboardEvent.code such as KeyQ, never a layout-translated character from KeyboardEvent.key. Runtime activation, rebinding capture, held-target release, repeat handling, and conflict detection all use the physical code. Resolved command descriptors keep identity and presentation separate as hotkeyCode and the canonical QWERTY-position hotkey label; command buttons mirror those as data-hotkey-code and data-hotkey. Text entry remains layout-aware and never routes through gameplay bindings. The Map Editor’s WASD camera controls likewise use physical KeyW/KeyA/KeyS/KeyD positions.

Schema-v1 custom profiles and active-profile selections are intentionally reset rather than migrated: v2 uses new rts.hotkeyProfiles.v2 and rts.activeHotkeyProfile.v2 local-storage keys, and v1 imports are rejected. Exported hotkey JSON is intentionally client-local: schemaVersion, profileId, mode, name, description, createdWithBuild, basePreset, bindings, and factionBindings. Direct-mode bindings hold global commands such as unit.move, unit.attack, unit.holdPosition, unit.stop, worker.buildMenu, worker.return, support-weapon setup, and production cancel. Grid profiles compute command-card bindings from slots but store standalone HUD actions such as hud.selectIdleWorkers in bindings. Faction catalog actions are stored under factionBindings[factionId] with namespaced command ids shaped as kriegsia.build.<kind>, kriegsia.train.<kind>, kriegsia.research.<upgrade>, and kriegsia.ability.<ability>. Ekat uses the same ekat.* namespace for its exposed ability commands, currently ekat.ability.ekatTeleport, ekat.ability.ekatLineShot, and ekat.ability.ekatMagicAnchor. The always-active hud.selectIdleWorkers action appears in the HUD Shortcuts editor context and participates in conflict checks against every command-card context. Grid binds it to T; Classic RTS binds it to I because T is already assigned to training-centre, tank, and tank-research actions. Imports migrate old flat Kriegsia ids like build.resource_depot into the Kriegsia binding set, preserve structurally valid unavailable faction commands with warnings, ignore unknown non-faction commands with warnings, reject invalid keys and same-context duplicates, and store accepted payloads as custom profiles. Untargeted imports rewrite ids/names to avoid local collisions; targeted imports replace the whole target profile payload instead of merging individual bindings. When the Classic preset’s mnemonic key collides within a rendered context, conflict resolution prefers that command’s grid key before falling back alphabetically.

The long-lived SettingsContainer is constructed by App with #settings-button and the #settings-menu mount point. App mounts the lobby context; Match/ReplayViewer remount live, spectator, and replay contexts through dependency-injected collaborators. The stable rendered ids inside the settings mount point are #pointer-lock-toggle, #unit-range-toggle, #always-show-health-bars-toggle, #debug-path-toggle, and #give-up-open plus live-match action #live-pause-open; they may not exist until their owning tab/action is visible. Live controllable matches mount a separate #tab-menu-button hamburger and #tab-menu Auto-Build panel under #game-screen; the hamburger never replaces, moves, or aliases the Settings gear. Match owns LivePauseOverlay under #game-screen for reliable livePauseState messages; the overlay resolves pausedBy through the match roster, exposes direct Game-settings and Hotkeys-tab actions, and raises only #game-menu above its screen blocker while paused. Resume remains visible only when the server grants canUnpause, and the overlay is destroyed with the match. Once resume is accepted, the pause actions give way to the same large, spoken Drei! Zwei! Eins! presentation used before match start. The client begins at the server-reported remaining phase for a mid-countdown attach, while authoritative simulation stays paused until the server’s final unpaused state. When Windows exclusive fullscreen has enabled native cursor capture, the injected onOpenChange callback lets Match release capture while Settings is open and resume it after close. The hold-Tab menu uses the same match-owned policy so every interactive menu remains clickable while fullscreen is active.

state.js

export class GameState {
  playerId
  startInfo                              // §2.3 payload
  map                                    // {width,height,tileSize,terrain}
  players                                // [{id,teamId,name,color,startTileX,startTileY}]
  playerById(id)
  teamIdForPlayer(id)
  isOwnOwner(owner)
  isAllyOwner(owner)
  isEnemyOwner(owner)
  isNeutralOwner(owner)
  // snapshot buffering for interpolation:
  applySnapshot(msg)                     // pushes msg, keeps prev+current, stamps recvTime
  entitiesInterpolated(alpha)            // -> entities with lerped x,y,facing,weaponFacing
  get tick()                             // latest authoritative simulation tick; 0 before snapshots
  get prevRecvTime() / get currRecvTime()// recv timestamps of the two buffered snapshots
                                         //   (null until two exist); main.js derives interp alpha
  resources                             // {steel,oil,supplyUsed,supplyCap} (latest)
  events                                 // latest snapshot's events
  // selection (client-only):
  selection                              // Set<entityId>; playable own selections are admitted by command supply
  selectionBudgetOverflow               // null | {used, cap, seq}; short-lived HUD feedback after ignored overflow
  setSelection(ids), addToSelection(ids), clearSelection()
  selectedEntities()                     // resolved entity objects from current snapshot
  entityById(id)
  setProgressPredictionPaused(paused)    // exposes frozen/resumed WASM progress state to diagnostics
  // control groups (client-only):
  controlGroups                          // ten budget-admitted Array<entityId> slots; slot 9 maps to key 0
  setControlGroup(slot, ids), addToControlGroup(slot, ids)
  selectControlGroup(slot), controlGroupEntities(slot)
  setOptimisticCommandState(state)        // production/rally optimism display overlay
  setPredictionFrame(frame, diagnostics, options), clearPredictionFrame(), clearPredictionPose()
}

client_intent.js

export class ClientIntent {
  placement                              // null | { building, valid, tileX, tileY, lineSites? }
  commandCardMode                        // null | "workerBuild"
  openWorkerBuildMenu(), closeCommandCardMenu()
  beginPlacement(buildingKind), updatePlacement(tileX,tileY,valid,options?), endPlacement()
  commandTarget                          // null | "move" | "attack" | "setupAntiTankGuns" | ability target object
  beginCommandTarget(kind, options), issueCommandTarget(ev), endCommandTarget()
  holdCommandTarget(kind, key, shiftKey, options?), releaseCommandTargetKey(key, shiftKey)
  releaseCommandTargetShift()
  commandFeedback, addCommandFeedback(kind, x, y, append?, radiusTiles?, now?), liveCommandFeedback(now)
  resourceMiningPreview, updateResourceMiningPreview(preview)
  antiTankGunSetupPreview, updateAntiTankGunSetupPreview(preview)
  abilityTargetPreview, updateAbilityTargetPreview(preview)
  recordPlannedCommand(command, selectedEntities, result?)
  plannedOrderPlanForEntity(entity), entityWithPlannedOrder(entity)
  reconcilePlannedOrders(entities, options?), clearPlannedOrdersForUnits(ids), clearPlannedOrders()
  activeLabTool, labToolPreview, labRuler
  beginLabTool(tool), updateLabToolPreview(preview), cancelLabTool(reason?)
  updateLabRulerCursor(point), placeLabRulerPoint(point), clearLabRuler()
}

Client Boundary Migration Target

Match remains the app-shell composer and owner of cross-area dependency injection. It constructs GameState for authoritative snapshot display data and constructs ClientIntent for browser-local cursor/command intent, then injects the intent facade into HUD, input, minimap, and renderer feedback. Map-editor symmetry repair rolls back partial site relocation when no complete spawn slot can be preserved and tries alternate split slots before rejecting symmetry selection. The app shell owns DOM-presentation resize handling. Renderer teardown destroys renderer-owned adjusted canvas textures and clears its texture maps without destroying raw Pixi asset-cache textures; raster loads completing after teardown are not cached or displayed. Artillery landing audio is scheduled from the authoritative impact delay, and match teardown cancels pending landing timers. Mobile camera pan and pinch gestures track only touch identifiers whose touches begin on the viewport; HUD and off-viewport touches do not join those gestures. Minimap gestures use native Pointer Events with capture; touch and pen activate targets only on clean taps so inspection does not issue accidental commands, while desktop right-click and queued-order behavior is preserved. Camera instances own a per-session maximum zoom with fallback handling for invalid options. Lab live and replay sessions use an 8x maximum zoom, while non-Lab sessions retain the 2x cap; Lab initial-camera views are restored under that limit during normal match initialization. Room-time controls de-duplicate matching touch and pen activation while preserving unrelated click sources, retain server-confirmed state while requests are pending, and confirm time movement against the authoritative baseline and controller identity. Blocked, failed, or unconfirmed sends are exposed instead of applying optimistic selection. Read-only Lab viewers keep these controls inactive with an authorization-specific status. Coarse-pointer layouts arrange existing mobile debug chrome around safe areas, keep low-priority detail scrollable, and keep controls reachable. Floating room-time, spectator, and observer-analysis titlebars support bounded touch dragging there, while their mobile positions remain session-local so saved desktop placement is not overwritten. Desktop layout is unchanged, and no touch world-command behavior is added. Runtime modules should not gain direct imports across the model, input, UI, minimap, renderer, and prediction areas except for pinned mirrors such as protocol.js and config.js, or for explicitly documented architecture-check exceptions.

GameState is the authoritative browser view of server snapshots, interpolation, selected ids, control groups, relationship helpers, fog-facing visibility data, and display overlays derived from authoritative snapshots. ClientIntent owns placement intent, command-card submenu state, command-target arming, hover previews, command feedback, ability previews, and the short-lived local planned-order stages used only for previews while the server echo is pending. Smoke Plus world and minimap targeting feedback uses the mirrored cloud radius and duration effect fields. An unqueued local order replaces the stale authoritative plan when composing subsequent queued previews, and asynchronous Lab command results are not recorded as durable local plans. Contextual oil right-clicks compose a Pump Jack build intent on the clicked oil patch rather than a gather command. The worker build submenu leaves the retired top-middle W slot empty so removing depot-built extractors does not compact the remaining Engineer build hotkeys. Contextual Pump Jack placement snaps to the closest live oil patch within one map tile of the cursor before applying the normal footprint validation. Pump Jack construction remains legal outside the completed friendly Resource Depot/Zamok mining radius, while the normal resource-mining preview warns that the distant extractor will be inactive. Completed owned or allied Pump Jacks with inactive extraction show a red prohibited-sign badge above the building until a completed friendly mining anchor comes into range. If an owned or allied unit covers the patch, right-clicking that unit’s body still resolves to the live oil beneath its Pump Jack footprint. Advisory building placement ignores unit types whose client configuration marks them as non-ground placement blockers. The Scout Plane stays out of the shared ground vehicle-body classifier, so its body does not block build previews. Normal gameplay selection and control-group commands exclude it, while Lab and spectator inspection paths allow selecting and grouping it. Its generated Fw 189-style top-down PNG frame-strip rig is scaled to the mirrored aircraft body so the art, hit testing, and selection ring remain aligned. It uses the existing team-light tint pipeline with a darker, desaturated color target and has a render-preview visual profile for lab comparison. Normal contextual right-click hover refreshes ClientIntent.attackTargetPreview from the same rules used to compose orders. Visible Scout Planes are not classified as attackable client targets, so contextual right-click and explicit attack targeting route through valid move or attack-move commands instead of target attacks. Scout Plane command cards expose only retarget and dismiss, including the hidden dismiss ability; mixed selections keep land-unit commands scoped to land units. Renderer feedback draws a red ring around an enemy when the selected units would attack it; gather, build, deconstruct, and targeted-command modes suppress the preview. GameState must not grow compatibility accessors for those intent fields; HUD, input, minimap, and renderer feedback use the injected facade or a narrow read model. Lab Unit Spawn and Building Spawn panels expose the target player’s color through DOM data/style hooks before map placement. In Lab, visual and audio feedback for controlled-side selections and commands is issued as the selected controlled player instead of the raw local player id. Lab command-card and targeted-command policy resolves resources, faction catalogs, per-owner upgrades when present, prerequisites, production helpers, self-ability hover origins, and hostility from the selected issue-as owner rather than the spectator/viewer id. Lab Options, Lab Spawn, Lab Tools, and floating room-time panel headers preserve drag, collapse, and keyboard geometry handling without visible reset actions. The Lab Spawn panel owns entity setup, removal, target-player, and player-state controls. The separate Lab Tools panel owns the browser-local Ruler. While armed, its hover point displays tile coordinates and its first click starts a live Euclidean tile-distance segment; the second click fixes that segment, and the next click replaces it. Fixed ruler geometry remains in ClientIntent.labRuler when another Lab tool is armed and is removed only by a new ruler start, the Tools-panel Clear action, or match teardown.

Frame-local entity views belong to the app-shell frame loop, not to GameState. Rendering, local fog fallback, minimap blips, HUD selection/tech checks, renderer feedback, and observer Army Value should accept the injected frame view when called from the RAF path and fall back to GameState queries only for direct module tests or event handlers outside the frame. Static resource nodes with no remaining resources are omitted from frame-local entity views and minimap blips. Minimap artillery firing indicators render as 30x24 PNG-backed icon markup without an extra surrounding ring. Selected worker units do not draw weapon range indicators, even when their frame-local view exposes weapon range metadata. Entrenched units render as smaller, trench-tinted rig instances without a separate occupied-infantry trench ring in the selection layer. Trench ground decals render at half the authoritative trench radius; snapshot data and the gameplay radius remain unchanged. Frame-local entity views may carry bounded render diagnostics for local profiling consumers without changing the authoritative snapshot model. Server-authored, fog-discovered death and impact marks are normalized by GameState into deduped pending stamps and rendered below resources and fog as visual-only decals. Death decals use the generated PNG atlas only after readiness; unretained durable batches are retried rather than stamped before readiness. Decals do not affect simulation or balance.

Renderer feedback should consume a narrow read model containing placement, command feedback, support-weapon setup previews, ability targeting previews, ability objects, and selected entities, rather than relying on the full mutable GameState. Queued support-weapon setup previews use accepted move or attack-move order-plan endpoints as their field-of-fire origin, plus local pending move/setup stages when the command has been sent but no owner-only orderPlan echo has arrived; unqueued setup previews use the current support-weapon position. Minimap hover and click targeting feed support-weapon setup previews and commands from minimap world coordinates for Anti-Tank Guns, Mortar Teams, and Artillery. Anti-Tank Gun group setup interprets cursor distance from the selected guns’ centroid as a formation-facing control: clicks through 14 tiles converge on the literal point; from 14 to 20 tiles a smoothstep interpolation straightens the guns until their facings are parallel; from 20 to 25 tiles a second smoothstep opens a lateral-rank fan that reaches 90 degrees total (leftmost −45 degrees, median straight ahead, rightmost +45 degrees). The client submits the whole selection and literal click as one budgeted setup command; the authoritative simulation resolves individualized Anti-Tank Gun facing rays, while Mortar Teams and Artillery in a mixed selection retain the literal clicked setup point. Viewport and minimap previews mirror those same curves. The input router owns the active preview surface: while the minimap is hovered it suppresses viewport-derived attack, resource, ability, placement, and Lab-tool previews without hiding minimap-authored setup cones or issued-command feedback. HUD command-target arming preserves Shift, and the frame refresh reads live keyboard state so stationary minimap previews switch between current and queued origins as Shift changes. Armed Artillery Fire previews compute advisory per-artillery firing positions from the current gun origin or authoritative/local pending planned origin and the 10-to-35 tile range band. Out-of-range previews retain the literal center, draw the projected movement leg and future setup cone, and command feedback marks the clicked point while the server authoritatively validates and executes the movement/setup plan. Local planned setup/fire stages are reconciled on snapshots using the owner-only orderPlan and command acknowledgement metadata, and are cleared on deselection, Stop/Hold, replacement commands, rejected command receipts, and match teardown. If Tank Traps are re-enabled, their placement previews keep normal terrain, resource, building, and map-bounds checks, allow infantry overlap, and reject vehicle-body units. Their line dragging treats terrain, building, and map-bounds blockers as skipped sites, omits illegal build commands for those sites, and resumes on the far side; vehicle-body unit blockers still break the line. HUD and input should exchange command intent through descriptor/facade methods, while gameplay command emission continues to flow through commandIssuer.issueCommand. HUD selected-unit strip cells support direct selection refinement: left-click selects only that unit, Shift-click removes it from the selection, and Ctrl/Meta-click or control context-click filters the current selection to that unit kind. Unit command-card descriptors include Stop on S and Hold Position on W; Command Car selections also expose Breakthrough on E. In lab rooms, injected lab control policy owns selection, inspection, and single-owner issue-as routing without changing the client player id. PredictionController owns client sequence allocation and optimistic bookkeeping; GameState applies named display overlays but does not own prediction policy.

camera.js

export class Camera {
  // Semantic renderer-neutral API; all screen values are viewport-local CSS px.
  project(point) -> {x,y,depth,clip,visible}
  groundAtScreen(screen) -> {x,y}|null
  projectedExtent(point, worldW, worldH) -> {width,height,scaleX,scaleY,visible}
  viewportGroundPolygon() -> [{x,y}, ...]
  viewportGroundBounds() -> {minX,minY,maxX,maxY}|null
  containsProjected(point, marginCssPx?) -> boolean
  focusAt(point), framingForWorldPoints(points, options?), fitWorldPoints(points, options?)
  panByScreenDelta(delta), dollyBy(factor, anchorScreen?)
  resize(viewW, viewH), setMapBounds(worldW, worldH)
  snapshot(), restore(snapshotOrLegacy), projectionSnapshot()
  audioListener(), subscribe(listener) -> unsubscribe

  // Private orthographic compatibility used only by Camera and named Pixi adapters.
  x, y, zoom                             // world coords of viewport top-left, framing scale
  update(dt, input)                      // apply pan (keys/edge/virtual pointer-lock cursor) & clamp
  worldToScreen(wx, wy) -> {x,y}
  screenToWorld(sx, sy) -> {x,y}
  centerOn(wx, wy)
  setZoom(zoom, anchorSx?, anchorSy?), setBounds(worldW, worldH, viewW, viewH), setView(view)
}

auto_spectator.js

export class AutoSpectatorDirector {
  constructor({camera, state, enabled?})
  setEnabled(enabled)
  observeSnapshot(snapshot)              // ingest combat activity; routine decisions at most once per three simulated seconds
  update(dt)                             // advance the active smooth camera transition
  diagnostics(), destroy()
}

Replay viewers and ordinary live spectators receive a floating, draggable Spectator Controls panel with a default-off Follow active fights switch. Every spectator or replay session starts with the switch off; enabling it lasts only for the current session. The control is deliberately kept out of the gear-menu settings. Lab sessions do not mount the director. While enabled, the director retains three simulated seconds of attack, death, and positioned-impact activity, groups samples within ten tiles, and frames the highest-weight group; deaths count as four attacks, occupied impacts as two, and the current fight receives a small stickiness bonus. Empty artillery impacts are ignored. When combat activity expires, the director prefers a likely contact between opposing-team units: units already within 28 tiles qualify, as do movement tracks whose inferred closest approach comes within eight tiles over the next six simulated seconds. Nearby members of both formations are included in the shot, same-team pairs and scout planes are ignored, and worker-only contacts receive a ranking penalty. Combat and likely-contact shots show 50% more world span than their fitted framing. If no contact is plausible, quiet mode favors clusters of units and buildings whose authoritative order plan, rally, or production intent just changed. Runtime state transitions and moving attack-marker coordinates are ignored. These activity reframes preserve the current zoom and update at most once per simulated second. With no recent command activity, the camera preserves its focus and widens by 6% per second, finishing each small widening before beginning another. The local overview never widens past 70% of either map dimension, so a large display cannot turn it into a whole-map shot. Combat and contact decisions occur no more than once every 90 simulation ticks, while persistent quiet mode may reconsider command activity every 30 ticks. Any combat activity interrupts quiet mode immediately, and a newly dominant fight beyond the current viewport also bypasses the routine interval. Nearby reframes pan and zoom with a one-second smooth transition. Backward replay seeks clear future combat, command activity, and motion tracking before the rebuilt timeline is evaluated.

renderer/index.js (worker-owned; importing it from a main-thread production area is forbidden)

export class Renderer {
  static create(canvasParent, options)    // worker creates one WebGL PIXI.Application
  resize(w,h,dpr)
  buildStaticMap(map)                    // draw terrain once into a cached layer
  render(stateFacade, cameraFacade, fogFacade, alpha, options?) // Pixi-private engine seam
  present()                              // one synchronous app.render(); increments on success
  captureReadiness({subjectIds?, subjectKinds?}) // bounded visual asset/error state for Interact capture
  app                                    // the PIXI.Application (for ticker/stage if needed)
  // Pixi implementation used by PixiPresentationAdapter's screenOverlay reconciliation:
  drawSelectionBox(rectOrNull)
}

renderer/pixi_compatibility_adapter.js

export const PIXI_LEGACY_READ_ALLOWLIST // frozen ids plus concrete review triggers
export class PixiPresentationAdapter {
  constructor(canvasParent, sources, {renderer}) // worker injects its Renderer
  render(presentationFrame) -> PresentationSubmissionV1
  resize(w,h), enterFixedCapture(clock), exitFixedCapture(clock)
  captureReadiness(query), destroy()
}

PresentationSubmissionV1 carries the submitted generation/frame id, an asynchronous nullable retained outcome for the exact ground-decal revision, and an asynchronous terminal outcome from presented | superseded | failed | destroyed. Fixed capture awaits presented for its requested id and returns that public acknowledged id; it never reads a renderer-private counter.

Normal Match rendering uses this adapter. The direct Renderer surface remains Pixi-private and is reachable only from pixi_render_worker.js; MapEditorPixiPresentationAdapter is a narrow main-thread wrapper around the same worker host and submits detached editor records. Pixi uses autoStart:false. Fixed capture drives the ordinary host once per requested capture frame, awaits that exact presented id and same-task RGBA readback, and resumes one Match RAF; it never stops or restarts a ticker. Map Editor owns its separate RAF and submits one editor record exactly once after each editor scene/camera update. Both owners cancel their RAFs during teardown, and worker/renderer destruction is idempotent. No main-thread Pixi runtime, feature flag, synchronous renderer, hidden canvas, WebGPU path, or fallback exists.

fog.js

export class Fog {
  constructor(mapWidth, mapHeight, terrain?)
  update(ownEntities, tileSize, serverVisibleTiles?, serverExploredTiles?)
  isVisible(tileX,tileY), isExplored(tileX,tileY)
  setRevealAll(enabled)
  // renderer reads the grids to draw the black/dim overlay; minimap caches against revision
  visibleGrid, exploredGrid              // Uint8Array length w*h
  revision, visibleRevision, exploredRevision
}

match.js must exclude legacy/special visionOnly and shot-reveal entities from ownEntities before calling fog.update; those views are rendered as intel, not as local fog sources. Normal match snapshots provide visibleTiles and exploredTiles, so the overlay replaces both grids from the current authoritative perspective, including smoke blockers, five-second lingering death sight, and replayed exploration history. Local stamping and cumulative exploration remain a fallback for older/dev object snapshots. That fallback still rebuilds the complete visibility union on every rendered frame. It may reuse one entity’s previously computed tile stamp only while identity, kind, exact world position, tile size, sight, footprint, and terrain are unchanged; any changed input invalidates the stamp and performs the ordinary line-of-sight rays in the same frame. This is exact memoization, not a lower fog cadence or stale-state allowance.

The checked-in Hellhole client-performance stream follows the normal path: it is active Player 1’s fog-filtered projection from the canonical 1+3 versus 2+4 scenario and carries the full server-authored visibility grid. It must not be converted to a full-world spectator projection, which would benchmark local fallback ray casting instead of ordinary player presentation.

Playable own selections and human multi-unit commands use the mirrored command-supply budget from command_budget.js: 24 base command supply plus COMMAND_CAR_SUPPLY_CAP_BONUS = 20 and the Command Car’s own command weight per admitted Command Car, with unit supply as weight and a fallback weight of 1. Drag selection, shift-add, double-click same-kind selection, and control-group save/add/recall preserve their normal candidate ordering, except Command Cars in the candidate set are admitted first so their budget bonus is reliable. Overflow candidates are ignored client-side and surface selectionBudgetOverflow for the HUD; outgoing commands that still exceed the budget are blocked before Net.command.

input/index.js

export class Input {
  constructor(domElement, camera, state, commandIssuer, drawMarquee, fog, audio?, inputRouter?, hotkeyProfiles?, clientIntent?, labToolController?, desktopCursor?)
  // installs listeners; translates gestures into selection + protocol commands.
  // number keys recall control groups; double-tap jumps the camera to the largest
  // local cluster. Alt/Ctrl/Cmd+number replaces a group, Shift+number adds to it.
  // On Windows, browser saves use Alt+number, including browser fullscreen;
  // installed-app/standalone saves accept Alt/Ctrl/Cmd+number.
  // optional pointer-lock mode traps the browser cursor and drives a visible
  // virtual cursor for edge pan on multi-monitor setups. In the macOS Tauri
  // spike, the optional desktopCursor bridge replaces browser Pointer Lock
  // while keeping the same selection, command, HUD, minimap, wheel, and Escape
  // routing contracts. Match auto-requests that native bridge for Tauri matches
  // and retries on focus/visibility return. Native desktop cursor visuals are
  // painted directly from the native event handler and diagnostics expose backend,
  // native/JS event counts, dropped events, and delivery latency.
  update(dt)                             // continuous handling (edge scroll handled by camera)
  publishSelectionScene(scene)           // after a successful presented frame only
  // emits nothing to return; mutates state.selection / clientIntent and calls commandIssuer.issueCommand
}

input/camera_navigation.js

export class CameraNavigationInput {
  constructor(domElement, camera, options?)
  // shared command-free camera gesture state for live input and replay/observer wrappers:
  // viewport mouse tracking, mouse-wheel cursor-anchored zoom, configured pan keys,
  // middle-mouse drag panning, optional Space+left-drag panning, touch drag/pinch
  // pan/zoom for mobile viewing, blur release, and teardown.
  // exposes keys + mouse for Camera.update(dt, input)
  static replayPanKeyCodes()
  install()
  destroy()
}

Live Input composes CameraNavigationInput for camera gestures, then layers pointer lock, selection, placement, command-card targeting, command hotkeys, minimap routing, and gameplay command issuance on top. Replay viewers use the same helper through ReplayCameraInput, with replay WASD pan-key aliases and a composed browser-local observer selection surface, but no gameplay command issuer API. Replay spectators can click or box-select visible entities for selection rings and the read-only unit detail panel. Replay middle-drag and Space+left-drag pan through semantic Camera.panByScreenDelta({x,y}); touch drag/pinch pan and dolly without emitting gameplay commands. Wheel/pinch anchors and every drag delta are viewport-local CSS pixels, including under non-1 DPR. Mouse-wheel dolly, keyboard pan state, edge scroll state, and blur release are shared observer navigation behavior. The minimap draws Camera.viewportGroundPolygon() and recenters with Camera.focusAt(), so perspective ground footprints may be partial or empty without invented bounds. Live spectators still use the live Input path so read-only selection inspection remains available while command emission stays gated by local-owner checks.

Shift-right-click appends queued orders only for selected units: move, attack-move, attack, gather, build/resume, Tank Trap deconstruct, and placement build commands set queued: true and rely on the server snapshot’s owner-only orderPlan for accepted markers. Shift-clicking or Shift-hotkeying Hold Position appends its terminal hold stance instead of replacing those queued orders. Production-building-only right-clicks set or append building rally stages and rely on owner-only rallyPlan for accepted markers. Resource patches suppress rally command emission. The minimap applies the same resource-target classification against known live map resources. Attack targeting with only production buildings selected creates attackMove rally stages. Right-dragging selected units promotes to a freehand formation move after three tiles. While attack targeting is armed, the same three-tile gesture is available on left-drag: its polyline and body-aware provisional slots render red, and release issues one atomic formationMove command with attack-move semantics so the server assigns and admits the full group together. Shorter gestures retain the existing contextual right-click or targeted left-click behavior, including command-composer Shift/held-key lifetime. Selection, hover, and entity targeting use the last successfully presented detached SelectionSceneV1; move/ground abilities/placement use its nullable ground query. Screen clicks and marquees project plain mirrored proxies rather than reading current state or renderer geometry. Selection and targeting still use GameState relationship helpers where the distinction is own/ally/enemy: single-click may select an allied entity for read-only inspection, box selection and same-kind selection stay own-only, and right-clicking own or allied entities with own units selected falls through to ordinary move-to-point behavior instead of attack. Command emission, prediction, optimistic production/rally, control groups, build/gather/train/research/cancel, and ability execution remain strict local-owner checks. Shift-confirmed build placement keeps placement mode armed while Shift is physically held, allowing multiple queued building placements; releasing Shift or losing window focus clears placement mode. Tank Trap placement uses the same local placement intent, with optional lineSites preview data: the first valid sites dispatch as one immediate single-worker build per selected worker, and any remaining valid sites dispatch as queued standard build commands against the selected worker set. Line placement only offers vehicle-closing Tank Trap steps: exact diagonal adjacency (1,1) or one-tile orthogonal gaps (2,0) / (0,2). Invalid intermediate sites break the line instead of letting dispatch skip ahead across a larger gap. The renderer draws Tank Traps larger than their 1x1 build footprint so these sparse vehicle-blocking gaps read as closed barrier segments.

command_composer.js owns command-target arming lifetime for command-card targets. HUD, input, and minimap receive ClientIntent from Match; input and minimap clicks call ClientIntent.issueCommandTarget, so held keys, Shift preservation, and repeated queued target clicks use one composer path instead of command-specific sticky flags. A plain targeted-order command-card hotkey tap arms the target after keyup; pressing the same resolved hotkey again inside the quick-cast window issues it at the current cursor world point. Shift does the same with queued: true and keeps the target armed until Shift is released. World-point ability hotkeys follow the same tap contract: tapping and releasing the key keeps targeting armed until the first unqueued world click, while physically holding the key only extends targeting for repeated clicks. After an unqueued quick-cast consumes the armed target, the next near, still viewport left-click is ignored as an accidental confirmation click; moving far enough to become a drag restores normal selection.

input/router.js

export class MatchInputRouter {
  constructor(viewportEl)
  registerZone(zone)                     // zone: {priority?, previewSurface?, contains(ev), pointerDown?, pointerMove?, pointerUp?}
                                         // returns unregister()
  activePreviewSurface() -> string|null  // hovered/captured surface currently covering the viewport
  pointerDown(ev) -> boolean             // routes to highest-priority matching zone
  pointerMove(ev) -> boolean             // captured zone receives moves until release
  pointerUp(ev) -> boolean               // releases capture after the originating source handles up
  wheel(ev) -> boolean                   // routes locked/native wheel events to DOM surfaces before camera zoom
}

Router events carry viewportX/viewportY plus clientX/clientY; pointer-lock input and DOM input use the same zone contract so DOM overlays can work while the browser routes mouse events to the locked viewport. Match registers one game-screen DOM zone that ignores the viewport subtree; explicit zones such as the minimap keep higher priority, and any future interactive panel above the battlefield receives pointer/mouse/click/wheel events without being separately listed.

audio.js

export class Audio {
  preload(manifest): Promise<void>        // decode sounds once the AudioContext is unlocked
  unlockFromGesture(ev?) -> Promise<boolean>
                                          // create/resume AudioContext from a user gesture
  isUnlocked() -> boolean                 // true when the AudioContext is running
  onUnlockChange(fn) -> unsubscribe       // notify settings UI after first successful unlock
  play(id, {x?, y?, directionalOnly?, priority?, category?, pitchVariance?, gain?, duck?, key?, loop?, fadeInMs?})
                                           // x/y spatialize; directionalOnly omits distance gain
  playUI(id, opts)                        // non-spatial ui category convenience
  stopByKey(key, {fadeOutMs?}?) -> number // stop or fade tagged sustained/abortable voices
  setVoicePosition(key, x, y) -> number   // repan active keyed spatial voices without restart
  setListener({x,y,referenceDistancePx})   // consumes semantic AudioListenerV1
  pickVariant(ids) -> id|null             // seeded RNG variant choice
  setMasterVolume(v), getMasterVolume()
  setCategoryVolume(cat, v), getCategoryVolume(cat)
  destroy()
}
export const SOUND_MANIFEST
export function noticeSoundId(msg)

match_notice_presenter.js

export class MatchNoticePresenter {
  constructor({toast, minimap, audio, isReplay, isSpectator, pointInViewport, now?})
  present(event) -> boolean              // fan out one existing server Notice when admitted
}

Match owns one presenter per match and injects the toast, minimap, persistent audio engine, and dynamic viewer/viewport predicates. The presenter owns only existing server Notice events; it does not create advisory or economy notices. Under-attack incidents use 960 px map buckets and a 10-second match-scoped cooldown, with one admission decision gating toast, minimap, and voice together. An admitted in-viewport incident still toasts and pings but stays silent and consumes the same cooldown; a distinct bucket remains immediately eligible. Replay viewers and live spectators still receive admitted toast/minimap presentation but never player notice audio.

Every existing notice voice selected by the presenter passes duck: true, including informational voices that retain the ui category and informational visual severity. The audio engine also keeps alert as a backward-compatible default ducking category. Duck depth is counted per active ducking voice; ambient drops by 12 dB and combat drops by 10 dB over 0.08 seconds, then both restore over 2.0 seconds only after the last ducking voice ends. Presenter-admitted under-attack voices bypass the generic spoken cooldown because the presenter is the sole owner of their incident admission.

Spatial voices in combat_self and combat_other share a combat-only radial profile. Its acoustic reference distance a is the renderer-neutral listener reference distance capped at 1280 world pixels, so zooming farther out changes visual framing without expanding the foreground combat mix. Gain stays at 1.0 through 0.4a; beyond that, effective distance grows four times as fast, yielding 0.5 gain at 0.5a and about 0.143 at 1.0a. Low-pass interpolation and a 0-to-30 voice-priority penalty both advance from 0.4a to the 1.2a hard-drop boundary. Active voices retain their category so camera updates recompute the same profile with the existing 30 ms ramps. Non-combat spatial voices retain the original renderer-relative envelope.

The authoritative snapshot’s 32-tile-quantized worldCombatPosition gates one fixed combat_distant_bed_01 loop on the combat_other bus. Its direction-only spatial profile pans toward that coarse world point while holding distance gain at 1.0, so camera zoom and map distance never change its 0.035 voice gain. It has no pitch variation, fades in over 750 ms, and fades out over 2500 ms. One stable key prevents voice-pool multiplication and allows 30 ms repanning without restarting; pause, ended-room-time, teardown, and match replacement stop it. The fixed loop reveals broad battle direction but not exact position, weapon mix, cadence, ownership, or number of fights. The current derived asset is a first-pass listening placeholder.

hud.js

export class HUD {
  constructor(rootEl, state, commandIssuer, audio?, hotkeyProfiles?, clientIntent?, controlPolicy?, camera?)
  update(frameViews?)                    // refresh resources/supply, minimap status, selection, commands
  // command card buttons call commandIssuer.issueCommand(...) or ClientIntent facade methods
}

The minimap status row shares its width between the game timer and an idle-worker status capped at half the minimap width. The status counts live local Workers whose authoritative activity is idle, shows the active profile’s selection hotkey in parentheses, and remains pointer-disabled. Pressing the configured hotkey selects the current idle set through normal command-supply admission when the command surface is writable.

The train command card is driven by the first selected production building type, but train clicks are issued to the selected completed compatible production buildings in round-robin order so a multi-building selection spreads queued units across its producers. Train and production-cancel hotkeys honor native keyboard repeat: after the OS repeat delay, repeated keydown events activate only those repeatable command-card buttons. Legal manual build, train, and research buttons remain actionable while their red cost is unaffordable: build enters placement and relies on the worker’s authoritative wait at the site, while train/research append an unpaid queue entry. Selected producers render prodWaiting as a striped zero-progress bar labeled waiting for resources / supply, and progress extrapolation stays disabled until the server reports the item paid. Alt-clicking a train button or pressing Alt or Ctrl with its resolved hotkey adds that unit to one selected compatible producer’s ordered standing repeat list; pressing Shift with the resolved hotkey removes it from one producer. The server applies each signed adjustment atomically so rapid inputs allocate distinct producers from current authoritative state. Each train button shows the authoritative active/compatible producer count (for example 2/3) and renders one gold rotating autocast swirl per active producer, evenly phase-offset around the ring. The server spreads additions toward the least-loaded producer and removes from the most-loaded one, which balances mixed unit ratios while preserving another automatic order when possible. When more than one unit is active on a building, it cycles through that list after each successful automatic enqueue. A repeated unit already inserted in the FIFO stays ahead of later manual clicks, and any Cancel clears the affected producer’s repeat state. Standing repeat controls never create unpaid queue entries; their swirl remains a policy indicator until a fully funded item is admitted. Research buttons that unlock production appear directly below the production button they unlock and disappear once complete. AT Guns and Artillery have separate stable Engineering Complex slots. A dependent button unlocks for queueing when its prerequisite is complete or already present earlier in the selected building’s authoritative prodUpgradeQueue. Cancel walks selected producing buildings in reverse round-robin order for the displayed producer type. Selecting an owned building under construction shows a dedicated construction card with Cancel in the bottom-right C slot. An unfinished production building also shows its production buttons with primary training disabled, while Alt/Ctrl/Shift production hotkeys can assign standing repeat production for after completion; click selection prefers the scaffold over an overlapping builder, and cancellation returns the full construction cost. The Scout Plane affordance is a Command Car world-point ability on the C grid slot, beside Breakthrough. It unlocks after the Scout Plane Engineering Complex research completes, costs 50 steel and 75 oil, has no Resource Depot requirement, disables while that Command Car has an active Scout Plane or its 30-second cooldown is running, and issues immediately rather than entering a building production queue. Scout Planes are hit-testable for hover/readout purposes but normal selection, box selection, control groups, right-click commands, and command-card descriptors filter them out, so they are unselectable and uncontrollable in live play. While the Scout Plane ability is armed, the ground overlay draws an advisory line from the launching Command Car to the cursor and a dotted ring around the car for the plane’s maximum 20-second travel distance; targets outside that ring remain valid and produce sorties that expire before arrival. Unit abilities remain on their declared grid slots in mixed selections rather than spilling into an unrelated empty hotkey. When abilities collide, Artillery Fire has the lowest command-card priority: Mortar Fire replaces it on X. The support-weapon Set Up command has a fixed Z slot when selected Anti-Tank Guns, Mortar Teams, or Artillery are present and that slot is available. Mortar-only setup issues in place directly from the button or hotkey; holding Shift appends a terminal setup after existing queued movement. Selections containing Anti-Tank Guns or Artillery retain the directional world-point target step. A deployed selected Mortar Team draws its 5-to-17-tile full-circle range band. Artillery-only selections expose Fire and Set Up together. Command identities are stable and split by scope: global tactical/navigation/production-control buttons remain un-namespaced, while build, train, research, and ability buttons emitted for a faction catalog use the local player’s faction id as the command-id prefix. config.js exposes the client-side faction catalog mirror used by command-card descriptors: workerBuildablesForFaction, trainableUnitsForFaction, researchableUpgradesForFaction, and commandCardAbilitiesForFaction. scripts/check-faction-catalog-parity.mjs compares those descriptors with the Rust catalog dump for every client-exposed faction. Unknown valid faction ids fail closed in command-card data, so future factions do not inherit Kriegsia build, train, research, or ability buttons before their catalog is intentionally exposed. The client mirror is a checked projection, not lifecycle admission: the lobby currently exposes no faction selector, fixture-only ids remain test harness data, public AI controls do not expose a faction selector, and local prediction remains disabled for unsupported local faction ids such as the current Ekat slice. Generation is not required as long as the parity check remains a required gate comparing every client-exposed descriptor against the Rust dump.

Internally, config.js is a facade over config/timing.js, config/presentation.js, config/rules_mirror.js, and config/factions.js. Runtime modules should keep importing config.js; internal config modules are in the pinned rules-mirror area and may import only protocol.js or same-area config modules.

minimap.js

export class Minimap {
  constructor(canvasEl, state, camera, fog, commandIssuer, inputRouter?, {clientIntent?, commandsEnabled?})
  render(frameViews?)                    // draw terrain + fog + entity blips + viewport rect
  markArtilleryFiring(event)             // transient global artillery icon from artilleryFiring events
  inputZone()                            // router zone for locked/unlocked minimap interaction
  updateCommandTargetPreview(shiftKey?)  // stationary setup refresh using live modifier state
  // click/drag -> camera.centerOn or issue move command (right-click)
}

commandsEnabled may be a boolean or a zero-argument predicate. When state.controlPolicy is the lab policy, the minimap uses that policy so lab operators can issue minimap commands even though their start payload remains spectator-shaped.

lobby.js

export class Lobby {
  constructor(rootEl, net, audio?, { ensureConnected?, disconnectWhenIdle?, autoRefreshLobbies? }?)
  show(), hide()
  // owns lobby state, manual pre-join browser refresh, latest-row join preflight,
  // ready/start/spectator role, and delegates browser DOM to lobby_browser_view.js and
  // joined-roster DOM to lobby_view.js.
  // The joined desktop lobby uses three columns: a narrower vertically stacked roster, match setup,
  // and docked ephemeral room chat. Team cards use compact one-line seats and team-scoped AI add
  // buttons; faction controls and redundant roster/summary counts are omitted.
  // The selected map's checked-in 512px production-minimap JPEG and creator credit remain visible
  // above the custom selector. Hosts can select; guests can open the same catalog and preview every
  // option, but each row is marked unavailable and cannot change the authoritative map.
  // Name-field edits are debounced, persisted locally, and sent through Net.setName while joined.
  // Replay lobbies are keyed by explicit `kind: "replay"` metadata: the joined view hides
  // Ready, team, faction, AI, map-selection, and active-seat controls, then shows the selected map,
  // its preview, only spectator occupants, and the host start control while the server reports
  // canStart.
  // Teams are layout groups only; player colors come from each player record.
  refreshLobbyBrowser()                  // one GET /api/lobbies request; never starts a timer
  joinReplayLobby(room)                  // lazily connect, then join a persisted replay lobby
  onGameStart(cb)                        // main.js subscribes to transition to game screen
}

lobby_browser_view.js

export function sortLobbySummaries(rows)
export function formatLobbyAge(createdAtUnixMs, nowMs?)
export function lobbyStatusLabel(joinState)
export function lobbyActionLabel(joinState)
export function lobbyJoinIntent(row)
export function validateLobbyName(rawName)
export function suggestLobbyName(playerName, existingRooms?)
export class LobbyBrowserView {
  constructor(rootEl)
  render({ rows?, loading?, loaded?, error?, nowMs?, actionsDisabled?, onCreateLobby?, onJoinLobby? })
  destroy()
}
export class LobbyCreateModal {
  constructor(hostEl, { onSubmit? })
  open(trigger?, { initialValue? }?)
  close({ restoreFocus? }?)
  setError(message)
  setPending(pending)
  destroy()
}

The ordinary pre-join lobby browser loads once when shown. It then refreshes at most every five seconds only while the document is visible and the player has generated pointer, keyboard, touch, or scroll activity within the last 30 seconds. Hidden or inactive tabs stop the timer and abort an in-flight list request; visibility or new activity resumes with a fresh request. The manual Refresh button and join-action freshness preflight each remain available. Explicit launch URLs skip this lobby-list loop. The app also leaves the WebSocket closed on an ordinary lobby page until Create, Join, Watch, Replay, or an explicit launch URL requires it. An open, unjoined connection is closed when the page becomes hidden so its heartbeat cannot extend the Fly Machine idle tail. If an established ordinary-lobby socket closes, the app silently resets the joined room UI to the main lobby browser; dedicated launch URLs and active matches retain their existing disconnect handling. An unexpected disconnect in a dedicated launch or active match raises a small, draggable, non-blocking connection-loss notice instead of relying on the transient toast. The notice can be dismissed and disappears automatically when a socket reconnects; intentional idle lobby disconnects do not show it. A fresh socket does not resume an existing match seat. The create modal derives its default from the player name and the latest browser rows, adding the first free numeric suffix when that suggestion is already listed. The create endpoint repeats that selection atomically and returns the authoritative room name, covering stale lists and concurrent creates before the client joins the reserved room. While connected, pointer, keyboard, wheel, and foregrounding input emits a transport-only activity notice at most once every 30 seconds so local-only camera interaction also prevents a false AFK disconnect.

The browser keeps in-progress rows labeled In match and exposes a spectator action for joinState: "inGame"; clicks still preflight against GET /api/lobbies before sending join with spectator: true. Replay rows are detected from explicit kind: "replay" metadata, labelled Replay, and joined as spectators. Replay staging rows join without replayOk; active replay rows use their explicit Join replay action as confirmation and send replayOk: true, so the viewer enters the room’s current shared playback without a second prompt. This applies both to persisted replay rooms and automatic post-match replay playback. Countdown, stale, and unknown rows remain disabled.

match_history.js performs one initial Recent Matches fetch and exposes a manual Refresh button; it never polls. It renders the API-provided Replay # for each visible row. The server calculates that one-based sequence across the full filtered history, oldest first, while the table remains newest first; therefore the latest visible replay has the highest number. Saved debug or solo replays do not consume a number. It launches persisted match replays by POSTing /api/matches/{id}/replay, then hands the returned __match_replay__:* room to App/Lobby.joinReplayLobby instead of redirecting the page into replay playback. App replaces the address with /replay/{id} and retains that dedicated-launch state through playback, so the address is a durable, copyable match link rather than a transient room link. Opening or reloading the route resolves the stored match through the same POST: the server returns its existing active replay room or creates a fresh room after disposal/restart. Direct replayArtifact URLs still auto-join the saved artifact playback path; legacy replayRoom URLs represent transient replay staging lobbies. The replay lobby UI is group-watch only: future playable resume work needs separate seat-claim controls and must not infer playable seats from replay lobby occupants or hidden active rows.

main.js starts App; app.js owns the on-demand Net and persistent Audio, derives the ws url from window.location, and shows Lobby; after the server confirms entry into a live or replay lobby it prepares the compatible renderer behind the still-visible lobby. During a normal multiplayer matchCountdown it sends matchLoadReady only after renderer initialization and required assets complete. One exclusive renderer-preparation slot owns that pre-match resource. Every start settles the slot before creating another renderer: a compatible live or replay match atomically adopts it, while Lab, failed, or superseded paths destroy it and await teardown. Ownership then transfers to Match, which remains responsible for renderer teardown. This preserves the invariant that #viewport never retains an abandoned preparation alongside the active renderer. Warmup failures are logged to the browser console. On start App creates Match or ReplayViewer. A bounded ?snapshotStream=<id> developer launch injects SnapshotStreamNet instead: it fetches a same-origin .rtsstream artifact, emits its static start payload, and clocks the contained exact MessagePack snapshot frames through Net._onMessage without constructing a WebSocket. This local benchmark lane therefore keeps the normal decode, GameState, Match, Pixi, HUD, minimap, audio, and rAF paths while explicitly excluding live server simulation and delivery. Its header is a local artifact contract, not an extension of the server wire protocol.

/stress-test is the shareable automatic wrapper around the canonical Hellhole snapshot stream. It warms and resets the existing frame profiler, measures one bounded window, saves environment and phase diagnostics, and uses Chromium’s JS Self-Profiling API for a sampled trace plus SVG flame graph when available. Hidden or unfocused attempts are discarded and restart after the tab returns; other browsers save the phase-timing fallback. The complete cross-file route, artifact, privacy, API, persistence, and input-limit contract lives in client-stress-tests.md.

match.js builds GameState, ClientIntent, Camera, Renderer, Fog, HUD, MatchInputRouter, Minimap, MatchNoticePresenter, Input, starts the rAF loop (compute alpha from snapshot timing, camera.update, audio.setListener, input.update, buildFrameEntityViews, fog.update, renderer.render, hud.update, minimap.render); on each snapshot it applies state and triggers transient event audio exactly once; on gameOver show the victory/defeat overlay with the frozen score table. Replay spectators instead see a winner headline built from the winning score rows (for example, Alex has won), while an actual replay draw remains Draw. The score table includes a Team column, highlights every row matching winnerTeamId, and falls back to winnerId for singleton FFA compatibility. For spectator starts without command-surface permission, match.js hides the command card and give-up action, computes local fog from the server-filtered union snapshot, and keeps the ordinary renderer/minimap/HUD pointed at snapshots with playerResources. Lab operators are the exception: their projection remains spectator-shaped, but LabControlPolicy.canUseCommandSurface(state) keeps the selected-unit panel and real command card visible while prediction stays disabled and issue-as remains the command authority. Spectators still receive admitted notice toasts and minimap alert pings, but the match-owned notice presenter suppresses notice audio so observers do not hear player callouts. Repeated under-attack events in one match-scoped incident are admitted once across toast, minimap, and audio together. artilleryFiring events are forwarded directly to Minimap.markArtilleryFiring; the minimap draws the artillery rig icon above fog for every recipient without using it as entity visibility.

Match composes a render-only clock and injects it into Renderer. Normal play reads monotonic performance.now() with the prior semantics. Interact fixed capture may explicitly suspend the ordinary rAF loop, replace only that render clock with a monotonically advanced capture clock, render with interpolation disabled, and then restore the normal clock and rAF ownership. Renderer rig sampling, deployed-weapon transitions, frame strips, recoil, command feedback, smoke, projectiles, impacts, muzzle flashes, and miss toasts use this clock. Snapshot receipt stamps, network latency, frame health/profiling, input deadlines, audio, daemon idle, and browser/server timeouts deliberately remain on real monotonic time; fixed capture never patches performance.now() globally.

The initial fixed-capture inventory intentionally excludes minimap pings/artillery markers, pointer/selection debounce feedback, HUD wall-clock labels, and audio. Those surfaces continue to use real time and are hidden or out of scope for the clean Pixi viewport artifact. Any future fixed capture of them must extend the injected seam explicitly rather than changing their clocks as a side effect.

4.1a Targeted ability mode (Smoke, Mortar Fire, Artillery Fire, Scout Plane)

input/commands.js exposes _onAbilityTarget and _refreshAbilityTargetPreview for world-point abilities. When the HUD command card calls ClientIntent.beginCommandTarget({ kind: "ability", ability }), the input module enters targeted cursor mode:

client_intent.js holds commandTarget (null or { kind, ability }), ability previews, and a small local planned-stage map keyed by unit id. The local map stores only pending move, attack-move, setup, and Artillery Fire stages needed for queued preview origins; it is not serialized and never replaces server orderPlan. abilityTargetPreview is null or { ability, mouseX, mouseY, carriers, rangeOrigins, pathOrigins, returnMarkers, hoverInRange, artilleryLocks? }. artilleryLocks is advisory client data for selected Artillery only: per-gun current/projected origin, clicked center, projected firing position, future/redeploy facing, whether movement is needed, and whether the center is inside the deployed current cone. commandTarget is a transient UI state; abilityTargetPreview is rebuilt every mouse move from the cursor world position and the current selection. Server-projected complex ability world objects are stored separately as state.abilityObjects from Snapshot.abilityObjects. They are authoritative, fog-filtered data for return-marker, Magic Anchor, and line-projectile rendering, so the client must not infer gameplay authority from local preview state.

Range preview rendering (renderer/feedback.js, _drawAbilityTargetPreview):

Ability object rendering (renderer/feedback.js, _drawAbilityObjects; drawn on the same ground overlay container as smoke clouds, below selection rings and HP bars):

Ground decal rendering (state_ground_decals.js, renderer/decals.js; layer decals between terrain and resources):

Trench terrain rendering (renderer/trenches.js; layer trenches between ground decals and resources):

Smoke rendering (renderer/feedback.js, _drawSmokes; layer smokes between unit bodies and selection rings):

4.2 Rendering & look (PixiJS rigs — neutral PS1 field-command style)

This section owns the current Pixi look and module behavior. Renderer-neutral camera, presentation, ownership, capture, backend, parity-gate, and benchmark contracts live in client-rendering.md and its active rendering parity ledger.

4.3 Client architecture workflow

Client modules are organized by area and checked by node scripts/check-client-architecture.mjs. The checker classifies every client/src/**/*.js file, reports the largest files and fan-in/out baseline, rejects unclassified files, and rejects cross-area imports that are not allowed by rule or by an explicit allowlist reason in the script. It also rejects production reads or writes through removed GameState intent shims such as state.commandTarget, state.placement, and preview update methods; use injected ClientIntent or a renderer read model instead.

Current areas:

Import rules:

Future client changes should use this checklist:

Large-file handling is ratcheted, not churn-driven. Do not split a large file only to reduce byte count. When adding behavior to an already large file, first look for a focused collaborator, descriptor, or area-local helper that reduces coupling. Update checker baselines or allowlists only with a written reason in the script and the change handoff.

Client-related suite selection lives in tests/select-suites.mjs. For client/src/ changes it selects client-architecture, js-protocol-contracts, node-minimap-input-contracts, and client-smoke; client transport/protocol changes also select node-server-integration. Architecture-policy files such as scripts/check-client-architecture.mjs, tests/select-suites.mjs, and plans/archive/client-arch/* select client-architecture. Docs-only changes select docs-only unless another rule applies.

4.4 Launch URL conventions

App-owned launch URLs use the rtsLaunch query parameter as the mode switch. rtsLaunch=match drives a normal live lobby through existing lobby messages rather than introducing a debug protocol: the browser joins the requested room, applies safe setup options, and starts only after ordinary server lobby.canStart is true. The convention is intentionally namespaced so future launch modes can coexist with replay, lab, and dev-scenario URLs.

Supported match-launch parameters:

Example spectator self-play URL:

/?rtsLaunch=match&rtsRoom=agent-ai-selfplay&rtsRole=spectator&rtsAi=1:ai_2_1&rtsAi=2:ai_turtle&rtsStart=1

When that launch produces an all-AI live match, the score screen retains the live matchRunId through automatic post-match replay playback and renders it as an Observation ID. The id is the handoff key for replay recovery and server lag-log inspection; it is not a player identity or a new gameplay control.