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 {
+
+