Commit Graph
24 Commits
Author SHA1 Message Date
22c873cf55 perf(client): tick-dispatch timing harness + main-thread tick optimizations (late-game p95 −65%) (#4512)
## Problem

Every 100 ms the main thread's worker `onmessage` callback processes a
full game tick (`gameView.update` → `webglBuilder.update` →
`renderer.tick`). At 60 fps this competes with the 16.7 ms frame budget,
and on the Giant World Map it takes several ms — frame drops on low-end
hardware.

## Harness (`npm run perf:client-tick`)

Headless-Chromium harness that times every worker→main `game_update`
dispatch on the main thread, with structured-clone deserialization
measured separately from the handler body (via a
`Worker.prototype.addEventListener` wrapper installed as a page init
script — no product-code changes). It reports windowed distributions,
captures `.cpuprofile` files at chosen ticks, writes raw samples and an
end-of-run screenshot. `AnalyzeCpuProfile.ts` breaks a profile down by
inclusive time under the dispatch subtree.

Init scripts are passed as **strings**: tsx compiles function-form init
scripts with esbuild `keepNames`, whose injected `__name` helper doesn't
exist in-page and silently kills the game worker setup.

## Baseline (Giant World Map, 400 bots, headless)

Dispatch handler ms — cost **grows with game progression**:

| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.22 | 2.20 | 3.40 | 5.00 |
| tick 1506 | 2.60 | 2.00 | 7.00 | 10.40 |
| tick 2000 | 2.67 | 1.90 | **8.70** | **12.70** |

Deserialization is negligible (0.12 ms mean). CPU profiles attributed
the growing tail to the leaderboard's once-per-second refresh: its
Max-troops column calls `config().maxTroops(p)` for **all ~508
players**, and `PlayerView.units()` scanned **every unit in the game**
per call — O(players × units), growing as units accumulate.

## Round 1 — algorithmic fixes

- **GameView**: new `unitsOwnedBy(smallID)` — an active-units-by-owner
index built lazily at most once per tick. `PlayerView.units()` reads its
own units from it: O(own units) instead of O(all units). Also speeds up
unit display, player panel, and buildables queries.
- **NamePass.updateNames**: reads player state directly from the
caller's map by smallID instead of rebuilding three lookup maps per
tick; skips the slot-assignment sweep once every player has a slot.

After (same map, same spawn tile):

| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.12 | 2.00 | 3.10 | 5.20 |
| tick 1506 | 1.86 | 1.80 | 2.90 | 4.30 |
| tick 2000 | 1.74 | 1.60 | **2.40** | **4.70** |

Late-game p95 −65% (8.7 → 2.4 ms), worst dispatch −63% (12.7 → 4.7 ms),
and per-dispatch cost no longer grows with game progression. The
leaderboard disappeared from the dispatch profile entirely.

## Round 2 — allocation churn + time slicing

Aimed at GC pauses and low-end CPUs; measures flat vs round 1 on a fast
machine, as expected:

- **`FrameData.changedTiles`** is now the plain tile-ref array GameView
already builds instead of a per-tile `{ref, state}` object copy — heavy
battle ticks allocated tens of thousands of objects per tick for a
`state` field that was always 0. `TilePair` removed; `TerritoryPass`
buckets refs synchronously, so the live reference is safe.
- **`UnitView.lastPos`** is only re-sliced when a move actually appended
a position — the unconditional `slice(-1)` allocated an identical
1-element array per unit per tick, including for structures that never
move.
- **`NamePass.updateNames`** refreshes slots round-robin, a quarter per
tick — the full per-player diff pass spreads over ~400 ms, under the
existing 500 ms troop-text cadence; positions lerp continuously. Unnamed
slots and snap passes (seeks) are always processed so nothing pops in
late. Dispatch share: 17% → 13%.

Not sliced on purpose: tile ingest and frame upload need a consistent
per-tick snapshot (stale `GameMap` reads would leak into hover queries,
minimap, attack targeting) — a correctness risk not worth ~1 ms while
the worst dispatch already fits in a quarter of the frame budget.

## Verification

- `npx tsc --noEmit`, eslint clean; full suite green (1929 tests)
- 6 new GameView tests cover the owner index (grouping, inactive
exclusion, ownership capture, death, type filtering, copy semantics);
changedTiles tests updated to the ref-array contract
- Headless end-of-run screenshots verified after each round: leaderboard
Max-troops values, map names + troop counts + flags all render correctly
(including with name slicing active)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:46:02 -07:00
22d5aba5ae refactor: standardize cardinal-neighbor iteration on neighbors() N,S,W,E order (#4495)
## Summary

Follow-up to #4494. That PR added `forEachNeighborNSWE` as a third
neighbor iterator because the existing allocation-free helpers
(`forEachNeighbor`, `neighbors4`) visit in W,E,N,S order while
`neighbors()` visits N,S,W,E — and substituting one for the other
changes simulation behavior at order-sensitive call sites.

This PR removes that duplication by standardizing on **one order
everywhere**: `forEachNeighbor` and `neighbors4` now visit in the same
N,S,W,E order as `neighbors()`, and `forEachNeighborNSWE` is deleted.

## ⚠️ Intentional behavior change

Callers of the flipped helpers that are order-sensitive now make
different (equally valid) decisions:

- `AttackExecution.addNeighbors` — PRNG values are drawn per neighbor
while building the conquest frontier, so attack expansion patterns
differ
- `AttackExecution.handleDeadDefender` — a dead defender's tiles go to
the *first-visited* adjacent player
- `WarshipExecution.bestNeighborToward` — distance ties break by visit
order
- `PlayerExecution` surrounded-cluster flood fill — set insertion order
propagates to conquer order

Game outcomes for a given seed differ from previous builds (verified:
the 12k-tick reference run ends with 31 players alive vs 24 before).
Determinism across clients *within* a build is unaffected — all clients
run the same code, so there is no desync risk. Replays/verification
pinned to old hashes will not match this build.

New reference hashes for the headless perf harness (seed
`perf-default`):

| Run | Final hash |
|---|---|
| giantworldmap, 12,000 ticks | `57830793797434300` |
| giantworldmap, 2,000 ticks | `55125379638382860` |
| world, 1,800 ticks | `32337437717390864` |

## Verification

- [x] Full suite green (1,901 tests), including new exact-order contract
tests: `forEachNeighbor` and `neighbors4` must match `neighbors()`
contents **and order** for every tile
- [x] 20-game-minute Giant World Map benchmark: no perf regression (73
ticks/sec, GC 1.2% of wall, allocation profile unchanged)
- [x] Order-sensitivity audit of every `forEachNeighbor`/`neighbors4`
call site (sensitive ones listed above; the rest are booleans, counts,
or min/max accumulations)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:42:22 -07:00
5e4b2791aa perf: reduce core-sim GC churn 42% and add GC-churn profiling to the perf harness (#4494)
## Summary

Reduces core-simulation GC churn by **42%** on a 20-game-minute Giant
World Map run, and extends the headless full-game perf harness so churn
is measurable and regressions are visible.

### 1. GC-churn measurement (`tests/perf/fullgame/GcProfiler.ts`)

`npm run perf:game` now reports:

- **GC pauses** by kind (minor/major/incremental) via a
`PerformanceObserver` on `'gc'` entries, bucketed into tick windows by
timestamp (V8 only delivers these entries on a timer task, so they're
flushed after the run)
- **Allocation rate** per `--window N` ticks (default 1000) from
used-heap deltas sampled every tick, so churn can be tracked across game
phases
- **Top allocating functions** from the V8 sampling heap profiler with
`includeObjectsCollectedBy{Major,Minor}GC` — i.e. actual churn including
short-lived garbage, not live memory — plus a `.heapprofile` loadable in
Chrome DevTools (Memory → Allocation sampling)

New flags: `--window N`, `--no-gc-profile`, `--no-alloc-profile`.

### 2. Allocation reductions in the hot paths it found

| Site | Change |
|---|---|
| `GameMap.bfs` | inline neighbor enumeration instead of an array per
visited tile |
| `GameMap`/`Game` | new `forEachNeighborNSWE` — allocation-free
iterator matching `neighbors()` N,S,W,E order for order-sensitive
callers (`forEachNeighbor` visits W,E,N,S, so substituting it would
change sim behavior) |
| `PlayerImpl.nearby` / `sharesBorderWith` / `shoreReachableNeighbors` |
no per-call neighbor arrays; no materialized shore-tile array |
| `PlayerImpl.units(types)` | gather into a reusable scratch buffer,
return one exact-size slice (still a fresh snapshot array per call) |
| `AiAttackBehavior.maybeAttack` | single pass over border neighbors
replacing the `flatMap`/`filter`/`map` chain over every border tile |
| `AiAttackBehavior.isBorderingNukedTerritory` | reusable `neighbors4`
buffer with early exit |
| `SharedWaterCache.build` | allocation-free neighbor iteration |
| `SpatialQuery.bfsNearest` | first-minimum scan instead of
collect-then-stable-sort (identical result incl. tie-breaking) |

### Results (Giant World Map, 400 bots, 12,000 ticks ≈ 20 game-minutes,
seed `perf-default`)

| Metric | Before | After |
|---|---|---|
| Sampled allocations (incl. collected) | 97.7 GB | **56.9 GB (−42%)** |
| GC count / total pause | 1,682 / 3,313 ms (1.8% of wall) | 1,058 /
2,087 ms (1.2%) |
| Ticks/sec | 66 | 70 |
| p99 / max tick | 49.9 ms / 988 ms | 43.5 ms / 689 ms |
| Ticks over 100 ms budget | 31 | 19 |

## Determinism

Every rewrite preserves exact iteration order (the new NSWE iterator
exists precisely for the order-sensitive sites). Verified by identical
final game-state hashes on three runs: Giant World Map 12,000 ticks
(`67286276735690560`), Giant World Map 2,000 ticks, and World 1,800
ticks.

## Test plan

- [x] Full suite green (1,896 tests)
- [x] New tests: `forEachNeighborNSWE` order contract vs `neighbors()`
over every tile; `units()` filtering semantics (insertion order,
fresh-array guarantee, duplicate types, Set path)
- [x] Final-hash equality on 3 seeded headless runs (2 maps)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:30:28 -07:00
Zixer1andGitHub 78ef7b56fd feat(doomsday-clock): battle-royale style zone gamemode (#4469)
Resolves Issue #4463

## Description:

An optional game mode that (almost) guarantees a finish instead of
letting late-game
stalemates drag on.
Originally called sudden death, renamed to Doomsday clock

Once enabled, every side (each player in FFA, each whole team in team
modes)
must hold a rising share of the map. A side below the bar is skulled;
after a
short warn its troops bleed to zero, forcing consolidation to a winner.

### How it works
- **Rising zone:** a grace period, then the required share ramps up
linearly to
each level with 30s pauses between (a battle-royale "zone"). Levels
track the
  ofstats FFA territory median (3/5/10/20/30%).
- **Four speed presets** (slow / normal / fast / very fast) change only
the pace:
  normal ends ~30 min, very fast ~15.
- **Troop decay:** a linear ramp as a % of max capacity, ~50s from
caught to zero
  (10s warn + ~50s ≈ 1 min total).
- **UI:** a HUD panel (live share vs target, wave/decay countdowns,
red/orange
cues) and an on-map skull above flagged players (blinks in danger,
steady while
  draining).

### Notes for review
- Off by default; no effect on existing games. However, as discussed we
can add it to the modifier pool for public games to see how popular the
gamemode is vs normal play.
- Sim is deterministic (integer-only, in `src/core`), covered by unit +
  integration tests.
- One-line addition to `GameServer.updateGameConfig` so the setting
survives the
  host → server → client round-trip.
- Status is packed into the existing name-pass data slot (`pd4.w`: 0/1/2
=
none/danger/draining); the skull is composited into the icon atlas at
load.

### Testing
`npm test`, `npm run lint`, `npx prettier --check .`, `npm run
build-prod` all pass.

### UI:
<img width="243" height="100" alt="Image"
src="https://github.com/user-attachments/assets/c4c9eeb0-4feb-437d-9aac-b2786a841b74"
/>

Dropdown between slow, normal, fast, very fast

Before zone:
<img width="302" height="175" alt="Image"
src="https://github.com/user-attachments/assets/7359a1ea-4951-446d-a23c-0711fe06cc5d"
/>

Zone started, player not affected the pannel also blinks orange for 10s:
<img width="297" height="175" alt="Image"
src="https://github.com/user-attachments/assets/fcc565a5-d5d0-47a7-97ea-d0ba9d9ad899"
/>

Player affected, grace period (Danger):
<img width="314" height="170" alt="Image"
src="https://github.com/user-attachments/assets/ff96d21e-96f3-4ef9-8190-48eecc7aac0f"
/>

Skull icon blinking over player (everyone sees it) - older screenshot,
the clipping has been fixed
<img width="462" height="145" alt="Image"
src="https://github.com/user-attachments/assets/53899211-33b1-40e1-83f2-77f2096f0cad"
/>

Player affected, grace period ended (Draining):
<img width="360" height="159" alt="Image"
src="https://github.com/user-attachments/assets/4b226d57-da4d-4866-ab5f-db48e4ed1ea2"
/>

Skull icon no longer blinking, everyone can see you are in a state of
decay, and troops are draining:
<img width="732" height="146" alt="image"
src="https://github.com/user-attachments/assets/cd10fedb-6e87-4dfc-9fbf-55d3945a7901"
/>


Skull is visible like alliances icon also on player tab
<img width="558" height="81" alt="Image"
src="https://github.com/user-attachments/assets/6acdbe91-bdd0-40c7-942b-3990d4dae87f"
/>

(just UI example, best way to see it is to hop on a solo game and play
against AI)

## 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._
2026-07-02 18:42:03 -07:00
6ff202afb5 feat: nuke-explosion cosmetic effects (per-bomb-type shockwave customization) (#4485)
## Description:

Adds a new `nukeExplosion` cosmetic effect type: when a bomb detonates,
every client renders the shockwave in the firing player's equipped
effect for that bomb type.

**Cosmetics / selection**
- New `nukeExplosion` effect schema (`CosmeticSchemas.ts`) with per-bomb
selection slots — a slot is the effectType for trails and the `nukeType`
for explosions (`atom` / `hydro` / `mirvWarhead`), so players can equip
a distinct explosion per bomb type.
- Slot resolution + validation is one shared helper
(`findEffectForSlot`) used by client selection, server privilege checks
(`Privilege.ts`), and the renderer; a compile-time guard keeps the
nukeType and effectType slot namespaces disjoint.
- Effects picker gains an Atom / Hydrogen / MIRV sub-tab bar when
browsing nuke explosions; selections persist per slot in UserSettings
and are validated/dropped like other cosmetics.

**Rendering**
- `WebGLFrameBuilder` resolves each dead nuke's owner cosmetic onto the
dead-unit event; `FxShockwavePass` renders an EMP-style procedural ring
(jagged crackling front, rotating lightning arcs, inner energy fill)
from per-instance attributes. SAM interceptions and players with no
cosmetic keep the classic white ring.
- Catalog attributes have literal units:
- `size` — final ring width (diameter) in world tiles at fade-out,
absolute — independent of the bomb's blast radius
- `speed` — tiles/s the width grows; duration = size / speed, clamped to
0.1–15 s
- `thickness` (required) — ring band thickness in tiles, constant while
the ring expands
- `colors` — palette of up to 4 colors, cycled at `transitionSpeed`
steps/s (0 = static, negative = reverse; same semantics as trail
transitions)
- The shockwave quad is sized radius + thickness so the absolute-width
band isn't clipped into a box while the ring is young.

## Please complete the following:

- [ ] 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:21:01 -07:00
FloPinguinandGitHub 805f0968b1 Add impassable terrain 🗺️ (#4340)
## 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
2026-06-19 14:54:09 -07:00
f4db4a33c8 Send nukes as motion plans and render them smoothly per frame (#4255)
## Summary

Follow-up to #4244's payload work: nukes were the last per-tick movers
flooding the worker → main update stream.

- **Core**: nuke trajectories are fully determined at launch
(precomputed parabola), so `NukeExecution` now records a `GridPathPlan`
when the nuke is built — same mechanism trade ships use — and the client
derives the position each tick. Per-tick `UnitUpdate`s for nukes in
flight are suppressed; only targetable flips and deletion
(interception/detonation) still emit. This covers atom bombs, hydrogen
bombs, and MIRV warheads (dozens of per-tick movers per MIRV
separation).
- The plan path replays a separate pathfinder rather than reusing the
stored trajectory array: the curve's cached points don't advance exactly
one index per tick, and the plan must match the movement pathfinder's
exact per-tick tile sequence.
  - `startTick` accounts for MIRV warheads' staggered `waitTicks`.
- **Render**: `UnitPass.drawMissiles` now lerps each nuke's instance
position `lastPos→pos` by wall-clock progress through the current tick,
so nukes glide along their arc at render framerate instead of jumping
once per 100ms tick. Both endpoints are real simulated positions — the
rendered nuke trails the sim by at most one tick and settles exactly on
it when ticks stop. Plan-driven units sync `lastPos` on path-stall ticks
so the lerp never replays a segment. Shells keep their existing
two-instance trail; SAM missiles are unchanged.

## Test plan

- New `tests/nukes/NukeMotionPlan.test.ts`: tick-exact alignment between
the recorded plan and core nuke position over the whole flight
(mirroring `GameView.advanceMotionPlannedUnits` math), `waitTicks`
offset, and that no per-tick unit updates are emitted in flight except
targetable flips and deletion.
- Full suite passes (1452 + 65), tsc/eslint/prettier clean.
- Verified in-game (headless Chromium, real WebGL): atom bomb arcs from
silo to target with the client position driven by the plan, missile
sprite renders intact while the smoothing rewrites the instance buffer
every frame, detonation FX land at the target.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 13:59:03 -07:00
7ec26df4b4 Fix three high-impact renderer performance issues (#4251)
## Summary

A performance review of `src/client/render/` found three issues where
per-tick work silently defeated existing optimizations. All three are
surgical fixes with no behavior change.

### 1. Relation matrix forced a full-map border recompute every tick

`buildRelationMatrix` ran unconditionally every tick and
`updateRelations` was pushed unconditionally, so every tick paid:
- a 1 MB `fill(0)` + rebuild on the CPU,
- a 1 MB `texSubImage2D` upload (~10 MB/s steady-state),
- a **full map-resolution border fragment pass** via `globalDirty` —
which also called `scatter.clear()`, making the incremental
`BorderScatterPass`/`patchTile` path dead code during live play.

Now the matrix is rebuilt and uploaded only when alliances/embargoes
actually change. `PlayerUpdate`s are delta-encoded (`diffPlayerUpdate`
content-compares `allies`/`embargoes`), so field presence is a reliable
change signal. The WebGL context-restore path force-pushes relations,
matching the existing structures/railroads pattern.

### 2. Heat decay pass + full-map blit ran every frame, forever

`HeatManager.decayHeat()` set `heatActive = true` on every tick
regardless of whether any fallout existed. With `heatDecayPerTick: 1`
the drain window (255 ticks) was always re-armed before expiring, so the
map-sized decay/transition fragment pass **plus a full-map R16UI
`blitFramebuffer`** ran at 60 Hz for the entire game — even if no nuke
was ever fired. On large maps this was likely the biggest fixed GPU cost
in the renderer.

Now `TerritoryPass` flags FALLOUT-bit flips at GPU-write time (delta,
drip-drain, and conservatively on full uploads), and the renderer
activates the heat pipeline only then. While inactive, `updateHeat()`
does no GL work at all. Skipping the prev-tile blit while inactive is
safe because the transition shader only reads the fallout bit, and every
fallout flip activates the pipeline before its tile flush reaches the
GPU.

### 3. `computePlayerStatus` was O(players × units) per tick

The per-player loop scanned **all units** looking for that player's
nukes (~1M+ iterations/tick at scale). Inverted to a single pass over
units building per-owner `nukeActive`/`nukeTargetsMe` sets, then O(1)
lookups in the player loop.

## Testing

- Full suite passes (1386 + 65 tests), including the 19 existing
`computePlayerStatus` behavior tests; `tsc --noEmit` and ESLint clean.
- Verified in a live singleplayer game (headless Chromium): territory
fill, borders, names/troop counts, and leaderboard all render correctly.
- Fallout path verified end-to-end: built a missile silo, launched an
atom bomb (1235 fallout tiles in tile state), and the fallout glow
rendered at the impact site — under the new gating that glow can only
appear if the `falloutTouched → activate()` chain works.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:06:53 -07:00
bca980f572 Shrink the per-tick worker → main update payload by ~90% (#4244)
Stacked on #4243 (the `perf:client` harness) — first step of fixing the
every-100ms main-thread stutter: make the per-tick burst small before
spreading what remains across frames.

## Problem

The harness showed the main-thread burst was dominated by
`structuredClone` of the `updates` object, and the clone was dominated
by two kinds of per-tick churn that re-sent object payloads every tick:

- `gold` / `troops` / `tilesOwned` change for nearly every alive player
every tick → ~278 partial `PlayerUpdate` objects per tick (world/400
bots), ~508 on giantworldmap.
- Attack troop counts tick down every tick → whole
`outgoingAttacks`/`incomingAttacks` arrays re-cloned for every fighting
player every tick.
- `playerNameViewData` (an all-players record) was cloned every tick but
only recomputed every 30 ticks.

## Change

Three additions to the worker → main protocol (all transferable,
zero-clone):

1. **`packedPlayerUpdates`** — `[smallID, tilesOwned, gold, troops]`
float64 quads for players whose stats changed. These fields no longer
appear in `PlayerUpdate` diffs (first emissions still carry the full
snapshot). Gold is exact in a float64 (game values ≪ 2^53).
2. **`packedAttackUpdates`** — `[ownerSmallID, direction, index,
troops]` quads. Attack arrays are only resent when
membership/order/retreating changes — which is exactly the condition
that keeps the patch indexes valid (a tick either resends an array or
patches it, never both).
3. **`playerNameViewData` is now optional** — attached only on
placement-rebuild ticks (spawn ticks, first ticks, every 30th, spawn
end). The client keeps the last applied values; dead players' name
placements freeze at death (matching the previous effective behavior).

On the client, `GameView.populateFrame` now also rebuilds `names` /
`relationMatrix` / `allianceClusters` only when their inputs changed
that tick — field presence on a partial `PlayerUpdate` marks them dirty.
(`playerStatus`, nuke telegraphs, and attack rings still recompute every
tick; they're tick- or unit-dependent.)

## Results (perf:client, this machine; low-end devices ~5–20× slower)

Default run (world, 400 bots, 1800 ticks):

| stage | before | after |
|---|---|---|
| clone (serialize+deserialize) | 1.02ms | **0.09ms** |
| GameView.update | 0.62ms | **0.29ms** |
| WebGLFrameBuilder.update | 0.04ms | 0.04ms |
| **TOTAL burst mean** | **1.67ms** | **0.42ms** |
| TOTAL p99 / max | 3.47 / 10.3ms | **1.21 / 3.92ms** |

giantworldmap/600t: 2.54 → 0.68ms mean. Player update objects: 278 → 6.5
per tick (world), 508 → 12 (giant). The remaining burst is mostly tile
apply + per-tick derivations — the part that frame-spreading (next step)
addresses.

## Verification

- **Sim final hash unchanged** on all three reference configs
(`5607618202213430`, `29309648281599524`, `39945089450032050`) — no
simulation behavior change.
- **View hash unchanged** on all three configs (`942106e9`, `a3aae227`,
`cbaaf265`) — the rendered view state is provably identical
tick-for-tick, including the name-freeze semantics.
- New tests: `tests/PackedPlayerUpdates.test.ts` (drain + GameRunner
cadence), packed-channel and freeze-at-death cases in
`tests/client/view/GameView.test.ts`, `packAttackTroopDeltas` unit tests
and updated diff contract in `tests/GameUpdateUtils.test.ts` /
`tests/PlayerUpdateDiff.test.ts`.
- `npm test` (1490 tests), `eslint`, `prettier`, `tsc --noEmit` all
pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:50:56 -07:00
evanpelle 03b405eea7 Color nuke telegraph circles by launcher relation (self/ally/enemy)
The blast-radius warning circle was always red, so players couldn't
tell who launched an incoming nuke. Now it's green for your own
nukes, yellow for ally/teammate nukes, and red for everyone else's.

Each telegraph carries a relation (0=self, 1=friendly, 2=enemy),
classified from the per-tick relation matrix — the same friend/foe
logic alt-view uses — and passed to the shader as a per-instance
attribute. Replay/spectator mode (no local player) stays all red.
Colors are tunable via the nukeTelegraph slice in render-settings.json.
2026-06-12 15:32:25 -07:00
b85d1fc372 Fix alt-view coloring teammates as enemies in team games (#4247)
## Problem

In team games, alternate view (space-hold) colored teammates' units red
(enemy color) instead of yellow (ally color). Teammates' territory
borders had the same problem.

## Root cause

`buildRelationMatrix()` in
`src/client/render/frame/derive/RelationMatrix.ts` already supports an
optional `teams` map that marks same-team pairs as `RELATION_FRIENDLY`,
but the call site in `GameView.populateFrame()` never passed it (the
companion `buildTeamMap` helper was dead code). Only explicit alliances
were marked friendly, so a teammate without a formal alliance read as
neutral — and the alt-view unit palette maps neutral to the enemy color.

## Fix

- `GameView` now tracks a `smallID → team` map, populated when each
`PlayerView` is first created (team is a static field, so once per
player is enough).
- The map is passed through to `buildRelationMatrix()`, which feeds both
the `AffiliationPalette` (unit colors) and `BorderComputePass` (border
colors).

## Testing

- New regression test in `tests/client/view/GameView.test.ts`: same-team
players are `RELATION_FRIENDLY` in `frame.relationMatrix`, cross-team
players stay neutral.
- All 36 GameView tests pass; typecheck clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:05:37 -07:00
aa4b490e68 Simplify WebGL renderer integration: remove dead extension code, untangle GameView naming (#4240)
## Summary

The WebGL renderer was adapted from an external extension and carried a
lot of machinery this integration never uses (replay playback, its own
input/event system, a GL radial menu). This PR is two mechanical cleanup
passes with **no behavior change**: delete the dead code, then untangle
the `GameView` naming collision.

**78 files, +142 / −2,197.**

### Pass 1 — remove dead extension baggage

- **Replay/copy mode**: `FrameData.tileMode` was hard-coded `"live"`;
the copy branches in `frame/Upload.ts`, `UploadOptions` (never passed),
`applyFullFrame`/`applyFullTiles`/`applyDelta` on the facade and
`GPURenderer`, `HeatManager.resetForSeek`, and the seek-upload methods
on `TerritoryPass`/`TrailPass` were all unreachable. Also deletes
`types/Replay.ts`, `types/FrameSource.ts`, `types/GameUpdates.ts`,
`types/Game.ts` (imported only by the types barrel).
- **FrameEvents**: trimmed from 14 fields to the 3 actually populated
and read (`deadUnits`, `conquestEvents`, `bonusEvents`). The other 11
fed the extension's stats system and were never written or read here.
- **GL radial menu**: `RadialMenuPass`, its 4 shaders, and ~10 API
methods on facade + renderer had zero callers — the game uses the DOM/d3
radial menu in `hud/layers/RadialMenu.ts`. The pass was constructed and
drawn every frame for nothing.
- **Facade event system**: `GameViewEventMap` defined 10 event types
(`click`, `hover`, `scroll`, …) but only `contextrestored` was ever
emitted — input actually flows through `InputHandler` → EventBus →
controllers. Replaced the listener map with a single `onContextRestored`
callback and deleted `Events.ts`. Also fixed the stale header comment
claiming the facade handles user interaction.
- **Unused API surface**: removed ~20 facade/renderer methods with zero
callers (camera passthroughs like
`panTo`/`zoomTo`/`fitMap`/`screenToWorld`, hit-testing queries, SAM
replay setters, `setSelectedUnit`, `clearFx`/`setFxTimeFn`,
`onFrame`/`afterRender`/fps tracking).

Deliberately left alone: `Camera`'s pan/zoom primitives (building blocks
for a possible future camera unification) and the `timeFn` plumbing
inside the FX passes (deeply embedded as defaults; only the dead
renderer-level wrappers were removed).

### Pass 2 — untangle the three GameViews

- `render/gl/GameView.ts` → **`MapRenderer.ts`** (class `MapRenderer`).
Every importer was already aliasing it as `WebGLGameView` to dodge the
collision with the simulation-mirror `GameView` in `client/view/`, so
this removes aliasing rather than adding churn. `render/CLAUDE.md`
updated.
- Deleted the `src/core/game/GameView.ts` back-compat shim (its own TODO
asked for this). All 51 importers now import from `src/client/view/`
directly via a new 3-line barrel `view/index.ts`.

## Test plan

- `tsc --noEmit` clean, `eslint` clean
- Full test suite passes (1,385 + 65 server tests)
- Manual verification via headless Chromium: started a singleplayer game
and confirmed the renderer works end-to-end — terrain draws, spawn-phase
overlay shows, territories fill with borders after spawning, player
names/flags render, no renderer console errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:21:24 -07:00
2789db8b96 Optimize core simulation hot paths (no behavior change) (#4230)
## Summary

Pure performance optimizations to the attack/conquer/cluster hot paths
in `src/core`, driven by the full-game perf harness from #4228. **No
behavior change**: the final game-state hash is identical before/after
on every config tested — world quick run (2 different seeds),
giantworldmap, and the default 1800-tick run.

### Changes

- **Flat-arithmetic neighbor iteration**: `forEachNeighbor` /
`forEachNeighborWithDiag` / `isBorder` / `isOceanShore` are now
implemented inside `GameMapImpl` using raw `ref±1` / `ref±width` index
math, skipping the per-neighbor `ref()` coordinate validation
(`Number.isInteger` etc.). `GameImpl` and `GameView` delegate.
- **New `neighbors4(ref, out)`**: zero-allocation, callback-free
neighbor query for hot loops (W, E, N, S — same order as
`forEachNeighbor`).
- **`AttackExecution`**: the per-tile closures in `tick()` /
`addNeighbors()` are replaced with reusable neighbor buffers, a cached
`GameMap` reference, and integer `smallID()` owner comparisons instead
of owner-object lookups.
- **`GameImpl`**: the per-conquer `updateBorders` closure is hoisted to
a method with a reusable buffer; `removeInactiveExecutions` compacts the
executions array in place instead of allocating a new ~4200-element
array every tick.
- **`PlayerExecution`**: `surroundedBySamePlayer` / `isSurrounded` /
`getCapturingPlayer` de-closured (`neighbors4` + integer compares;
neighbor visit order preserved, so `getCapturingPlayer`'s
Map-insertion-order tie-breaking is unchanged); flood-fill visit closure
hoisted out of the while loop.
- **`FlatBinaryHeap.dequeue`**: returns the tile directly instead of
allocating a `[tile, priority]` tuple per dequeued tile (AttackExecution
is the only caller).

### Performance (`npm run perf:game`, same machine, before → after)

| run | mean tick | ticks/sec | max tick |
|---|---|---|---|
| default (world, 400 bots, 1800 ticks) | 9.04 → **7.98 ms** | 111 →
**125** | 31.7 → 35.7 ms |
| giantworldmap, 600 ticks | 22.5 → **17.4 ms** | 44 → **58** | 52.8 →
**36.2 ms** |

The giantworldmap tail improvement (max tick −31%) is the most relevant
for the 100 ms tick budget.

### Determinism verification

Identical `Final hash` before and after on all configs:

| config | hash |
|---|---|
| `--map world --ticks 200 --bots 100` | `5455008589403520` |
| same + `--seed second-seed-check` | `5580840142777488` |
| `--map giantworldmap --ticks 600` | `37373734953428430` |
| default run | `26773450321979388` |

### Tests

- New `tests/NeighborIteration.test.ts` pins the exact neighbor
iteration orders (W,E,N,S cardinal; dx-major diagonal — conquest order
and RNG consumption depend on them) and conquer/border-tile invariants
checked mid-battle.
- New `tests/FlatBinaryHeap.test.ts` covers heap ordering, clear, and
growth.
- Full suite passes (122 files / 1386 tests + server tests); lint and
prettier clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:58:42 -07:00
noahschmalandGitHub 21776e81af Feature/colorblind mode (#4150)
**Add approved & assigned issue number here:**

Resolves #2549

## Description:

Adds colorblind mode. Similar to dark mode, it exists as a toggle in
settings. When enabled, it swaps the game's theme (which is refactored
to extend from a theme base class) to use more colorblind-friendly
colors and brightness variations. Borders are darkened, and terrarin is
separated by lightness. Friendly/Foe colors and switched to blue/orange
instead of red/green.

The theme refactor supports adding new themes without having to
reimplement the color distribution system. New themes can extend the
BaseTheme and supply the data, such as palettes, team-color variations,
and terrain.

New setting:
<img width="880" height="273" alt="Screenshot 2026-06-04 at 11 30 27 AM"
src="https://github.com/user-attachments/assets/d5d573d5-cc64-4ac1-95c2-00627faf17cc"
/>

New color palette:
<img width="1119" height="757" alt="Screenshot 2026-06-04 at 11 30
59 AM"
src="https://github.com/user-attachments/assets/2bb15bc9-992b-41ae-ab0e-b01fe0c3c6bb"
/>

## 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
2026-06-11 10:53:03 -07:00
FloPinguinandGitHub 3aaf0ea05d Remove lakes from the game 🌊 (#4214)
## Description:

Nametags look weird here because on the left is a lake:

<img width="954" height="765" alt="Screenshot 2026-06-10 170116"
src="https://github.com/user-attachments/assets/2b679a68-fab3-458e-8e29-e12b9a4f281b"
/>

I removed isLake from the nametag position calculation

Because isLake was unused then, I removed it completely.

Full changelog:

- Remove isLake() from GameMap interface, GameMapImpl, GameImpl, and
GameView
- Remove TerrainType.Lake enum value
- terrainType() now returns Ocean for all water tiles (previously
distinguished lake vs ocean, but nothing treated them differently)
- Remove Lake case from PastelTheme and PastelThemeDark (already fell
through to Ocean)
- Exclude lakes from nametag placement grid in NameBoxCalculator

Maybe as a next step also remove lakes metadata from the map generator?

AI Model used: 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
2026-06-10 13:20:52 -07:00
evanpelle 21e42ce461 fix(render): only fire conquest FX for the local player's conquests
Skip ConquestEvent updates whose conquerorId isn't the local player so the
gold popup + sword sprite only triggers on your own captures.
2026-05-27 16:07:38 -07:00
evanpelle 9bf6b5af74 fix(render): only show transport attack rings for the local player's boats
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.
2026-05-27 15:54:17 -07:00
evanpelle a2b3db616b WebGL: re-upload structures when owner changes (capture)
The structure dirty flag missed ownership changes, so captured buildings
kept rendering in the previous owner's color until another structure
event (level/active/construction) forced a re-upload.
2026-05-22 11:07:48 +01:00
VariableVinceandGitHub 513057a62c WebGL: show alliance request+duration icon, show ally and team mate targets too, some optimization (#3971)
## 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
2026-05-19 18:17:26 -07:00
EvanandGitHub 62e15d2794 Cut worker→main bandwidth ~3.3× by switching PlayerUpdate to deltas (#3967)
## Description:

Cut worker→main bandwidth ~3.3× by switching PlayerUpdate from a full
per-tick snapshot to a field-level diff. PlayerImpl.toUpdate() now
caches the last sent update and returns only changed fields, or null if
nothing changed. The client-side applyStateUpdate() merges instead of
overwriting.

Per-tick total dropped from ~297 KB to ~89 KB; the Player bucket alone
went from 258 KB/tick to 50 KB/tick. Diff/apply logic lives in a new
GameUpdateUtils.ts module with unit tests.

## 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
2026-05-18 17:07:40 -07:00
evanpelle 4cd22a9b5c rename render/ files to UpperCamelCase to match client convention
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.
2026-05-17 21:21:05 -07:00
evanpelle 45246f2085 make computePlayerStatus live-aware so status icons render
The replay-path computePlayerStatus left alliance/target/embargo/
nukeTargetsMe at false, which meant the WebGL NamePass had no data
for those status icons after we switched names off canvas2D — they
just stopped appearing.

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

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

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

18 new tests covering both modes — replay (relative flags forced
false), and live (alliance one-way, target one-way, embargo bilateral,
self-flags suppressed, nukeTargetsMe with/without tileState,
relative-flag-alone emits, localPlayerID=0 falls back to replay,
allianceReq/allianceFraction stay deferred).
2026-05-16 19:21:49 -07:00
evanpelle 8955be7667 fix: store embargoes as smallID numbers (drop string[] wart)
PlayerState.embargoes was string[] of stringified smallIDs — the
renderer parsed each entry with parseInt() to use as an array index.
Flagged in the integration handoff as something that should be number[].

Switch to number[] end-to-end: renderer type, relation-matrix derive
(no parseInt), PlayerView.setEmbargoSmallIDs / hasEmbargoAgainst
(numeric Array.includes, no String() temporaries), and GameView's
embargo translation pass. Also updates the PlayerView test that pinned
the old format.
2026-05-16 17:45:29 -07:00
evanpelle 5b663fae14 refactor: share renderer state shapes between game and WebGL renderer
PlayerView/UnitView now wrap renderer-shaped state objects (PlayerState,
PlayerStatic, UnitState) directly instead of holding engine wire types.
GameView owns a long-lived FrameData object kept in sync each tick:
players/units/tiles/trail/railroad are mutated in place; derived buffers
(playerStatus, relationMatrix, allianceClusters, nukeTelegraphs,
attackRings) and events are recomputed in a final populateFrame() pass.

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

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

Also: 39 new tests covering PlayerView/GameView/FrameData behavior;
replace .data field access in three layer call sites with accessor
methods (betrayals(), type(), getTraitorRemainingTicks()).
2026-05-16 13:27:31 -07:00