4. JS client — modules & exported APIs

client/ (ES modules, no bundler; index.html imports src/main.js as a module). PixiJS is loaded globally from CDN as PIXI.

index.html        # PINNED — CDN + #app + module entry + screens markup
map-editor.html   # standalone handcrafted-map editor; loads/saves server map JSON
styles.css        # HUD, lobby, menus, command card
src/
  protocol.js     # PINNED — message tag constants + builder helpers (mirror of §2)
  config.js       # PINNED — render/UI constants: colors, sizes, costs, sight (mirror balance)
  net.js          # Net: WebSocket wrapper, typed send helpers, event emitter
  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
  sim_wasm_adapter.js # optional WASM prediction adapter
  state.js        # GameState: holds prev+current snapshot, selection, control groups, display overlays
  client_intent.js # ClientIntent: browser-local placement, command targeting, previews, feedback
  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
  renderer/       # Pixi app facade plus layers, terrain, entities, units, buildings,
                  # resources, fog overlay, feedback, rig schema/import, and renderer-local palette helpers
  renderer/feedback_view_model.js # Builder for renderer feedback's narrow per-frame read model
  fog.js          # Fog overlay: accumulate explored, compute visible from own entities
  input/          # lifecycle facade plus selection, commands, placement, shared camera navigation, UI input routing
  audio.js        # Audio: Web Audio context, buses, one-shots, spatialization
  hud.js          # HUD: resources/supply bar, selected panel, command card (build/train)
  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: name/room, ready/start, host controls
  lobby_view.js   # Lobby roster renderer: team columns, seat rows, spectators
  scoreboard.js   # Shared score/result formatting helpers
  observer_analysis_overlay.js # replay/live spectator analysis overlay
  observer_analysis_signatures.js # dirty-body signatures for observer analysis DOM updates
  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
  branch_staging.js # replay branch staging panel
  lab_client.js  # LabClient: lab request ids, pending results, state/result subscriptions
  lab_panel.js   # LabPanel: app-owned lab controls/status UI mounted around Match
  lab_control_policy.js # Lab control collaborator placeholder injected into Match
  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
  match.js        # Match lifecycle, module dependency wiring, render loop, transient events
  frame_recovery.js # Frame-loop soft-failure logging and rescheduling diagnostics
  frame_entity_views.js # One-RAF entity view builder shared by render, fog, HUD, minimap, analysis
  replay_controls.js # Replay/scenario speed, seek, vision, and timeline 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

4.1 Module export contracts

net.js

export class Net {
  constructor(url)                       // 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?)
  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?)
  setAiProfile(id, aiProfileId)
  removeAi(id)
  setQuickstart(enabled)
  setSpectator(spectator, id?)
  command(cmd, clientSeq)                // lower-level sequenced gameplay command envelope
  giveUp()
  pauseGame()
  unpauseGame()
  returnToLobby()
  ping()
  netReport(report)
  setRoomTimeSpeed(speed)                // room-controlled replay/dev-scenario time
  stepRoomTime()                         // paused dev-scenario room time
  seekRoomTime(ticksBack)                // room-controlled replay time; pass huge N for full reset
  seekRoomTimeTo(tick)
  setReplayVision(vision)
  lab(requestId, op)                     // lab rooms only; request id allocated by LabClient
  requestReplayBranch()
  claimBranchSeat(playerId)
  releaseBranchSeat(playerId)
  startBranch()
  selectMap(map)
  get playerId()
  get bufferedAmount()
}

prediction_controller.js

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

Live player command sources receive a commandIssuer seam from Match and call commandIssuer.issueCommand(cmd). 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.

SVG unit art 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.

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, 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. The overlay owns its generated DOM and is read-only. The Army Value tab is client-side and viewport-specific; Production, Units, Units Lost, and Resources Lost render the latest server-authored replayAnalysis payload. 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.

frame_entity_views.js

buildFrameEntityViews(state, { alpha }) // frame-local interpolated/current/authoritative/selected entity arrays

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 GameState.entitiesInterpolated() and selectedEntities() results across frame consumers. 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.

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()
  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, onUnpause })
  applyLivePauseState(state)
  destroy()
}

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 controls, live pause controls, and read-only/gameplay command affordances. Product shells may still use product metadata for launch/routing and owned controls such as replay branch creation or lab scenario 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}
  request(op, options?)                  // allocates requestId, resolves with labResult/timeout
  destroy()
}
export function labVisionLabel(vision)
export const labVision                   // fullWorld(), team(teamId), teams(teamIds)

lab_panel.js

export class LabPanel {
  constructor({ root, labClient, launch, startPayload })
  destroy()
}

lab_control_policy.js

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

App 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 room-local vision, setup mutations, issue-as commands, and scenario import/export through those collaborators while keeping the normal match screen authentic.

hotkey_profiles.js

export class HotkeyProfileService {
  constructor({storage?, catalog?, profilesKey?, activeKey?})
  allProfiles()
  getActiveProfile()
  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?)
}

