Skip to content

Webgl#3942

Draft
evanpelle wants to merge 32 commits into
mainfrom
webgl
Draft

Webgl#3942
evanpelle wants to merge 32 commits into
mainfrom
webgl

Conversation

@evanpelle
Copy link
Copy Markdown
Collaborator

If this PR fixes an issue, link it below. If not, delete these two lines.
Resolves #(issue number)

Description:

Describe the PR.

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

DISCORD_USERNAME

@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 16, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​lil-gui@​0.21.0991008881100

View full report

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 16, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds WebGL GameView, many GL render passes, frame derive/cache/upload modules, debug GUI (lil-gui), keyboard/map interaction and controllers. Refactors GameRenderer to a Controller model and wires WebGL into ClientGameRunner. Removes legacy 2D canvas layers and related FX/UI implementations.

Changes

GL integration and pipeline

Layer / File(s) Summary
Complete GL migration and client wiring
src/client/render/gl/..., src/client/render/frame/..., src/client/controllers/*, src/client/graphics/GameRenderer.ts, src/client/ClientGameRunner.ts, resources/atlases/*, package.json
Adds WebGL GameView, Camera, MapInteraction, KeyboardPan, many GL passes (terrain/territory/units/structures/names/FX/lights/UI), dynamic buffers, frame derive/cache/upload helpers, debug GUI and wiring (lil-gui), WebGLFrameBuilder, Controller interface and controllers, and client runner integration; removes legacy 2D canvas Layer implementations and related FX/UI modules.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Worker
  participant Client as ClientGameRunner
  participant Builder as WebGLFrameBuilder
  participant GL as GameView/GPURenderer
  Worker->>Client: GameUpdateViewData
  Client->>Builder: update(gameView)
  Builder->>GL: uploadFrameData(frame)
  GL->>GL: Render passes (terrain/territory/units/structures/FX/names/lights)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

Pixels wake, the canvas sleeps,
Rails glow, a shockwave sweeps.
Names bloom bright in MSDF light,
Warships glide in GPU night.
Controllers hum, the passes sing—
From 2D past to WebGL spring. 🌊✨

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client/graphics/layers/StructureIconsLayer.ts (1)

224-230: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't confirm builds from throttled ghost state.

The new 50ms throttle can leave ghostUnit.buildableUnit describing the previous tile. If the player moves and confirms quickly, requestConfirmStructure() can go straight into createStructure() with stale canBuild / canUpgrade flags and send an intent for a tile that has not been revalidated yet. Please bypass the throttle for pending confirms, or invalidate readiness whenever the hovered tile changes. Based on learnings both mouse (MouseUpEvent) and keyboard (ConfirmGhostStructureEvent) confirmation paths go through requestConfirmStructure → createStructure.

Also applies to: 366-373, 375-412

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/graphics/layers/StructureIconsLayer.ts` around lines 224 - 230,
The 50ms throttle in renderGhost allows ghostUnit.buildableUnit to be stale and
can cause requestConfirmStructure → createStructure to act on outdated
canBuild/canUpgrade flags; update logic so that when the hovered tile or ghost
target changes (compare ghostUnit.tileRef or equivalent) you immediately
invalidate lastGhostQueryAt/readiness (or set buildableUnit to undefined) so the
next confirm triggers an immediate revalidation, and additionally ensure
requestConfirmStructure will bypass the throttle and force a fresh validation
when called from confirmation paths (MouseUpEvent or ConfirmGhostStructureEvent)
by invoking the same validation routine directly; change targets: renderGhost,
requestConfirmStructure, createStructure, and the ghostUnit.buildableUnit
handling to implement this bypass/invalidating behavior.
🟠 Major comments (21)
src/client/render/gl/keyboard-pan.ts-110-116 (1)

110-116: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent divide-by-zero/near-zero pan speed spikes.

Pan speed is divided by zoom directly. A zero or tiny zoom value can produce extreme movement and unstable camera behavior.

💡 Suggested fix
-    const speed = this.panSpeed / this.deps.zoom;
+    const safeZoom = Math.max(this.deps.zoom, 1e-3);
+    const speed = this.panSpeed / safeZoom;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/keyboard-pan.ts` around lines 110 - 116, The pan
computation divides by this.deps.zoom which can be zero or extremely small;
clamp the divisor to a safe minimum before computing speed to avoid spikes (e.g.
use const zoom = Math.max(this.deps.zoom, MIN_ZOOM) where MIN_ZOOM is a small
positive constant like 1e-3) and then compute const speed = this.panSpeed /
zoom; update dx/dy logic that uses this.panSpeed, held, and dt accordingly so
camera movement remains stable when zoom is near zero.
src/client/render/gl/passes/point-light-pass.ts-178-184 (1)

178-184: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate palette indexing before reading owner colors.

ownerID is used directly to index paletteData. If it is invalid, this writes undefined/NaN into instance data and can corrupt light rendering.

💡 Suggested fix
       const off = count * FLOATS_PER_LIGHT;
       const pOff = unit.ownerID * 4;
+      if (pOff < 0 || pOff + 2 >= this.paletteData.length) continue;
       this.lightData[off + 0] = x;
       this.lightData[off + 1] = y;
       this.lightData[off + 2] = typeIdx;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/point-light-pass.ts` around lines 178 - 184, The
code writes owner color bytes into this.lightData using unit.ownerID without
bounds checks (see unit.ownerID, pOff, this.paletteData and this.lightData in
point-light-pass.ts), which can produce undefined/NaN; fix by validating ownerID
before indexing: ensure ownerID is a finite non-negative integer and that pOff =
ownerID * 4 is within this.paletteData.length - 3; if invalid, use a safe
default color (e.g., zeros or a predefined fallback) and then write those
fallback values into this.lightData[off + 3..5]; keep the existing writes to
this.lightData[off + 0..2] intact.
src/client/render/gl/passes/point-light-pass.ts-104-109 (1)

104-109: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard unit type count against shader uniform capacity.

header.unitTypes is unbounded, but uniforms/arrays are hard-capped at MAX_LIGHT_TYPES. If this grows beyond 64, typeIdx can exceed shader array bounds and produce undefined rendering.

💡 Suggested fix
     this.typeNames = header.unitTypes;
+    if (this.typeNames.length > MAX_LIGHT_TYPES) {
+      throw new Error(
+        `PointLightPass supports up to ${MAX_LIGHT_TYPES} light types, got ${this.typeNames.length}`,
+      );
+    }
     this.typeConfigs = new Array(header.unitTypes.length);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/point-light-pass.ts` around lines 104 - 109,
Guard the number of unit types loaded from header.unitTypes against the shader
uniform capacity MAX_LIGHT_TYPES to avoid out-of-bounds type indices: in the
initialization that sets this.typeNames, this.typeConfigs and this.typeToIdx
(the loop that reads header.unitTypes and uses LIGHT_CONFIGS), limit or truncate
header.unitTypes to at most MAX_LIGHT_TYPES entries (or throw a clear error)
before populating this.typeConfigs and this.typeToIdx so that any computed
typeIdx never exceeds MAX_LIGHT_TYPES-1; ensure any downstream uses of
this.typeNames/typeToIdx assume the truncated length.
src/client/render/gl/dynamic-buffer.ts-16-20 (1)

16-20: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against zero initial capacity.

With initialCapacity = 0, Line 28 never terminates. Validate constructor input or clamp to at least 1.

Suggested fix
   ) {
-    this.capacity = initialCapacity;
+    this.capacity = Math.max(1, initialCapacity);

Also applies to: 26-29

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/dynamic-buffer.ts` around lines 16 - 20, The constructor
allows initialCapacity = 0 which causes downstream loops to never terminate;
clamp or validate the value in the DynamicBuffer constructor by ensuring
capacity = Math.max(1, initialCapacity) (or throw on invalid input) and allocate
data with that clamped capacity * floatsPerInstance so this.capacity and
this.data are never zero-sized; update any references to capacity/data
initialization in the constructor to use the clamped value.
src/client/render/gl/passes/name-pass/text-program.ts-125-140 (1)

125-140: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent async atlas creation after dispose.

Line 128 async onload can run after dispose() and create a texture that is never deleted. Add a disposed guard and clear handlers/source in dispose().

Suggested fix
 export class TextProgram {
+  private disposed = false;
@@
   private loadAtlas(): void {
     const gl = this.gl;
     const img = new Image();
     img.onload = () => {
+      if (this.disposed) return;
       const tex = gl.createTexture()!;
@@
       this.atlasTex = tex;
       this.atlasReady = true;
     };
     img.src = atlasUrl;
   }
@@
   dispose(): void {
+    this.disposed = true;
     const gl = this.gl;
     gl.deleteProgram(this.program);
     if (this.atlasTex) gl.deleteTexture(this.atlasTex);
   }
 }

Also applies to: 192-196

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/name-pass/text-program.ts` around lines 125 -
140, The img.onload in loadAtlas can fire after dispose and create a texture
that's never deleted; add a disposed guard (e.g., this._disposed boolean)
checked at the top of the onload handler and only create/bind the texture if not
disposed, set this.atlasTex and this.atlasReady there; in dispose(), set
this._disposed = true, if this.atlasTex exists delete the GL texture, and clear
the Image handlers and src (img.onload = null; img.src = '') or store the Image
reference on the instance so dispose can null its onload and src; apply the same
pattern for the other atlas/image creation block (the block referenced at lines
~192-196).
src/client/render/gl/passes/fx-pass/fx-attack-ring-pass.ts-80-86 (1)

80-86: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Delete the quad vertex buffer in dispose().

Line 80 creates a WebGL buffer that is never released. Keep it as a class field and delete it in lifecycle cleanup.

Suggested fix
 export class FxAttackRingPass {
@@
   private vao: WebGLVertexArrayObject;
+  private quadBuf: WebGLBuffer;
@@
-    const quadBuf = gl.createBuffer()!;
-    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
+    this.quadBuf = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
@@
   dispose(): void {
     const gl = this.gl;
     gl.deleteProgram(this.program);
     this.instanceBuf.dispose();
+    gl.deleteBuffer(this.quadBuf);
     gl.deleteVertexArray(this.vao);
   }
 }

Also applies to: 206-211

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/fx-pass/fx-attack-ring-pass.ts` around lines 80 -
86, The quad vertex buffer (quadBuf) created in the fx-attack-ring-pass should
be stored as a class field (e.g., this.quadBuf) instead of a local variable and
released in the class lifecycle cleanup: update the code that creates quadBuf to
assign it to the field and add gl.deleteBuffer(this.quadBuf) (and null the
field) inside the dispose() method; apply the same treatment to the other
buffer(s) created around the later block (lines ~206-211) so every WebGL buffer
allocated by this class (referenced by their variable names) is deleted in
dispose().
src/client/render/gl/passes/nuke-telegraph-pass.ts-61-67 (1)

61-67: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Release the static quad buffer in dispose().

The quad VBO created for attribute 0 is never deleted, so this pass leaks GPU memory across create/dispose cycles.

Suggested fix
 export class NukeTelegraphPass {
@@
   private vao: WebGLVertexArrayObject;
+  private quadBuf: WebGLBuffer;
   private instanceBuf: DynamicInstanceBuffer;
@@
-    const quadBuf = gl.createBuffer()!;
-    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
+    this.quadBuf = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
@@
   dispose(): void {
     const gl = this.gl;
     gl.deleteProgram(this.program);
     this.instanceBuf.dispose();
+    gl.deleteBuffer(this.quadBuf);
     gl.deleteVertexArray(this.vao);
   }
 }

Also applies to: 146-151

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/nuke-telegraph-pass.ts` around lines 61 - 67, The
pass creates a GPU buffer quadBuf (via gl.createBuffer and used for attribute 0)
but never deletes it, causing a GPU memory leak across create/dispose cycles;
update the pass's dispose() method to track and call gl.deleteBuffer(quadBuf)
(and any other static quad buffers created around lines 146-151) and null out
the reference so subsequent disposes are no-ops; ensure you reference the same
quadBuf variable used when binding (and any additional quad buffer variables) so
the GL resource is properly released.
src/client/render/gl/passes/trail-pass.ts-86-95 (1)

86-95: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bind texture unit 2 deterministically when drawing.

uAffiliation always points to unit 2, but unit 2 is only bound conditionally. If affiliationTex is absent, this pass can sample stale global GL state.

Suggested fix
-    gl.uniform1i(this.uAltView, this.altView ? 1 : 0);
+    const hasAffiliation = !!this.affiliationTex;
+    gl.uniform1i(this.uAltView, this.altView && hasAffiliation ? 1 : 0);
@@
-    if (this.affiliationTex) {
-      gl.activeTexture(gl.TEXTURE2);
-      gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
-    }
+    gl.activeTexture(gl.TEXTURE2);
+    gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/trail-pass.ts` around lines 86 - 95, The shader
uniform uAffiliation is always sampled from texture unit 2 but the code only
binds TEXTURE2 when this.affiliationTex exists, leaving unit 2 in stale state;
ensure TEXTURE2 is deterministically bound every frame by binding either
this.affiliationTex when present or a known fallback (e.g. a pre-created 1x1
transparent/default WebGLTexture stored on the pass, or explicitly binding
null/empty texture) before drawing; update the block around
gl.activeTexture(gl.TEXTURE2) / gl.bindTexture(...) to always activate TEXTURE2
and bind the appropriate texture so uAffiliation never reads undefined state.
src/client/render/gl/passes/territory-pass.ts-117-129 (1)

117-129: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset dirty-row sentinels when forcing full uploads.

These methods mark textures dirty but keep previous partial-row bounds. If a partial range was set earlier, the next flush can upload only that stale range instead of the new full state.

Suggested fix
   uploadFullTileState(tileState: Uint16Array): void {
     this.liveTileRef = null;
     this.cpuTileState.set(tileState);
+    this.dirtyRowMin = Infinity;
+    this.dirtyRowMax = -1;
     this.tilesDirty = true;
   }

   setLiveRefs(tileState: Uint16Array, trailState: Uint8Array): void {
     this.liveTileRef = tileState;
     this.liveTrailRef = trailState;
+    this.dirtyRowMin = Infinity;
+    this.dirtyRowMax = -1;
+    this.trailDirtyRowMin = Infinity;
+    this.trailDirtyRowMax = -1;
     this.tilesDirty = true;
     this.trailsDirty = true;
   }
@@
   uploadFullTrailState(trailState: Uint8Array): void {
     this.liveTrailRef = null;
     this.cpuTrailState.set(trailState);
+    this.trailDirtyRowMin = Infinity;
+    this.trailDirtyRowMax = -1;
     this.trailsDirty = true;
   }
@@
   clearTrails(): void {
     this.cpuTrailState.fill(0);
+    this.trailDirtyRowMin = Infinity;
+    this.trailDirtyRowMax = -1;
     this.trailsDirty = true;
   }

Also applies to: 173-189

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/territory-pass.ts` around lines 117 - 129, When
forcing full uploads in uploadFullTileState and setLiveRefs, also reset the
partial-row sentinels so the next flush does a full upload instead of a stale
subrange: after setting tilesDirty = true (and trailsDirty = true for trail
refs), set the per-texture dirty-range fields (e.g.
tilesDirtyRowStart/tilesDirtyRowEnd and trailsDirtyRowStart/trailsDirtyRowEnd)
to cover the whole texture (start=0, end=this.tileRows or this.trailRows / the
appropriate row count) so any prior partial bounds are cleared; make the same
change for the analogous full-upload methods around lines 173-189.
src/client/render/gl/passes/range-circle-pass.ts-37-40 (1)

37-40: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Store the quad buffer as an instance property and release it in dispose().

The buffer allocated at line 39 is created as a local variable and never released. The dispose() method only deletes the program and VAO, allowing GPU memory to leak when the renderer is recreated multiple times.

Suggested fix
 export class RangeCirclePass {
   private gl: WebGL2RenderingContext;
   private program: WebGLProgram;
   private vao: WebGLVertexArrayObject;
+  private quadBuf: WebGLBuffer;
@@
-    const quadBuf = gl.createBuffer()!;
-    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
+    this.quadBuf = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
@@
   dispose(): void {
     const gl = this.gl;
     gl.deleteProgram(this.program);
     gl.deleteVertexArray(this.vao);
+    gl.deleteBuffer(this.quadBuf);
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/range-circle-pass.ts` around lines 37 - 40, The
quad vertex buffer created as a local const (quadBuf) should be stored as an
instance property (e.g., this.quadBuf) so it can be deleted later; change the
creation to assign to the property where this.vao is set, update subsequent uses
to bind this.quadBuf instead of quadBuf, and add gl.deleteBuffer(this.quadBuf)
in the class dispose() method (alongside deletion of this.vao and the program)
to free GPU memory; reference the constructor/initialization where this.vao is
created and the dispose() method to make these edits.
src/client/render/gl/passes/unit-pass.ts-270-271 (1)

270-271: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard atlas loading against unhandled errors and dispose races.

loadAtlas() is fire-and-forget without error handling. If img.decode() fails, it creates an unhandled promise rejection. If dispose() runs before the image loads, GL writes happen on deleted textures.

Add a disposed flag, wrap GL operations in try-catch, and check the flag after await img.decode():

Suggested fix
 export class UnitPass {
   private atlasTex: WebGLTexture;
+  private disposed = false;
   
   constructor(...) {
     ...
-    this.loadAtlas();
+    void this.loadAtlas();
   }
   
   private async loadAtlas(): Promise<void> {
-    const img = new Image();
-    img.src = unitAtlasUrl;
-    await img.decode();
-    const gl = this.gl;
-    gl.bindTexture(gl.TEXTURE_2D, this.atlasTex);
-    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
-    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
-    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+    try {
+      const img = new Image();
+      img.src = unitAtlasUrl;
+      await img.decode();
+      if (this.disposed) return;
+      const gl = this.gl;
+      gl.bindTexture(gl.TEXTURE_2D, this.atlasTex);
+      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+    } catch {
+      // Placeholder atlas stays active on load failure.
+    }
   }
   
   dispose(): void {
+    this.disposed = true;
     const gl = this.gl;
     gl.deleteProgram(this.program);
     ...
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/unit-pass.ts` around lines 270 - 271, The call to
loadAtlas() is fire-and-forget and can cause unhandled promise rejections and GL
writes after dispose; modify the loadAtlas function to introduce a disposed flag
(tied to the existing dispose() logic), wrap GL texture creation/update steps in
try/catch, await img.decode() then immediately check the disposed flag before
performing any GL calls, and ensure any caught errors are handled/logged to
prevent unhandled rejections; reference the loadAtlas function, img.decode()
call, GL texture upload logic, and the dispose method when making these changes.
src/client/render/frame/railroad-cache.ts-124-124 (1)

124-124: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Shared-tile removals can leave the wrong rail type encoded.

tileRefCount only tells you that some railroad still owns the tile; it does not tell you which RailType should survive there. If two railroads share a junction tile and one is removed, removeRailroad() keeps the previous railroadState whenever the count stays above zero, so the GPU can keep drawing the deleted railroad's orientation.

Also applies to: 223-239

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/frame/railroad-cache.ts` at line 124, tileRefCount
currently tracks only total references per tile (Map<number, number>), which
lets removals leave the wrong RailType encoded; update the ownership model to
track references per railroad/rail type so you can recompute the surviving
RailType on removal. Replace tileRefCount with a per-tile map of owners (e.g.,
Map<number, Map<railroadId, RailType>> or Map<number, Map<RailType, count>>) and
in removeRailroad() decrement only that railroad's entry, then recompute the
tile's railroadState from the remaining entries (choose the remaining railroad's
RailType or the highest-count RailType) and update the GPU state accordingly so
shared junctions adopt the correct orientation; apply the same fix pattern to
the logic referenced around the 223-239 removal code paths.
src/client/render/gl/passes/conquest-popup-pass.ts-56-60 (1)

56-60: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Localize the popup suffixes and sign formatting.

formatGold() and the popup builders hardcode "K", "M", "+", and "-" into user-visible strings, so these popups bypass the repo's translation flow. Please move the visible pieces through translateText()/en.json, or through a localized formatter wired into that flow.

As per coding guidelines "src/client/**/*.{ts,tsx}: All user-visible text must go through translateText() function with corresponding entries in resources/lang/en.json"

