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 default backend bundle creates the existing orthographic semantic camera and asynchronous Pixi worker host. The explicit live-player/Lab rtsRenderer=babylon selector lazily loads the pinned Babylon dependency and creates the fixed perspective camera before its renderer; ordinary spectators and replays remain on Pixi, and Match still 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
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_compatibility.js # server/client prediction-build compatibility guard
  prediction_settings.js # localStorage-backed prediction toggle
  unit_range_settings.js # localStorage-backed selected-unit range overlay toggle
  sim_wasm_adapter.js # optional WASM prediction adapter
  state.js        # GameState: holds prev+current snapshot, selection, control groups, display overlays
  state_ground_decals.js # client-only death/impact decal queue, classification, owner/facing recovery, building-footprint sizing
  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
  progress_extrapolator.js # local display extrapolation for active construction progress
  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
  auto_spectator_settings.js # persisted opt-in preference for automatic spectator framing
  spectator_controls_panel.js # floating live-spectator/replay camera 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/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
  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
  lobby.js        # Lobby screen controller: browser polling, joins, ready/start, host controls
  lobby_browser_view.js # Pre-join lobby browser rows, state rendering, and age/status formatting
  lobby_view.js   # Lobby roster renderer: team columns, seat rows, spectators
  match_history.js # Lobby match-history table and replay launch affordance
  scoreboard.js   # Shared score/result formatting helpers
  status_badge.js # Build/network/frame status badge with copyable diagnostics
  ai_diagnostics_panel.js # dedicated live/replay AI decision diagnostics panel
  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 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/workspace query parsing
  map_editor_handoff.js # Short-lived HTTP map handoff create/consume client
  map_editor_session.js # Flat authored-map state, local storage, undo/redo, stroke transactions
  map_editor_panel.js # Dedicated editor controls for maps, terrain, start/base locations, save/export, and Lab launch
  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_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 toast formatting
  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; MapEditorPresentationV1 carries revisioned terrain replacement/patch data 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?)
  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.

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. Prediction display writes go through GameState.applyPredictionDisplayOverlay({optimisticCommands?, predictedSnapshot?, diagnostics?, smoothCorrections?}), so controller bookkeeping and WASM render snapshots stay outside broad snapshot mutation. Replay viewers, spectators, and dev-watch passive viewers keep prediction disabled and clear this overlay instead of allocating gameplay prediction state.

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 and covers Worker, Rifleman, Machine Gunner, Anti-Tank Gun, Mortar Team, Artillery, Scout Car, Tank, Command Car, and Ekat. Missing or invalid unit rig definitions fail through the renderer’s soft missing-texture guard rather than falling back to a procedural unit branch. 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 SVG rig definition through the same renderLiveUnitRig runtime path. 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:

Prototype 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 {
  applySnapshotEvents(events, context)
  reconcilePending()                    // stage shared pre-assembly batch used by Match
  acknowledgeReconciled()               // release it after a successful backend frame
  consumePending()
  get pendingCount()
  clear()
}
export function normalizeGroundDecalEvent(ev, context?)
export function groundDecalClassForKind(kind)
export function groundDecalClassForImpactEvent(eventKind)

GameState.applySnapshot feeds fog-filtered transient death, mortar-impact, and artillery-impact events into this browser-local buffer. The buffer dedupes deaths by id and impact events by their received snapshot identity, recovers owner/facing from the prior visible entity snapshot only for death marks, and never infers a hidden death or impact from missing entities.

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 pixels, not retained display objects or per-frame records. 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/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 rebuilds so selected tab, visible state, and collapsed state survive replay seek-triggered start messages 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; Production, Research, Units, Resources, Units Lost, and Resources Lost render the latest server-authored observerAnalysis payload. 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. Babylon follows the same update/present attribution with one scene.render(). Neither backend owns 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? })
  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 })

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. 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 and shows every viewer, including the controller, a Seeking forward/backward X seconds… toast before the synchronous replay rebuild can stall visible snapshots.

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 and rtsRenderer. 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/. 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 consumes raw Chrome DevTools screencast frames and assigns them 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 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/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. Spectators cannot call move or give-up; 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 3. 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()
  materialized(), exportMap(), saveLocal(key), loadLocal(key)
  mapOverlay()
}

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. The panel loads bundled JSON from /maps/catalog and /maps/<file>, creates configurable 16–166-tile square blank maps with a 126-tile default and a compact size field that follows the active draft, edits name/description plus flat start and base locations, and provides undo/redo, local save/load, and JSON export. Start locations set map player capacity; every base location is permanent and its resources spawn even when no player starts there. Editor drafts may temporarily contain zero start locations so authors can clear and rebuild the player 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 tools support brush and inclusive drag-box fills, plus none, horizontal, vertical, half-turn, four-way radial, or either single-diagonal symmetry; grass is the erase material. Symmetry expands every terrain tile before it is painted, moves matching start locations, removes matching neutral base locations when moving a selected base, and adds all symmetric locations. 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, bare road, and the four marked road orientations are passable paint materials; roads 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. Editor status stays above the scrolling controls; 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.

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. A bounded workspace id keeps the editor’s local map workspace available across the round trip when browser storage is available. 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.city_centre 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, #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. 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.

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)    // freezes/resumes wall-clock progress display prediction
  // 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
  setPredictedSnapshot(snapshot, diagnostics, options), clearPredictedSnapshot()
}

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 also exposes Pump Jack in the top-middle W slot; while armed, its placement preview 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 City Centre/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 SVG rig images 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. Visible unit death events are normalized by GameState into deduped, browser-local pending ground decal 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. They do not change server protocol, 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 positioned combat activity and decide at most once per simulated second
  update(dt)                             // advance the active smooth camera transition
  diagnostics(), destroy()
}

Replay viewers and ordinary live spectators receive a floating, draggable Spectator Controls panel with one persisted, default-off Follow active fights switch. 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, impacts as two, and the current fight receives a small stickiness bonus. 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 reserve 50% more screen-space context than the initial director tuning. If no contact is plausible, 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. Decisions occur no more than once every 30 simulation ticks. Nearby reframes pan and zoom with a one-second smooth transition, distant combat/contact reframes cut immediately, and backward replay seeks clear future combat 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 R&D 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; 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 costs 50 steel and 75 oil, has no City Centre 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: lobby selectors must expose only playable human choices, 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.
  // Host lobby controls expose grouped team cards, per-seat team assignment, team-scoped AI add
  // buttons, and a map selector in the lobby summary row through Net setTeam/addAi/selectMap.
  // 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 only
  // spectator occupants plus the host start control while the server reports canStart.
  // The normal product lobby exposes an Open Lab route affordance instead of a debug setup toggle.
  // 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; when the local player becomes ready it prepares the selected renderer behind the still-visible lobby. During a normal multiplayer matchCountdown it sends matchLoadReady only after renderer initialization and required assets complete. Match adopts that exact prepared renderer on start instead of creating a second worker. Unready, failed, or superseded preparations are destroyed, and 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. 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, SVG 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.