Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,14 @@
"enter": "Enter fullscreen",
"exit": "Exit fullscreen"
},
"game_health": {
"connection_unstable": "Connection unstable",
"connection_unstable_description": "Game updates are arriving late. Your connection or the server may be experiencing delays.",
"high_latency": "High latency",
"high_latency_description": "Your connection to the server is slower than usual. Your actions may take longer to arrive.",
"performance": "Low performance",
"performance_description": "Your device is struggling to keep up with the game. Try lowering your graphics settings."
},
"game_info_modal": {
"all_gold": "All gold",
"atoms": "Atoms",
Expand Down Expand Up @@ -1414,7 +1422,6 @@
"territory_sat_label": "Territory saturation"
},
"heads_up_message": {
"catching_up": "Catching up...",
"choose_spawn": "Choose a starting location",
"choose_spawn_spectator": "Players are choosing starting locations",
"dont_show_again": "Don't show again",
Expand Down
64 changes: 60 additions & 4 deletions src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import { reloadForUpdate, translateText } from "../client/Utils";
import { rememberAdmitted } from "./AdmittedLobbies";
import { MapLayerController } from "./controllers/MapLayerController";
import { isDesktopShell } from "./DesktopShell";
import { GameHealth } from "./GameHealth";
import { GameMetrics } from "./GameMetrics";
import { createRenderer, GameRenderer } from "./hud/GameRenderer";
import { GameHealthIndicators } from "./hud/layers/GameHealthIndicators";
import { goldRateTracker } from "./hud/layers/lib/GoldRateTracker";
import { OnboardingOverlay } from "./hud/layers/OnboardingOverlay";
import { UnitDisplay } from "./hud/layers/UnitDisplay";
Expand Down Expand Up @@ -885,6 +887,7 @@ async function createClientGame(
);

const metrics = new GameMetrics(lobbyConfig.gameID, clientID);
const health = new GameHealth();
const { builder: webglBuilder, stopFrameLoop } = mountWebGLFrameLoop(
gameMap,
view,
Expand All @@ -893,7 +896,10 @@ async function createClientGame(
gameRenderer.transformHandler,
gameView,
eventBus,
(nowMs) => metrics.recordFrame(nowMs),
(nowMs) => {
metrics.recordFrame(nowMs);
health.recordFrame(nowMs);
},
);

// Releases all WebGL/DOM resources this game created. Without it, stopping
Expand Down Expand Up @@ -949,6 +955,7 @@ async function createClientGame(
graphicsListenerAbort,
disposeRenderer,
metrics,
health,
);
} catch (err) {
soundManager.dispose();
Expand All @@ -973,6 +980,29 @@ export class ClientGameRunner {

private lastTickReceiveTime: number = 0;
private currentTickDelay: number | undefined = undefined;
private healthTimer: ReturnType<typeof setInterval> | null = null;
private healthPaused = false;
private healthIndicators: GameHealthIndicators | null = null;

private updateHealth = (): void => {
const status = this.health.update(performance.now(), {
catchingUp:
!this.gameView.config().isReplay() && this.gameView.isCatchingUp(),
paused: this.healthPaused,
hidden: document.hidden,
networkEnabled:
!this.transport.isLocal && !this.gameView.config().isReplay(),
active: this.isActive && !this.awaitingStart,
});
const indicators = this.healthIndicators;
if (
indicators &&
(indicators.status.highLatency !== status.highLatency ||
indicators.status.connectionUnstable !== status.connectionUnstable ||
indicators.status.performance !== status.performance)
)
indicators.status = { ...status };
};

constructor(
private lobby: LobbyConfig,
Expand All @@ -989,6 +1019,7 @@ export class ClientGameRunner {
private graphicsListenerAbort: AbortController | null = null,
private disposeRenderer: (() => void) | null = null,
private metrics: GameMetrics | null = null,
private health: GameHealth = new GameHealth(),
) {
this.lastMessageTime = Date.now();
}
Expand All @@ -1015,6 +1046,13 @@ export class ClientGameRunner {
this.isActive = true;
this.lastMessageTime = Date.now();
this.metrics?.start();
this.health.reset(performance.now());
this.healthPaused = false;
this.healthIndicators = new GameHealthIndicators();
document.body.append(this.healthIndicators);
this.healthTimer = setInterval(this.updateHealth, 250);
document.addEventListener("visibilitychange", this.updateHealth);
this.updateHealth();
setTimeout(() => {
this.connectionCheckInterval = setInterval(
() => this.onConnectionCheck(),
Expand Down Expand Up @@ -1072,6 +1110,11 @@ export class ClientGameRunner {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
this.gameView.update(gu);
const pauseUpdates = gu.updates[GameUpdateType.GamePaused];
if (pauseUpdates?.length) {
this.healthPaused = pauseUpdates[pauseUpdates.length - 1].paused;
}
this.updateHealth();
this.webglBuilder?.update(this.gameView);
this.renderer.tick();
if (gu.tickExecutionDuration !== undefined) {
Expand All @@ -1090,6 +1133,7 @@ export class ClientGameRunner {
const onconnect = () => {
console.log("Connected to game server!");
this.awaitingStart = true;
this.updateHealth();
void this.transport.rejoinGame(this.turnsSeen);
};

Expand All @@ -1099,6 +1143,8 @@ export class ClientGameRunner {
if (message.type === "start") {
console.log("starting game! in client game runner");
this.awaitingStart = false;
this.health.recordTurn(performance.now());
this.updateHealth();

if (this.gameView.config().isRandomSpawn()) {
const goToPlayer = () => {
Expand Down Expand Up @@ -1173,9 +1219,12 @@ export class ClientGameRunner {
);
}
if (message.type === "pong") {
this.metrics?.recordRoundTrip(
Math.floor(performance.now()) - message.sentAt,
);
const now = performance.now();
const roundTrip = Math.floor(now) - message.sentAt;
this.metrics?.recordRoundTrip(roundTrip);
this.updateHealth();
this.health.recordRoundTrip(roundTrip, now);
this.updateHealth();
}
if (message.type === "new_lobby") {
// The host reused this private lobby: surface the successor id so the
Expand All @@ -1184,6 +1233,7 @@ export class ClientGameRunner {
this.eventBus.emit(new NewLobbyEvent(message.gameID));
}
if (message.type === "turn") {
this.health.recordTurn(performance.now());
if (
!this.gameView.inSpawnPhase() &&
!hasGoneToPlayer &&
Expand Down Expand Up @@ -1235,6 +1285,12 @@ export class ClientGameRunner {
}

public stop() {
if (this.healthTimer !== null) clearInterval(this.healthTimer);
this.healthTimer = null;
document.removeEventListener("visibilitychange", this.updateHealth);
this.healthIndicators?.remove();
this.healthIndicators = null;
this.health.reset(performance.now());
this.soundManager.dispose();
this.graphicsListenerAbort?.abort();
// Detach the input handler's window/canvas listeners and its EventBus
Expand Down
166 changes: 166 additions & 0 deletions src/client/GameHealth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
export interface GameHealthState {
highLatency: boolean;
connectionUnstable: boolean;
performance: boolean;
}

export interface GameHealthContext {
catchingUp: boolean;
paused: boolean;
hidden: boolean;
networkEnabled: boolean;
active: boolean;
}

/** Client-only diagnostics. All timestamps use the performance.now() clock. */
export class GameHealth {
private roundTrips: number[] = [];
private lastRoundTripAt: number | null = null;
private latencySpike = false;
private lastTurnAt: number | null = null;
private lastTroubledAt = -Infinity;
private lastFrameAt: number | null = null;
private frameWindowMs = 0;
private frameWindowCount = 0;
private lowFrameMs = 0;
private lowFrameRate = false;
private catchingUpSince: number | null = null;
private sampling = false;
private networkEnabled = false;
private struggling = false;
private readonly state: GameHealthState = {
highLatency: false,
connectionUnstable: false,
performance: false,
};

reset(nowMs: number): void {
this.roundTrips = [];
this.lastRoundTripAt = null;
this.latencySpike = false;
this.lastTurnAt = null;
this.lastTroubledAt = nowMs;
this.sampling = false;
this.networkEnabled = false;
this.struggling = false;
this.resetFrames();
this.catchingUpSince = null;
this.clearState();
}

recordFrame(nowMs: number): void {
if (!this.sampling) return;
if (this.lastFrameAt !== null) {
const elapsed = nowMs - this.lastFrameAt;
if (elapsed >= 100) this.lastTroubledAt = nowMs;
if (elapsed > 0) {
this.frameWindowMs += elapsed;
this.frameWindowCount++;
if (this.frameWindowMs >= 1000) {
const fps = (this.frameWindowCount * 1000) / this.frameWindowMs;
if (fps < 30) {
// One blocked frame is jank, not sustained low FPS. Require
// several completed windows before displaying the warning.
this.lowFrameMs += Math.min(this.frameWindowMs, 1000);
this.lowFrameRate = this.lowFrameMs >= 3000;
} else if (fps >= 35) {
this.lowFrameMs = 0;
this.lowFrameRate = false;
}
this.frameWindowMs = 0;
this.frameWindowCount = 0;
}
}
}
this.lastFrameAt = nowMs;
}

recordTurn(nowMs: number): void {
this.lastTurnAt = nowMs;
}

recordRoundTrip(ms: number, nowMs: number): void {
if (this.lastFrameAt !== null && nowMs - this.lastFrameAt >= 100) {
this.lastTroubledAt = nowMs;
}
if (
!this.sampling ||
!this.networkEnabled ||
!Number.isFinite(ms) ||
ms < 0 ||
nowMs - ms <= this.lastTroubledAt
) {
// A pong queued behind client work cannot diagnose network latency.
this.latencySpike = false;
return;
}
const sorted = [...this.roundTrips].sort((a, b) => a - b);
const baseline = sorted[Math.floor(sorted.length / 2)];
this.latencySpike =
sorted.length >= 3 && ms > Math.max(baseline * 2, baseline + 100);
this.lastRoundTripAt = nowMs;
// A bounded rolling baseline can adapt if the connection changes mid-match.
this.roundTrips.push(ms);
if (this.roundTrips.length > 12) this.roundTrips.shift();
}

update(nowMs: number, context: GameHealthContext): GameHealthState {
const sampling = context.active && !context.hidden && !context.paused;
this.networkEnabled = context.networkEnabled;
if (!sampling) {
this.lastTroubledAt = nowMs;
this.sampling = false;
this.struggling = false;
this.resetFrames();
this.catchingUpSince = null;
this.lastTurnAt = null;
this.latencySpike = false;
this.clearState();
return this.state;
}
if (!this.sampling) {
this.sampling = true;
// Reject any ping still in flight across a hidden/paused/rejoin period.
this.lastTroubledAt = nowMs;
this.lastTurnAt = nowMs;
}
if (context.catchingUp) {
this.catchingUpSince ??= nowMs;
} else {
this.catchingUpSince = null;
}
const struggling =
context.catchingUp || this.lowFrameRate || this.lowFrameMs > 0;
// Include the final interval before recovery, even if no update ran in it.
if (struggling || this.struggling) this.lastTroubledAt = nowMs;
this.struggling = struggling;
this.state.performance =
this.lowFrameRate ||
(this.catchingUpSince !== null && nowMs - this.catchingUpSince >= 1000);
this.state.highLatency =
context.networkEnabled &&
!struggling &&
this.latencySpike &&
this.lastRoundTripAt !== null &&
nowMs - this.lastRoundTripAt < 10_000;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
this.state.connectionUnstable =
context.networkEnabled &&
this.lastTurnAt !== null &&
nowMs - this.lastTurnAt >= 500;
return this.state;
}

private resetFrames(): void {
this.lastFrameAt = null;
this.frameWindowMs = 0;
this.frameWindowCount = 0;
this.lowFrameMs = 0;
this.lowFrameRate = false;
}

private clearState(): void {
this.state.highLatency = false;
this.state.connectionUnstable = false;
this.state.performance = false;
}
}
Loading
Loading