Also applies to: 231-264

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/conquest-popup-pass.ts` around lines 56 - 60,
formatGold() and the popup builder code currently hardcode user-visible suffixes
("K","M") and sign characters ("+","-"); update formatGold and the popup
creation logic in conquest-popup-pass.ts to use translateText() for all visible
strings (e.g., "K", "M", "+", "-") and wire any numeric-formatting through a
localized formatter if available, then add matching keys in
resources/lang/en.json; ensure you replace direct string concatenation in
formatGold and the popup builders with translateText(...) calls (or the
formatter) so all user-facing text follows the repo's translation flow.
src/client/WebGLFrameBuilder.ts-53-67 (1)

53-67: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This path rebuilds and uploads the whole tile state on every update.

update() ignores the delta information in gu, walks every tile in fillTileState(), and then calls uploadTileAndTrailState() unconditionally. That turns sparse live updates into O(map size) CPU work plus a full GPU upload every tick, which is a big regression risk for larger matches.

Also applies to: 181-193

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/WebGLFrameBuilder.ts` around lines 53 - 67, The update() path
currently rebuilds and uploads the entire map every tick (fillTileState() +
view.uploadTileAndTrailState()), ignoring the delta info in gu; change update()
and the analogous block at the other mentioned range to apply only the sparse
deltas from gu: read gu's changed tile/unit/structure lists and update
this.tileState and this.unitMap only for those coordinates instead of walking
every tile in fillTileState(); add a minimal api on view (or reuse
uploadTileAndTrailState) to accept incremental updates or per-row dirty ranges
and call that with only the changed tiles/trail rows; keep
trailManager.update/clearDirtyRows compatible by marking only affected trail
rows from gu so GPU uploads and CPU work are proportional to the delta, not the
whole map.
src/client/render/gl/passes/name-pass/index.ts-109-109 (1)

109-109: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't size the name textures from the initial player list when late joins are supported.

this.maxPlayers = header.maxPlayers ?? header.players.length under-allocates every CPU/GPU row buffer when players arrive after construction. addPlayers() can then assign slots past the allocated capacity, and the next string/player-data write will overflow the fixed row budget for this pass.

Also applies to: 181-192

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/name-pass/index.ts` at line 109, The code sets
this.maxPlayers from header.players.length which under-allocates buffers when
late joins occur; change the initialization to prefer header.maxPlayers
(this.maxPlayers = header.maxPlayers ???) and never use header.players.length as
the capacity; instead use a declared safe upper bound (or require
header.maxPlayers) for initial CPU/GPU row buffer allocation, and update
addPlayers() to either grow this.maxPlayers and reallocate the row buffers
(CPU/GPU) before assigning new slots or to reject/handle overflow safely —
modify the constructor/site that assigns this.maxPlayers and the buffer
allocation logic (and the addPlayers() implementation referenced later) to
perform capacity checks and reallocation to prevent writes past the allocated
row budget.
src/client/render/gl/passes/name-pass/text-layout.ts-30-31 (1)

30-31: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle empty strings before visual-bounds math.

When text is empty, len is 0, and lastCode becomes invalid, producing NaN width/layout.

Patch
  const len = Math.min(text.length, MAX_CHARS);
+  if (len === 0) {
+    return 0;
+  }

Also applies to: 50-56

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/name-pass/text-layout.ts` around lines 30 - 31,
The code computes visual-bounds math using len and lastCode which breaks when
text is empty; add a guard after computing const len = Math.min(text.length,
MAX_CHARS) to handle len === 0 (either return an empty layout immediately or
skip the lastCode/visual-bounds calculations), and ensure any variables like
lastCode and subsequent width/layout math are not executed for len === 0; update
the same check around the other block referenced (lines ~50-56) so both places
short-circuit for empty text.
src/client/ClientGameRunner.ts-247-250 (1)

247-250: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add teardown for debug DOM/event resources.

The debug canvas and global keydown listener are attached but no cleanup handle is returned. Re-entering games can stack listeners/canvases.

