The special effects toggle wrote settings.specialEffects but nothing in
the WebGL pipeline read it — the FX pass is gated on passEnabled.fx. The
setting was orphaned when the old canvas renderer was removed, so the
toggle had no visual effect.
Move the toggle into the graphics settings modal (under a new Effects
section) and remove it from the in-game settings modal and the homepage
user settings modal. Rewire it to a passEnabled.fx graphics override so
it actually toggles the FX pass, applied live via the existing graphics
override listener.
Delete the now-dead fxLayer()/toggleFxLayer() from UserSettings.
Note: users who previously disabled special effects will reset to on,
since the old settings.specialEffects key is no longer read.
Extend GraphicsOverrides with a mapOverlay group (territory highlight,
border highlight amount, border highlight thickness) and a railroad
group (train track draw distance), wired through the schema,
applyGraphicsOverrides, and new sliders in the graphics settings modal.
Fix the territory hover highlight: the shader received uHighlightBrighten
but ignored it, applying a hardcoded saturation boost so the setting had
no effect. It now drives a contrast boost (push channels away from
mid-gray), with 0 disabling the effect.
The train track slider is presented as a "draw distance" (inverted
railMinZoom) so higher = tracks stay visible when more zoomed out.
Also move the Graphics settings button to the top of the settings modal.
## Description
Scales the defense-post border effect so it works with **thousands** of
Defense Posts instead of silently capping at 64.
### Problem
The border "checkerboard" (drawn on a player's border tiles when a
same-owner Defense Post is within range) was computed per-pixel: for
every border fragment, the shader looped over a `uniform vec4
uDefensePosts[64]` array doing a distance test. Two issues:
- **Hard cap of 64** — posts beyond the first 64 were dropped, so their
checkerboard never appeared.
- **Wrong cost shape** — work was `border_tiles × posts`; every added
post made every border pixel slower.
### Solution: invert the loop into a coverage texture
New `DefenseCoveragePass` stamps one instanced circle per post into a
map-resolution `R8` coverage texture (`1.0` = tile is within range of a
**same-owner** post; the owner check samples `tileTex` at stamp time, so
enemy posts never light up your border). It's a single
`drawArraysInstanced` regardless of post count — the same instancing
pattern `UnitPass`/`StructurePass` already use. The border-stamp shader
now reads one texel of that texture instead of looping; the old uniform
array, the 64-cap, and the per-fragment scan are removed from
`border-compute`/`BorderStampPass`/`BorderScatterPass`.
### Incremental re-stamping (dirty-block grid)
Coverage depends on tile ownership, which drips every frame during
combat, so a full re-stamp every frame would be wasteful at high post
counts. Because a tile changing owner only changes *its own* coverage,
the pass tracks a grid of dirty **blocks** and re-stamps only the blocks
containing changed tiles, scissored to each block (`gl.scissor` confines
the clear + draw to the changed region). Post add/remove and full tile
uploads fall back to a whole-map stamp; so does a frame where most
blocks are dirty. Per-frame cost tracks *how much changed*, not *how
many posts exist*, and scattered fronts (e.g. opposite corners) become
independent small block draws.
### Territory-fill darkening
The coverage texture marks every same-owner in-range tile (interior
included, not just borders), so `TerritoryPass` now also samples it to
darken the territory **fill** around posts. New tunable
`mapOverlay.territoryDefenseDarken` (live-editable in the graphics debug
GUI alongside `defenseCheckerDarken`).
### Performance
Tested with ~1,000 posts blanketing a map — smooth, including on a
low-end (~10-year-old) Chromebook.
## Files
- **New:** `passes/DefenseCoveragePass.ts`,
`shaders/defense-coverage/defense-coverage.{vert,frag}.glsl`
- **Edited:** `Renderer.ts`, `BorderStampPass.ts`,
`BorderComputePass.ts`, `BorderScatterPass.ts`, `TerritoryPass.ts`,
`border-stamp.frag.glsl`, `border-compute.frag.glsl`,
`territory.frag.glsl`, `RenderSettings.ts`, `render-settings.json`,
`debug/Layout.ts`
## Notes
- No user-facing text (no `translateText`/`en.json` changes needed).
- No `src/core` changes — purely client rendering, so no simulation
tests; verified via `tsc`, ESLint, `build-prod`, and in-game.
Render a soft radial glow underneath the hydrogen bomb sprite in
UnitPass. H-bomb instances draw an enlarged quad (hBombGlowScale) so
there's room for the halo; a cell-space UV remap keeps the sprite at its
normal size while the margin becomes glow area. The glow is a steady
(non-pulsing) radial falloff in a warm amber, alpha-blended underneath
the sprite and suppressed in alt/affiliation view.
Detection uses a HYDROGEN_BOMB_COL shader define derived from
UNIT_ORDER, so it tracks the atlas layout rather than hard-coding the
column. All other units are unaffected (scale 1, same fillrate); this
stays a single program / two instanced draw calls.
Glow color, scale, strength, and falloff are exposed in
render-settings.json for live tuning via the debug GUI.
## Description:
Incremental GPU border recompute — sequel to #4159.
On 10 yo low-end chrome book this increased performance by ~5fps. I'm
now able to get 40fps on GWM.
`BorderComputePass` previously re-ran its fragment shader over every
tile on
the map every time any input changed (tile flip, highlight, relation,
defense post). Cost was O(mapW × mapH) per invalidation, and tile flips
invalidate it ~every render frame in live play.
This PR adds `BorderScatterPass`, which runs the same fragment shader
but
rasterizes only one POINT per dirty tile (plus its 4 cardinal neighbors,
to
cover the cardinal-neighbor read in the border shader). Cost is O(dirty
tiles) regardless of map size or spatial distribution.
### What changed
- New `BorderScatterPass` — owns its own FBO, VAO, and instance buffer;
shares the border fragment shader with `BorderComputePass` so the two
paths can't diverge in output.
- `BorderComputePass.draw()` now picks per frame:
- **Full recompute** — when `globalDirty` is set by highlight / relation
/
defense-post changes (those affect tiles across the whole map).
- **Scatter** — when only per-tile patches have been queued via
`patchTile()`.
- `TerritoryPass.flushTileTexture()` now returns `"none" | "full" |
"scatter"` instead of `boolean`, so the renderer can pick the right
downstream invalidation:
- `"full"` → `borderPass.markGlobalDirty()` (full tile upload supersedes
per-tile patches).
- `"scatter"` → no-op; per-tile patches were already pushed via the
wired `borderPatchConsumer` callback during drip drain.
- Renderer wires `territoryPass.setBorderPatchConsumer((x, y) =>
borderPass.patchTile(x, y))` so every per-tile scatter write to
`tileTex`
also schedules an incremental border recompute for that tile + its
neighbors.
### Known limitation
Highlight-thicken rings (within `uHighlightThicken` of a changed tile)
are
NOT incrementally repainted — they'll lag visually until the next full
recompute. In practice this is short-lived (the next highlight change or
seek triggers a full recompute) and not visible during normal play; the
trade is documented in the `BorderScatterPass` header.
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
evan
## Description
Reduces the amount of tile data sent to the gpu each tick, roughly
~10fps rate increase on 10 year old chromebook.
Two changes to the territory rendering path:
### 1. Split `passEnabled.mapOverlay` into four flags
The single `mapOverlay` toggle controlled four unrelated passes
(territory fill, border compute, border stamp, trail). Splits it into
`territory`, `borderCompute`, `borderStamp`, `trail` so each can be
toggled independently in the debug GUI. Pure rename — default behavior
is unchanged (all four default to `true`).
### 2. GPU scatter for per-frame tile texture updates
Replaces the dirty-row bbox `texSubImage2D` upload in `TerritoryPass`
with a new `TileScatterPass` that uploads a small attribute buffer of
`(x, y, state)` patches and runs a single `POINTS` draw into an FBO
bound to `tileTex`. Each patch rasterizes as a 1×1 point into exactly
its target texel.
**Why:** the old path's cost scaled with the bounding box of the dirty
rows, not the number of changed tiles. In typical play, tile changes are
spread across the whole map (multiple players fighting in different
regions, scattered trails/fallout), so the bbox covered most of the
map's rows and we re-uploaded mostly-unchanged data every frame. The new
path is constant cost in patch count regardless of spatial distribution,
and no longer scales with map size.
The full-upload path (initial load / seek / spawn-phase flush) is
unchanged. `fullUploadPending` correctly supersedes any queued scatter
patches.
## Please complete the following:
- [x] I have added screenshots for all UI updates *(N/A — no UI
changes)*
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file *(N/A — no user-facing text)*
- [x] I have added relevant tests to the test directory *(renderer code,
not covered by unit tests; verified visually)*
## Please put your Discord username so you can be contacted if a bug or
regression is found:
evan
## Description:
On low-end machines, the fillrate was too high causing framerate to
drop. The graphical difference is pretty negligible since fallout &
light are meant to be blurred anyways.
Reduces fillrate cost of the fallout bloom and fallout-light passes on
low-end GPUs:
- Extract step now renders at `mapW/8 × mapH/8` (64× fewer fragments).
Output is heavily blurred + LINEAR-magnified, so the visual difference
is minimal.
- Bloom blur reduced from 2× 9-tap to 1× 5-tap Gaussian (the smaller
kernel is sufficient given the lower-res source).
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
evan
Attack troop labels, the ghost-cost chip, and the bonusPopup
minScreenScale floor were dividing by `zoom`, which Camera.ts stores
as device-pixels-per-world-unit (canvasW = cssWidth * dpr). The result
was constant device-pixel size — labels rendered ~2x larger on DPR=1
displays than on retina. Multiply each screen-relative scale by dpr so
the on-screen size stays constant in CSS pixels.
Retune the now-correct sizes: attack labels 34 -> 17, ghost-cost
screenScale 30 -> 18, screenYOffset 50 -> 25.
## Description:
- The renderer no longer knows what "dark mode" is.
`RenderSettings.dayNight.mode` (`"light" | "dark"`) is gone — passes
read neutral values (`lighting.ambient: number`, `lighting.enabled:
boolean`).
- `render-settings.json` holds the light-mode baseline. Dark mode is
just another override layer, applied the same way as graphics settings
(`darkNames`, `classicIcons`, etc.).
- New `src/client/render/gl/RenderOverrides.ts` exposes two in-place
mutators with matching shapes:
- `applyGraphicsOverrides(settings, overrides)` — replaces the old
`generateRenderSettings`
- `applyDarkModeOverride(settings, isDark)`
- `ClientGameRunner` regenerates the live settings each time the user
setting changes via `deepAssign(live, createRenderSettings())` + the
override chain. No per-slice copy list, no intermediate object — adding
a new override that touches a new section just works.
- Renamed `dayNight` → `lighting`; collapsed `nightAmbient`/`dayAmbient`
into single `ambient`; renamed `enableLightCompositing` → `enabled`.
- Bumped dark-mode ambient from 0.15 → 0.35 so terrain stays readable.
<img width="1250" height="846" alt="Screenshot 2026-06-02 at 11 47
28 AM"
src="https://github.com/user-attachments/assets/b41e8ffb-6011-4ba0-9e1f-c2a21ff90794"
/>
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
evan
**Add approved & assigned issue number here:**
Resolves#2549
## Description:
Themes are purely for the client's rendering, and the server doesn't
need context on them. This PR moves `Theme.ts` from
`src/core/configuration` to `src/client/theme` and moves affiliation
colors to `render-settings.json`.
This is to support the ability to add additional themes more quickly,
such as colorblind-friendly themes. No visible changes occur from this
refactor.
## Please complete the following:
- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
jetaviz
---------
Co-authored-by: Josh Harris <josh@wickedsick.com>
> **Before opening a PR:** discuss new features on
[Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small
improvements as
[issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose).
You must be assigned to an `approved` issue — unsolicited PRs will be
auto-closed.
**Add approved & assigned issue number here:**
Resolves#4059
## Description:
Makes ghost radius centred on building being upgrading (if upgrading
building)
<img width="1920" height="1080" alt="Screenshot from 2026-06-01
15-45-37"
src="https://github.com/user-attachments/assets/9cf19e59-0e20-4b43-b65f-35eebb37fa8e"
/>
<img width="1920" height="1080" alt="Screenshot from 2026-06-01
15-45-24"
src="https://github.com/user-attachments/assets/1953238f-5156-4bf7-aa18-8278c27d1a68"
/>
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
tktk123456
> **Before opening a PR:** discuss new features on
[Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small
improvements as
[issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose).
You must be assigned to an `approved` issue — unsolicited PRs will be
auto-closed.
**Add approved & assigned issue number here:**
Resolves#2668
## Description:
Makes radius that factories effect equal to the max distance that a
railroad can be and enable factory ghosts
<img width="1920" height="1080" alt="Screenshot from 2026-06-01
20-13-58"
src="https://github.com/user-attachments/assets/1e2f4de8-79fc-4034-b6ad-3c71255c0410"
/>
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
## Please put your Discord username so you can be contacted if a bug or
regression is found:
tktk1234567
The renderer kept a parallel CONSTRUCTION_DURATIONS table in
src/client/render/GameConstants.ts that had drifted from Config: port
showed as 20 ticks but the simulation builds it in 50, so the bar hit
100% and idled for 30 ticks. SAM/silo cooldown constants were also
stale (120/75 vs Config's 90/90), making the missile-readiness bar
slightly wrong too.
Delete GameConstants.ts entirely. Thread the Config instance through
WebGLGameView → GPURenderer → BarPass / FxPass / FxSpritePass /
WorldTextPass; passes call config.unitInfo(...).constructionDuration,
config.SAMCooldown(), config.deletionMarkDuration(), config.msPerTick()
directly. Add Config.msPerTick() since no method existed for it.
Move the visual-only NUKE_EXPLOSION_RADII (not a game-logic value)
into FxSpritePass where it's used.
Adds a "Classic icons" toggle in the structure-icons section of the
Graphics Settings modal. Off (default) keeps today's renderer look;
on switches to a classic style — lighter player-colored shape behind
a dark icon glyph, with 0.75 alpha for a subtle translucent feel.
Exposes the underlying tuning as new render-settings knobs
(`structure.fillDarken`, `borderDarken`, `iconAlpha`, `iconR/G/B`) and
threads them through the structure shader as uniforms, replacing the
previously hardcoded `darken(_, 0.65)` / `darken(_, 0.35)` calls and
the hardcoded white `vec3(1.0)` icon color. The `classicIcons` boolean
in the override schema is the single user-facing knob; the generator
derives the five underlying field values from it. Extends the
ClientGameRunner live-apply path to copy the `structure` slice too,
and adds tests covering the schema and preset derivation.
Adds a single "Name color" toggle (Colored / Black) to the Graphics
Settings modal, backed by a `darkNames` boolean in the override schema
that derives the five underlying name-rendering fields
(fill/outline player-color flags + static outline RGB). Forcing the
outline RGB to 0 in dark mode is what makes the shader's defaultFill
ramp actually render black — flipping the boolean uniforms alone
wasn't enough because the fill is derived from uOutlineColor when
fillUsePlayerColor is false.
Flips the render-settings.json defaults so black names are the
renderer baseline; the modal's no-override state follows the JSON
source of truth. Adds tests covering schema parse behavior and the
generateRenderSettings derivation for each override field.
Chip used a world-space scale and y-offset, so it shrank as the camera
zoomed out and slid under the cursor. Now both the scale and the
downward offset are divided by zoom each frame, mirroring the existing
attack-troop-label pattern. Tunable via ghostCost in render-settings.
## Description:
- Add a user-facing **Graphics Settings** modal accessible from the
in-game Settings menu, with live preview as sliders change.
- First two knobs: **Name Scale** and **Minimum Name Size** (the
name-cull threshold).
- Overrides stored as a single JSON blob in `localStorage` under
`settings.graphics`, validated by a Zod schema
(`GraphicsOverridesSchema`). Future graphics knobs just extend the
schema + slider list.
## How it fits together
- `generateRenderSettings(overrides)` (`RenderSettings.ts`) — pure
function: clones `render-settings.json` defaults, layers overrides on
top, returns a fresh `RenderSettings`.
- `UserSettings.graphicsOverrides()` / `setGraphicsOverrides()` —
read/write the blob; falls back to `{}` on a missing/corrupt entry.
- `ClientGameRunner` listens for
`USER_SETTINGS_CHANGED_EVENT:settings.graphics`, regenerates, and
`Object.assign`s each category into the live `view.getSettings()` slice
so passes pick up the new values on the next frame (no renderer
reconstruction).
- Modal reads defaults straight from `render-settings.json` so there's
no duplication.
<img width="599" height="515" alt="Screenshot 2026-05-28 at 11 18 43 AM"
src="https://github.com/user-attachments/assets/263d7d91-10d8-4a66-a069-10015c735d60"
/>
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
evan
Wire targetingAlly through GhostPreviewData.rangeWarning so RangeCirclePass
can color the blast-radius circle red instead of white when the ghosted nuke
would trigger a traitor penalty. Restores behavior lost in the canvas→WebGL
migration.
Filter extractAttackRings to the local player's smallID so FxAttackRingPass
only draws rings on the player's own transports. Drop the unused
extractAttackRingsFromIds variant and the dead frame/index.ts barrel
re-exports.
Replace the hard zoom cutoff in RailroadPass with a linear alpha fade
controlled by a new `railFadeRange` setting. Rails (and bridge pixels)
ramp from invisible at `railMinZoom - railFadeRange` to fully opaque at
`railMinZoom`, instead of popping in. Adds a uRailFade shader uniform
and a debug slider.
## Description:
Add image-based territory skins as a new cosmetic type, rendered
alongside the existing 1-bit patterns. Skins render a single PNG
centered on each player's spawn tile — opaque pixels show the skin
(multiplied by team color in team games, raw colors in FFA), transparent
pixels and tiles outside the image bounds fall through to the regular
player palette color.
**Cosmetic plumbing**
- `SkinSchema` in `CosmeticSchemas.ts`, optional `skins` map on
`CosmeticsSchema`
- `PlayerSkin`, `PlayerCosmetics.skin`, `PlayerCosmeticRefs.skinName` in
`Schemas.ts`
- Server-side resolution: `PrivilegeCheckerImpl.isSkinAllowed` (gated by
`skin:*` / `skin:<name>` flares)
- Client persistence: stored under `PATTERN_KEY` (`pattern:` and `skin:`
share one slot — they're mutually exclusive)
- `getPlayerCosmeticsRefs` only emits a `skinName` when cosmetics are
loaded, the skin exists in the catalog, and the user has the right flare
— otherwise drops the ref and clears storage
**Renderer**
- `SkinAtlasArray` — fixed `TEXTURE_2D_ARRAY`, 1024×1024 per layer,
exact layer count allocated once at game start from the locked-in player
set. No resize, no callbacks, no retained `HTMLImageElement`. Zero GPU
cost when no players have skins (1×1 placeholder).
- `skinLayerTex` (R8UI 4096×1) — per-player `layer + 1` (`0` = no skin)
- `skinAnchorTex` (RG16UI 4096×1) — per-player spawn tile, so the PNG
center anchors at each player's spawn (re-uploads when the player
re-picks during spawn phase)
- `WebGLFrameBuilder.syncPlayers` collects unique skin URLs on first
sync and calls `view.initSkinAtlas(urls)` once; `clearCaches()` resets
so seek/replay re-initializes
- `territory.frag.glsl`: skin branch is mutually exclusive with
patterns; bounds-checks UVs against `[0, 1]` so the image is a single
stamp, not tiled; alpha-blends against the player palette color so
transparent pixels and out-of-bounds tiles render as the regular player
color
**Hover highlight (global UX change, not skin-scoped)**
- Existing hover highlight changed from "brighten toward white" to
"saturation boost." Applies to all players regardless of
skin/pattern/flat-color — looks better across the board.
**UI**
- `CosmeticButton` renders skins as a single `<img>` (object-contain)
- `TerritoryPatternsModal` merges patterns + skins into one grid; single
"default" tile clears both
- Selecting a pattern clears the skin and vice versa (mutually
exclusive)
- `Store` pattern tab includes skin entries (purchasable, not-yet-owned)
- `PatternInput` lobby button previews the active skin when one is set
**Memory**
- 0 skin players → ~4 bytes (placeholder) + ~40 KB fixed per-player
tables
- 1 skin player → ~5.6 MB GPU
- 5 skin players → ~28 MB GPU
- 10 skin players → ~56 MB GPU
**Tests**
- `tests/Privilege.test.ts`: 13 new cases covering `isSkinAllowed`
(wildcard, exact-match, missing flare, missing skin, forged refs) and
`isAllowed` integration (allowed/forbidden paths, short-circuit when
invalid skin is paired with valid other cosmetics)
## 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
- [x] 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:
evan
## Description:
The unit fragment shader (unit.frag.glsl) checked vFlags > 1.5 to
colorize warships in their attacking/angry solid red territory band
(FLAG_ANGRY = 2.0). However, trade-friendly ships use
FLAG_TRADE_FRIENDLY = 3.0 which also matched this condition, causing
friendly/trade-allied ships to incorrectly render as hostile/angry
warships in normal camera view.
This fix refactors unit.frag.glsl to use precise float range queries via
abs() to verify FLAG_ANGRY and FLAG_FLICKER flags specifically,
preventing the trade-friendly flag from triggering the angry-red
colorization.
## Please complete the following:
- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] 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:
barfires
## Description:
Replaces the DOM-based `AttackingTroopsOverlay` with
`AttackingTroopsController`, rendering attack troop counts through
`WorldTextPass` instead of a separate fixed-position DOM container.
## Summary
- New `AttackingTroopsController` polls `attackClusteredPositions()`
every 200ms and pushes labels to the WebGL view each frame, lerping
cluster positions over 250ms for smooth front-line movement (replaces
the old CSS `transform 0.25s` transition).
- `WorldTextPass` gains `setAttackTroopLabels()` and renders them at a
fixed on-screen size (zoom-independent) using `screenScale / zoom`.
- World text now draws on top of `NamePass` so attack callouts aren't
hidden behind centered player names.
- Fragment shader adds a soft quadratic dark halo around every
world-text label; extent uses the remaining SDF range after the hard
outline so it fades smoothly to zero (no rectangular clipping).
- Deletes `AttackingTroopsOverlay.ts`; existing unit tests repointed to
the controller's exported `alignClusterOrder`.
<img width="369" height="395" alt="Screenshot 2026-05-24 at 4 43 51 PM"
src="https://github.com/user-attachments/assets/4dbffe20-77f9-4c0f-b956-ecf543538f8d"
/>
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
evan
## Description:
Stop deleted missile silos from reappearing after cooldown expiry
before
https://github.com/user-attachments/assets/714d3580-b60d-479e-8e45-6ee065b79d54
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
aotumuri
Moves the AlternateViewEvent / ToggleCoordinateGridEvent subscriptions out of
ClientGameRunner into a dedicated controller in src/client/controllers/. Also
wires ToggleCoordinateGridEvent (M keybind) — previously emitted with no
listener — so the persistent coordinate-grid toggle works. Grid + alt-view
hide names only under alt-view; M keeps names visible.
## Description:
# Ghost structure cost label
Renders the gold cost of the currently-selected build under the ghost
structure cursor, with color-coded affordability/placement state. Honors
the
existing `cursorCostLabel` user setting (legacy name `ghostPricePill`,
already
shipping ON by default).
## Behavior
| State | Color |
|---|---|
| Can afford + valid placement | white |
| Can afford + can't place here (port on land, overlap, …) | gray |
| Can't afford | red |
The number is formatted via `renderNumber` (project-wide convention —
`1.5K`,
`1.23M`, etc.) and rendered as MSDF text at a fixed world-space scale,
centered
under the ghost icon.
## Implementation
The cost was already plumbed end-to-end on
[`GhostPreviewData.cost`](src/client/render/types/Renderer.ts) but never
visualized. This PR:
- Extends [`GhostPreviewData`](src/client/render/types/Renderer.ts) with
`showCost` (from setting) and `canAfford` (gold
vs. cost check, computed in
[BuildPreviewController](src/client/controllers/BuildPreviewController.ts)).
- Adds a `setGhostCostLabel(...)` channel to the MSDF text pass — one
persistent,
non-animated text instance alongside the existing ephemeral popups. No
new
pass, no new shader.
- Wires
[`Renderer.updateGhostPreview`](src/client/render/gl/Renderer.ts) to
push the label whenever a ghost is active.
- Renames `ConquestPopupPass` →
[`WorldTextPass`](src/client/render/gl/passes/WorldTextPass.ts) (and its
shader dir
`conquest-popup/` → `world-text/`) since it now handles conquest popups,
bonus popups, and the ghost cost label. Done with `git mv` so history is
preserved.
https://github.com/user-attachments/assets/c5b21bf3-f440-4c28-9b94-843df9bf6a37
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
evan
# Dynamic flag atlas (runtime TEXTURE_2D_ARRAY)
Replaces the build-time `flag-atlas.png` with a runtime
`TEXTURE_2D_ARRAY`
populated on demand from each player's server-resolved flag URL. Layers
are
deduped by URL (every "Mercia" bot shares one slot), so the per-game
working
set is bounded by unique flags, not player count.
## Why
The store will eventually ship hundreds of custom flags fetched from the
CDN,
which can't be baked into a static atlas. Moving to a runtime array also
lets
the catalog grow without bloating the client bundle.
## Side effect (bonus)
Human players' country flags (`country:US`, etc.) now display next to
their
names in-game. The old atlas only contained nation names, so non-nation
flags
were silently dropped.
## Notes
- Cell size is fixed at 128×85; loaded images are aspect-fit and
centered.
- Layer cap is 512 (clamped to `MAX_ARRAY_TEXTURE_LAYERS`). Past the
cap,
further flag requests render no icon.
- Mipmaps are regenerated after each layer upload.
- Recommend store pipeline caps custom flag uploads at SVG or PNG ≤
256×170,
≤ 50 KB (decode-time RAM and bandwidth, not VRAM).
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
evan
## Description:
Reworks the visual look of nuked tiles to read uniformly green (no more
brown/black bleed-through), and moves the ember "particle" effect out of
the border passes — where it lived as a storage-sharing hack — into the
fallout system where it belongs.
## What changed visually
- **Fresh fallout**: bright uniform bloom with a hint of flickering
green particles dampened on fresh tiles, ramping up as heat decays
(`particleFreshScale` controls the fresh-tile dampening).
- **Stale fallout**: dark-green ground (was near-black charcoal), with
full-strength flickering particles in dark-green ↔ light-green.
- **Particles**: per-tile flicker is now de-synced (each tile pulses at
its own rate, 0.4×–1.6× base speed) so the eye can't lock onto a global
rhythm.
- **No more brown/black pixels** in fallout zones. Two root causes were
fixed:
- The territory pass now renders stale-nuke ground for **all** fallout
tiles, not just unowned ones — so an owned player's color can't show
through where the bloom is dim/transparent.
- The ember stamp (which fully replaced tile color with orange) is gone;
particle render is now additive and color-tuned green.
## Architecture cleanup
The ember effect was conceptually fallout-domain, but lived in
`BorderComputePass` (writing intensity into `borderTex.g`) and
`BorderStampPass` (stamping orange dots), just because the border pass
already had an RGBA8 texture with a free G channel. Two consumers read
from it (`BorderStampPass`, `FalloutLightPass`), and the per-tile
flicker math used no border data at all.
This PR relocates the math inline into the two passes that actually need
it (`FalloutBloomPass.extract.frag.glsl` and
`FalloutLightPass.fallout-light.frag.glsl`), drops the ember code from
both border passes, and renames `mapOverlay.ember*` →
`falloutBloom.particle*` so the settings live with their pass.
Side benefits:
- **Animation correctness**: the old setup only updated ember intensity
when `BorderComputePass`'s dirty flag flipped (highlight change,
relations update, etc.), so the supposed flicker was actually a frozen
snapshot between border events. The new inline path runs every frame as
intended.
- **Slightly cheaper per-frame compute**: removed a per-dirty-event
full-map writeback to `borderTex.g`; added a few cheap ALU ops (1 sin +
2 hashes) per fallout tile in shaders that were already running. Same
texture memory.
## Other small changes
- Renamed `mapOverlay.charcoal*` → `mapOverlay.staleNuke*` (charcoal was
a misnomer now that the ground is green).
- Added `staleNukeR/G/B` for the ground color (was hardcoded grey).
- `intensityHot` bumped 0.6 → 1.8 for a brighter fresh-nuke glow.
- Raised `railroad.railMinZoom` 2 → 4 and `railDetailZoom` 4 → 6 so
rails pop in later (separate small commit).
<img width="354" height="371" alt="Screenshot 2026-05-22 at 10 37 34 AM"
src="https://github.com/user-attachments/assets/03b46c45-c617-41b3-b3e4-9934f064bfe1"
/>
<img width="335" height="358" alt="Screenshot 2026-05-22 at 10 37 43 AM"
src="https://github.com/user-attachments/assets/af370b19-8f22-4694-9859-1ad52aa755a7"
/>
<img width="651" height="613" alt="Screenshot 2026-05-22 at 10 38 09 AM"
src="https://github.com/user-attachments/assets/e06e5101-8529-49f6-b29a-ce0563eb52d6"
/>
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
evan
## Description:
Show factory and defence post radius for ghost structure when placing
structures from build bar (unitdisplay).
Show when city/port is placed directly over existing railroad, by
highlighting the railroad green. The railroad is not highlighted when
instead a city/port nearby the ghost structure will be upgraded instead
of placing it on the railroad. This works with the existing code in
buildableUnits in PlayerImpl: it would already return an empty array []
for overlappingRailroads and for ghostRailPaths when canUpgrade is
false. So the old checks for uiState for Canvas2D in
BuildPreviewController weren't even needed per se, they followed the
same logic as buildableUnits in PlayerImpl already did.
Both changes emulate how it worked before the move to WebGL.
- OverlappingRailroads now returns TileRefs instead of a railroad ID,
and it does so with less allocations than the previous code. It's a
determistic outcome, sorted and deduplicated. In doubt about this a bit,
because it's better also in case we ever do desync checks using this
data, but for the rendering it isn't needed per se and could be more
performant without allocations.
- Also: Cleanup obsolete Canvas2D rail highlighting state (UIState) that
was superseded by GhostPreviewData.
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
tryout33
## Description:
In the WebGL implementation, room was reserved for emoji icon between
the status icons and the name.. This is a regression from the HTML
NameLayer and it looks weird having that space sit there unused the
majority of the time when no emoji is shown.
Show status icons closer above names again.
And when emoji icon is shown shortly, display it in place of the other
icons (with the HTML NameLayer it would be drawn on top of the other
icons but that could look messy and it's only for a short time anyway).
Not addressed in this PR: icon size is different from before WebGL
implementation, they seem smaller.
**BEFORE (after initial WebGL implementation):**
<img width="1007" height="577" alt="Icons too high up because room is
kept for emoji while on canvas they where stacked"
src="https://github.com/user-attachments/assets/ca6937c8-e265-467d-a8f5-1424540da1c1"
/>
**AFTER:**
<img width="816" height="538" alt="Icons closer and stacked again just
their size needs attention later on"
src="https://github.com/user-attachments/assets/8a700d23-ea82-4b61-b897-109dbd0e3a32"
/>
<img width="1878" height="758" alt="Icons closer and stacked again just
their size needs attention later on B"
src="https://github.com/user-attachments/assets/f6502ad1-8a6a-4dd8-80a1-7f0f2ed590a3"
/>
<img width="443" height="335" alt="Emoji replaces normal status icons
this was after emoji was just shown"
src="https://github.com/user-attachments/assets/a8b35385-08d1-4c00-9881-9607d880048e"
/>
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
tryout33
## Description:
When WebGL context is lost, restore context and all elements.
In GameView, handle potentially transient undefined states during
context loss gracefully.
Test with chrome://gpucrash from another tab, then return to the game
tab to see it being restored
(This fake gpucrash only works once sometimes. Because the second time
the browser might reject the tab it thinks caused the gpu crash, access
to hardware acceleration. And after even more tries even disables it
browser-wide. A browser restart resets it in that case.)
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
tryout33
Resolves#3910
## Description:
- Split the events HUD into two components: a new
**`<actionable-events>`** that owns alliance prompts (request / renew)
and a slimmed-down **`<events-display>`** for everything else.
- Reworked `<events-display>` into two visual tiers: dim/scrolling tier
2 on top (trade results, unit losses, donations, alliance status),
prominent tier 1 anchored at the bottom (inbound nukes, naval invasion,
attack requests, alliance broken, conquered player, chat). Tier 2 caps
at the 4 newest entries; events expire after 8s.
- Added a transient **+gold pip** above the gold pill in
`<control-panel>`, animated with a small fade-in. Fires for trade ships,
trains, donations, and conquest. Trade-ship and train arrivals are
removed from the events scroll since they're surfaced here instead.
- New `MessageType.NUKE_DETONATED` and a server-side emission in
`NukeExecution.detonate` — once an inbound nuke lands or gets
intercepted, the inbound warning vanishes and a "detonated" entry takes
its place.
- `displayMessage` gained optional `unitID` and `focusPlayerID` params
so events can link to a unit or a player. Unit captures and destructions
now navigate to the unit's last tile when clicked; donations navigate to
the other player.
- ActionableEvents card width matches `<events-display>`; cards persist
until the user clicks Accept/Reject/Renew/Ignore or the server-side
request timeout expires.
- Removed the in-events category filter UI and the gold-amount banner —
`<events-display>` is now a lightweight log that hides entirely when
empty.
<img width="570" height="444" alt="Screenshot 2026-05-21 at 1 42 30 PM"
src="https://github.com/user-attachments/assets/f103efb3-0e11-4b72-a11b-91ff6896177c"
/>
<img width="430" height="296" alt="Screenshot 2026-05-21 at 1 41 34 PM"
src="https://github.com/user-attachments/assets/ae58475a-b252-4aa6-9ce5-99dea7575ce3"
/>
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
evan
## Description:
Show nuke icons during replay too (when there's no localPlayer).
Show alliance request envelope icon, and duration in alliance icon
(weren't calculated yet).
Show ally and team mates' targets too (weren't calculated yet).
Remove unnecessary allocations. Nukes loop allocated two new sets,
transitive targets was a new set and now uses predicate with fallback to
localPlayer.targets, localPlayer.allies and localPlayer.embargoes were
both put in new set instead of using .includes directly.
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
tryout33
relates to #893
## Description:
Territory updates were uploaded in one shot per game tick, producing a
10 Hz tile update which looked choppy. This change drips each tick's
tile changes across the ~6 render frames between ticks so the fill flows
continuously instead of stepping.
Inside TerritoryPass, each changed tile is hashed by ref into one of N
buckets (configurable via tileDrip.bucketCount, set to 9 — gives ~50 ms
of jitter headroom over the tick period without making attacks feel
laggy). One bucket drains per render frame. The stable per-ref hash
keeps repeated updates to the same tile in arrival order, so the latest
owner always wins.
While in there, moved trail state ownership out of TerritoryPass and
into TrailPass where it belongs — the territory shader doesn't sample
trailTex, so the colocation was just code-reuse drift.
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
evan
Plumb spawnTile through PlayerUpdate / PlayerState / applyStateUpdate
so the WebGL spawn overlay can read it directly. The glow was reading
nameData.x/y (territory centroid for label placement) which only
recomputes every 2 ticks and only when largestClusterBoundingBox has
been updated by PlayerExecution — both lag the player's actual spawn
click. Using spawnTile updates the same tick setSpawnTile() fires.
Also adds spawnTile to diffPlayerUpdate / applyStateUpdate so changes
after the initial full snapshot actually propagate (the recent
diff-only PlayerUpdate path silently dropped any field not enumerated
in those helpers).
## Description:
Display territory skins (patterns) again.
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] 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:
tryout33
The contents (Lit web components for in-game chat, build menu, leaderboard,
attack displays, etc.) are HUD, not graphics — the actual graphics is in
client/render/.
Terrain was uploaded once at game start and treated as static — water
nukes (land → water conversion) mutated the sim's terrain bytes but
the rendered terrain stayed dry.
Plumbed a delta path: TerrainPass and RailroadPass each get
applyTerrainDelta(refs, bytes), Renderer + GameView forward, and
WebGLFrameBuilder pushes each tick from gameView.recentlyUpdatedTerrainTiles().
Per-tile encoding is shared via the new encodeTerrainTile helper in
ColorUtils so the startup full-map build and the per-tile delta updates
can't drift.
SpawnOverlayPass had everything wired except a caller. WebGLFrameBuilder
now collects spawned human players each tick during spawn phase and
pushes their territory centroid + color through view.updateSpawnOverlay.
myPlayer reads as white so the local-player ring stands out.
Reshaped the shader animation: dropped the growing-disc effect, gave
the ring a true breath — radius scales 0.5×→1.15× while opacity pulses
35%→100% in phase. Replaced the sharp inner-edge ramp with a smooth
center-to-boundary fill so there's no hard cutoff or empty hole in
the middle. animSpeed bumped to 0.0035 (~1 breath/sec).
InputHandler still emits AlternateViewEvent on space down/up, and the
renderer still has setAltView. The bridge between them lived in
MapInteraction's applyAltView, which got deleted with the rest of
MapInteraction — nothing was wiring the event to the view anymore.
Expose view.setAltView and have ClientGameRunner subscribe.
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.
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 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.
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.
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/.
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.
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.