buildHotkeyCommandCatalog(cards)
normalizeHotkey(value)

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. 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. 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.

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 is read-only for spectators and 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 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)
  // 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), releaseCommandTargetKey(key, shiftKey)
  releaseCommandTargetShift()
  commandFeedback, liveCommandFeedback(now)
  resourceMiningPreview, updateResourceMiningPreview(preview)
  antiTankGunSetupPreview, updateAntiTankGunSetupPreview(preview)
  abilityTargetPreview, updateAbilityTargetPreview(preview)
}

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. 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, and ability previews. 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.

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.

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. Tank Trap placement previews keep normal terrain, resource, building, and map-bounds checks, allow infantry overlap, and reject vehicle-body units. HUD and input should exchange command intent through descriptor/facade methods, while gameplay command emission continues to flow through commandIssuer.issueCommand. PredictionController owns client sequence allocation and optimistic bookkeeping; GameState applies named display overlays but does not own prediction policy.

camera.js

export class Camera {
  x, y, zoom                             // world coords of viewport top-left, zoom factor
  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)
  setBounds(worldW, worldH, viewW, viewH)
}

renderer/index.js

export class Renderer {
  constructor(canvasParent)              // creates PIXI.Application, layers
  resize(w,h)
  buildStaticMap(map)                    // draw terrain once into a cached layer
  render(state, camera, fog, alpha, options?) // per-frame; draws entities, fog, selection, placement, Tank Traps
  app                                    // the PIXI.Application (for ticker/stage if needed)
  // exposes screen->world hit info if helpful; selection box drawing lives here too:
  drawSelectionBox(rectOrNull)
}

fog.js

export class Fog {
  constructor(mapWidth, mapHeight)
  update(ownEntities, tileSize, serverVisibleTiles?) // copy server visibility when provided; accumulate explored
  isVisible(tileX,tileY), isExplored(tileX,tileY)
  // 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 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, so the overlay follows server-authoritative fog including smoke blockers; local stamping remains a fallback for older/dev object snapshots.

Playable own selections and human multi-unit commands use the mirrored command-supply budget from command_budget.js: 24 base command supply plus 12 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, renderer, fog, audio?, inputRouter?, hotkeyProfiles?, clientIntent?)
  // 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.
  update(dt)                             // continuous handling (edge scroll handled by camera)
  // 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, 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 no gameplay command issuer API. Replay middle-drag and Space+left-drag pan through Camera.panByScreenDelta; mouse-wheel zoom, keyboard pan state, edge scroll state, and blur release are shared observer navigation behavior. 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. Production-building-only right-clicks set or append building rally stages and rely on owner-only rallyPlan for accepted markers. Attack targeting with only production buildings selected creates attackMove rally stages. Selection and targeting 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. 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?, contains(ev), pointerDown?, pointerMove?, pointerUp?}
                                         // returns unregister()
  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
}

Router events carry viewportX/viewportY plus clientX/clientY; pointer-lock input and DOM input use the same zone contract so HUD interactions can work while the browser routes mouse events to the locked viewport.

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?, priority?, category?, pitchVariance?, gain?})
                                           // x/y present -> StereoPanner + lowpass + distance gain
  playUI(id, opts)                        // non-spatial ui category convenience
  stopByKey(key) -> number                // stop tagged active voices, for sustained/abortable cues
  setListener(x, y, zoom, viewW?)          // camera center in world px; derives screen-width refDist
  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)

hud.js

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

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. Research buttons that unlock production appear directly below the production button they unlock and disappear once complete. Cancel walks selected producing buildings in reverse round-robin order for the displayed producer type. 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.

minimap.js

export class Minimap {
  constructor(canvasEl, state, camera, fog, commandIssuer, inputRouter?, {clientIntent?, commandsEnabled?})
  render(frameViews?)                    // draw terrain + fog + entity blips + viewport rect
  inputZone()                            // router zone for locked/unlocked minimap interaction
  // click/drag -> camera.centerOn or issue move command (right-click)
}

lobby.js

export class Lobby {
  constructor(rootEl, net)
  show(), hide()
  // owns lobby state, joins, ready/start/spectator role, and delegates 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.
  // Teams are layout groups only; player colors come from each player record.
  onGameStart(cb)                        // main.js subscribes to transition to game screen
}

main.js starts App; app.js owns the persistent Net and Audio, derives the ws url from window.location, and shows Lobby; on start it creates Match. match.js builds GameState, ClientIntent, Camera, Renderer, Fog, HUD, MatchInputRouter, Minimap, 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, 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. Spectators still receive notice toasts and minimap alert pings, but match.js suppresses notice alert audio so observers do not hear player alert callouts.

4.1a Targeted ability mode (Smoke, Mortar Fire, Point Fire)

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 }) and abilityTargetPreview (null or { ability, mouseX, mouseY, carriers, rangeOrigins, pathOrigins, returnMarkers, hoverInRange }). 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):

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

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

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.