Suggested direction
-function mountWebGLDebugRenderer(...): { builder: WebGLFrameBuilder; syncCamera: () => void } {
+function mountWebGLDebugRenderer(...): {
+  builder: WebGLFrameBuilder;
+  syncCamera: () => void;
+  dispose: () => void;
+} {
   ...
-  window.addEventListener("keydown", (e) => {
+  const onKeyDown = (e: KeyboardEvent) => {
     if (e.key === "\\") {
       glCanvas.style.display =
         glCanvas.style.display === "none" ? "block" : "none";
     }
-  });
+  };
+  window.addEventListener("keydown", onKeyDown);
   ...
-  return { builder: new WebGLFrameBuilder(view, gameView), syncCamera };
+  return {
+    builder: new WebGLFrameBuilder(view, gameView),
+    syncCamera,
+    dispose: () => {
+      window.removeEventListener("keydown", onKeyDown);
+      glCanvas.remove();
+    },
+  };
}

Also applies to: 265-270

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/ClientGameRunner.ts` around lines 247 - 250, ClientGameRunner is
adding a debug canvas (created via createCanvas and assigned to glCanvas with id
"webgl-debug-canvas") and a global keydown listener but never removes them,
causing duplicates on re-entry; update the code that creates these resources
(the createCanvas/glCanvas block and the keydown event registration) to return
or attach a cleanup function/handle (or implement a dispose method on
ClientGameRunner) that removes the canvas from document.body (selecting by the
"webgl-debug-canvas" id or the stored glCanvas reference) and calls
removeEventListener for the keydown handler, and ensure this cleanup is invoked
when the game is torn down or before creating a new debug canvas so
listeners/canvases do not stack.
src/client/ClientGameRunner.ts-344-350 (1)

344-350: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate the WebGL mirror behind a debug flag.

This path mounts and syncs a second renderer for every game session, even outside debugging. That adds avoidable CPU/GPU work on the hot frame loop.

Suggested direction
-    const { builder: webglBuilder, syncCamera } = mountWebGLDebugRenderer(
-      gameMap,
-      gameView,
-      gameRenderer.transformHandler,
-    );
-    gameRenderer.onPreRender = syncCamera;
+    let webglBuilder: WebGLFrameBuilder | null = null;
+    if (userSettings.debugWebGLMirror?.()) {
+      const mounted = mountWebGLDebugRenderer(
+        gameMap,
+        gameView,
+        gameRenderer.transformHandler,
+      );
+      webglBuilder = mounted.builder;
+      gameRenderer.onPreRender = mounted.syncCamera;
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/ClientGameRunner.ts` around lines 344 - 350, Wrap the call to
mountWebGLDebugRenderer and the assignment gameRenderer.onPreRender = syncCamera
behind a runtime debug flag (e.g. DEBUG_WEBGL_MIRROR or a
config.debug.webglMirror) so the WebGL mirror is only created when debugging;
locate mountWebGLDebugRenderer(...) and webglBuilder/syncCamera references and
conditionally call it, avoid assigning gameRenderer.onPreRender when syncCamera
is undefined, and ensure any created webglBuilder is properly disposed/cleaned
up when the game session ends.
src/client/render/gl/debug/layout.ts-9-746 (1)

9-746: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Route the debug labels through translateText().

This tree is full of user-visible labels, but they are all hardcoded English strings. Please move them behind translateText() and add matching en.json entries so the new debug UI follows the client i18n rule. As per coding guidelines src/client/**/*.{ts,tsx}: "All user-visible text must go through translateText() function with corresponding entries in resources/lang/en.json".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/debug/layout.ts` around lines 9 - 746, The buildTree
function returns many user-visible labels passed as raw strings to helper
constructors (folder, toggle, slider, color, select); wrap each label string in
translateText(...) and add corresponding keys/entries to resources/lang/en.json
(use a clear namespace like debug.layout.<Section>.<Label> or derive keys from
folder names and field names) so every label (folder titles like "Pass Enables",
slider/ toggle/ color labels like "Bloom Color", option labels like "Mode", and
any custom tooltip strings) goes through translateText; update calls in
buildTree to reference the new keys via translateText and ensure en.json
contains matching English values.
src/client/render/gl/game-view.ts-72-100 (1)

72-100: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Dispose never detaches the DOM event handlers.

All of these listeners are added with anonymous functions, so dispose() cannot remove them. A disposed GameView will still react to document/canvas events, and remounting another view will duplicate input handling.

Also applies to: 403-409

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/game-view.ts` around lines 72 - 100, The event listeners
on canvas and document are added with inline anonymous callbacks so dispose()
cannot remove them; update GameView to store the bound handler functions as
properties (e.g., this._onPointerDown, this._onPointerMove, this._onPointerUp,
this._onPointerCancel, this._onWheel, this._onContextMenu, this._onDblClick,
this._onAuxClick, this._onKeyDown, this._onKeyUp) that call the corresponding
this.interaction.handle* methods, attach those named handlers instead of
anonymous functions, and then call removeEventListener for each stored handler
inside dispose() to fully detach all listeners (include pointercancel, wheel,
contextmenu, dblclick, auxclick, keydown, keyup).
src/client/render/gl/passes/sam-radius-pass.ts-258-270 (1)

258-270: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rebuild owner-mode instances after palette or cluster changes.

updateStructures() writes color/group data into the GPU buffer, but setPaletteData() and setAllianceClusters() only update fields. In owner mode, changing either input leaves stale colors and stale merged arcs on screen until some later structure refresh happens.

Suggested fix
  setPaletteData(data: Float32Array): void {
    this.paletteData = data;
+   if (this.colorMode === "owner") this.rebuild();
  }

  setAllianceClusters(clusters: Map<number, number>): void {
    this.allianceClusters = clusters;
+   if (this.colorMode === "owner") this.rebuild();
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/sam-radius-pass.ts` around lines 258 - 270,
setPaletteData and setAllianceClusters only update internal fields but don’t
refresh GPU owner-mode instances, so colors and merged arcs remain stale; modify
setPaletteData(data: Float32Array) and setAllianceClusters(clusters: Map<number,
number>) to, after updating this.paletteData / this.allianceClusters, call
this.rebuild() when this.colorMode === "owner" (so owner-mode structures created
in updateStructures() get recreated with the new palette/cluster data);
reference setPaletteData, setAllianceClusters, colorMode, rebuild(), and
updateStructures() when making the change.
🟡 Minor comments (12)
src/client/render/gl/passes/point-light-pass.ts-139-145 (1)

139-145: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Track and dispose the quad vertex buffer.

The quad buffer is allocated but never deleted. Recreating this pass leaks GPU memory.

💡 Suggested fix
 export class PointLightPass {
@@
   private lightVao: WebGLVertexArrayObject;
   private lightBuf: WebGLBuffer;
+  private quadBuf: WebGLBuffer;
@@
-    const quadBuf = gl.createBuffer()!;
-    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
+    this.quadBuf = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
@@
   dispose(): void {
     const gl = this.gl;
     gl.deleteProgram(this.lightProg);
     gl.deleteVertexArray(this.lightVao);
     gl.deleteBuffer(this.lightBuf);
+    gl.deleteBuffer(this.quadBuf);
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/point-light-pass.ts` around lines 139 - 145, The
quad vertex buffer (quadBuf) is created but never freed, leaking GPU memory;
store the buffer on the pass instance (e.g., this.quadBuf) when creating it in
the point-light-pass initialization and call gl.deleteBuffer(this.quadBuf) in
the pass's dispose/cleanup method (or the existing destroy method) to ensure the
buffer is deleted when the pass is torn down; also null out this.quadBuf after
deletion and remove any lingering bindings to avoid use-after-free.
src/client/render/gl/render-settings.ts-251-253 (1)

251-253: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Delay URL.revokeObjectURL to avoid download race.

Revoking right after Line 251 can cancel downloads in some environments. Revoke on a short timeout.

Suggested fix
   a.download = "render-settings.json";
   a.click();
-  URL.revokeObjectURL(url);
+  setTimeout(() => URL.revokeObjectURL(url), 0);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/render-settings.ts` around lines 251 - 253, The
immediate call to URL.revokeObjectURL(url) after a.click() can abort some
downloads; update the function that creates the anchor and calls a.click() to
revoke the object URL asynchronously (e.g., wrap URL.revokeObjectURL(url) in a
short timeout or next tick). Locate the code that calls a.click() and uses the
variable url and replace the direct revoke with a delayed revoke (setTimeout or
similar) so the download can start before URL.revokeObjectURL(url) is invoked.
src/client/render/gl/debug/index.ts-12-12 (1)

12-12: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize the debug GUI title.

"Render Settings" is user-visible text and should be routed through translateText() with an en.json key.

As per coding guidelines "All user-visible text must go through translateText() function with corresponding entries in resources/lang/en.json".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/debug/index.ts` at line 12, Replace the hardcoded GUI
title string with a call to translateText and add the matching en.json key:
change the GUI instantiation (const gui = new GUI({ title: "Render Settings",
width: 320 });) to use title: translateText('<your_key_here>') (e.g.,
'gui.renderSettings'), and add the corresponding entry in resources/lang/en.json
("gui.renderSettings": "Render Settings"); ensure you import translateText if
not already imported.
src/client/render/gl/passes/name-pass/icon-program.ts-110-148 (1)

110-148: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Prevent late atlas callbacks from allocating textures after disposal.

When dispose() happens before image load completion, img.onload can still allocate textures and mark the pass ready, leaking GPU memory.

Suggested fix
 export class IconProgram {
+  private disposed = false;
@@
   private loadAtlases(): void {
     const gl = this.gl;
     const load = (url: string, cb: (tex: WebGLTexture) => void) => {
       const img = new Image();
       img.onload = () => {
+        if (this.disposed) return;
         const tex = gl.createTexture()!;
@@
   dispose(): void {
+    this.disposed = true;
     const gl = this.gl;
     gl.deleteProgram(this.program);
     if (this.flagAtlasTex) gl.deleteTexture(this.flagAtlasTex);
     if (this.emojiAtlasTex) gl.deleteTexture(this.emojiAtlasTex);
   }
 }

Also applies to: 180-185

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/name-pass/icon-program.ts` around lines 110 -
148, The image onload handlers in loadAtlases can run after dispose and allocate
GPU textures; modify loadAtlases and dispose so the onload callback first checks
a disposed flag (e.g., this.disposed) and returns early if true, and ensure
dispose nulls or removes img.onload handlers to prevent them firing; update the
onload paths that create textures (producing flagAtlasTex/emojiAtlasTex and
setting iconsReady) to only do so when this.disposed is false, and ensure
dispose also deletes any created WebGL textures if necessary.
src/client/render/gl/passes/name-pass/status-icon-program.ts-103-123 (1)

103-123: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard async atlas loading after disposal.

If dispose() runs before the image finishes loading, img.onload can still allocate a texture and store it on a disposed pass. This leaks GPU resources.

Suggested fix
 export class StatusIconProgram {
+  private disposed = false;
@@
   private loadAtlas(): void {
     const gl = this.gl;
     const img = new Image();
     img.onload = () => {
+      if (this.disposed) return;
       const tex = gl.createTexture()!;
@@
       this.statusAtlasTex = tex;
       this.atlasReady = true;
     };
@@
   dispose(): void {
+    this.disposed = true;
     const gl = this.gl;
     gl.deleteProgram(this.program);
     if (this.statusAtlasTex) gl.deleteTexture(this.statusAtlasTex);
   }
 }

Also applies to: 158-162

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/name-pass/status-icon-program.ts` around lines
103 - 123, The image onload handler in loadAtlas currently creates a GL texture
even if the pass has been disposed, leaking GPU resources; modify the img.onload
callbacks (in loadAtlas and the other onload at lines ~158-162) to first check a
disposed flag (e.g., this.disposed) and return early if set, and only then
create and assign the texture (this.statusAtlasTex) and set this.atlasReady;
also ensure dispose() sets this.disposed = true, removes or nulls pending
img.onload handlers, and deletes any created texture via
gl.deleteTexture(this.statusAtlasTex) to fully guard against leaks.
src/client/render/gl/passes/railroad-pass.ts-192-193 (1)

192-193: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Release the pass-owned VAO in dispose().

this.vao is created in the constructor but never deleted, so this pass leaks GPU resources.

Suggested fix
   dispose(): void {
     const gl = this.gl;
     gl.deleteProgram(this.program);
+    gl.deleteVertexArray(this.vao);
     gl.deleteTexture(this.railroadTex);
     gl.deleteTexture(this.ghostRailTex);
     gl.deleteTexture(this.terrainTex);

Also applies to: 332-339

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/railroad-pass.ts` around lines 192 - 193, The
pass creates a VAO via this.vao = createMapQuad(gl, mapW, mapH) (and again in
the other creation block around lines 332-339) but never frees it; add cleanup
in the class dispose() method to check for this.vao, call
gl.deleteVertexArray(this.vao) (or the appropriate deletion function for your GL
context), and set this.vao = null to avoid double-free and leaking GPU
resources; apply the same pattern for any other pass-owned GL objects created in
those blocks.
src/client/render/frame/derive/attack-rings.ts-35-41 (1)

35-41: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep the targeted extractor aligned with the transport-only contract.

extractAttackRingsFromIds currently trusts transportIds completely, but unlike the full-scan variant it does not re-check u.unitType === UT_TRANSPORT. A stale/misclassified ID can produce incorrect attack rings.

Suggested patch
   for (const id of transportIds) {
     const u = units.get(id);
-    if (!u || u.targetTile === null || !u.isActive || u.retreating) continue;
+    if (
+      !u ||
+      u.unitType !== UT_TRANSPORT ||
+      u.targetTile === null ||
+      !u.isActive ||
+      u.retreating
+    ) continue;
     if (ownerFilter !== undefined && u.ownerID !== ownerFilter) continue;
     const t = u.targetTile;
     rings.push({ x: t % mapW, y: (t - (t % mapW)) / mapW, unitId: u.id });
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/frame/derive/attack-rings.ts` around lines 35 - 41, The
loop in extractAttackRingsFromIds trusts transportIds and can include
stale/non-transport units; add an explicit check that u.unitType ===
UT_TRANSPORT (using the UT_TRANSPORT enum/constant) before pushing a ring.
Update the loop in attack-rings.ts to skip entries where u is falsy or
u.unitType !== UT_TRANSPORT (in addition to the existing checks like
u.targetTile === null, !u.isActive, u.retreating and ownerFilter), so only
confirmed transport units produce rings.
src/client/render/gl/passes/move-indicator-pass.ts-57-59 (1)

57-59: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Store the quad buffer as a class field and delete it in dispose().

Line 57 creates a WebGL buffer that is never deleted. The VAO at line 55 stores a reference to this buffer, but deleting the VAO does not release the buffer. This leaks GPU memory each time the pass is recreated.

Store the buffer as private quadBuf: WebGLBuffer and delete it in dispose() with gl.deleteBuffer(this.quadBuf).

Suggested fix
 export class MoveIndicatorPass {
   private gl: WebGL2RenderingContext;
   private settings: RenderSettings;
   private program: WebGLProgram;
   private vao: WebGLVertexArrayObject;
+  private quadBuf: WebGLBuffer;

   private uCamera: WebGLUniformLocation;
     // Unit quad [0,1]
     this.vao = gl.createVertexArray()!;
     gl.bindVertexArray(this.vao);
-    const buf = gl.createBuffer()!;
-    gl.bindBuffer(gl.ARRAY_BUFFER, buf);
+    this.quadBuf = gl.createBuffer()!;
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
     gl.bufferData(
   dispose(): void {
     const gl = this.gl;
     gl.deleteProgram(this.program);
+    gl.deleteBuffer(this.quadBuf);
     gl.deleteVertexArray(this.vao);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/move-indicator-pass.ts` around lines 57 - 59, The
WebGL quad buffer created with gl.createBuffer() in MoveIndicatorPass is never
deleted, leaking GPU memory; add a private field (e.g. private quadBuf:
WebGLBuffer | null) to the MoveIndicatorPass class, assign the created buffer to
this.quadBuf instead of a local variable when creating it (where buf is
currently created), and then release it in dispose() by calling
gl.deleteBuffer(this.quadBuf) (and nulling it) alongside existing VAO cleanup to
ensure the buffer is freed when the pass is destroyed.
src/client/render/gl/passes/bar-pass.ts-94-95 (1)

94-95: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add quadBuf as an instance variable and delete it in dispose().

Line 94 creates quadBuf as a local variable, but it is never deleted. This leaks one WebGL buffer per BarPass instance. Move quadBuf to a class instance variable and call this.gl.deleteBuffer(this.quadBuf) in the dispose() method alongside the existing buffer cleanup.

Suggested fix
 export class BarPass {
   private gl: WebGL2RenderingContext;
   private settings: RenderSettings;
   private program: WebGLProgram;
   private maxBars = 2048;
 
   private uCamera: WebGLUniformLocation;
   private uBarSize: WebGLUniformLocation;
   private uBarOffset: WebGLUniformLocation;
   private uBorderWidth: WebGLUniformLocation;
   private uThresholds: WebGLUniformLocation;
   private uColorRed: WebGLUniformLocation;
   private uColorOrange: WebGLUniformLocation;
   private uColorYellow: WebGLUniformLocation;
   private uColorGreen: WebGLUniformLocation;
 
   private vao: WebGLVertexArrayObject;
+  private quadBuf: WebGLBuffer;
   private instanceBuf: WebGLBuffer;
 
     // Quad vertices (2 triangles)
-    const quadBuf = gl.createBuffer()!;
+    this.quadBuf = gl.createBuffer()!;
-    gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
+    gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
 
   dispose(): void {
     this.gl.deleteProgram(this.program);
+    this.gl.deleteBuffer(this.quadBuf);
     this.gl.deleteBuffer(this.instanceBuf);
     this.gl.deleteVertexArray(this.vao);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/bar-pass.ts` around lines 94 - 95, The quadBuf is
created as a local in the BarPass constructor and never deleted, leaking a WebGL
buffer; make quadBuf an instance property on the BarPass class (e.g.,
this.quadBuf) where it's currently created (replace const quadBuf = ... with
this.quadBuf = ...) and add a deletion in the dispose() method by calling
this.gl.deleteBuffer(this.quadBuf) alongside the existing buffer cleanup, then
remove any leftover local references so all uses reference this.quadBuf.
src/client/render/gl/debug/wiring.ts-71-72 (1)

71-72: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Route debug menu labels through translateText().

These strings are user-visible and currently hardcoded.

As per coding guidelines src/client/**/*.{ts,tsx}: “All user-visible text must go through translateText() function with corresponding entries in resources/lang/en.json.”

Also applies to: 96-109, 196-197

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/debug/wiring.ts` around lines 71 - 72, The GUI labels
are hardcoded; update all user-visible strings added via gui.add (e.g., the call
gui.add({ dump: () => dumpSettings(settings) }, "dump").name("Download JSON"))
and the other strings noted (lines ~96-109 and ~196-197) to use translateText()
with matching keys in resources/lang/en.json. Replace literal label arguments
passed to .name(...) and any other visible text in the wiring.ts debug menu with
translateText("your.key.here"), add the corresponding keys/values to en.json,
and ensure dumpSettings/settings references remain unchanged.
src/client/render/gl/passes/border-compute-pass.ts-258-264 (1)

258-264: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Release the VAO in dispose() as well.

this.vao is created but never deleted, so repeated renderer lifecycles leak GPU objects.

Patch
  dispose(): void {
    const gl = this.gl;
    gl.deleteProgram(this.program);
    gl.deleteTexture(this.borderTex);
    gl.deleteTexture(this.relationTex);
    gl.deleteFramebuffer(this.borderFbo);
+    gl.deleteVertexArray(this.vao);
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/border-compute-pass.ts` around lines 258 - 264,
dispose() currently deletes program, textures and framebuffer but never releases
the created VAO, leaking GPU objects; update the dispose() implementation in
border-compute-pass.ts to check for this.vao and call
gl.deleteVertexArray(this.vao) (or the appropriate OES deleteVertexArrayOES if
using the extension), then null out this.vao after deletion so the VAO is
actually released and not retained.
src/client/render/gl/map-interaction.ts-209-247 (1)

209-247: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle modifier changes for hold bindings.

With REPLAY_KEY_BINDINGS, peek turns on only when KeyG goes down with Shift already pressed, and it turns off only when KeyG goes up. Pressing G before Shift, or releasing Shift first, leaves the hold state out of sync with the actual combo.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/map-interaction.ts` around lines 209 - 247, The
hold-binding logic fails when modifiers change after the main keydown; update
handleKeyDown/handleKeyUp to track current pressed keys and evaluate the hold
condition against the current modifier state instead of only the event that
started the hold: add a small Set (e.g. pressedKeys) that you update in
handleKeyDown and handleKeyUp, then replace the one-off activation code to check
matchesHold(e, this.keys.altViewPeek) using the current pressedKeys and
e.getModifierState (or e.shiftKey) so that when Shift is pressed or released
while the main key (this.keys.altViewPeek.code) remains down you start or stop
peek accordingly; adjust matchesHold (or call it with synthesized state) to
consult current modifier state, and ensure peekHeld is toggled and
applyAltView/applyGridView and deps.emit("altviewpeek", ...) are invoked when
the overall hold condition transitions on or off.

coderabbitai[bot]
coderabbitai Bot previously approved these changes May 16, 2026
evanpelle added 17 commits May 16, 2026 13:27
PlayerView/UnitView now wrap renderer-shaped state objects (PlayerState,
PlayerStatic, UnitState) directly instead of holding engine wire types.
GameView owns a long-lived FrameData object kept in sync each tick:
players/units/tiles/trail/railroad are mutated in place; derived buffers
(playerStatus, relationMatrix, allianceClusters, nukeTelegraphs,
attackRings) and events are recomputed in a final populateFrame() pass.

The renderer reads gameView.frameData() and the same byte-identical
state objects PlayerView/UnitView wrap. WebGLFrameBuilder shrinks from
~270 to ~70 LOC: palette management + a single uploadFrameData() call,
no per-frame UnitState allocation on the hot path.

Wiring: maxPlayers=1024 on RendererConfig (pre-sizes NamePass/palette/
relation matrix textures); NamePass disabled so HTML NameLayer remains
the only on-screen player names.

Also: 39 new tests covering PlayerView/GameView/FrameData behavior;
replace .data field access in three layer call sites with accessor
methods (betrayals(), type(), getTraitorRemainingTicks()).
Two issues with mounting the WebGL renderer alongside canvas2D:

1. One-frame camera lag. WebGL had its own RAF loop independent of
   canvas2D's. When the user panned, WebGL's RAF could fire before
   canvas2D's syncCamera ran, drawing with stale camera state.
   Fix: pass a capturing raf/caf to the renderer so its loop never
   actually schedules itself; invoke the captured frame callback
   synchronously from canvas2D's onPreRender hook, after setCameraState.
   Both renderers now lock-step on a single RAF.

2. Layout thrashing. syncCamera read glCanvas.clientWidth/Height every
   frame, forcing a synchronous layout flush — ~11% CPU under Layout.
   Fix: cache canvas dimensions and update via ResizeObserver. Canvas
   size changes are rare; the cached values are accurate between
   resizes.
DynamicUILayer subscribed to ConquestEvent and drew a floating "+gold"
text on capture, but the WebGL renderer now draws the same popup via
ConquestPopupPass (fed through applyConquestEvents in uploadFrameData).
The result was two popups stacked on every capture.

Drop the canvas2D handler. Bonus events and unit-death FX use the same
duplication pattern but are left intact for now — separate change when
the WebGL versions are verified visually.
Structures collapse to a dot when zoomed out past dotsZoomThreshold.
The dot scale was hardcoded to 1.0 / 2.5 (≈0.4) in structure.vert.glsl.

Promote it to a render setting (`structure.dotScale`) so it's tunable
alongside iconSize / dotsZoomThreshold. Default 0.4 preserves current
behavior. Plumbed via uDotScale uniform on StructurePass.
Previously structures capped at iconScale = 1.0 once zoomed past
iconScaleFactorZoomedOut, staying at a fixed pixel size no matter how
far you zoomed in. They felt overlaid on the map instead of part of it.

Add a third zoom band controlled by structure.iconGrowZoom. Past this
threshold iconScale = uZoom / iconGrowZoom — structures grow with the
canvas (world-anchored, fixed map-area coverage). Plumbed via the
uIconGrowZoom uniform on StructurePass.

Default 7 keeps normal play unchanged; only kicks in at deep zoom.
PlayerState.embargoes was string[] of stringified smallIDs — the
renderer parsed each entry with parseInt() to use as an array index.
Flagged in the integration handoff as something that should be number[].

Switch to number[] end-to-end: renderer type, relation-matrix derive
(no parseInt), PlayerView.setEmbargoSmallIDs / hasEmbargoAgainst
(numeric Array.includes, no String() temporaries), and GameView's
embargo translation pass. Also updates the PlayerView test that pinned
the old format.
DynamicUILayer was a canvas2D mix of: bonus-event gold/troops popups
(already duplicated by WebGL BonusPopupPass), nuke/transport telegraph
indicators (duplicated by WebGL passes), and a warship move-indicator
chevron drawn via MoveIndicatorUI. Delete the layer outright along
with its three orphan UI helpers (MoveIndicatorUI, NavalTarget,
NukeTelegraph).

That deletion uncovered a pre-existing bug from the "migrate away from
canvas" commit: warship select/move no longer worked. The deleted
UnitLayer had owned the click flow that emits MoveWarshipIntentEvent.
Re-add the flow inside UILayer (which already tracks selected /
multi-selected warships for its selection box): MouseUpEvent →
move-multi → move-single → select-nearest, plus shift+drag box
complete and select-all hotkey.

Wire MoveWarshipIntentEvent → view.showMoveIndicator(tx, ty, ownerID)
in mountWebGLDebugRenderer so the WebGL MoveIndicatorPass draws the
converging-chevron animation at the move target, colored by the
warship's owner. mountWebGLDebugRenderer now takes gameView + eventBus
to resolve the owner and subscribe.
Stop drawing names on canvas2D — NamePass already gets the placement
data (gameView.frameData().names) and lerps positions in-shader. Drop
the runtime passEnabled.name=false override in ClientGameRunner,
remove NameLayer from the layers list, and delete NameLayer.ts.

Known gaps (deferred):
- Player-uploaded flags not in the bundled atlas render as no-flag;
  needs a JIT atlas built at game start.
- The shared computePlayerStatus is the replay variant, so the
  alliance / target / embargo / nukeTargetsMe status icons stay off
  for the local player's perspective. Needs a live-aware variant.
The replay-path computePlayerStatus left alliance/target/embargo/
nukeTargetsMe at false, which meant the WebGL NamePass had no data
for those status icons after we switched names off canvas2D — they
just stopped appearing.

Add an opts param taking localPlayerID + tileState. When localPlayerID
is set, fill the relative flags by checking the local player's
allies/targets/embargoes against each other player's smallID;
embargo is bilateral (either side). nukeTargetsMe walks active nukes
and checks their targetTile's owner via the tileState buffer.

Plumb localPlayerID = myPlayer?.smallID() and tileState from
populateFrame so the live path uses the new mode. Emit an entry when
only a relative flag is true (previously could be dropped if no base
flag was set).

allianceReq and allianceFraction stay deferred (need local PlayerID
string for outgoing requests and current tick for fraction).

18 new tests covering both modes — replay (relative flags forced
false), and live (alliance one-way, target one-way, embargo bilateral,
self-flags suppressed, nukeTargetsMe with/without tileState,
relative-flag-alone emits, localPlayerID=0 falls back to replay,
allianceReq/allianceFraction stay deferred).
The shift+drag warship selection rectangle was drawn on a second
offscreen canvas, blitted onto the main canvas2D context every frame
via world-coord transform. It's a screen-space rectangle though, so
none of that math was load-bearing.

Replace with a `<div>` positioned via inline left/top/width/height in
screen pixels. Same color tinting (player territoryColor lightened
0.2, dashed border at 0.85 alpha, fill at 0.06). pointer-events:none
so it doesn't intercept the drag.

Drops ~95 LOC of canvas2D drawing (renderSelectionBox, drawDashedLine,
selectionBoxCanvas/Ctx, the redraw() init, the renderLayer() blit).
One step closer to retiring the canvas2D map canvas — UILayer's
per-unit selection outlines are the last canvas2D draws on it.
UnitSelectionEvent now forwards to view.setSelectedUnit(unit.id()) in
mountWebGLDebugRenderer; the renderer's SelectionBoxPass draws the
animated stippled outline on the GPU. UILayer still tracks
selectedUnit for game-logic readers (the click handlers) but no longer
paints to canvas2D for it.

Drops drawSelectionBox + lastSelectionBoxCenter (~50 LOC) plus the
per-tick single-unit redraw in tick(). Multi-selection stays on
canvas2D — SelectionBoxPass is single-unit only.

Test update: replaces the now-dead drawSelectionBox spy with a
selectedUnit state assertion + a deselect case.
SelectionBoxPass now stores an array of selections and renders one
quad per entry. GPURenderer gains setSelectedUnits(ids) — the
single-unit setSelectedUnit becomes a wrapper. Position + color are
rebuilt each frame from lastUnits; dead unit IDs get pruned in place.

ClientGameRunner's UnitSelectionEvent listener forwards both single
and multi to view.setSelectedUnits — no more single/multi split.

UILayer drops everything canvas2D-related: the offscreen canvas +
context, theme, selectionAnimTime, multiSelectionBoxCenters,
SELECTION_BOX_SIZE, drawSelectionBoxMulti, paintSelectionBoxAt,
clearSelectionBox, paintCell, clearCell, and renderLayer / redraw /
shouldTransform. tick() now only prunes destroyed warships from the
selection list; the layer is purely state + click handling. ~120 LOC
gone.

Tests: UILayer.test.ts updated — drops the canvas/redraw asserts,
adds a multi-selection state assertion.
The WebGL ghost (StructurePass + RangeCirclePass + RailroadPass +
CrosshairPass) is fed via GhostPreviewUpdatedEvent and looks correct;
the Pixi-rendered ghost was a duplicate.

Strip the Pixi side out of StructureIconsLayer:
- delete imports for pixi.js / pixi-filters / colord plugins / Theme /
  SpriteFactory / StructureDrawingUtils / bitmapFont / renderNumber
- delete fields: pixicanvas, ghostStage, rootStage, renderer,
  rendererInitialized, theme, factory, filterRedArray, rebuildPending,
  and the Pixi half of ghostUnit (container, priceText, priceBg,
  priceGroup, priceBox, range, rangeLevel, targetingAlly) — now just
  { buildableUnit }
- delete methods: setupRenderer, redraw, rendererOrGLContextLost,
  resizeCanvas, renderLayer, shouldTransform, updateGhostPrice,
  updateGhostRange, plus the Pixi guts of moveGhost /
  createGhostStructure / clearGhostStructure

init() is now sync; the per-RAF state checks move to a tick()-driven
syncGhostState() + renderGhost() (renderGhost still re-queries
buildables, just no longer paints anything).

Net: ~250 LOC gone, no canvas2D drawing left in the layer. The
canvas2D map canvas itself has no remaining writers — ready to be
deleted in a follow-up.
After every map-anchored visual moved to WebGL (terrain, territory,
structures, names, selection boxes, ghost preview, move chevrons,
FX) there's nothing drawing to the canvas2D context. Rip it out.
The Layer interface dates to the canvas2D era when each entry drew to
the shared 2D context via renderLayer(ctx). With canvas2D gone, nothing
draws there and the renderLayer hook is dead. Rename the interface
("main-thread analog of the worker's Execution") and trim it:

  interface Controller {
    init?: () => void;
    tick?: () => void;
    getTickIntervalMs?: () => number;
  }

renderLayer / shouldTransform / redraw are gone.

Sweep across 28 files: from "./Layer" → "./Controller", implements
Layer → implements Controller, Layer[] / Map<Layer,…> →
Controller[] / Map<Controller,…>. Delete the no-op renderLayer +
shouldTransform method bodies that every layer had inherited.

GameRenderer drops the RedrawGraphicsEvent listener + redraw() fanout
(nothing implements redraw anymore) and the now-unused eventBus
constructor field.

One real case: AttackingTroopsOverlay.renderLayer wasn't a no-op — it
updates DOM label transforms each frame so labels track the WebGL
camera during pan/zoom. Rename to private updateLabelDOM() and start
a self-driven RAF in init() so the per-frame updates keep running.

Class names ending in "Layer" (UILayer, StructureIconsLayer, NameLayer,
etc.) intentionally left as-is — those are separate identifiers and
the class-rename / file-move is a follow-up.

407 tests pass.
…/controllers/

UILayer → WarshipSelectionController and StructureIconsLayer →
BuildPreviewController. These are the two real Controller implementations
(state + click handling, no rendering) — the new names + location reflect
what they actually do now that all rendering lives in WebGL passes.
…lays

BuildPreviewController and WarshipSelectionController now take the WebGL
view in their constructor and call view.updateGhostPreview /
view.setSelectedUnits themselves instead of emitting bus events that
ClientGameRunner forwarded. Splits the old mountWebGLDebugRenderer in
two — createWebGLView builds the view up front so the renderer can wire
controllers to it, mountWebGLDebugRenderer does the per-frame plumbing
after the transformHandler exists. GhostPreviewUpdatedEvent had no
remaining consumers and is removed.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
src/client/ClientGameRunner.ts (2)

310-315: 💤 Low value

Missing cleanup for keydown listener.

The keydown listener for toggling the WebGL canvas is added to window but never removed. In a long-running session or if the game is stopped and restarted, this can accumulate duplicate listeners.

Consider returning a cleanup function from mountWebGLDebugRenderer or storing the listener reference for later removal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/ClientGameRunner.ts` around lines 310 - 315, The keydown listener
added in mountWebGLDebugRenderer (window.addEventListener("keydown", ...)) never
gets removed; update mountWebGLDebugRenderer to either return a cleanup function
that removes that listener or store the handler reference and call
window.removeEventListener("keydown", handler) when unmounting/stopping the
game; specifically capture the listener callback (the function checking e.key
=== "\\") as a named/const handler so it can be passed to removeEventListener
and ensure any code that stops/restarts the renderer calls the cleanup.

243-248: 💤 Low value

Nested loop for terrain bytes could be simplified.

The double loop is fine, but consider using a single linear iteration for clarity and a small perf win:

Optional simplification
-  for (let y = 0; y < mapHeight; y++) {
-    for (let x = 0; x < mapWidth; x++) {
-      terrainBytes[y * mapWidth + x] = gameMap.terrainByte(gameMap.ref(x, y));
-    }
-  }
+  for (let i = 0; i < mapWidth * mapHeight; i++) {
+    const x = i % mapWidth;
+    const y = Math.floor(i / mapWidth);
+    terrainBytes[i] = gameMap.terrainByte(gameMap.ref(x, y));
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/ClientGameRunner.ts` around lines 243 - 248, Replace the nested
x/y loops that fill terrainBytes with a single linear loop over i from 0 to
mapWidth*mapHeight-1; compute x = i % mapWidth and y = Math.floor(i / mapWidth)
and set terrainBytes[i] = gameMap.terrainByte(gameMap.ref(x, y)). Update the
block in ClientGameRunner (variables: terrainBytes, mapWidth, mapHeight,
gameMap) accordingly to simplify iteration and slightly improve performance.
src/client/controllers/BuildPreviewController.ts (1)

135-138: 💤 Low value

Unused variable targetingAlly with void cast.

The targetingAlly variable is computed but explicitly voided. If the ally-targeting warning visual is planned for later, consider removing this dead code and adding it back when the feature is implemented. Otherwise, this adds unnecessary computation each frame.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/controllers/BuildPreviewController.ts` around lines 135 - 138,
Remove the dead `targetingAlly` computation and the `void targetingAlly;` line
from BuildPreviewController (the variable and any upstream calculation that only
exists to be void-cast), or if you intend to keep it for a planned visual
feature, instead stop computing it each frame and defer its calculation until
the feature is used; reference the `targetingAlly` symbol and its associated
computation in the BuildPreviewController to locate and remove/refactor those
lines.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/ClientGameRunner.ts`:
- Around line 322-331: The ResizeObserver instance created as resizeObs
(observing glCanvas and updating cachedCanvasW/cachedCanvasH) is never
disconnected; make resizeObs a field on the ClientGameRunner instance (instead
of a local const) so it can be accessed later, and call resizeObs.disconnect()
in the game's cleanup/stop method (e.g., in the existing stop/destroy/cleanup
function) to unobserve glCanvas and release resources. Ensure you null out the
field after disconnecting to avoid retaining references.
- Around line 367-375: The RAF loop created in driveFrame uses
requestAnimationFrame without ever saving its handle, so it never stops; modify
the start-up code that calls requestAnimationFrame(driveFrame) to store the
returned handle (e.g., this.driveRafId or a local field) and have driveFrame
update that field each frame, and then cancel that RAF in
ClientGameRunner.stop() (call cancelAnimationFrame(this.driveRafId) and clear
the field) as part of cleanup; ensure you check for presence of the handle
before calling cancelAnimationFrame to avoid errors.

In `@src/client/controllers/WarshipSelectionController.ts`:
- Around line 89-102: The created div (dragRectEl) is appended in
ensureDragRectEl but never removed; add a public dispose() method on
WarshipSelectionController that checks this.dragRectEl, calls remove() on the
element (or document.body.removeChild), and sets this.dragRectEl = null to
prevent duplicates and leaks; update any controller teardown path (where the
controller is destroyed on game end/restart) to call
WarshipSelectionController.dispose() so the element is cleaned up.

In `@src/client/graphics/layers/AttackingTroopsOverlay.ts`:
- Around line 91-99: The RAF loop started in the constructor via the drive()
function keeps re-scheduling forever and isn’t cancelled on teardown; capture
the requestAnimationFrame handle (e.g., store it on this.rafId when calling
requestAnimationFrame(drive)) and change the loop to update that handle each
frame, then cancel it in destroy() using cancelAnimationFrame(this.rafId); also
ensure any re-init path clears an existing rafId before starting a new loop so
multiple loops cannot stack (references: drive, updateLabelDOM, destroy).

In `@src/client/WebGLFrameBuilder.ts`:
- Around line 52-69: The writePaletteEntry method currently writes into
this.palette using smallID without validating bounds, risking out-of-range
writes when smallID >= PALETTE_SIZE; update WebGLFrameBuilder.writePaletteEntry
to check that smallID is within 0 <= smallID < PALETTE_SIZE (use the
PALETTE_SIZE constant) before computing offsets and writing to this.palette, and
on violation either early-return (or throw/log an error) to prevent
out-of-bounds writes—apply this guard for both fill and border writes so the
Float32Array is never indexed past its length.

---

Nitpick comments:
In `@src/client/ClientGameRunner.ts`:
- Around line 310-315: The keydown listener added in mountWebGLDebugRenderer
(window.addEventListener("keydown", ...)) never gets removed; update
mountWebGLDebugRenderer to either return a cleanup function that removes that
listener or store the handler reference and call
window.removeEventListener("keydown", handler) when unmounting/stopping the
game; specifically capture the listener callback (the function checking e.key
=== "\\") as a named/const handler so it can be passed to removeEventListener
and ensure any code that stops/restarts the renderer calls the cleanup.
- Around line 243-248: Replace the nested x/y loops that fill terrainBytes with
a single linear loop over i from 0 to mapWidth*mapHeight-1; compute x = i %
mapWidth and y = Math.floor(i / mapWidth) and set terrainBytes[i] =
gameMap.terrainByte(gameMap.ref(x, y)). Update the block in ClientGameRunner
(variables: terrainBytes, mapWidth, mapHeight, gameMap) accordingly to simplify
iteration and slightly improve performance.

In `@src/client/controllers/BuildPreviewController.ts`:
- Around line 135-138: Remove the dead `targetingAlly` computation and the `void
targetingAlly;` line from BuildPreviewController (the variable and any upstream
calculation that only exists to be void-cast), or if you intend to keep it for a
planned visual feature, instead stop computing it each frame and defer its
calculation until the feature is used; reference the `targetingAlly` symbol and
its associated computation in the BuildPreviewController to locate and
remove/refactor those lines.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6fdae6da-bdd9-4e8b-9c7b-da54872373fd

📥 Commits

Reviewing files that changed from the base of the PR and between 53cf2d4 and 7b1557b.

⛔ Files ignored due to path filters (1)
  • src/client/render/gl/shaders/structure/structure.vert.glsl is excluded by !**/*.glsl
📒 Files selected for processing (66)
  • src/client/ClientGameRunner.ts
  • src/client/InputHandler.ts
  • src/client/WebGLFrameBuilder.ts
  • src/client/controllers/BuildPreviewController.ts
  • src/client/controllers/WarshipSelectionController.ts
  • src/client/graphics/GameRenderer.ts
  • src/client/graphics/TransformHandler.ts
  • src/client/graphics/layers/AlertFrame.ts
  • src/client/graphics/layers/AttackingTroopsOverlay.ts
  • src/client/graphics/layers/AttacksDisplay.ts
  • src/client/graphics/layers/BuildMenu.ts
  • src/client/graphics/layers/ChatDisplay.ts
  • src/client/graphics/layers/ChatModal.ts
  • src/client/graphics/layers/ControlPanel.ts
  • src/client/graphics/layers/Controller.ts
  • src/client/graphics/layers/DynamicUILayer.ts
  • src/client/graphics/layers/EventsDisplay.ts
  • src/client/graphics/layers/GameLeftSidebar.ts
  • src/client/graphics/layers/GameRightSidebar.ts
  • src/client/graphics/layers/HeadsUpMessage.ts
  • src/client/graphics/layers/ImmunityTimer.ts
  • src/client/graphics/layers/InGamePromo.ts
  • src/client/graphics/layers/Layer.ts
  • src/client/graphics/layers/Leaderboard.ts
  • src/client/graphics/layers/MainRadialMenu.ts
  • src/client/graphics/layers/MultiTabModal.ts
  • src/client/graphics/layers/NameLayer.ts
  • src/client/graphics/layers/PerformanceOverlay.ts
  • src/client/graphics/layers/PlayerInfoOverlay.ts
  • src/client/graphics/layers/PlayerPanel.ts
  • src/client/graphics/layers/RadialMenu.ts
  • src/client/graphics/layers/ReplayPanel.ts
  • src/client/graphics/layers/SettingsModal.ts
  • src/client/graphics/layers/SpawnTimer.ts
  • src/client/graphics/layers/StructureIconsLayer.ts
  • src/client/graphics/layers/TeamStats.ts
  • src/client/graphics/layers/UILayer.ts
  • src/client/graphics/layers/UnitDisplay.ts
  • src/client/graphics/layers/WinModal.ts
  • src/client/graphics/ui/MoveIndicatorUI.ts
  • src/client/graphics/ui/NavalTarget.ts
  • src/client/graphics/ui/NukeTelegraph.ts
  • src/client/render/frame/derive/player-status.ts
  • src/client/render/frame/derive/relation-matrix.ts
  • src/client/render/gl/game-view.ts
  • src/client/render/gl/passes/selection-box-pass.ts
  • src/client/render/gl/passes/structure-pass.ts
  • src/client/render/gl/render-settings.json
  • src/client/render/gl/render-settings.ts
  • src/client/render/gl/renderer.ts
  • src/client/render/types/renderer.ts
  • src/client/view/GameView.ts
  • src/client/view/PlayerView.ts
  • src/client/view/UnitView.ts
  • src/core/game/GameImpl.ts
  • src/core/game/GameMap.ts
  • src/core/game/GameView.ts
  • tests/client/controllers/BuildPreviewController.test.ts
  • tests/client/controllers/WarshipSelectionController.test.ts
  • tests/client/graphics/UILayer.test.ts
  • tests/client/render/frame/derive/player-status.test.ts
  • tests/client/view/GameView.test.ts
  • tests/client/view/PlayerView.test.ts
  • tests/client/view/UnitView.test.ts
  • tests/core/game/GameMap.tileStateBuffer.test.ts
  • tests/util/viewStubs.ts
💤 Files with no reviewable changes (8)
  • src/client/graphics/ui/MoveIndicatorUI.ts
  • src/client/graphics/ui/NukeTelegraph.ts
  • src/client/graphics/ui/NavalTarget.ts
  • src/client/graphics/layers/Layer.ts
  • src/client/graphics/layers/DynamicUILayer.ts
  • src/client/graphics/layers/NameLayer.ts
  • src/client/graphics/layers/UILayer.ts
  • src/client/graphics/layers/StructureIconsLayer.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/client/render/frame/derive/relation-matrix.ts
  • src/client/render/gl/game-view.ts

Comment on lines +322 to +331
const resizeObs = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) {
cachedCanvasW = width;
cachedCanvasH = height;
}
}
});
resizeObs.observe(glCanvas);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

ResizeObserver is never disconnected.

The ResizeObserver at line 322 observes glCanvas but is never disconnected when the game stops. This keeps the observer alive and can cause memory leaks if the canvas is removed from the DOM.

Store resizeObs and call resizeObs.disconnect() in the cleanup path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/ClientGameRunner.ts` around lines 322 - 331, The ResizeObserver
instance created as resizeObs (observing glCanvas and updating
cachedCanvasW/cachedCanvasH) is never disconnected; make resizeObs a field on
the ClientGameRunner instance (instead of a local const) so it can be accessed
later, and call resizeObs.disconnect() in the game's cleanup/stop method (e.g.,
in the existing stop/destroy/cleanup function) to unobserve glCanvas and release
resources. Ensure you null out the field after disconnecting to avoid retaining
references.

Comment on lines +367 to +375
// Self-driving RAF: syncCamera reads the latest camera state from
// TransformHandler, pushes it to WebGL, and synchronously invokes the
// renderer's captured frame callback (which draws). One RAF = one
// synchronized camera-update + WebGL render.
const driveFrame = (): void => {
syncCamera();
requestAnimationFrame(driveFrame);
};
requestAnimationFrame(driveFrame);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Self-driving RAF loop has no cancellation mechanism.

The driveFrame function schedules itself indefinitely via requestAnimationFrame. When the game stops (ClientGameRunner.stop()), this loop keeps running, potentially causing errors or wasted CPU cycles.

Return the RAF handle and cancel it in the cleanup path:

Proposed fix
+  let rafHandle: number;
   const driveFrame = (): void => {
     syncCamera();
-    requestAnimationFrame(driveFrame);
+    rafHandle = requestAnimationFrame(driveFrame);
   };
-  requestAnimationFrame(driveFrame);
+  rafHandle = requestAnimationFrame(driveFrame);

-  return { builder: new WebGLFrameBuilder(view) };
+  return {
+    builder: new WebGLFrameBuilder(view),
+    stop: () => cancelAnimationFrame(rafHandle),
+  };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/ClientGameRunner.ts` around lines 367 - 375, The RAF loop created
in driveFrame uses requestAnimationFrame without ever saving its handle, so it
never stops; modify the start-up code that calls
requestAnimationFrame(driveFrame) to store the returned handle (e.g.,
this.driveRafId or a local field) and have driveFrame update that field each
frame, and then cancel that RAF in ClientGameRunner.stop() (call
cancelAnimationFrame(this.driveRafId) and clear the field) as part of cleanup;
ensure you check for presence of the handle before calling cancelAnimationFrame
to avoid errors.

Comment on lines +89 to +102
private ensureDragRectEl(): void {
if (this.dragRectEl !== null) return;
const el = document.createElement("div");
el.id = "warship-drag-rect";
el.style.position = "fixed";
el.style.pointerEvents = "none";
el.style.display = "none";
el.style.zIndex = "30";
el.style.borderStyle = "dashed";
el.style.borderWidth = "1px";
el.style.boxSizing = "border-box";
document.body.appendChild(el);
this.dragRectEl = el;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

DOM element dragRectEl is never removed from the body.

The ensureDragRectEl method appends a div to document.body but there is no cleanup. When the game ends or restarts, the element remains in the DOM, potentially accumulating duplicates.

Consider adding a dispose() method:

Proposed cleanup
+  dispose(): void {
+    if (this.dragRectEl) {
+      this.dragRectEl.remove();
+      this.dragRectEl = null;
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private ensureDragRectEl(): void {
if (this.dragRectEl !== null) return;
const el = document.createElement("div");
el.id = "warship-drag-rect";
el.style.position = "fixed";
el.style.pointerEvents = "none";
el.style.display = "none";
el.style.zIndex = "30";
el.style.borderStyle = "dashed";
el.style.borderWidth = "1px";
el.style.boxSizing = "border-box";
document.body.appendChild(el);
this.dragRectEl = el;
}
private ensureDragRectEl(): void {
if (this.dragRectEl !== null) return;
const el = document.createElement("div");
el.id = "warship-drag-rect";
el.style.position = "fixed";
el.style.pointerEvents = "none";
el.style.display = "none";
el.style.zIndex = "30";
el.style.borderStyle = "dashed";
el.style.borderWidth = "1px";
el.style.boxSizing = "border-box";
document.body.appendChild(el);
this.dragRectEl = el;
}
dispose(): void {
if (this.dragRectEl) {
this.dragRectEl.remove();
this.dragRectEl = null;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/controllers/WarshipSelectionController.ts` around lines 89 - 102,
The created div (dragRectEl) is appended in ensureDragRectEl but never removed;
add a public dispose() method on WarshipSelectionController that checks
this.dragRectEl, calls remove() on the element (or document.body.removeChild),
and sets this.dragRectEl = null to prevent duplicates and leaks; update any
controller teardown path (where the controller is destroyed on game end/restart)
to call WarshipSelectionController.dispose() so the element is cleaned up.

Comment on lines +91 to 99
// Self-driven RAF: DOM label positions must update every frame so the
// labels track the WebGL camera as the user pans/zooms. (Previously this
// ran via the now-deleted canvas2D RAF loop.)
const drive = () => {
this.updateLabelDOM();
requestAnimationFrame(drive);
};
requestAnimationFrame(drive);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop the self-driven RAF loop on teardown.

Line 96 re-queues forever, but destroy() never cancels it. Re-inits can stack multiple loops and keep updating detached DOM.

🔧 Suggested fix
 export class AttackingTroopsOverlay implements Controller {
   private container: HTMLDivElement;
   private labelTemplate: HTMLDivElement;
+  private rafId: number | null = null;
+  private destroyed = false;
@@
   init() {
+    this.destroyed = false;
@@
     const drive = () => {
+      if (this.destroyed) return;
       this.updateLabelDOM();
-      requestAnimationFrame(drive);
+      this.rafId = requestAnimationFrame(drive);
     };
-    requestAnimationFrame(drive);
+    this.rafId = requestAnimationFrame(drive);
   }
@@
   destroy() {
+    this.destroyed = true;
+    if (this.rafId !== null) {
+      cancelAnimationFrame(this.rafId);
+      this.rafId = null;
+    }
     if (!this.container) return;
     this.clearAllLabels();
     this.container.remove();
     this.eventBus.off(AlternateViewEvent, this.onAlternateView);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/graphics/layers/AttackingTroopsOverlay.ts` around lines 91 - 99,
The RAF loop started in the constructor via the drive() function keeps
re-scheduling forever and isn’t cancelled on teardown; capture the
requestAnimationFrame handle (e.g., store it on this.rafId when calling
requestAnimationFrame(drive)) and change the loop to update that handle each
frame, then cancel it in destroy() using cancelAnimationFrame(this.rafId); also
ensure any re-init path clears an existing rafId before starting a new loop so
multiple loops cannot stack (references: drive, updateLabelDOM, destroy).

Comment on lines +52 to +69
private writePaletteEntry(
smallID: number,
fill: Colord,
border: Colord,
): void {
const fillRgba = fill.toRgb();
const fillOff = smallID * 4;
this.palette[fillOff] = fillRgba.r / 255;
this.palette[fillOff + 1] = fillRgba.g / 255;
this.palette[fillOff + 2] = fillRgba.b / 255;
this.palette[fillOff + 3] = 150 / 255;

const borderRgba = border.toRgb();
const borderOff = PALETTE_SIZE * 4 + smallID * 4;
this.palette[borderOff] = borderRgba.r / 255;
this.palette[borderOff + 1] = borderRgba.g / 255;
this.palette[borderOff + 2] = borderRgba.b / 255;
this.palette[borderOff + 3] = 1.0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

No bounds check on smallID before palette write.

If smallID >= PALETTE_SIZE (4096), writePaletteEntry writes past the Float32Array bounds. While the WebGL view is constructed with maxPlayers: 1024, this assumption is not enforced here.

Consider adding a guard:

Defensive check
 private writePaletteEntry(
   smallID: number,
   fill: Colord,
   border: Colord,
 ): void {
+  if (smallID >= PALETTE_SIZE) {
+    console.warn(`smallID ${smallID} exceeds palette size`);
+    return;
+  }
   const fillRgba = fill.toRgb();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/WebGLFrameBuilder.ts` around lines 52 - 69, The writePaletteEntry
method currently writes into this.palette using smallID without validating
bounds, risking out-of-range writes when smallID >= PALETTE_SIZE; update
WebGLFrameBuilder.writePaletteEntry to check that smallID is within 0 <= smallID
< PALETTE_SIZE (use the PALETTE_SIZE constant) before computing offsets and
writing to this.palette, and on violation either early-return (or throw/log an
error) to prevent out-of-bounds writes—apply this guard for both fill and border
writes so the Float32Array is never indexed past its length.

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management May 17, 2026
evanpelle added 3 commits May 17, 2026 12:24
…GhostStructureChangedEvent

graphics/ was a canvas2D-era directory name — TransformHandler, UIState,
and the Controller interface aren't graphics, they're cross-cutting
client state. Hoist them to src/client/ so the path matches what they
are. GhostStructureChangedEvent had three emitters and zero listeners;
removed.
…tWebGLFrameLoop

ProgressBar and StructureDrawingUtils had no production callers — only
their own test referenced ProgressBar, and StructureDrawingUtils was a
canvas2D-era helper module that nothing imports anymore.

mountWebGLDebugRenderer was named back when WebGL was a side-by-side
debug overlay; it's the only renderer now, so the "Debug" prefix is
misleading. Also dropped the `\` keybind that hid the GL canvas — with
no other renderer, hiding it just blanks the game.
src/client/render/gl/assets/ held 11 atlas files (PNGs + JSON metadata)
that bypassed the asset-manifest pipeline — they were imported via
Vite's ?url query, bundled, and served same-origin instead of going
through the CDN like every other game asset. Moved them to
resources/atlases/, switched the PNG imports to assetUrl("atlases/...")
so they flow through the manifest, and updated the JSON metadata
imports to "resources/atlases/..." paths. Also dropped an orphan copy
of MissileSiloIconWhite.svg (no callers; resources/images/ already had
the canonical version).

render-settings.json stays in src/ — it's renderer tuning config
consumed at bundle time, not a URL-served asset.
graphics/fx/ (6 files) and the AnimatedSprite/AnimatedSpriteLoader pair
were the canvas2D-era visual-effects pipeline. WebGL has its own FX
stack now (render/gl/passes/fx-pass/), so nothing outside the dead
cluster imported any of these. The only "reference" left was a stale
comment in fx-sprite-pass.ts.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/ClientGameRunner.ts`:
- Around line 250-254: The injected DOM nodes (the canvas created by
createCanvas() with id "webgl-debug-canvas" and the inputOverlay element created
elsewhere) are never removed when stop() is called, causing stacked invisible
overlays/handlers; update ClientGameRunner construction to accept and store a
cleanup closure and implement stop() (or the runner's dispose path) to call that
closure which removes inputOverlay and glCanvas if they are still connected (use
element.isConnected or parentNode checks) and remove any event listeners; locate
the canvas created via createCanvas() and the inputOverlay variables and ensure
the cleanup closure is passed into the ClientGameRunner constructor and invoked
on stop/dispose so repeated enters don't leave stale DOM nodes or handlers.

In `@src/client/graphics/GameRenderer.ts`:
- Around line 319-321: The resize listener added in initialize() uses an
anonymous handler and can be added multiple times; change initialize() to
register a single named/bound handler (e.g., store this._onWindowResize =
this._onWindowResize || (() =>
this.transformHandler.updateCanvasBoundingRect())) and use that for
window.addEventListener("resize", this._onWindowResize) so duplicates are
avoided, and ensure you remove it where appropriate (e.g., in a dispose/destroy
method or before re-adding) via window.removeEventListener("resize",
this._onWindowResize); reference initialize(),
transformHandler.updateCanvasBoundingRect(),
window.addEventListener/removeEventListener, and the stored this._onWindowResize
handler to locate the changes.

In `@src/client/render/gl/passes/name-pass/text-program.ts`:
- Around line 127-141: The async img.onload in loadAtlas() can run after
dispose() and recreate atlasTex / set atlasReady; to fix, guard the onload
handler by checking a disposed flag (e.g., this.disposed) before creating the
texture and setting atlasReady, and ensure dispose() clears the pending load by
nulling img.onload or setting img.src = '' and deleting/disposing any existing
atlasTex; update loadAtlas() (and the other similar block at lines ~194-198) to
bail if this.disposed is true and have dispose() explicitly cancel the image
load and delete the GL texture.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 592343b9-88d2-44fd-87fc-7d44300f865c

📥 Commits

Reviewing files that changed from the base of the PR and between 7b1557b and 8a4b12c.

⛔ Files ignored due to path filters (7)
  • resources/atlases/emoji-atlas.png is excluded by !**/*.png
  • resources/atlases/flag-atlas.png is excluded by !**/*.png
  • resources/atlases/fx-atlas.png is excluded by !**/*.png
  • resources/atlases/icon-atlas.png is excluded by !**/*.png
  • resources/atlases/msdf-atlas.png is excluded by !**/*.png
  • resources/atlases/status-atlas.png is excluded by !**/*.png
  • resources/atlases/unit-atlas.png is excluded by !**/*.png
📒 Files selected for processing (58)
  • resources/atlases/emoji-atlas-meta.json
  • resources/atlases/flag-atlas-meta.json
  • resources/atlases/fx-atlas-meta.json
  • resources/atlases/msdf-atlas.json
  • resources/atlases/status-atlas-meta.json
  • src/client/ClientGameRunner.ts
  • src/client/Controller.ts
  • src/client/InputHandler.ts
  • src/client/TransformHandler.ts
  • src/client/UIState.ts
  • src/client/controllers/BuildPreviewController.ts
  • src/client/controllers/WarshipSelectionController.ts
  • src/client/graphics/GameRenderer.ts
  • src/client/graphics/ProgressBar.ts
  • src/client/graphics/layers/AlertFrame.ts
  • src/client/graphics/layers/AttackingTroopsOverlay.ts
  • src/client/graphics/layers/AttacksDisplay.ts
  • src/client/graphics/layers/BuildMenu.ts
  • src/client/graphics/layers/ChatDisplay.ts
  • src/client/graphics/layers/ControlPanel.ts
  • src/client/graphics/layers/EmojiTable.ts
  • src/client/graphics/layers/EventsDisplay.ts
  • src/client/graphics/layers/GameLeftSidebar.ts
  • src/client/graphics/layers/GameRightSidebar.ts
  • src/client/graphics/layers/HeadsUpMessage.ts
  • src/client/graphics/layers/ImmunityTimer.ts
  • src/client/graphics/layers/InGamePromo.ts
  • src/client/graphics/layers/Leaderboard.ts
  • src/client/graphics/layers/MainRadialMenu.ts
  • src/client/graphics/layers/MultiTabModal.ts
  • src/client/graphics/layers/PerformanceOverlay.ts
  • src/client/graphics/layers/PlayerActionHandler.ts
  • src/client/graphics/layers/PlayerInfoOverlay.ts
  • src/client/graphics/layers/PlayerPanel.ts
  • src/client/graphics/layers/RadialMenu.ts
  • src/client/graphics/layers/RadialMenuElements.ts
  • src/client/graphics/layers/ReplayPanel.ts
  • src/client/graphics/layers/SendResourceModal.ts
  • src/client/graphics/layers/SettingsModal.ts
  • src/client/graphics/layers/SpawnTimer.ts
  • src/client/graphics/layers/StructureDrawingUtils.ts
  • src/client/graphics/layers/TeamStats.ts
  • src/client/graphics/layers/UnitDisplay.ts
  • src/client/graphics/layers/WinModal.ts
  • src/client/graphics/ui/TextIndicator.ts
  • src/client/render/gl/passes/conquest-popup-pass.ts
  • src/client/render/gl/passes/fx-pass/fx-sprite-pass.ts
  • src/client/render/gl/passes/name-pass/atlas-data.ts
  • src/client/render/gl/passes/name-pass/debug-program.ts
  • src/client/render/gl/passes/name-pass/icon-program.ts
  • src/client/render/gl/passes/name-pass/status-icon-program.ts
  • src/client/render/gl/passes/name-pass/text-program.ts
  • src/client/render/gl/passes/radial-menu-pass.ts
  • src/client/render/gl/passes/structure-level-pass.ts
  • src/client/render/gl/passes/structure-pass.ts
  • src/client/render/gl/passes/unit-pass.ts
  • tests/InputHandler.test.ts
  • tests/client/graphics/ProgressBar.test.ts
💤 Files with no reviewable changes (2)
  • src/client/graphics/ProgressBar.ts
  • src/client/graphics/layers/StructureDrawingUtils.ts
✅ Files skipped from review due to trivial changes (3)
  • resources/atlases/fx-atlas-meta.json
  • src/client/UIState.ts
  • resources/atlases/flag-atlas-meta.json
🚧 Files skipped from review as they are similar to previous changes (27)
  • src/client/graphics/layers/ChatDisplay.ts
  • src/client/graphics/layers/GameLeftSidebar.ts
  • src/client/graphics/layers/ImmunityTimer.ts
  • src/client/graphics/layers/PlayerPanel.ts
  • src/client/graphics/layers/GameRightSidebar.ts
  • src/client/graphics/layers/SettingsModal.ts
  • src/client/graphics/layers/AlertFrame.ts
  • src/client/graphics/layers/WinModal.ts
  • src/client/graphics/layers/BuildMenu.ts
  • src/client/render/gl/passes/name-pass/icon-program.ts
  • src/client/render/gl/passes/name-pass/status-icon-program.ts
  • src/client/graphics/layers/MultiTabModal.ts
  • src/client/render/gl/passes/name-pass/atlas-data.ts
  • src/client/graphics/layers/AttackingTroopsOverlay.ts
  • src/client/graphics/layers/SpawnTimer.ts
  • src/client/render/gl/passes/name-pass/debug-program.ts
  • src/client/graphics/layers/PerformanceOverlay.ts
  • src/client/graphics/layers/AttacksDisplay.ts
  • src/client/graphics/layers/ReplayPanel.ts
  • src/client/graphics/layers/ControlPanel.ts
  • src/client/graphics/layers/PlayerInfoOverlay.ts
  • src/client/controllers/BuildPreviewController.ts
  • src/client/render/gl/passes/conquest-popup-pass.ts
  • src/client/graphics/layers/TeamStats.ts
  • src/client/controllers/WarshipSelectionController.ts
  • src/client/graphics/layers/RadialMenu.ts
  • src/client/render/gl/passes/fx-pass/fx-sprite-pass.ts

Comment on lines +250 to +254
const glCanvas = createCanvas();
glCanvas.id = "webgl-debug-canvas";
glCanvas.style.pointerEvents = "none";
document.body.insertBefore(glCanvas, document.body.firstChild);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Clean up injected overlay/canvas nodes on stop.

Line 253 and Line 425 add persistent DOM nodes, but stop() never removes them. Re-entering games can stack invisible overlays/canvases and keep stale handlers alive.

Proposed direction
-function createWebGLView(...): { view: WebGLGameView; glCanvas: HTMLCanvasElement; ... } {
+function createWebGLView(...): { view: WebGLGameView; glCanvas: HTMLCanvasElement; ... } {
   ...
   document.body.insertBefore(glCanvas, document.body.firstChild);
   ...
 }

 export class ClientGameRunner {
+  private readonly cleanupDom: (() => void) | null;
   constructor(
     ...
     private webglBuilder: WebGLFrameBuilder | null = null,
+    cleanupDom: (() => void) | null = null,
   ) {
+    this.cleanupDom = cleanupDom;
     this.lastMessageTime = Date.now();
   }

   public stop() {
     ...
+    this.cleanupDom?.();
   }
 }

And when constructing ClientGameRunner, pass a cleanup closure that removes inputOverlay and glCanvas if still connected.

Also applies to: 417-425, 753-768

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/ClientGameRunner.ts` around lines 250 - 254, The injected DOM
nodes (the canvas created by createCanvas() with id "webgl-debug-canvas" and the
inputOverlay element created elsewhere) are never removed when stop() is called,
causing stacked invisible overlays/handlers; update ClientGameRunner
construction to accept and store a cleanup closure and implement stop() (or the
runner's dispose path) to call that closure which removes inputOverlay and
glCanvas if they are still connected (use element.isConnected or parentNode
checks) and remove any event listeners; locate the canvas created via
createCanvas() and the inputOverlay variables and ensure the cleanup closure is
passed into the ClientGameRunner constructor and invoked on stop/dispose so
repeated enters don't leave stale DOM nodes or handlers.

Comment on lines +319 to +321
window.addEventListener("resize", () =>
this.transformHandler.updateCanvasBoundingRect(),
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Prevent duplicate resize listeners on repeated initialize() calls.

Each call adds a new anonymous resize handler, which can stack listeners and duplicate work.

Suggested fix
 export class GameRenderer {
+  private initialized = false;
+  private readonly onResize = () =>
+    this.transformHandler.updateCanvasBoundingRect();
   private layerTickState = new Map<Controller, { lastTickAtMs: number }>();

   initialize() {
+    if (this.initialized) return;
+    this.initialized = true;
     this.layers.forEach((l) => l.init?.());

-    window.addEventListener("resize", () =>
-      this.transformHandler.updateCanvasBoundingRect(),
-    );
+    window.addEventListener("resize", this.onResize);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/graphics/GameRenderer.ts` around lines 319 - 321, The resize
listener added in initialize() uses an anonymous handler and can be added
multiple times; change initialize() to register a single named/bound handler
(e.g., store this._onWindowResize = this._onWindowResize || (() =>
this.transformHandler.updateCanvasBoundingRect())) and use that for
window.addEventListener("resize", this._onWindowResize) so duplicates are
avoided, and ensure you remove it where appropriate (e.g., in a dispose/destroy
method or before re-adding) via window.removeEventListener("resize",
this._onWindowResize); reference initialize(),
transformHandler.updateCanvasBoundingRect(),
window.addEventListener/removeEventListener, and the stored this._onWindowResize
handler to locate the changes.

Comment on lines +127 to +141
private loadAtlas(): void {
const gl = this.gl;
const img = new Image();
img.onload = () => {
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
this.atlasTex = tex;
this.atlasReady = true;
};
img.src = atlasUrl;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard atlas async load against dispose-time race.

loadAtlas() can still run onload after dispose() is called, recreating a texture on a torn-down program lifecycle. This can leak GPU memory and flip ready back to true unexpectedly.

Proposed fix
 export class TextProgram {
+  private disposed = false;
+  private atlasImage: HTMLImageElement | null = null;
   private gl: WebGL2RenderingContext;
@@
   private loadAtlas(): void {
     const gl = this.gl;
     const img = new Image();
+    this.atlasImage = img;
     img.onload = () => {
+      if (this.disposed) return;
       const tex = gl.createTexture()!;
@@
       this.atlasTex = tex;
       this.atlasReady = true;
     };
+    img.onerror = () => {
+      if (this.disposed) return;
+      this.atlasReady = false;
+    };
     img.src = atlasUrl;
   }
@@
   dispose(): void {
+    this.disposed = true;
+    if (this.atlasImage) {
+      this.atlasImage.onload = null;
+      this.atlasImage.onerror = null;
+      this.atlasImage = null;
+    }
     const gl = this.gl;
     gl.deleteProgram(this.program);
     if (this.atlasTex) gl.deleteTexture(this.atlasTex);
+    this.atlasTex = null;
+    this.atlasReady = false;
   }
 }

Also applies to: 194-198

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/name-pass/text-program.ts` around lines 127 -
141, The async img.onload in loadAtlas() can run after dispose() and recreate
atlasTex / set atlasReady; to fix, guard the onload handler by checking a
disposed flag (e.g., this.disposed) before creating the texture and setting
atlasReady, and ensure dispose() clears the pending load by nulling img.onload
or setting img.src = '' and deleting/disposing any existing atlasTex; update
loadAtlas() (and the other similar block at lines ~194-198) to bail if
this.disposed is true and have dispose() explicitly cancel the image load and
delete the GL texture.

resources/atlases/ wasn't in the manifest glob list, so the build
skipped hashing/copying it into static/_assets/ and the deploy
pipeline's R2 uploader had no keys for it — atlases 404'd on staging.
evanpelle added 2 commits May 17, 2026 13:48
updateFrameMetrics had zero callers — the canvas2D RAF loop used to
invoke it per-frame, and that loop died with canvas2D. Tick metrics
were unaffected since GameRenderer.tick() still calls
updateTickLayerMetrics directly.

The WebGL renderer doesn't expose a per-frame hook for the overlay, so
the overlay now drives its own RAF, started/stopped with visibility so
it stays off the hot path when hidden.
Atlases now load from the CDN; without crossOrigin = "anonymous" the
browser refuses to texImage2D the cross-origin image. Requires the CDN
to send Access-Control-Allow-Origin for /_assets/atlases/.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/client/render/gl/passes/conquest-popup-pass.ts (1)

208-224: ⚡ Quick win

Add error handler for atlas image load.

If the MSDF atlas fails to load (network error, CDN issue, missing file), atlasReady stays false and popups silently never render. Adding an onerror handler helps with debugging.

Add error logging
 private loadAtlas(): void {
   const img = new Image();
   img.crossOrigin = "anonymous";
   img.onload = () => {
     const gl = this.gl;
     const tex = gl.createTexture()!;
     gl.bindTexture(gl.TEXTURE_2D, tex);
     gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
     gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
     gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
     gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
     gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
     this.atlasTex = tex;
     this.atlasReady = true;
   };
+  img.onerror = () => {
+    console.error("ConquestPopupPass: failed to load MSDF atlas from", atlasUrl);
+  };
   img.src = atlasUrl;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/conquest-popup-pass.ts` around lines 208 - 224,
The loadAtlas method doesn't handle image load failures; add an img.onerror
handler alongside img.onload to set this.atlasReady = false (or keep false) and
log the error so failures are visible; specifically update the loadAtlas
function to attach img.onerror that captures the error/event and calls
this.logger.error (or console.error) with context including atlasUrl and the
event, and ensure any partially created texture is cleaned up (using
this.atlasTex / gl.deleteTexture if created) so state remains consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/render/gl/passes/conquest-popup-pass.ts`:
- Around line 58-62: The formatGold function currently returns hard-coded "K"
and "M" suffixes; update formatGold to obtain suffixes via translateText (e.g.,
translateText("suffix.k") and translateText("suffix.m")) so all user-visible
text goes through localization, and add the corresponding keys to your language
resources (e.g., English entries for the K and M suffixes) to ensure the popups
use localized abbreviations across locales.

---

Nitpick comments:
In `@src/client/render/gl/passes/conquest-popup-pass.ts`:
- Around line 208-224: The loadAtlas method doesn't handle image load failures;
add an img.onerror handler alongside img.onload to set this.atlasReady = false
(or keep false) and log the error so failures are visible; specifically update
the loadAtlas function to attach img.onerror that captures the error/event and
calls this.logger.error (or console.error) with context including atlasUrl and
the event, and ensure any partially created texture is cleaned up (using
this.atlasTex / gl.deleteTexture if created) so state remains consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b36bbeea-d073-4cd5-8df4-195ea4ef408d

📥 Commits

Reviewing files that changed from the base of the PR and between b27c298 and b8d72d3.

📒 Files selected for processing (10)
  • src/client/graphics/layers/PerformanceOverlay.ts
  • src/client/render/gl/passes/conquest-popup-pass.ts
  • src/client/render/gl/passes/fx-pass/fx-sprite-pass.ts
  • src/client/render/gl/passes/name-pass/icon-program.ts
  • src/client/render/gl/passes/name-pass/status-icon-program.ts
  • src/client/render/gl/passes/name-pass/text-program.ts
  • src/client/render/gl/passes/radial-menu-pass.ts
  • src/client/render/gl/passes/structure-level-pass.ts
  • src/client/render/gl/passes/structure-pass.ts
  • src/client/render/gl/passes/unit-pass.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/client/render/gl/passes/name-pass/text-program.ts
  • src/client/render/gl/passes/name-pass/icon-program.ts
  • src/client/render/gl/passes/fx-pass/fx-sprite-pass.ts
  • src/client/render/gl/passes/name-pass/status-icon-program.ts

Comment on lines +58 to +62
function formatGold(gold: number): string {
if (gold >= 1_000_000) return (gold / 1_000_000).toFixed(1) + "M";
if (gold >= 1_000) return (gold / 1_000).toFixed(1) + "K";
return gold.toString();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Number suffixes need localization.

The "K" and "M" suffixes are user-visible text in floating popups. Per coding guidelines, all user-visible text must go through translateText(). Some locales use different abbreviations (e.g., "mil" for million in some languages).

Suggested fix using translateText
+import { translateText } from "../../Utils";
+
 function formatGold(gold: number): string {
-  if (gold >= 1_000_000) return (gold / 1_000_000).toFixed(1) + "M";
-  if (gold >= 1_000) return (gold / 1_000).toFixed(1) + "K";
+  if (gold >= 1_000_000)
+    return (gold / 1_000_000).toFixed(1) + translateText("gold.suffix_million");
+  if (gold >= 1_000)
+    return (gold / 1_000).toFixed(1) + translateText("gold.suffix_thousand");
   return gold.toString();
 }

As per coding guidelines: "All user-visible text must go through translateText() function with corresponding entries in resources/lang/en.json"

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function formatGold(gold: number): string {
if (gold >= 1_000_000) return (gold / 1_000_000).toFixed(1) + "M";
if (gold >= 1_000) return (gold / 1_000).toFixed(1) + "K";
return gold.toString();
}
import { translateText } from "../../Utils";
function formatGold(gold: number): string {
if (gold >= 1_000_000)
return (gold / 1_000_000).toFixed(1) + translateText("gold.suffix_million");
if (gold >= 1_000)
return (gold / 1_000).toFixed(1) + translateText("gold.suffix_thousand");
return gold.toString();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/render/gl/passes/conquest-popup-pass.ts` around lines 58 - 62, The
formatGold function currently returns hard-coded "K" and "M" suffixes; update
formatGold to obtain suffixes via translateText (e.g., translateText("suffix.k")
and translateText("suffix.m")) so all user-visible text goes through
localization, and add the corresponding keys to your language resources (e.g.,
English entries for the K and M suffixes) to ensure the popups use localized
abbreviations across locales.

evanpelle added 4 commits May 17, 2026 19:13
Bypass the snap-to-tile in TransformHandler by adding
screenToWorldCoordinatesFloat. Each render frame, BuildPreviewController
re-emits the ghost preview at the cursor's exact world position
(adjusted by -0.5 to cancel the shader's tile-center offset). Buildable
validation still runs on the snapped tile at the 50ms throttle, but the
icon now follows the cursor 1:1 instead of stepping tile-to-tile.
NukeTrajectoryPass and the rangeRadius pipe existed but had no caller —
trajectory arc and outer-blast circle never appeared during build mode.
BuildPreviewController now picks the closest active player silo as the
launch source, collects non-allied SAMs as threats, and pushes a
NukeTrajectoryData each preview tick. rangeRadius is set to
nukeMagnitudes(type).outer for AtomBomb / HydrogenBomb so the existing
RangeCirclePass renders the blast radius at the target.
…tings

The cycling sun/moon animation was distracting and not a fan favorite.
Drops the cycle path entirely — RenderSettings.dayNight.mode is now
"light" | "dark", and the cycle-only fields (cycleTicks, startPhase,
noonHold, nightHold) plus the passEnabled.dayNight toggle are gone.
getAmbient is a one-liner. The in-game mode follows the existing
darkMode UserSetting (same one that drives the page-level CSS class);
ClientGameRunner applies it on startup and on the per-key change event.
The hover wiring already pushed setHighlightOwner into the border pass,
but the WebGL canvas has pointer-events: none (post-migration to the
inputOverlay div) so MapInteraction's pointermove listener never fired.
Forward pointermove from the input overlay to view.handlePointerMove
so hover actually triggers.

While there, brighten every tile owned by the hovered player — the
territory frag shader now reads uHighlightOwner / uHighlightBrighten
and mixes toward white when the tile owner matches. Wired through
territory-pass.ts; renderer.setHighlightOwner forwards to both border
and territory passes. New highlightFillBrighten setting (0.15) keeps
the fill tint tunable independently of the existing highlightBrighten
border setting, which is dropped from 0.6 → 0.25 so neither effect
blows out.
evanpelle added 2 commits May 17, 2026 20:46
MapInteraction bound DOM events to the WebGL canvas, but the canvas has
pointer-events: none post-migration so its pointermove/down/up/wheel/
keydown listeners never fired — duplicating InputHandler (which owns
the inputOverlay div + EventBus pipeline) and leaving most features
dead. The one alive bit was hover→setHighlightOwner, which I'd
manually forwarded as a workaround.

Now there's a HoverHighlightController that listens to MouseMoveEvent,
computes the cursor's tile owner, and pushes setHighlightOwner. Delete
map-interaction.ts (418 lines) + keyboard-pan.ts, trim the DOM-binding
constructor + proxy methods (setFitZoomOnDoubleClick, setPanSpeed,
setZoomSpeed, etc.) out of GameView, and drop the ClientGameRunner
pointermove forwarder.

Input flows through one path: DOM → inputOverlay → InputHandler →
EventBus → controllers/renderer.
The render/ tree was the only place in the client still using kebab-case
filenames. Brings ~80 files in line with the rest of src/client/
(BuildPreviewController, TransformHandler, etc.). Directories kept as
they were (name-pass/, fx-pass/, passes/, utils/, debug/) since the
codebase already mixes those.

Two collisions surfaced and got resolved: render/types/ is a directory,
not a file, so its imports kept the lowercase form; and the sed pass
incidentally normalized core/pathfinding imports, which had to be
reverted since that file is actually lowercase on disk despite some
imports having referenced it as ./Types under macOS case-insensitive
resolution.
) {
deepAssign(target[key] as Obj, source[key] as Obj);
} else if (key in target) {
target[key] = source[key];
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

2 participants