From f5ef306d995b60d2a0181df99b163b36cd6f1035 Mon Sep 17 00:00:00 2001
From: Zixer1 <99333209+Zixer1@users.noreply.github.com>
Date: Wed, 8 Jul 2026 15:30:37 -0400
Subject: [PATCH] feat(client): highlight small players with a pulsing glow
(#4525)
## Description:
Adds a **"Highlight small players"** client setting (in the in-game
Settings menu). When it's on, human players holding **0.2% or less of
the map** get a soft red glow that breathes (a pulsing aura) around
their territory, starting **one minute into the game**. This makes
near-eliminated players easy to spot on a busy map, even when their
territory is scattered in fragments or sitting under structures.
How it works:
- **Purely client-side**, no simulation or determinism impact.
`SmallPlayerGlowPass` renders a tile-space bloom: extract a sub-tile
mask of the small players' tiles, run a separable Gaussian blur, then
composite the soft aura over the map additively. It's camera-independent
(no shimmer when panning/zooming), so scattered tiles blur into one
clean halo. The aura breathes: its intensity fades fully to 0 at the
trough for clear contrast.
- The set is recomputed each tick in `WebGLFrameBuilder`: alive human
players with `tilesOwned / (landTiles - fallout) <= 0.002` (0.2%),
suppressed during the spawn phase and the first minute.
- Backed by a persisted `UserSettings` flag, toggleable live from the
in-game Settings modal (with a new, distinct icon).
- Drawn after the structure passes so buildings can't hide it.
- Mirrors the existing FalloutBloom pipeline and reuses the shared blur
shader and render-target helpers rather than duplicating them. Tunable
via `render-settings.json` (`smallPlayerGlow`: color / alpha /
pulseSpeed).
UI (glow fades in and out):
## 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._
---
resources/images/HighlightIconWhite.svg | 5 +
resources/lang/en.json | 2 +
src/client/WebGLFrameBuilder.ts | 56 +++++
src/client/hud/layers/SettingsModal.ts | 31 +++
src/client/render/gl/MapRenderer.ts | 5 +
src/client/render/gl/RenderSettings.ts | 5 +
src/client/render/gl/Renderer.ts | 17 ++
.../render/gl/passes/FalloutBloomPass.ts | 2 +-
.../render/gl/passes/FalloutLightPass.ts | 2 +-
.../render/gl/passes/SmallPlayerGlowPass.ts | 234 ++++++++++++++++++
src/client/render/gl/render-settings.json | 5 +
.../shaders/fallout-bloom/composite.vert.glsl | 11 -
.../map-quad.vert.glsl} | 24 +-
.../small-player-glow/composite.frag.glsl | 16 ++
.../small-player-glow/extract.frag.glsl | 30 +++
src/client/render/gl/utils/GlUtils.ts | 31 +++
src/core/game/UserSettings.ts | 11 +
tests/UserSettings.test.ts | 28 +++
18 files changed, 491 insertions(+), 24 deletions(-)
create mode 100644 resources/images/HighlightIconWhite.svg
create mode 100644 src/client/render/gl/passes/SmallPlayerGlowPass.ts
delete mode 100644 src/client/render/gl/shaders/fallout-bloom/composite.vert.glsl
rename src/client/render/gl/shaders/{day-night/fallout-composite.vert.glsl => shared/map-quad.vert.glsl} (63%)
create mode 100644 src/client/render/gl/shaders/small-player-glow/composite.frag.glsl
create mode 100644 src/client/render/gl/shaders/small-player-glow/extract.frag.glsl
diff --git a/resources/images/HighlightIconWhite.svg b/resources/images/HighlightIconWhite.svg
new file mode 100644
index 000000000..607277ee2
--- /dev/null
+++ b/resources/images/HighlightIconWhite.svg
@@ -0,0 +1,5 @@
+
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 852b4c33a..4e1d7b3d3 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -1488,6 +1488,8 @@
"ground_attack_desc": "Send a ground attack to the tile under your cursor.",
"help_messages_desc": "Show contextual tips and warnings during gameplay, such as army limit warnings and general gameplay advice.",
"help_messages_label": "Help Messages",
+ "highlight_small_players_desc": "Add a glow to small players so they're easy to find.",
+ "highlight_small_players_label": "Highlight small players",
"keybind_conflict_error": "The key {key} is already bound to another action.",
"keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.",
"left_click_desc": "When ON, left-click opens menu and sword button attacks. When OFF, left-click attacks directly.",
diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts
index e6030ce5a..cf569a3db 100644
--- a/src/client/WebGLFrameBuilder.ts
+++ b/src/client/WebGLFrameBuilder.ts
@@ -14,6 +14,7 @@ import {
} from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerType } from "../core/game/Game";
+import { UserSettings } from "../core/game/UserSettings";
import { getCachedCosmetics } from "./Cosmetics";
import { uploadFrameData } from "./render/frame/Upload";
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
@@ -40,6 +41,14 @@ import type { GameView } from "./view";
const PALETTE_SIZE = 4096;
+// A human player counts as "small" (and glows) at or below this fraction of the
+// map; the glow is suppressed for a grace window after the game starts.
+const SMALL_PLAYER_MAX_MAP_FRACTION = 0.002; // 0.2%
+const SMALL_PLAYER_GLOW_GRACE_SECONDS = 60;
+// The set is a visual aid, not tick-critical, so rescan ~once a second
+// (10 ticks) instead of every tick.
+const SMALL_PLAYER_GLOW_RESCAN_TICKS = 10;
+
// The effect-palette block order: index = block (rows block·MAX_TRAIL_COLORS …).
// trail.frag.glsl picks its block from the trail tile's nuke bit — block 0 =
// transportShipTrail (nuke bit 0), block 1 = nukeTrail (nuke bit 1, set by
@@ -187,12 +196,17 @@ export class WebGLFrameBuilder {
this.view.refreshNames(displayNames);
}
+ private readonly highlightSetBuf = new Uint8Array(PALETTE_SIZE);
+ private readonly userSettings = new UserSettings();
+ private glowRescanTick = 0;
+
update(gameView: GameView): void {
this.syncPlayers(gameView);
this.syncPlayerEffects(gameView);
this.syncPlayerSpawns(gameView);
this.syncLocalPlayer(gameView);
this.syncSpawnOverlay(gameView);
+ this.syncSmallPlayerGlow(gameView);
this.syncTerrainDeltas(gameView);
this.resolveDeadUnitExplosions(gameView);
uploadFrameData(this.view, gameView.frameData());
@@ -328,6 +342,48 @@ export class WebGLFrameBuilder {
this.view.updateSpawnOverlay(true, centers);
}
+ /**
+ * Small-player glow: when the client "Highlight small players" setting is on,
+ * collect the alive human players holding <=0.2% of the map and push their
+ * smallIDs so the glow pass radiates around their territory. Skips the first
+ * minute of play so everyone's tiny starting territory doesn't glow.
+ * Client-only view — toggle it live in the settings.
+ */
+ private syncSmallPlayerGlow(gameView: GameView): void {
+ if (
+ !this.userSettings.highlightSmallPlayers() ||
+ gameView.inSpawnPhase() ||
+ gameView.elapsedGameSeconds() < SMALL_PLAYER_GLOW_GRACE_SECONDS
+ ) {
+ this.view.updateSmallPlayerGlow(null);
+ return;
+ }
+ // Throttle the per-player scan + upload; the glow keeps rendering the last
+ // set between rescans. The off/spawn/grace checks above run every tick, so
+ // toggling off takes effect on the next tick (deferred while the game is
+ // paused, since ticks stop; it clears on unpause).
+ if (this.glowRescanTick++ % SMALL_PLAYER_GLOW_RESCAN_TICKS !== 0) return;
+ // "% of the map" uses the same denominator the leaderboard/win-check use.
+ const denom = gameView.numLandTiles() - gameView.numTilesWithFallout();
+ if (denom <= 0) {
+ this.view.updateSmallPlayerGlow(null);
+ return;
+ }
+ const set = this.highlightSetBuf;
+ set.fill(0);
+ let any = false;
+ for (const p of gameView.players()) {
+ if (!p.isPlayer() || p.type() !== PlayerType.Human || !p.isAlive()) {
+ continue;
+ }
+ if (p.numTilesOwned() / denom <= SMALL_PLAYER_MAX_MAP_FRACTION) {
+ set[p.smallID()] = 1;
+ any = true;
+ }
+ }
+ this.view.updateSmallPlayerGlow(any ? set : null);
+ }
+
private syncPlayers(gameView: GameView): void {
if (!this.skinsInitialized) {
this.skinsInitialized = true;
diff --git a/src/client/hud/layers/SettingsModal.ts b/src/client/hud/layers/SettingsModal.ts
index 14c40e79a..6b905f63a 100644
--- a/src/client/hud/layers/SettingsModal.ts
+++ b/src/client/hud/layers/SettingsModal.ts
@@ -19,6 +19,7 @@ import { ShowGraphicsSettingsModalEvent } from "./GraphicsSettingsModal";
const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg");
const emojiIcon = assetUrl("images/EmojiIconWhite.svg");
const exitIcon = assetUrl("images/ExitIconWhite.svg");
+const highlightIcon = assetUrl("images/HighlightIconWhite.svg");
const mouseIcon = assetUrl("images/MouseIconWhite.svg");
const ninjaIcon = assetUrl("images/NinjaIconWhite.svg");
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
@@ -134,6 +135,11 @@ export class SettingsModal extends LitElement implements Controller {
this.requestUpdate();
}
+ private onToggleHighlightSmallPlayersButtonClick() {
+ this.userSettings.toggleHighlightSmallPlayers();
+ this.requestUpdate();
+ }
+
private onToggleAlertFrameButtonClick() {
this.userSettings.toggleAlertFrame();
this.requestUpdate();
@@ -350,6 +356,31 @@ export class SettingsModal extends LitElement implements Controller {
+
+