## Summary
The game simulation runs **client-side**, so the server can't directly
see what's happening in a running game. This adds a way for the admin
bot to observe a live game: clients report a live stats snapshot every
~10s, the server reaches consensus on it (reusing the winner's vote
mechanism), and a new admin-bot endpoint serves it.
## How it works
1. **`LiveStatsController`** (client) emits a snapshot every **100
turns** (~10s at 100ms/turn) — only deterministic sim values, with
players sorted by clientID, so in-sync clients produce an identical
payload.
2. The snapshot is sent as a new **`live_stats`** wire message wrapping
a `LiveStats` object (`turn` + per-human-player
`tilesOwned`/`troops`/`gold`/`isAlive`/`team`).
3. **`GameServer.handleLiveStats`** tallies a per-turn **IP-weighted
majority vote** — the same consensus the winner uses — and keeps the
latest agreed snapshot.
4. **`GET /api/adminbot/game/:id/stats`** returns it, enriched with
usernames the server already holds. `liveStats` is `null` until the
first consensus.
The winner's vote tally was extracted into a small reusable
**`VoteRound`** (`src/server/VoteTally.ts`) and is now used for both
winner and live-stats consensus.
Names are deliberately **excluded** from the voted payload (they vary
per client under name anonymization, which would break exact-match
consensus); the server joins `clientID → username` instead.
## Changes
- `src/server/VoteTally.ts` *(new)* — reusable IP-weighted `VoteRound`
- `src/core/Schemas.ts` — `PlayerLiveStatsSchema`, `LiveStatsSchema`,
`ClientSendLiveStatsSchema` + unions
- `src/client/controllers/LiveStatsController.ts` *(new)* — per-100-turn
snapshot reporter
- `src/client/Transport.ts` — `SendLiveStatsEvent` + sender
- `src/client/hud/GameRenderer.ts` — register the controller
- `src/server/GameServer.ts` — refactor winner onto `VoteRound`; add
live-stats consensus + `liveStats()` accessor
- `src/server/AdminBotRoutes.ts` — `GET …/stats` endpoint
## Testing
- **Unit:** `tests/server/VoteTally.test.ts` (majority/dedup/ties),
`tests/server/LiveStats.test.ts` (consensus, disagreement, per-client
dedup, stale-turn rejection, turn advance, out-of-sync exclusion, +
endpoint 200/404/400). Full suite green (`npm test`), typecheck + lint
clean.
- **Manual e2e** against the dev server: created an admin-bot game,
joined it in a browser, force-started via `toggle_game_start_timer`, and
confirmed `GET …/stats` returned the consensus snapshot with username
enrichment and an advancing `turn`. Also verified wrong-worker → 400 and
missing-key → 401.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Description:
Add an optional `targetPublicId` to KickPlayerIntent; the server
resolves it against the connected clients to the live clientID, then
kicks as before. Existing clientID targeting (lobby / in-game kick) is
unchanged. That way you can kick player with both the clientID and
playerID
## 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:
zixer._
**Add approved & assigned issue number here:**
Resolves#4296
## Description:
Adds an "Anonymous players" option to private lobbies (host toggle, off
by default).
When it is on, the server sends each client anonymized usernames for
everyone except themselves. The lobby creator and admins still see real
names so they can moderate. Names are hidden on every player-facing
surface: the game start message, lobby info, /api/game/:id, and the link
preview. It is enforced server-side, so a client extension cannot read
real names off the wire. Initially added as part of our overhaul of
OpenFront masters, but this feature can very well be useful for content
creators, and other tournament hosts.
Anonymized names reuse the existing tribe word lists (no emoji), so they
pass UsernameSchema, and they are seeded per user, so a player looks
different to different users but stays consistent from the lobby into
the game.
The saved game record keeps real names (anonymization is a per-send
transform, gameStartInfo is never mutated), so replays and stats are
unaffected. Nothing changes for normal games.
New option selection:
<img width="990" height="918" alt="image"
src="https://github.com/user-attachments/assets/31df0b0b-7757-4b2b-9bff-84310faee8d9"
/>
The host, when enabling the option, gets a little eye icon next to the
players(including himself to enable/disable the anon names for himself,
and/or other player)
By default(the names everyone will see are random and unique):
<img width="979" height="188" alt="image"
src="https://github.com/user-attachments/assets/f0caa4a4-9f14-41d3-89c6-9a38e8c2e6f0"
/>
Toggling the eye ON for yourself (the host, or any given player, will
allow them to see the real names of everyone, in the lobby and in game):
<img width="969" height="138" alt="image"
src="https://github.com/user-attachments/assets/89abf0e0-1433-43ea-9870-49d96ca46d30"
/>
## 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:
zixer._
**Add approved & assigned issue number here:**
Resolves#4316
## Description:
At the beginning of the leaderboard update cycle, evaluates `maxTroops`
once for each `PlayerView` and uses the cached value for the rest of the
`maxTroops` lookups in the function.
Measured a reduction in `updateLeaderboard` processing time from 6ms/sec
down to 2ms/sec (measured over the first minute of a singleplayer game
on world map and default settings).
## 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
## Please put your Discord username so you can be contacted if a bug or
regression is found:
Demonessica
Resolves#4307
## Description:
Adds a check for the "delete" element of the raidial menu, to make sure
the unit attemped to be removed is a structure.
Previously, non-structure units such as trains could be deleted using
the menu.
## Please complete the following:
- [ ] I have added screenshots for all UI updates (No UI updates)
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file (No text added)
- [ ] I have added relevant tests to the test directory (Not sure how
this works or if it's needed)
## Please put your Discord username so you can be contacted if a bug or
regression is found:
unne27
---------
Co-authored-by: unne27 <uno.gunnar.johansson@gmail.com>
## What
Game creation no longer requires the caller to pick the `gameID` or
compute its owning worker. The client POSTs to a prefix-less
`/api/create_game`; **nginx (prod) and the vite dev proxy randomly route
it to a worker**, which **mints an id that hashes back to itself** and
returns it along with its `workerIndex`.
## Why it stays correct
The minted id still hashes to the creating worker (via the existing
`generateGameIdForWorker`), so everything downstream that derives the
worker from the gameID — websocket connect, share URL, join flow — keeps
working unchanged. The only thing that moved is *who picks the id and
worker*.
## Changes
- **`src/server/Worker.ts`** — factor create into a shared
`createGameForId`; add `POST /api/create_game` (no id) that mints a
self-owned id and returns `gameInfo` + `workerIndex`/`workerPath`. The
existing `POST /api/create_game/:id` stays.
- **`nginx.conf`** — `location = /api/create_game` proxies to a `random`
worker upstream.
- **`generate-nginx-upstream.sh` + `Dockerfile`** — the entrypoint
generates that upstream from `NUM_WORKERS` at container **start** time.
`NUM_WORKERS` isn't known at image build time (the image is built once
and deployed with different env), so it can't be baked into `nginx.conf`
— hence runtime generation of exactly the live worker ports (no
dead-server padding).
- **`vite.config.ts`** — dev-only middleware forwards `POST
/api/create_game` to a random worker. Vite's `http-proxy` can't pick a
per-request random target, so this is a small middleware plugin (same
pattern as the existing `serveProprietaryDir`), registered before the
`/api` proxy.
- **`src/client/HostLobbyModal.ts`** — stop generating the id
client-side; use the server's.
## Behavior change to note
The host's share link used to be copied **instantly** from a
client-generated id. Now the id comes from the server, so the copy waits
one create round-trip — I moved the URL build/copy into the create
`.then` (and kept the failure path that clears the clipboard). Brief
empty-link state in the modal until create resolves.
## Verification
- tsc + eslint clean; full suite green (1543 tests).
- nginx additions validated with `nginx -t` in isolation (the full file
references container-only paths like `/etc/nginx/mime.types`); upstream
+ `proxy_pass` resolve.
- `generate-nginx-upstream.sh` tested with `NUM_WORKERS` set and unset
(defaults to 1).
Not yet exercised live end-to-end (needs a dev-server restart —
`vite.config.ts` + `Worker.ts` changes aren't hot-reloaded).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## What
Adds an on/off **Structure dots** toggle to the graphics settings modal
(Structure Icons section), controlling whether structures collapse into
small dots when zoomed out.
## How
The renderer already gates the dots LOD on `structure.dotsZoomThreshold`
(structures become dots when `zoom <= threshold`). The debug GUI exposes
that threshold as a slider; this surfaces a simple player-facing toggle:
- `GraphicsOverrides.ts` — adds `structure.showDots` (boolean) to the
override schema.
- `RenderOverrides.ts` — when `showDots === false`,
`applyGraphicsOverrides` sets `dotsZoomThreshold = 0`. Since zoom is
always > 0, the dots LOD never triggers, so structures keep their full
icon at every zoom. When enabled (default), the threshold is left
untouched.
- `GraphicsSettingsModal.ts` — toggle button mirroring the existing
Classic icons / Classic level numbers toggles; defaults to On.
- `en.json` — `structure_dots_label` / `structure_dots_desc`.
The change applies live: a `settings.graphics` change re-runs
`applyGraphicsOverrides` onto the live settings object the passes read
each frame, and `dotsZoomThreshold` is a per-frame uniform.
## Testing
- `tsc --noEmit` clean.
- Verified in a headless solo game: the toggle renders (default On),
flipping it persists `structure.showDots = false`, and
`applyGraphicsOverrides` yields `dotsZoomThreshold` 1.2 when on / 0 when
off.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Donation events were split into DONATION_SENT/DONATION_RECEIVED, and
DONATION_RECEIVED was grouped with the green "success" colors. In v31
all player donations (sent and received) were blue. Move DONATION_RECEIVED
back to the blue group so both directions render blue again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `warning` variant to o-button that reuses the existing
`--color-cyber-yellow` brand token, and apply it to the host lobby's
start button while the "Starting in Xs" countdown is active. The button
stays primary blue in the Waiting/Start states.
## Problem
The important-events notification panel splits events into a prominent
**tier-1** list and a dim, auto-expiring **tier-2** list (capped at the
4 newest entries). Two alliance notifications were landing in tier-2,
making them easy to miss:
- **"X wants to renew your alliance"** (`RENEW_ALLIANCE`) — the core
simulation still sends this when another player requests a renewal, but
it was buried in tier-2, so you couldn't reliably tell whether someone
had asked to renew.
- **"X rejected your alliance"** (`ALLIANCE_REJECTED`) — also tier-2, so
the outcome of a sent request was inconsistent (accepted was prominent,
rejected was not).
## Fix
Add `MessageType.RENEW_ALLIANCE` and `MessageType.ALLIANCE_REJECTED` to
`TIER_1_TYPES` in `EventsDisplay.ts`, so they show prominently alongside
the already-tier-1 `ALLIANCE_ACCEPTED` event.
No core/simulation changes — the messages were already being emitted;
only the client presentation needed fixing. Display styling for both
message types already exists in `Utils.ts`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
fix(render): update coastline color dynamically when ocean color changes
Resolves#4329
## Description:
Previously, the shoreline water color (`isShoreline && !isLand`) was
hardcoded to a static bright blue (`rgb(100, 143, 255)`) in
`encodeTerrainTile`. When a user customized the ocean/water color in
Settings, the deep ocean changed colors but the shoreline water remained
bright blue, causing a jarred, visually mismatched appearance.
This PR updates `encodeTerrainTile` in `ColorUtils.ts` to dynamically
calculate the shoreline water color by scaling the configured
`oceanColor` channels:
- Red and Blue channels scale by `1.4` (clamped to `255`).
- Green channel scales by `1.08` (clamped to `255`).
This scales the coastline water color harmoniously alongside any custom
water color settings (e.g. green, red, or dark ocean tones).
## Please complete the following:
- [x] I have added screenshots for all UI/rendering updates (Attached
`coastline_screenshot.png` showing dynamic color integration)
- [ ] 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
additions)
- [ ] I have added relevant tests to the test directory (N/A — WebGL
rendering code has no automated test harness)
## Please put your Discord username so you can be contacted if a bug or
regression is found:
barfires
Resolves#4365
## Description:
Currently the border of icons are using borderColor which is grey for
local player
<img width="227" height="281" alt="image"
src="https://github.com/user-attachments/assets/9e334e19-c5b2-49ca-a85d-4576a5bbc1a9"
/>
This set it to territory color
<img width="187" height="102" alt="image"
src="https://github.com/user-attachments/assets/9b9f27f9-69e2-4ae7-9f35-a789b56b45de"
/>
## 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
- [ ] 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:
Mr. Box
**Add approved & assigned issue number here:**
Resolves#4349
## Description:
1. **Private-lobby allowlist.** `create_game` accepts an optional
`allowedPublicIds`. It's set by whoever creates the lobby (admin-token
gated, no client UI), the game server pulls it out of the config so it's
never broadcast to clients or written to the game record, and it rejects
any joiner whose OF publicId isn't on the list before they take a slot
(stickily, so they can't retry on reconnect). Lobbies created without it
behave exactly as before.
It is off by default
Previews:
<img width="241" height="140" alt="image"
src="https://github.com/user-attachments/assets/30c4e47b-399d-4720-b25b-a04c63668577"
/>
<img width="982" height="456" alt="image"
src="https://github.com/user-attachments/assets/1b5c68b7-9b99-4ccc-b987-e70c8ec25dce"
/>
<img width="547" height="369" alt="image"
src="https://github.com/user-attachments/assets/1623090b-ea2b-4657-9cd8-903fbabca51b"
/>
I am not able to manually test all of it since it needs to also run the
auth API (infra) and actually be connected to disc and whatnot (but
still tested the refused flow)..
Also, we would need to place some guards and visual error feedback, but
since this only would affect casual of players and is more of a
improvement to the feature, I will consider it out of scope for now.
## Please complete the following:
- [x] I have added screenshots for all UI updates (no UI changes in this
PR)
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file (no new user-facing text)
- [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:
zixer._
<img width="1152" height="866" alt="settings"
src="https://github.com/user-attachments/assets/dff00f2e-775d-4b7e-8590-855813dcff7d"
/>
## Description:
Small qol change to be able to preview graphics settings changes
## 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:
Mr. Box
**Add approved & assigned issue number here:**
N/A — bug fix for the recently-merged Google OAuth login (#4028), paired
with infra PR #378.
## Description:
Two bugs were reported after Google login merged. Both stem from the API
(`/users/@me`) only reporting the identity used to sign in — fixed in
infra PR #378, which must deploy first. This PR is the client half.
- **Still says "Link":** a Discord user who linked Google saw the
account page still offer "Link Google" (the response never reported
`google`). Now that the API reports all linked identities, the account
page shows a positive **"Linked to Google (<email>)"**
confirmation instead of just hiding the button — so the link visibly
succeeds and the user won't re-link (which would replace the prior
Google account).
- **Avatar replaced by email badge:** signing in via Google dropped the
linked Discord profile, so the top bar lost the Discord avatar. This is
fixed entirely by the API change (the existing Discord-first logic in
`Main.ts`/`hasLinkedAccount` restores the avatar) — no client change
needed beyond this PR's linked-state display.
## 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:
jish
## What
Adds a **Nuke fallout color** option to the in-game graphics settings
modal (Effects section), letting players recolor the fallout tint left
on territory after a nuke.

## How
Mirrors the existing **Ocean color** override pattern:
- `GraphicsOverrides.ts` — adds `staleNukeColor` (hex string) to the
`mapOverlay` override schema.
- `RenderOverrides.ts` — `applyGraphicsOverrides` parses the hex and
writes the renderer's `staleNukeR/G/B` 0–1 float channels (`hexToRgb`
yields 0–255, so it divides by 255).
- `GraphicsSettingsModal.ts` — new hex-text + native color-picker row,
default computed from `render-settings.json`.
- `en.json` — `nuke_color_label` / `nuke_color_desc`.
The value persists via `UserSettings.graphicsOverrides()` and is cleared
by the modal's existing "Reset to defaults".
The render debug GUI already exposes the same setting as **Stale Nuke
Color** (Map Overlay), so no change was needed there.
## Testing
- `tsc --noEmit` clean.
- Verified in a headless solo game: the row renders with the green
default (`#0d8c12`), changing it persists `mapOverlay.staleNukeColor`,
and `applyGraphicsOverrides("#ff0000")` produces `staleNukeR=1, G=0,
B=0`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Problem
The green alliance icon above player names blends into similarly-colored
terrain — most notably irradiated land, which is the same green — making
it hard to spot allied players.
## Fix
Add a configurable dark outline to the alliance status icon, rendered in
the status-icon shader (the icons come from a pre-baked atlas with no
regeneration script, so this is done in-shader rather than by editing
the PNG).
- **Outline**: an alpha dilation gated to the alliance icon (slot 3).
8-direction sampling of the icon's alpha builds a black halo around its
silhouette; interior pixels and all other status icons are untouched.
- **No clipping**: the alliance icon's quad is grown outward into the
atlas cell's existing transparent padding so the halo isn't clipped at
the quad edge. The icon's on-screen size and position are unchanged; 8px
of the cell's 16px mipmap-safety padding is preserved.
- **Drain stays aligned**: the alliance-expiry drain effect's cut line
and faded-icon UVs are remapped into the expanded quad space so the
animation still lines up.
- **Tunable**: width is driven by `name.statusOutlineWidth` in
`render-settings.json` (default 6 texels; 0 disables), with a matching
"Status Outline Width" slider in the debug GUI.
## Testing
`tsc` and `eslint` pass. Verified in-game: the handshake now reads
clearly against irradiated terrain, with the outline rendering fully (no
edge clipping) and the drain animation still aligned.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What
Cap the height of the **Tier 1 (important) events panel** and make it
scroll when many events stack up, instead of letting it grow unbounded
up the screen.
## Why
The less-important (Tier 2) events panel was already height-capped and
scrollable, but the important panel had no limit — a burst of important
events (chat, nukes inbound, alliance changes, etc.) could push the
panel arbitrarily tall.
## Changes (`src/client/hud/layers/EventsDisplay.ts`)
- Added `max-h-[30vh] lg:max-h-[40vh] overflow-y-auto` to the
important-events container.
- Mirrored the existing Tier 2 auto-scroll-to-bottom behavior for the
important panel (new `.important-events-container` query + scroll
tracking), so the newest important events stay in view rather than being
hidden below the fold. If the player scrolls up, auto-scroll pauses
(same as Tier 2).
## Testing
Verified in the live game by injecting 15 important events:
- Panel is height-capped and scrollable (`scrollHeight 540 >
clientHeight 400`).
- Auto-scrolls to the newest (`scrollTop` pinned to bottom); events 5–15
visible, older ones reachable by scrolling up.
lint clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Description:
Relates to #3725
Adds a new **Impassable** terrain type that enables non-rectangular maps
and creates impassable barriers on the map. Painted with pure black
(`#000`) in the map editor's `image.png`.
**Encoding:** Impassable terrain is encoded in the binary format as
`isLand=1, magnitude=31` (previously unused). The Go map generator
detects `#000` pixels and produces this encoding. The map generator's
minimap downscaling gives impassable highest priority (Impassable >
Water > Land). Thumbnails render impassable as transparent so the map
picker background shows through.
**Rendering:** Impassable tiles render as the map background colour
(`rgb(60, 60, 60)`, matching `gl.clearColor` in `Renderer.ts`), making
them visually indistinguishable from the area outside the map quad. This
enables maps to appear non-rectangular.
**Gameplay restrictions:** Impassable terrain cannot be:
- Owned (`conquer()` throws)
- Attacked (`AttackExecution` skips impassable tiles in both `tick()`
and `addNeighbors()`)
- Nuked (targeting rejected in `nukeSpawn()`, blast radius filtered in
`tilesToDestroy()`)
- Spawned on (nations, human players, and structures all reject
impassable tiles)
- Converted to water (guarded in `WaterManager` and `setWater()`)
**Nuke trajectories:** Nuke trajectories cannot cross impassable
terrain, matching the existing map-border enforcement. This is checked
at launch time in `NukeExecution.tick()`. The client-side trajectory
preview turns red with a red X where the arc crosses impassable terrain
(reusing the existing SAM-intercept visual pipeline in
`NukeTrajectory.ts`). The nuke ghost preview is completely hidden when
hovering over impassable terrain (same as hovering outside the map).
https://github.com/user-attachments/assets/ff131146-9749-41e0-892a-617e5cd16c54
Impassable terrain is transparent on the thumbnail:
<img width="213" height="152" alt="Screenshot 2026-06-18 211640"
src="https://github.com/user-attachments/assets/ede16f8c-9239-4ab1-be5d-0ba81cce5e9e"
/>
Tested with water nukes, made sure there is no water depth gradient near
the impassable terrain, just like at the world border:
<img width="774" height="771" alt="Screenshot 2026-06-18 212348"
src="https://github.com/user-attachments/assets/4429069d-911b-48e8-91e3-7307d42c9397"
/>
Models used: GLM 5.2 and MiMo 2.5 Pro 😄
## 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:
FloPinguin
Resolves#4028 (client half — backend is openfrontio/infra#368, which
must be deployed first).
## Description:
Adds "Login with Google" to the client, alongside the existing Discord
login. Companion to the backend PR (openfrontio/infra#368).
- `Auth.ts` — `googleLogin()` (full-page redirect to
`/auth/login/google?redirect_uri=…`, mirrors `discordLogin()`).
- `ApiSchemas.ts` — `GoogleUserSchema` + optional `user.google` on
`UserMeResponseSchema`.
- `AccountModal.ts` — a "Login with Google" button (Google brand
guidelines: white surface, dark text, the multicolor "G" mark) in the
login options, and the logged-in view now renders a Google-authenticated
user's email (also added `google` to `isLinkedAccount()`).
- `en.json` — `main.login_google`.
- `resources/images/GoogleLogo.svg` — the Google "G" mark.
> **Draft.** Depends on infra#368 being deployed (the button hits the
live `/auth/login/google`).
## Please complete the following:
- [x] I have added screenshots for all UI updates <!-- TODO: add
screenshot of the Google button -->
- [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 <!-- no client
tests exist for AccountModal/Auth; verified via tsc --noEmit + eslint.
Backend behaviour is covered in infra#368 -->
## Please put your Discord username so you can be contacted if a bug or
regression is found:
jish
## Problem
Enabling the **hidden names** (anonymous names) setting hid names in the
leaderboard/HUD but **not on the map**.
The GL name renderer (`NamePass`) drew `slot.static.displayName` —
always the real name — and never consulted
`userSettings.anonymousNames()`. The HUD works because it calls
`PlayerView.displayName()` (which honors the setting) on each render,
but the names baked into the GPU texture bypassed that path entirely.
## Fix
Push the *resolved* name into the renderer instead of the raw static
name:
- **`WebGLFrameBuilder.syncPlayers`** registers each player with
`displayName: p.displayName()` (honors the setting) instead of
`static.displayName`. Covers enabling the setting before a game and
players who join after a toggle.
- **`WebGLFrameBuilder.refreshNames` → `MapRenderer` → `Renderer` →
`NamePass.refreshNames`** is a new path that re-resolves cached names
and forces a re-upload (resets `slot.nameLen = 0`, which also recomputes
the name half-width so it stays centered).
- **`ClientGameRunner`** listens for the `settings.anonymousNames`
change event and calls `refreshNames`, mirroring the existing
territory-patterns live toggle.
## Behavior
- Enabled before a game → players register with anonymous names.
- Toggled mid-game → map names flip to/from anonymous on the next sim
tick (~100ms), matching the leaderboard.
- Your own name is unaffected (unchanged — `PlayerView` maps the local
player's anonymous name to their real name).
## Testing
`tsc --noEmit` passes for all edited files. This is a WebGL rendering
change with no straightforward unit test; verified by tracing the data
flow (resolved name → cached `slot.static.displayName` → re-upload on
dirty).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Problem
Changing the ocean/water color in **Graphics settings** repaints the
terrain — and any water tiles created by water nukes (land → water) snap
back to their original land appearance.
## Root cause
`TerrainPass` captures the `terrainBytes` buffer at construction and
reuses it in two places:
- `setOceanColor()` does a **full** terrain texture re-upload from
`terrainBytes` when the ocean color changes.
- `applyTerrainDelta()` applies live land→water nuke conversions, but
only wrote to the **GPU texture** — never back into `terrainBytes`.
So the CPU buffer stayed frozen at the map's original terrain. Changing
the ocean color rebuilt the whole texture from that stale buffer,
reverting every nuke crater to land.
## Fix
Write each delta byte back into `terrainBytes` inside
`applyTerrainDelta()`, so the buffer stays the live source of truth and
full re-uploads reflect conversions.
```ts
this.terrainBytes[ref] = bytes[i];
```
The indexing already lines up — `terrainBytes` is indexed by linear ref
(`y * mapW + x`), the same `ref` the delta loop iterates. The buffer is
only otherwise read once at construction by `RailroadPass`/`TerrainPass`
to seed GPU textures (which copy), so mutating it has no side effects
elsewhere.
## Testing
The WebGL passes have no unit-test harness (they need a live GL
context), so this isn't covered by an automated test. Verified by
reasoning through the data flow; can confirm in-game by nuking land into
water and then changing the ocean color.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Problem
In the nuke trajectory preview, the SAM-intercept **"X"** marker was
drawn over **teammates'** SAMs — implying their SAM would shoot down
your missile. It shouldn't: like allies, a teammate's SAM never engages
your nuke. The bug only affected teammates; allies already worked.
## Cause
The preview built its threat set from `myPlayer.allies()` only — formal
alliances — and never considered teammates. That diverged from the sim
([`SAMLauncherExecution.ts`](src/core/execution/SAMLauncherExecution.ts#L118-L134)),
which skips any nuke whose owner it's `isFriendly()` with (**same team
OR allied**).
## Fix
`samThreatensNukePreview` now takes a teammate set and excludes
teammates **unconditionally**.
The subtlety: allies keep the existing *betrayal* exception — a strike
close enough to break the alliance makes that ally's SAM engage at
launch (`listNukeBreakAlliance`, the same function the sim uses).
Teammates get **no** such exception, because a strike can break an
alliance but never a team relationship. So even a player who is both a
teammate *and* a betrayed ally is correctly left off the threat set.
## Notes
- The sim has an "aftergame fun" exception where teammate SAMs *do*
target teammate nukes once there's a winner. The preview only appears
while aiming a buildable mid-game (no winner yet), so that case doesn't
apply here.
## Tests
Updated `samThreatensNukePreview` unit tests for the new signature and
added coverage for: teammate excluded, and teammate stays excluded even
when listed as betrayed. All 11 tests pass.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary
Clans aren't supported on CrazyGames, so don't let players set a clan
tag there.
- Tag the clan tag input wrapper with the existing `no-crazygames` class
so Main.ts's hiding logic removes it on CrazyGames, matching how other
CrazyGames-hidden elements work.
- Guard loading the stored clan tag (`loadStoredUsername`) so a tag
saved on the main site isn't silently submitted in the handshake while
on CrazyGames — CSS hiding alone wouldn't prevent that.
- Guard storing the tag (`validateAndStore`) so a returning user's saved
tag isn't clobbered with an empty value during a CrazyGames session.
## Testing
- `npx tsc --noEmit` — clean (no UsernameInput errors)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What
Routes every renderer call site that read `window.devicePixelRatio`
through a single `renderDpr()` helper that caps the value at **2**.
```ts
export function renderDpr(): number {
return Math.min(window.devicePixelRatio || 2, 2);
}
```
## Why
On very high-DPI displays (DPR 3, common on phones) the WebGL backing
store was sized at 3× CSS pixels — ~9× the fragment work of 1× — for a
marginal visual gain over 2×. Capping at 2 keeps retina (DPR 2)
pixel-perfect while clamping the 3× case.
## How it stays correct
DPR isn't just the canvas size — it's one coordinate system shared by:
- the canvas backing-store size (`Renderer.resize`)
- the camera's screen↔world math (`Camera.resize` / `screenToWorld` /
`worldToScreen`)
- the camera zoom scale (`ClientGameRunner.syncCamera`)
- the constant-CSS-pixel-size world text (`WorldTextPass`)
These must all use the same DPR value or pointer hit-testing and text
sizing drift. Routing them through one helper guarantees that. The
diagnostics reporter (`Diagnostic.ts`) is intentionally left reading the
real hardware DPR, since its job is to report the actual device.
## Test
- `tsc --noEmit` clean for all touched files (one pre-existing unrelated
`marked` types error remains on `main`).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Problem
The spawn-phase overlay stored every human's spawn center in GLSL
**uniform arrays** (capped at `MAX_SPAWNS = 32`) and looped over all of
them **per screen pixel** in a fullscreen pass.
In lobbies with more than 32 humans, centers past the cap were silently
dropped in join order — so a few seconds into the spawn phase the
**local player's own ring could disappear while the phase was still
active**. Team modes make this worse: `playerTeams` can be a raw team
count, so a single team can have far more than 32 members, all of which
need rings.
The two walls that blocked simply raising the constant:
- **Uniform arrays cap out ~96** against WebGL2's 224-vec4 fragment
floor — 1024 would never link.
- The **fullscreen per-pixel loop** over every spawn is `O(pixels ×
spawns)` — raising the cap makes it a GPU hazard during the spawn phase.
## Fix
Rewrite `SpawnOverlayPass` to draw **one instanced quad per spawn
center**, sized to that center's influence radius (mirroring
`SAMRadiusPass`). This removes the uniform-array limit and the per-pixel
loop, so cost scales with the number of spawns rather than screen area,
and the overlay supports the renderer's full ~1024-player ceiling.
Instances are ordered **enemies → teammates → self** so the local
player's ring composites on top under normal alpha blending.
Self/teammate render as breathing rings; enemies render as tile-fill
highlights on unowned tiles — identical visuals and render-settings to
before.
## Changes
- `gl/passes/SpawnOverlayPass.ts` — instanced rendering via
`DynamicInstanceBuffer` + `drawArraysInstanced`; no `MAX_SPAWNS` cap.
- `shaders/spawn-overlay/spawn-overlay.frag.glsl` — per-instance
(kind-dispatched) instead of a uniform-array loop; self white→color
pulse moved into the shader.
- `shaders/spawn-overlay/spawn-overlay.vert.glsl` — new instanced vertex
shader.
## Testing
- `tsc` (full project) + `eslint` clean.
- Headless WebGL run: shaders **compile and link** (game starts normally
with 123 players), and the genuine `updateSpawnOverlay → update() →
drawArraysInstanced()` path renders self/teammate rings and enemy tile
highlights with **no GL errors**.
- ⚠️ Not yet verified end-to-end in a real 30+ human FFA lobby (the
original repro) — that needs multiple real clients. The instanced draw
path and rendering were confirmed in singleplayer with the overlay
force-activated.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Problem
`ClientGameRunner.stop()` tore down the worker, network, and sound, but
left the `MapRenderer` (and its WebGL context), the WebGL canvas, the
input overlay, and the self-driving RAF loop in place.
When you exit a game via the **Exit button** or browser **back**, the
page navigates to `/`, so the browser reclaims everything — that path is
fine. But you can start a new game **without** a reload: matchmaking and
joining another lobby go through `handleJoinLobby`, which calls
`lobbyHandle.stop(true)` then `joinLobby()` on the same document. The
old WebGL context stayed alive (the never-cancelled RAF kept it
referenced, so it wasn't even GC'd), and each new game stacked another
context. After a few games, mobile browsers hit their WebGL context
limit — matching the repro in #4267.
## Fix
`stop()` now disposes the renderer:
- cancels the self-driving RAF loop and disconnects the frame-loop
resize observer
- disposes the `MapRenderer` (frees all GPU resources)
- removes the WebGL canvas and the input overlay from the DOM
`GPURenderer.dispose()` additionally calls
`WEBGL_lose_context.loseContext()` so the context is released promptly
instead of waiting on unreliable GC. The territory-patterns settings
listener is wired to the existing graphics `AbortController` so it no
longer outlives the disposed view.
The cleanup runs unconditionally in `stop()` (a superseded join can stop
before the game becomes active) and is idempotent against repeated
`stop()` calls.
Fixes#4267🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Problem
The troop and population ratio bars in `ControlPanel` and
`PlayerInfoOverlay` update their inline `width` on every game tick, with
a `transition-[width] duration-200` to smooth the change. But `width` is
a layout property — animating it forces the browser to **recalculate
layout for the surrounding HUD components every animation frame**. Since
the width changes every tick, this kept the whole HUD in a near-constant
relayout loop and showed up as jank.
## Fix
Keep the smooth animation, but drive it with `transform`
(GPU-composited, no layout) instead of `width`:
- Replace the two flex `width: %` segments with absolutely-positioned,
full-width bars.
- Segment 1: `transform: scaleX(green/100)` anchored to the left edge
(`origin-left`).
- Segment 2: `transform: translateX(green%) scaleX(orange/100)` so it
stays flush against the first segment.
- Animate with `transition-transform duration-200 ease-out`.
Because `transform` is composited rather than laid out, the bars animate
smoothly **without** triggering the per-frame HUD relayout.
The segments are now always mounted (`scaleX(0)` when empty) instead of
conditionally rendered, which also prevents the transition from
resetting as values cross zero.
Files:
- `src/client/hud/layers/ControlPanel.ts` (mobile + desktop troop bars:
malibu-blue / aquarius)
- `src/client/hud/layers/PlayerInfoOverlay.ts` (sky-700 / malibu-blue)
A grep confirmed these were the only `transition-[width]` usages in the
client.
## Testing
- `eslint --fix` / prettier ran clean via the pre-commit hook.
- CSS-only change; no sim/behavioral logic touched.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Problem
Focusing the number field of a `toggle-input-card` (Game Timer / Gold
Multiplier / Starting Gold, in both the single-player and host-lobby
modals) cost several ms of layout/paint **every tick** for as long as
the field stayed focused.
## Root cause
The input was rendered **conditionally** — `${this.checked ?
html`…<input>…` : nothing}`. Enabling a toggle therefore **freshly
inserts** the `<input>` into the DOM, and **focusing a just-inserted
input** is what forced the per-frame layout/paint. An input that was
already present in the DOM doesn't do this.
## Fix
Keep the input **permanently mounted** and toggle a `hidden` class when
unchecked, instead of conditionally rendering it. Focusing it is then
always focusing an element that was already there. Because both modals
share `<toggle-input-card>`, this single change fixes both.
Also restores the **autofocus + select** of the field on enable (it had
been removed earlier while chasing this bug) — safe now that the input
isn't freshly inserted.
No other UX change: the toggle behavior, checkmark, styling, and all
three cards behave identically.
## Testing
Hard-reload, then in both the Solo and Host-lobby modals, enable each of
Game Timer / Gold Multiplier / Starting Gold, type a value, and keep the
field focused — smooth, no per-frame jank, and the field autofocuses on
enable.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
Adds a **"Fallout effects"** toggle to the *Effects* section of the
graphics settings modal, letting players disable the nuclear fallout
visuals (useful for performance).
Fallout is rendered by two passes — the broiling green **bloom** on
irradiated territory and its additive **light** contribution in
day/night mode. The bloom pass was already gated by
`passEnabled.falloutBloom`, but the light pass had no gate. This adds a
`passEnabled.falloutLight` flag and a single user-facing
`passEnabled.fallout` graphics override that drives both together.
## Changes
- **`RenderSettings.ts` / `render-settings.json`** — new
`passEnabled.falloutLight` flag (default `true`).
- **`LightmapPass.ts`** — gate the fallout light pass behind
`passEnabled.falloutLight`.
- **`GraphicsOverrides.ts`** — add `fallout: z.boolean()` to the
`passEnabled` override group.
- **`RenderOverrides.ts`** — apply `passEnabled.fallout` to both
`falloutBloom` and `falloutLight`.
- **`GraphicsSettingsModal.ts`** — `currentFallout()` /
`onToggleFallout()` + a toggle button (mirrors the existing Special
Effects toggle).
- **`en.json`** — `graphics_setting.fallout_label` / `fallout_desc`.
## Testing
- `tsc --noEmit` passes; JSON files validated.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Problem
In the structure render pass, when **classic icons** are enabled the
inner icon glyph is tinted with a *darkened version of the player's fill
color* (`uIconDarken = 0.3`). When a player's territory color is already
dark, both the structure shape and its glyph render dark, so the icon
blends into the shape and the dark territory behind it — making it
effectively unreadable.
(With non-classic icons the glyph is already the light `uIconColor`, so
only the classic path was affected.)
## Fix
In `structure.frag.glsl`, when classic icons are active, compute the
fill's perceptual luminance and flip the glyph to the light icon color
(`uIconColor`, white by default) when the fill is too dark:
```glsl
vec3 glyphColor = uIconColor;
if (uIconDarken > 0.0) {
float fillLum = dot(fillColor.rgb, vec3(0.299, 0.587, 0.114));
glyphColor = fillLum < 0.25 ? uIconColor : darken(fillColor.rgb, uIconDarken);
}
```
The non-classic path is unchanged. The change is contained to the shader
— no new uniforms or plumbing.
## Notes
- The `0.25` luminance threshold is hardcoded in the shader to keep the
change surgical. It could be promoted to a `render-settings.json` knob
if preferred.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
Follow-up to #4255. That PR made nuke **sprites** glide per render frame
— `UnitPass.drawMissiles` lerps each nuke's `lastPos→pos` by wall-clock
progress through the current tick. But in ambient/night mode the glow
*behind* a nuke comes from a separate pass, `PointLightPass`, whose
instance buffer is packed once per tick in `updateLights()` from the raw
`unit.pos`. Its per-frame `draw()` (run every frame via `LightmapPass`)
only set uniforms and issued the instanced draw — it never repositioned
the lights. So the sprite moved at 60fps while its light jumped once per
100ms tick.
## Fix
Mirror `UnitPass`'s smoothing in `PointLightPass`:
- `updateLights()` records a `smoothSegs` tuple `(lightIdx, lastX,
lastY, x, y)` for each `SMOOTHED_NUKE_TYPES` unit whose `lastPos !==
pos`, and stamps `lastUnitsUpdateMs`.
- A new `applySmoothing()`, called at the top of `draw()`, lerps those
lights by wall-clock tick progress (`(now - lastUnitsUpdateMs) /
tickIntervalMs`, clamped to 1) and re-uploads **only** the affected
instances. Unlike `UnitPass` (which re-uploads its tiny missile buffer
wholesale), the light buffer can hold thousands of static structure
lights, so a full per-frame re-upload would be wasteful.
- `tickIntervalMs` comes from a new `config` constructor param, wired
through in `Renderer.ts` (the same `config` already passed to
`UnitPass`).
The light now uses the exact same `lastPos→pos` endpoints and alpha as
the sprite, so the two track together.
## Test plan
- `npx tsc --noEmit`, eslint, and prettier all clean.
- `npx vitest tests/client/render --run` — 40 passed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Lower territory saturation, highlight thickness, and border darkening
to bring the rendered map closer to the v31 look.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Problem
When you hover over a territory, it highlights with a band that is
`highlightThicken` (default **2**) tiles deep — the edge plus 2 interior
rings, computed via a Chebyshev expansion in `border-compute.frag.glsl`.
Starting a hover triggers a full border recompute, which paints the band
correctly. But while you keep hovering and tiles change owner (territory
growing/shrinking, combat at the front), only the cheap **incremental**
scatter path runs. `BorderScatterPass.pushWithNeighbors` repainted only
the changed tile **+ its 4 cardinal neighbors** (radius 1) — fine for
normal borders, but not for the highlight band. A changed tile affects
the thickening of *every* highlight-owner tile within `highlightThicken`
of it, and those interior tiles were never repainted, so the **inner
edge of the highlight band stayed stale** ("the inside border is not
getting updated"). This was a documented trade-off in the class comment.
## Fix
When a highlight is active, `pushWithNeighbors` now repaints a Chebyshev
**box of radius `highlightThicken`** around each changed tile (the box
subsumes the cardinal cross, so normal borders still update). With no
highlight active it stays on the cheap 5-point cross, preserving the
pass's O(dirty-tiles) scaling. The extra cost (~25 vs 5 points/tile at
default) only applies while actually hovering.
## Testing
Hover over a territory while it grows/shrinks (early-game expansion or a
war front) and confirm the inner edge of the highlight band now tracks
the moving border instead of lagging.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Problem
Nuclear fallout was rendering on top of UI overlays (most visibly the
SAM launcher range circles), hiding them.
## Cause
In `renderOverlays()` (`src/client/render/gl/Renderer.ts`), the fallout
bloom pass was drawn near the end of the overlay sequence — after the
SAM radius, range circles, structures, bars, etc. — so it painted over
all of them.
## Fix
Moved `bloomPass.draw(...)` (fallout bloom) to draw right after the
ground units and before all UI overlays. Fallout is a
ground-contamination effect, so it now sits above the terrain/units but
below every UI overlay, which all render on top.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
The in-game map background changed from gray (v31) to near-black after
the WebGL renderer rewrite. This restores the gray.
The renderer rewrite hardcoded the base-layer clear color to `(0.04,
0.04, 0.06)` in `drawBaseLayer` (`src/client/render/gl/Renderer.ts`).
v31 set the background via `PastelTheme.backgroundColor()`, which
returned `rgb(60,60,60)`. This change sets the clear color back to that
gray.
## Notes
- The old theme-based `backgroundColor()` system was removed in the
rewrite, so this hardcoded clear color is now the single source for the
map background.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Problem
Enabling **Starting Gold** (or **Game Timer** / **Gold Multiplier**) in
the single-player / host-lobby modal made the page spend several ms of
"Render" time **every frame** — for as long as the toggle stayed
enabled. Disabling the option made it stop.
## Root cause
Each `toggle-input-card`, on enable, auto-focused and selected its
number input so you could type immediately:
```ts
input.focus();
input.select();
```
A focused/selected editable inside the modal keeps the browser doing
layout/paint work every frame for as long as it stays focused. It
reproduces for any of the toggle-input cards because they all auto-focus
on enable, which is why Starting Gold, Game Timer, and Gold Multiplier
all triggered it.
> **Note on the earlier revision of this PR:** the first attempt passed
`{ preventScroll: true }` to `focus()`, on the theory that
scroll-into-view was the cause. It successfully stopped the scroll
(verified: modal scroll container `scrollTop 0 → 0`), but the per-frame
render cost remained. That ruled out scroll-into-view and proved the
focused editable itself — not the scroll — was the trigger.
## Fix
Remove the auto-focus entirely. Enabling a toggle no longer focuses its
number input, and the per-frame render cost is gone.
## Trade-off
You no longer get type-to-replace on enable — click the field before
typing the value. Worth it to eliminate the per-frame render.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Problem
The debug render GUI's **"Reset to Defaults"** restored bare
`createRenderSettings()` defaults, wiping the user's graphics overrides
(colorblind theme, ocean color, lighting, name scaling, etc.) from the
live render settings. The per-prop right-click "reset to default" and
the modified-indicators had the same flaw — their captured defaults were
raw, ignoring overrides.
## Fix
Thread the existing `resolveRenderSettings` (`createRenderSettings()` +
`applyGraphicsOverrides()`) into the debug GUI as the defaults provider,
so reset restores the same settings the renderer was actually built
with.
- **`debug/index.ts`** — added a `resolveDefaults` param (defaults to
`createRenderSettings` to keep the module decoupled). The captured
`defaults` now include overrides, fixing the per-prop reset and modified
indicators too.
- **`debug/Wiring.ts`** — `wireActions` takes `resolveDefaults`; the
reset handler `deepAssign`s `resolveDefaults()` instead of
`createRenderSettings()`.
- **`ClientGameRunner.ts`** — passes `resolveRenderSettings` into
`createDebugGui`, and extracts a `refreshDerivedGraphics` helper
(terrain rebuild + re-theme/palette) from `onGraphicsChanged`, wired as
the GUI's `onSettingsChanged` so the reapplied terrain/colorblind
overrides become *visible* after reset (they're baked into GPU textures
and aren't picked up per-frame).
Side benefit: editing terrain/theme settings in the debug GUI now
refreshes those textures live too (that callback was previously never
wired).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ember/particle flicker in the fallout effect was gated only by the
fallout bit, which is permanent on tiles that stay unowned. It also ramped
to full strength as the per-tile heat decayed to 0 and animated on the
global tick, so it kept flickering indefinitely after the blast had cooled —
visible both as the bloom dots and (more prominently) as the ambient ember
light when dynamic lighting is enabled.
Fade both with heat so they vanish along with the glow:
- extract.frag.glsl: bloom dots multiplied by the glow's opacity
- fallout-light.frag.glsl: ember light multiplied by heat
Heat decay timing is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Description:
Add a search input field to the map picker section header, allowing
users to quickly filter maps by name.
- Place transparent search input on the right side of the "Maps" section
header
- Filter maps by translated name and map ID as the user types
- Hide Featured/All/Favourites tab buttons while search is active
- Show filtered results with a count heading, or a "no results" empty
state
- Clear button appears when search input has text
<img width="857" height="463" alt="Screenshot 2026-06-15 001415"
src="https://github.com/user-attachments/assets/35e1101a-177e-4923-bb1d-34eb683c6f80"
/>
No search results:
<img width="855" height="454" alt="Screenshot 2026-06-15 001433"
src="https://github.com/user-attachments/assets/bf27211d-5891-4739-a92f-0fc44b3c9c61"
/>
## 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:
FloPinguin
## Description:
Add support for opening clan details directly with `clan=<tag>`
## 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
## What
Removes the binary **dark mode** feature and replaces it with a
player-adjustable **Lighting** section in graphics settings.
### In-game settings
- Removed the Dark Mode toggle from both `SettingsModal` and
`UserSettingModal`, and `darkMode()`/`toggleDarkMode()`/`DARK_MODE_KEY`
from `UserSettings`.
### New Lighting section (Graphics Settings)
- **Ambient light** slider (1–3): mapped to the renderer's ambient as
`ambient = 1 / level`. **1.0 = no effect (unchanged look), 3.0 = darkest
with the strongest structure glow.**
- **Light falloff** slider (1–3): writes straight to
`lighting.falloffPower`.
- Lighting auto-enables only when ambient < 1, so the default (slider at
1) has zero GPU cost — off by default.
### Removed dark-mode overrides
- Deleted `applyDarkModeOverride()` + `DARK_AMBIENT` and their wiring in
`ClientGameRunner`, `gl/index.ts`, and the `DARK_MODE_KEY` listener.
- Removed the `.dark` HUD-class toggle in `Main.ts` and the
`userSettings.darkMode()` read in `PlayerIcons`.
### Train glow
- `UT_TRAIN` light reduced (intensity `2.0 → 0.5`, radius `8 → 6`) so
structures dominate the glow.
## Notes
- Removing the dark-mode setting also retires the HUD's Tailwind dark
theme (same setting). The dormant `dark:` CSS variants and unused
white-icon assets are left in place (out of scope).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
In alt-view, trade ships are colored by their owner's affiliation:
self green, ally yellow, enemy red. The FLAG_TRADE_FRIENDLY override
recolors an enemy ship red→yellow when it's heading to a self/allied
port. That flag was decided solely from the destination port's owner,
ignoring who owns the ship — so a captured trade ship (now owned by us,
heading to our port) got flagged yellow instead of keeping its green
affiliation color.
Gate FLAG_TRADE_FRIENDLY on the ship being enemy-owned, since self/allied
ships already render the correct color without the override. Also fixes
our own trade ships heading to an ally's port flipping green→yellow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Problem
Players reported having to turn the volume slider down to ~20% before
noticing any change in loudness.
The sliders fed their linear 0–1 position straight to Howler's
`volume()`, which is linear amplitude gain. Human loudness perception is
roughly logarithmic, so the top ~80% of the slider all sounds nearly
identical — the classic linear-fader problem.
## Fix
Square the slider position into a perceptual (audio-taper) gain inside
`SoundManager`. The stored setting and the displayed `%` remain the
intuitive linear slider position; only the gain handed to Howler is
curved.
| Slider | Old gain (linear) | New gain (x²) |
|--------|-------------------|---------------|
| 100% | 1.00 | 1.00 |
| 90% | 0.90 | 0.81 |
| 80% | 0.80 | 0.64 |
| 50% | 0.50 | 0.25 |
| 20% | 0.20 | 0.04 |
Lowering the slider from 100→80 now produces an audible drop instead of
nothing until ~20%.
## Notes
- Quadratic (x²) was chosen as a balanced, conservative taper. Cubic
(x³) would make the top-end drop-off even more immediate if preferred.
- Existing saved settings are unaffected; the same slider position will
simply sound slightly quieter, which is the intended correction.
## Tests
Updated `SoundManager.test.ts` to assert the curved gain and added a
dedicated test locking in the top-of-range behavior. All 18 tests pass.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What
Adds a **Terrain** section to the graphics settings modal with a color
picker and a hex-code text field (paste a `#rrggbb` code) for the
**ocean** (deep water) color.
## Details
- The picked color sets the *shallow-water base*; the existing per-depth
brightness gradient is preserved (deeper water still darkens).
- Only deep water is affected — shoreline water and land are untouched.
- Follows the same override pattern as every other graphics setting: the
default lives in `render-settings.json` (`terrain.oceanColor`), the
override is a field in `GraphicsOverrides`, and `applyGraphicsOverrides`
copies it into the live `RenderSettings`.
- Rebased on #4271 (settings resolved before renderer construction): the
terrain texture **bakes the resolved ocean color at construction**, so a
saved override shows on load with no special-casing. Terrain is baked
into a GPU texture rather than read per-frame, so a *live* change still
triggers an explicit `view.rebuildTerrain()`.
- Resetting graphics overrides clears it back to the default ocean
color.
## Testing
Verified live in a headless singleplayer game:
- A **saved** ocean override renders green deep-water on load, baked at
construction with no settings-change event fired.
- A mid-game color change recolors the deep ocean instantly, gradient
preserved, shoreline/land untouched.
`tsc` and ESLint clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What
The client now resolves render settings (defaults + user overrides) **up
front** and passes the result into the renderer, instead of the renderer
constructing defaults itself and the client re-applying overrides
afterward.
```
before: new GPURenderer(...) // this.settings = createRenderSettings() (defaults)
view.getSettings() → deepAssign(defaults) → applyGraphicsOverrides(...) // patch after the fact
after: const settings = createRenderSettings(); applyGraphicsOverrides(settings, ...); applyDarkModeOverride(settings, ...)
new GPURenderer(..., settings) // built with the final values
```
## Why
- **Removes the construct-with-defaults / re-apply-overrides dance.**
Every pass — including texture-baking ones like terrain that read their
settings *once* at build time rather than every frame — is now built
with the final values on the first try. (This is the cleanup that
motivated the change, surfaced while adding a terrain color override in
a separate PR.)
- **Fixes a latent context-restore bug.** On WebGL context loss/restore
the renderer was rebuilt via `createRenderSettings()` → fresh
**defaults**, silently dropping any user overrides until the next
settings change. `MapRenderer` now holds the resolved settings object
and hands the same one to the recreated `GPURenderer`, so overrides
survive a restore.
Live setting changes still work exactly as before:
`regenerateRenderSettings()` re-resolves and `deepAssign`s onto the
renderer's live settings object in place (passes hold a reference, so
they pick it up next frame).
## Changes
- `Renderer.ts` (`GPURenderer`) — constructor takes a `settings:
RenderSettings`; drops the internal `createRenderSettings()` call.
- `MapRenderer.ts` — holds the resolved settings and passes it through
on construction and on context-restore re-init.
- `ClientGameRunner.ts` — new `resolveRenderSettings()` helper used both
at construction and by `regenerateRenderSettings()`; `createWebGLView`
takes the resolved settings; the now-redundant initial
`regenerateRenderSettings()` call is removed.
## Testing
Verified live in a headless singleplayer game:
- A saved `nameScaleFactor` override is present in `getSettings()`
immediately after game start, with no settings-change event fired
(construction path).
- A mid-game override change is reflected in the live settings
(regenerate/in-place path).
- The map renders correctly through spawn phase.
`tsc` and ESLint clean.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
Adds a new **Structure icon size** option to `GraphicsOverrides`,
exposed as a slider in the Graphics Settings modal. Players can now
scale how large structure icons are drawn on the map.
## Changes
- **`GraphicsOverrides.ts`** — add `iconSize: z.number()` to the
`structure` override schema.
- **`RenderOverrides.ts`** — apply the override onto
`settings.structure.iconSize` (consumed by
`StructurePass`/`StructureLevelPass` shaders).
- **`GraphicsSettingsModal.ts`** — add a slider (range 20–120, step 5)
in the "Structure Icons" section, with getter/handler following the
existing pattern. Falls back to the `render-settings.json` default of 60
when unset.
- **`resources/lang/en.json`** — add `icon_size_label` /
`icon_size_desc` (English only, per i18n rules).
- **`tests/GraphicsOverrides.test.ts`** — schema-validation cases plus
application tests (override sets the value; absence keeps the default).
The setting persists via the existing `userSettings.graphicsOverrides()`
localStorage flow and takes effect live through the existing
`regenerateRenderSettings` wiring.
## Testing
- `npx vitest tests/GraphicsOverrides.test.ts --run` — 35 passed
- `tsc --noEmit` — no new type errors
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## What
Structure **level numbers** now render in the **`round_6x6_modified`**
bitmap font by default (matching the old PIXI-based `StructureLayer` /
`v31`), with a graphics setting to switch back to the smooth
`overpass-bold` MSDF font.
Two commits:
1. **Default to the classic bitmap font** — `StructureLevelPass` drew
level digits from the `overpass-bold` MSDF atlas (the one `NamePass`
uses for player names); switch the default to the `round_6x6_modified`
pixel font (white digits with a baked-in dark outline).
2. **Add a runtime toggle** — load both fonts and switch between them
live via a new `Classic level numbers` graphics setting.
## How
- `StructureLevelPass` loads both atlases up front and selects one per
frame from `settings.structureLevel.classicFont`, re-laying-out the
digits when the toggle flips (digit advances differ between the fonts).
The fragment shader is a single program with a `uClassic` branch: direct
bitmap sample (white fill + baked outline) vs. MSDF median + synthesized
outline.
- New override `structure.classicNumbers` in `GraphicsOverrides`
(default `true` = classic), applied onto
`settings.structureLevel.classicFont` in `applyGraphicsOverrides` — so
it switches live, like the existing colorblind/classic-icons toggles.
- `GraphicsSettingsModal` gets a `Classic level numbers` toggle next to
`Classic icons` (with `en.json` strings).
## Testing
- `tsc --noEmit`, ESLint, Prettier, and `npm run build-prod` all pass.
- Ran the game headless, built/upgraded cities to level 2–3, and
confirmed: the classic toggle renders the pixel font, flipping it
renders the smooth MSDF font, and flipping back restores the pixel font
— switching live with no shader errors.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>