Add performance stats (#2338)

## Description:

Enhanced the performance overlay to display additional tick-related
performance metrics. The overlay now shows:

1. **Tick Execution Duration** - Average and maximum time (in
milliseconds) it takes to execute a game tick
2. **Tick Delay** - Average and maximum time (in milliseconds) between
receiving tick updates from the server

The server sends 10 updates per second (100ms interval), so these
metrics help identify:
- Client-side performance bottlenecks (tick execution duration)
- Network latency issues (tick delay)

**Additional improvements:**
- Renamed `FPSDisplay` component to `PerformanceOverlay` to better
reflect its expanded purpose
- Updated method names (`updateFPS` → `updateFrameMetrics`) and CSS
classes for consistency

All metrics are tracked over the last 60 ticks, providing rolling
averages and maximum values for performance analysis.

## Please complete the following:

- [x] I have added screenshots for all UI updates:
- <img width="495" height="227" alt="image"
src="https://github.com/user-attachments/assets/142b0313-61bf-46cc-b595-61fe73f6b54c"
/>


- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- Translation keys already exist in en.json for
"performance_overlay_label" and "performance_overlay_desc"

- [x] I have added relevant tests to the test directory
  - All existing tests pass (309/310 tests passed)
  - No new tests added as this is primarily a display enhancement

- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
  - Tested locally with npm test
  - Verified performance overlay displays all metrics correctly
  - Confirmed tick metrics are calculated and displayed accurately

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Discord: kerverse

---------

Co-authored-by: Evan <evanpelle@gmail.com>
This commit is contained in:
Kerod Kibatu
2025-11-03 16:25:54 -05:00
committed by evanpelle
parent 45539623fc
commit 822d08bb7c
7 changed files with 136 additions and 32 deletions
+19
View File
@@ -33,6 +33,7 @@ import {
InputHandler,
MouseMoveEvent,
MouseUpEvent,
TickMetricsEvent,
} from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { getPersistentID } from "./Main";
@@ -201,6 +202,8 @@ export class ClientGameRunner {
private lastMessageTime: number = 0;
private connectionCheckInterval: NodeJS.Timeout | null = null;
private lastTickReceiveTime: number = 0;
private currentTickDelay: number | undefined = undefined;
constructor(
private lobby: LobbyConfig,
@@ -292,6 +295,14 @@ export class ClientGameRunner {
this.gameView.update(gu);
this.renderer.tick();
// Emit tick metrics event for performance overlay
this.eventBus.emit(
new TickMetricsEvent(gu.tickExecutionDuration, this.currentTickDelay),
);
// Reset tick delay for next measurement
this.currentTickDelay = undefined;
if (gu.updates[GameUpdateType.Win].length > 0) {
this.saveGame(gu.updates[GameUpdateType.Win][0]);
}
@@ -359,6 +370,14 @@ export class ClientGameRunner {
this.transport.joinGame(0);
return;
}
// Track when we receive the turn to calculate delay
const now = Date.now();
if (this.lastTickReceiveTime > 0) {
// Calculate delay between receiving turn messages
this.currentTickDelay = now - this.lastTickReceiveTime;
}
this.lastTickReceiveTime = now;
if (this.turnsSeen !== message.turn.turnNumber) {
console.error(
`got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`,
+7
View File
@@ -115,6 +115,13 @@ export class AutoUpgradeEvent implements GameEvent {
) {}
}
export class TickMetricsEvent implements GameEvent {
constructor(
public readonly tickExecutionDuration?: number,
public readonly tickDelay?: number,
) {}
}
export class InputHandler {
private lastPointerX: number = 0;
private lastPointerY: number = 0;
+12 -10
View File
@@ -13,7 +13,6 @@ import { ChatModal } from "./layers/ChatModal";
import { ControlPanel } from "./layers/ControlPanel";
import { EmojiTable } from "./layers/EmojiTable";
import { EventsDisplay } from "./layers/EventsDisplay";
import { FPSDisplay } from "./layers/FPSDisplay";
import { FxLayer } from "./layers/FxLayer";
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
import { GameRightSidebar } from "./layers/GameRightSidebar";
@@ -23,6 +22,7 @@ import { Leaderboard } from "./layers/Leaderboard";
import { MainRadialMenu } from "./layers/MainRadialMenu";
import { MultiTabModal } from "./layers/MultiTabModal";
import { NameLayer } from "./layers/NameLayer";
import { PerformanceOverlay } from "./layers/PerformanceOverlay";
import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay";
import { PlayerPanel } from "./layers/PlayerPanel";
import { RailroadLayer } from "./layers/RailroadLayer";
@@ -202,12 +202,14 @@ export function createRenderer(
const structureLayer = new StructureLayer(game, eventBus, transformHandler);
const fpsDisplay = document.querySelector("fps-display") as FPSDisplay;
if (!(fpsDisplay instanceof FPSDisplay)) {
console.error("fps display not found");
const performanceOverlay = document.querySelector(
"performance-overlay",
) as PerformanceOverlay;
if (!(performanceOverlay instanceof PerformanceOverlay)) {
console.error("performance overlay not found");
}
fpsDisplay.eventBus = eventBus;
fpsDisplay.userSettings = userSettings;
performanceOverlay.eventBus = eventBus;
performanceOverlay.userSettings = userSettings;
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
if (!(alertFrame instanceof AlertFrame)) {
@@ -263,7 +265,7 @@ export function createRenderer(
multiTabModal,
new AdTimer(game),
alertFrame,
fpsDisplay,
performanceOverlay,
];
return new GameRenderer(
@@ -273,7 +275,7 @@ export function createRenderer(
transformHandler,
uiState,
layers,
fpsDisplay,
performanceOverlay,
);
}
@@ -287,7 +289,7 @@ export class GameRenderer {
public transformHandler: TransformHandler,
public uiState: UIState,
private layers: Layer[],
private fpsDisplay: FPSDisplay,
private performanceOverlay: PerformanceOverlay,
) {
const context = canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
@@ -371,7 +373,7 @@ export class GameRenderer {
requestAnimationFrame(() => this.renderGame());
const duration = performance.now() - start;
this.fpsDisplay.updateFPS(duration);
this.performanceOverlay.updateFrameMetrics(duration);
if (duration > 50) {
console.warn(
@@ -2,11 +2,14 @@ import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { UserSettings } from "../../../core/game/UserSettings";
import { TogglePerformanceOverlayEvent } from "../../InputHandler";
import {
TickMetricsEvent,
TogglePerformanceOverlayEvent,
} from "../../InputHandler";
import { Layer } from "./Layer";
@customElement("fps-display")
export class FPSDisplay extends LitElement implements Layer {
@customElement("performance-overlay")
export class PerformanceOverlay extends LitElement implements Layer {
@property({ type: Object })
public eventBus!: EventBus;
@@ -22,6 +25,18 @@ export class FPSDisplay extends LitElement implements Layer {
@state()
private frameTime: number = 0;
@state()
private tickExecutionAvg: number = 0;
@state()
private tickExecutionMax: number = 0;
@state()
private tickDelayAvg: number = 0;
@state()
private tickDelayMax: number = 0;
@state()
private isVisible: boolean = false;
@@ -38,9 +53,11 @@ export class FPSDisplay extends LitElement implements Layer {
private lastSecondTime: number = 0;
private framesThisSecond: number = 0;
private dragStart: { x: number; y: number } = { x: 0, y: 0 };
private tickExecutionTimes: number[] = [];
private tickDelayTimes: number[] = [];
static styles = css`
.fps-display {
.performance-overlay {
position: fixed;
top: 20px;
left: 50%;
@@ -57,25 +74,25 @@ export class FPSDisplay extends LitElement implements Layer {
transition: none;
}
.fps-display.dragging {
.performance-overlay.dragging {
cursor: grabbing;
transition: none;
opacity: 0.5;
}
.fps-line {
.performance-line {
margin: 2px 0;
}
.fps-good {
.performance-good {
color: #4ade80; /* green-400 */
}
.fps-warning {
.performance-warning {
color: #fbbf24; /* amber-400 */
}
.fps-bad {
.performance-bad {
color: #f87171; /* red-400 */
}
@@ -108,6 +125,9 @@ export class FPSDisplay extends LitElement implements Layer {
this.eventBus.on(TogglePerformanceOverlayEvent, () => {
this.userSettings.togglePerformanceOverlay();
});
this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => {
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
});
}
setVisible(visible: boolean) {
@@ -159,7 +179,7 @@ export class FPSDisplay extends LitElement implements Layer {
document.removeEventListener("mouseup", this.handleMouseUp);
};
updateFPS(frameDuration: number) {
updateFrameMetrics(frameDuration: number) {
this.isVisible = this.userSettings.performanceOverlay();
if (!this.isVisible) return;
@@ -216,14 +236,54 @@ export class FPSDisplay extends LitElement implements Layer {
this.requestUpdate();
}
updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
// Update tick execution duration stats
if (tickExecutionDuration !== undefined) {
this.tickExecutionTimes.push(tickExecutionDuration);
if (this.tickExecutionTimes.length > 60) {
this.tickExecutionTimes.shift();
}
if (this.tickExecutionTimes.length > 0) {
const avg =
this.tickExecutionTimes.reduce((a, b) => a + b, 0) /
this.tickExecutionTimes.length;
this.tickExecutionAvg = Math.round(avg * 100) / 100;
this.tickExecutionMax = Math.round(
Math.max(...this.tickExecutionTimes),
);
}
}
// Update tick delay stats
if (tickDelay !== undefined) {
this.tickDelayTimes.push(tickDelay);
if (this.tickDelayTimes.length > 60) {
this.tickDelayTimes.shift();
}
if (this.tickDelayTimes.length > 0) {
const avg =
this.tickDelayTimes.reduce((a, b) => a + b, 0) /
this.tickDelayTimes.length;
this.tickDelayAvg = Math.round(avg * 100) / 100;
this.tickDelayMax = Math.round(Math.max(...this.tickDelayTimes));
}
}
this.requestUpdate();
}
shouldTransform(): boolean {
return false;
}
private getFPSColor(fps: number): string {
if (fps >= 55) return "fps-good";
if (fps >= 30) return "fps-warning";
return "fps-bad";
private getPerformanceColor(fps: number): string {
if (fps >= 55) return "performance-good";
if (fps >= 30) return "performance-warning";
return "performance-bad";
}
render() {
@@ -239,29 +299,39 @@ export class FPSDisplay extends LitElement implements Layer {
return html`
<div
class="fps-display ${this.isDragging ? "dragging" : ""}"
class="performance-overlay ${this.isDragging ? "dragging" : ""}"
style="${style}"
@mousedown="${this.handleMouseDown}"
>
<button class="close-button" @click="${this.handleClose}">×</button>
<div class="fps-line">
<div class="performance-line">
FPS:
<span class="${this.getFPSColor(this.currentFPS)}"
<span class="${this.getPerformanceColor(this.currentFPS)}"
>${this.currentFPS}</span
>
</div>
<div class="fps-line">
<div class="performance-line">
Avg (60s):
<span class="${this.getFPSColor(this.averageFPS)}"
<span class="${this.getPerformanceColor(this.averageFPS)}"
>${this.averageFPS}</span
>
</div>
<div class="fps-line">
<div class="performance-line">
Frame:
<span class="${this.getFPSColor(1000 / this.frameTime)}"
<span class="${this.getPerformanceColor(1000 / this.frameTime)}"
>${this.frameTime}ms</span
>
</div>
<div class="performance-line">
Tick Exec:
<span>${this.tickExecutionAvg.toFixed(2)}ms</span>
(max: <span>${this.tickExecutionMax}ms</span>)
</div>
<div class="performance-line">
Tick Delay:
<span>${this.tickDelayAvg.toFixed(2)}ms</span>
(max: <span>${this.tickDelayMax}ms</span>)
</div>
</div>
`;
}
+1 -1
View File
@@ -405,7 +405,7 @@
<news-modal></news-modal>
<game-left-sidebar></game-left-sidebar>
<flag-input-modal></flag-input-modal>
<fps-display></fps-display>
<performance-overlay></performance-overlay>
<div
id="language-modal"
class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex justify-center items-center"
+5
View File
@@ -130,9 +130,13 @@ export class GameRunner {
this.currTurn++;
let updates: GameUpdates;
let tickExecutionDuration: number = 0;
try {
const startTime = performance.now();
updates = this.game.executeNextTick();
const endTime = performance.now();
tickExecutionDuration = endTime - startTime;
} catch (error: unknown) {
if (error instanceof Error) {
console.error("Game tick error:", error.message);
@@ -173,6 +177,7 @@ export class GameRunner {
packedTileUpdates: new BigUint64Array(packedTileUpdates),
updates: updates,
playerNameViewData: this.playerViewData,
tickExecutionDuration: tickExecutionDuration,
});
this.isExecuting = false;
}
+1
View File
@@ -19,6 +19,7 @@ export interface GameUpdateViewData {
updates: GameUpdates;
packedTileUpdates: BigUint64Array;
playerNameViewData: Record<string, NameViewData>;
tickExecutionDuration?: number;
}
export interface ErrorUpdate {