This commit is contained in:
192
apps/server/src/client-profile-store.ts
Normal file
192
apps/server/src/client-profile-store.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
export interface ClientFrameProfile {
|
||||
fps: number;
|
||||
averageMs: number;
|
||||
p95Ms: number;
|
||||
maximumMs: number;
|
||||
slowFrames: number;
|
||||
longTasks: number;
|
||||
longTaskMs: number;
|
||||
}
|
||||
|
||||
export interface ClientNetworkProfile {
|
||||
roundTripTimeMs: number;
|
||||
jitterMs: number;
|
||||
clockOffsetMs: number;
|
||||
inputLeadTicks: number;
|
||||
connection: string;
|
||||
validation: string;
|
||||
}
|
||||
|
||||
export interface ClientRendererProfile {
|
||||
drawCalls: number;
|
||||
triangles: number;
|
||||
points: number;
|
||||
lines: number;
|
||||
geometries: number;
|
||||
textures: number;
|
||||
gpuVendor: string;
|
||||
gpuRenderer: string;
|
||||
}
|
||||
|
||||
export interface ClientPerformanceSample {
|
||||
sessionId: string;
|
||||
game: string;
|
||||
playerId: number | null;
|
||||
capturedAt: string;
|
||||
frame: ClientFrameProfile;
|
||||
network: ClientNetworkProfile;
|
||||
renderer: ClientRendererProfile | null;
|
||||
device: {
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
devicePixelRatio: number;
|
||||
hardwareConcurrency: number;
|
||||
deviceMemoryGb: number | null;
|
||||
visibility: string;
|
||||
};
|
||||
heap: {
|
||||
usedBytes: number;
|
||||
totalBytes: number;
|
||||
limitBytes: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface StoredSession {
|
||||
remoteAddress: string;
|
||||
lastSeenAt: number;
|
||||
samples: ClientPerformanceSample[];
|
||||
}
|
||||
|
||||
export interface ClientProfileSnapshot {
|
||||
sessionId: string;
|
||||
remoteAddress: string;
|
||||
game: string;
|
||||
playerId: number | null;
|
||||
lastSeenSecondsAgo: number;
|
||||
latest: ClientPerformanceSample;
|
||||
recent: {
|
||||
samples: number;
|
||||
averageFps: number;
|
||||
minimumFps: number;
|
||||
averageFrameMs: number;
|
||||
worstFrameMs: number;
|
||||
p95FrameMs: number;
|
||||
slowFrames: number;
|
||||
longTasks: number;
|
||||
longTaskMs: number;
|
||||
averageRoundTripTimeMs: number;
|
||||
maximumRoundTripTimeMs: number;
|
||||
averageJitterMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
const maximumSamples = 30;
|
||||
const staleSessionMs = 30_000;
|
||||
|
||||
export class ClientProfileStore {
|
||||
private readonly sessions = new Map<string, StoredSession>();
|
||||
|
||||
record(sample: unknown, remoteAddress: string, now = performance.now()): boolean {
|
||||
if (!isClientSample(sample)) return false;
|
||||
let session = this.sessions.get(sample.sessionId);
|
||||
if (!session) {
|
||||
session = { remoteAddress, lastSeenAt: now, samples: [] };
|
||||
this.sessions.set(sample.sessionId, session);
|
||||
}
|
||||
session.remoteAddress = remoteAddress;
|
||||
session.lastSeenAt = now;
|
||||
session.samples.push(sample);
|
||||
if (session.samples.length > maximumSamples) session.samples.shift();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot(now = performance.now()): ClientProfileSnapshot[] {
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
if (now - session.lastSeenAt > staleSessionMs) this.sessions.delete(sessionId);
|
||||
}
|
||||
return [...this.sessions.entries()]
|
||||
.map(([sessionId, session]) => summarize(sessionId, session, now))
|
||||
.sort((left, right) => left.game.localeCompare(right.game));
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(
|
||||
sessionId: string,
|
||||
session: StoredSession,
|
||||
now: number,
|
||||
): ClientProfileSnapshot {
|
||||
const latest = session.samples[session.samples.length - 1]!;
|
||||
const frames = session.samples.map((sample) => sample.frame);
|
||||
const networks = session.samples.map((sample) => sample.network);
|
||||
const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);
|
||||
const average = (values: number[]) => values.length === 0 ? 0 : sum(values) / values.length;
|
||||
return {
|
||||
sessionId,
|
||||
remoteAddress: session.remoteAddress,
|
||||
game: latest.game,
|
||||
playerId: latest.playerId,
|
||||
lastSeenSecondsAgo: round((now - session.lastSeenAt) / 1_000, 2),
|
||||
latest,
|
||||
recent: {
|
||||
samples: session.samples.length,
|
||||
averageFps: round(average(frames.map((frame) => frame.fps)), 1),
|
||||
minimumFps: round(Math.min(...frames.map((frame) => frame.fps)), 1),
|
||||
averageFrameMs: round(average(frames.map((frame) => frame.averageMs)), 2),
|
||||
worstFrameMs: round(Math.max(...frames.map((frame) => frame.maximumMs)), 2),
|
||||
p95FrameMs: round(average(frames.map((frame) => frame.p95Ms)), 2),
|
||||
slowFrames: sum(frames.map((frame) => frame.slowFrames)),
|
||||
longTasks: sum(frames.map((frame) => frame.longTasks)),
|
||||
longTaskMs: round(sum(frames.map((frame) => frame.longTaskMs)), 2),
|
||||
averageRoundTripTimeMs: round(
|
||||
average(networks.map((network) => network.roundTripTimeMs)),
|
||||
2,
|
||||
),
|
||||
maximumRoundTripTimeMs: round(
|
||||
Math.max(...networks.map((network) => network.roundTripTimeMs)),
|
||||
2,
|
||||
),
|
||||
averageJitterMs: round(average(networks.map((network) => network.jitterMs)), 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isClientSample(value: unknown): value is ClientPerformanceSample {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const sample = value as Partial<ClientPerformanceSample>;
|
||||
return (
|
||||
typeof sample.sessionId === "string" &&
|
||||
sample.sessionId.length > 0 &&
|
||||
sample.sessionId.length <= 128 &&
|
||||
typeof sample.game === "string" &&
|
||||
sample.game.length <= 32 &&
|
||||
(sample.playerId === null || Number.isInteger(sample.playerId)) &&
|
||||
isFiniteRecord(sample.frame, [
|
||||
"fps",
|
||||
"averageMs",
|
||||
"p95Ms",
|
||||
"maximumMs",
|
||||
"slowFrames",
|
||||
"longTasks",
|
||||
"longTaskMs",
|
||||
]) &&
|
||||
isFiniteRecord(sample.network, [
|
||||
"roundTripTimeMs",
|
||||
"jitterMs",
|
||||
"clockOffsetMs",
|
||||
"inputLeadTicks",
|
||||
]) &&
|
||||
typeof sample.device === "object" &&
|
||||
sample.device !== null
|
||||
);
|
||||
}
|
||||
|
||||
function isFiniteRecord(value: unknown, keys: string[]): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
return keys.every((key) => typeof record[key] === "number" && Number.isFinite(record[key]));
|
||||
}
|
||||
|
||||
function round(value: number, digits: number): number {
|
||||
const scale = 10 ** digits;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
284
apps/server/src/game-profiler.ts
Normal file
284
apps/server/src/game-profiler.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
export type InboundMessageKind = "input" | "ping" | "state-report";
|
||||
|
||||
export interface StepTimingSample {
|
||||
simulationMs: number;
|
||||
perceptionMs: number;
|
||||
snapshotMs: number;
|
||||
replayMs: number;
|
||||
sendMs: number;
|
||||
totalMs: number;
|
||||
authorityEvents: number;
|
||||
snapshotDue: boolean;
|
||||
}
|
||||
|
||||
interface MetricWindow {
|
||||
startedAt: number;
|
||||
elapsedMs: number;
|
||||
steps: number;
|
||||
simulationMs: number;
|
||||
perceptionMs: number;
|
||||
snapshotMs: number;
|
||||
replayMs: number;
|
||||
sendMs: number;
|
||||
totalStepMs: number;
|
||||
maximumStepMs: number;
|
||||
authorityEvents: number;
|
||||
snapshots: number;
|
||||
inputMessages: number;
|
||||
pingMessages: number;
|
||||
stateReports: number;
|
||||
inboundBytes: number;
|
||||
outboundFrames: number;
|
||||
outboundBytes: number;
|
||||
backpressuredSends: number;
|
||||
droppedSends: number;
|
||||
maximumBufferedBytes: number;
|
||||
}
|
||||
|
||||
interface ClientMetrics {
|
||||
playerId: number;
|
||||
remoteAddress: string;
|
||||
connectedAt: number;
|
||||
current: MetricWindow;
|
||||
previous: MetricWindow;
|
||||
bufferedBytes: number;
|
||||
}
|
||||
|
||||
export interface RateProfile {
|
||||
sampleSeconds: number;
|
||||
stepsPerSecond: number;
|
||||
averageStepMs: number;
|
||||
maximumStepMs: number;
|
||||
simulationMsPerStep: number;
|
||||
perceptionMsPerStep: number;
|
||||
snapshotMsPerStep: number;
|
||||
replayMsPerStep: number;
|
||||
sendMsPerStep: number;
|
||||
authorityEventsPerSecond: number;
|
||||
snapshotsPerSecond: number;
|
||||
inputsPerSecond: number;
|
||||
pingsPerSecond: number;
|
||||
stateReportsPerSecond: number;
|
||||
inboundBytesPerSecond: number;
|
||||
outboundFramesPerSecond: number;
|
||||
outboundBytesPerSecond: number;
|
||||
backpressuredSendsPerSecond: number;
|
||||
droppedSendsPerSecond: number;
|
||||
maximumBufferedBytes: number;
|
||||
}
|
||||
|
||||
export interface ClientProfileSnapshot {
|
||||
playerId: number;
|
||||
remoteAddress: string;
|
||||
connectedSeconds: number;
|
||||
bufferedBytes: number;
|
||||
recent: RateProfile;
|
||||
}
|
||||
|
||||
export interface GameProfileSnapshot {
|
||||
path: string;
|
||||
configuredTickRateHz: number;
|
||||
connectedPlayers: number;
|
||||
recent: RateProfile;
|
||||
clients: ClientProfileSnapshot[];
|
||||
}
|
||||
|
||||
/** Low-overhead one-second rolling profiler for a hosted networked game. */
|
||||
export class GameServerProfiler {
|
||||
private current: MetricWindow;
|
||||
private previous: MetricWindow;
|
||||
private readonly clients = new Map<number, ClientMetrics>();
|
||||
|
||||
constructor(
|
||||
readonly path: string,
|
||||
readonly tickRateHz: number,
|
||||
now = performance.now(),
|
||||
) {
|
||||
this.current = createWindow(now);
|
||||
this.previous = createWindow(now);
|
||||
}
|
||||
|
||||
openClient(playerId: number, remoteAddress: string, now = performance.now()): void {
|
||||
this.roll(now);
|
||||
this.clients.set(playerId, {
|
||||
playerId,
|
||||
remoteAddress,
|
||||
connectedAt: now,
|
||||
current: createWindow(now),
|
||||
previous: createWindow(now),
|
||||
bufferedBytes: 0,
|
||||
});
|
||||
}
|
||||
|
||||
closeClient(playerId: number): void {
|
||||
this.clients.delete(playerId);
|
||||
}
|
||||
|
||||
recordInbound(
|
||||
playerId: number,
|
||||
kind: InboundMessageKind,
|
||||
bytes: number,
|
||||
now = performance.now(),
|
||||
): void {
|
||||
this.roll(now);
|
||||
recordInbound(this.current, kind, bytes);
|
||||
const client = this.clients.get(playerId);
|
||||
if (client) recordInbound(client.current, kind, bytes);
|
||||
}
|
||||
|
||||
recordOutbound(
|
||||
playerId: number,
|
||||
bytes: number,
|
||||
sendResult: number,
|
||||
bufferedBytes: number,
|
||||
now = performance.now(),
|
||||
): void {
|
||||
this.roll(now);
|
||||
recordOutbound(this.current, bytes, sendResult, bufferedBytes);
|
||||
const client = this.clients.get(playerId);
|
||||
if (client) {
|
||||
client.bufferedBytes = bufferedBytes;
|
||||
recordOutbound(client.current, bytes, sendResult, bufferedBytes);
|
||||
}
|
||||
}
|
||||
|
||||
recordStep(sample: StepTimingSample, now = performance.now()): void {
|
||||
this.roll(now);
|
||||
const window = this.current;
|
||||
window.steps += 1;
|
||||
window.simulationMs += sample.simulationMs;
|
||||
window.perceptionMs += sample.perceptionMs;
|
||||
window.snapshotMs += sample.snapshotMs;
|
||||
window.replayMs += sample.replayMs;
|
||||
window.sendMs += sample.sendMs;
|
||||
window.totalStepMs += sample.totalMs;
|
||||
window.maximumStepMs = Math.max(window.maximumStepMs, sample.totalMs);
|
||||
window.authorityEvents += sample.authorityEvents;
|
||||
if (sample.snapshotDue) window.snapshots += 1;
|
||||
}
|
||||
|
||||
snapshot(now = performance.now()): GameProfileSnapshot {
|
||||
this.roll(now);
|
||||
return {
|
||||
path: this.path,
|
||||
configuredTickRateHz: this.tickRateHz,
|
||||
connectedPlayers: this.clients.size,
|
||||
recent: rateProfile(selectWindow(this.current, this.previous, now)),
|
||||
clients: [...this.clients.values()]
|
||||
.sort((left, right) => left.playerId - right.playerId)
|
||||
.map((client) => ({
|
||||
playerId: client.playerId,
|
||||
remoteAddress: client.remoteAddress,
|
||||
connectedSeconds: round((now - client.connectedAt) / 1_000, 2),
|
||||
bufferedBytes: client.bufferedBytes,
|
||||
recent: rateProfile(selectWindow(client.current, client.previous, now)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private roll(now: number): void {
|
||||
if (now - this.current.startedAt >= 1_000) {
|
||||
this.current.elapsedMs = Math.max(1, now - this.current.startedAt);
|
||||
this.previous = this.current;
|
||||
this.current = createWindow(now);
|
||||
}
|
||||
for (const client of this.clients.values()) {
|
||||
if (now - client.current.startedAt < 1_000) continue;
|
||||
client.current.elapsedMs = Math.max(1, now - client.current.startedAt);
|
||||
client.previous = client.current;
|
||||
client.current = createWindow(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(now: number): MetricWindow {
|
||||
return {
|
||||
startedAt: now,
|
||||
elapsedMs: 0,
|
||||
steps: 0,
|
||||
simulationMs: 0,
|
||||
perceptionMs: 0,
|
||||
snapshotMs: 0,
|
||||
replayMs: 0,
|
||||
sendMs: 0,
|
||||
totalStepMs: 0,
|
||||
maximumStepMs: 0,
|
||||
authorityEvents: 0,
|
||||
snapshots: 0,
|
||||
inputMessages: 0,
|
||||
pingMessages: 0,
|
||||
stateReports: 0,
|
||||
inboundBytes: 0,
|
||||
outboundFrames: 0,
|
||||
outboundBytes: 0,
|
||||
backpressuredSends: 0,
|
||||
droppedSends: 0,
|
||||
maximumBufferedBytes: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function recordInbound(window: MetricWindow, kind: InboundMessageKind, bytes: number): void {
|
||||
window.inboundBytes += bytes;
|
||||
if (kind === "input") window.inputMessages += 1;
|
||||
else if (kind === "ping") window.pingMessages += 1;
|
||||
else window.stateReports += 1;
|
||||
}
|
||||
|
||||
function recordOutbound(
|
||||
window: MetricWindow,
|
||||
bytes: number,
|
||||
sendResult: number,
|
||||
bufferedBytes: number,
|
||||
): void {
|
||||
window.outboundFrames += 1;
|
||||
window.outboundBytes += bytes;
|
||||
if (sendResult === 0) window.backpressuredSends += 1;
|
||||
if (sendResult === 2) window.droppedSends += 1;
|
||||
window.maximumBufferedBytes = Math.max(window.maximumBufferedBytes, bufferedBytes);
|
||||
}
|
||||
|
||||
function selectWindow(
|
||||
current: MetricWindow,
|
||||
previous: MetricWindow,
|
||||
now: number,
|
||||
): MetricWindow {
|
||||
const currentElapsed = now - current.startedAt;
|
||||
if (currentElapsed >= 350 || previous.elapsedMs === 0) {
|
||||
return { ...current, elapsedMs: Math.max(1, currentElapsed) };
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
function rateProfile(window: MetricWindow): RateProfile {
|
||||
const seconds = Math.max(0.001, window.elapsedMs / 1_000);
|
||||
const perSecond = (value: number) => round(value / seconds, 2);
|
||||
const perStep = (value: number) =>
|
||||
round(window.steps === 0 ? 0 : value / window.steps, 4);
|
||||
return {
|
||||
sampleSeconds: round(seconds, 3),
|
||||
stepsPerSecond: perSecond(window.steps),
|
||||
averageStepMs: perStep(window.totalStepMs),
|
||||
maximumStepMs: round(window.maximumStepMs, 4),
|
||||
simulationMsPerStep: perStep(window.simulationMs),
|
||||
perceptionMsPerStep: perStep(window.perceptionMs),
|
||||
snapshotMsPerStep: perStep(window.snapshotMs),
|
||||
replayMsPerStep: perStep(window.replayMs),
|
||||
sendMsPerStep: perStep(window.sendMs),
|
||||
authorityEventsPerSecond: perSecond(window.authorityEvents),
|
||||
snapshotsPerSecond: perSecond(window.snapshots),
|
||||
inputsPerSecond: perSecond(window.inputMessages),
|
||||
pingsPerSecond: perSecond(window.pingMessages),
|
||||
stateReportsPerSecond: perSecond(window.stateReports),
|
||||
inboundBytesPerSecond: perSecond(window.inboundBytes),
|
||||
outboundFramesPerSecond: perSecond(window.outboundFrames),
|
||||
outboundBytesPerSecond: perSecond(window.outboundBytes),
|
||||
backpressuredSendsPerSecond: perSecond(window.backpressuredSends),
|
||||
droppedSendsPerSecond: perSecond(window.droppedSends),
|
||||
maximumBufferedBytes: window.maximumBufferedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function round(value: number, digits: number): number {
|
||||
const scale = 10 ** digits;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
295
apps/server/src/host-networked-game.ts
Normal file
295
apps/server/src/host-networked-game.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
FixedStepClock,
|
||||
type BinaryProtocol,
|
||||
type ClientStateReport,
|
||||
type InputDecision,
|
||||
type InputPacket,
|
||||
type NetworkedServerStepResult,
|
||||
type PlayerId,
|
||||
type ReplayTicket,
|
||||
type SnapshotBatch,
|
||||
type StateSnapshot,
|
||||
type ValidationResult,
|
||||
} from "@syncer/engine";
|
||||
import {
|
||||
DISABLED,
|
||||
type TemplatedApp,
|
||||
type WebSocket,
|
||||
} from "uWebSockets.js";
|
||||
import {
|
||||
GameServerProfiler,
|
||||
type GameProfileSnapshot,
|
||||
type InboundMessageKind,
|
||||
} from "./game-profiler.js";
|
||||
|
||||
interface HostableEngine<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent> {
|
||||
readonly playerIds: PlayerId[];
|
||||
addPlayer(playerId: PlayerId): void;
|
||||
removePlayer(playerId: PlayerId): void;
|
||||
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision;
|
||||
submitStateReport(playerId: PlayerId, report: ClientStateReport<ClientState>): ValidationResult | null;
|
||||
step(): NetworkedServerStepResult<AuthorityEvent>;
|
||||
createSnapshot(playerId: PlayerId, serverTime: number): StateSnapshot<ClientState>;
|
||||
createSnapshotBatches(serverTime: number): SnapshotBatch<ClientState>[];
|
||||
createPerceptions(playerId: PlayerId, events: readonly AuthorityEvent[]): PerceptionEvent[];
|
||||
drainReplayTickets?(playerId: PlayerId): Array<ReplayTicket<ClientState, PerceptionEvent>>;
|
||||
}
|
||||
|
||||
interface HostableGame<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent> {
|
||||
tickRateHz: number;
|
||||
protocol: BinaryProtocol<Input, ClientState, PerceptionEvent>;
|
||||
createServer(): HostableEngine<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent>;
|
||||
}
|
||||
|
||||
export interface HostedGameLoop {
|
||||
readonly path: string;
|
||||
advance(now: number): void;
|
||||
profile(now?: number): GameProfileSnapshot;
|
||||
}
|
||||
|
||||
/** Mounts any defined networked game on the shared WebSocket server. */
|
||||
export function hostNetworkedGame<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent>(
|
||||
app: TemplatedApp,
|
||||
path: string,
|
||||
game: HostableGame<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent>,
|
||||
): HostedGameLoop {
|
||||
const engine = game.createServer();
|
||||
const protocol = game.protocol;
|
||||
const clock = new FixedStepClock({ rateHz: game.tickRateHz, maxCatchUpSteps: 5 });
|
||||
const profiler = new GameServerProfiler(path, game.tickRateHz);
|
||||
const socketPlayers = new Map<WebSocket<unknown>, PlayerId>();
|
||||
const playerSockets = new Map<PlayerId, WebSocket<unknown>>();
|
||||
let nextPlayerId = 1;
|
||||
|
||||
app.ws<unknown>(path, {
|
||||
compression: DISABLED,
|
||||
maxPayloadLength: 512 * 1_024,
|
||||
maxBackpressure: 256 * 1_024,
|
||||
closeOnBackpressureLimit: true,
|
||||
idleTimeout: 32,
|
||||
sendPingsAutomatically: true,
|
||||
open(socket) {
|
||||
const playerId = nextPlayerId;
|
||||
nextPlayerId = (nextPlayerId + 1) >>> 0;
|
||||
socketPlayers.set(socket, playerId);
|
||||
playerSockets.set(playerId, socket);
|
||||
profiler.openClient(playerId, readRemoteAddress(socket));
|
||||
engine.addPlayer(playerId);
|
||||
sendFrame(
|
||||
socket,
|
||||
playerId,
|
||||
protocol.encodeServer({
|
||||
kind: "welcome",
|
||||
playerId,
|
||||
snapshot: engine.createSnapshot(playerId, performance.now()),
|
||||
}),
|
||||
);
|
||||
},
|
||||
message(socket, payload) {
|
||||
const playerId = socketPlayers.get(socket);
|
||||
if (playerId === undefined) {
|
||||
socket.end(1011, "Missing player session");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = protocol.decodeClient(payload);
|
||||
profiler.recordInbound(
|
||||
playerId,
|
||||
message.kind as InboundMessageKind,
|
||||
payload.byteLength,
|
||||
);
|
||||
switch (message.kind) {
|
||||
case "input": {
|
||||
const decision = engine.submitInput(playerId, message.packet);
|
||||
if (!decision.accepted) {
|
||||
sendFrame(
|
||||
socket,
|
||||
playerId,
|
||||
protocol.encodeServer({ kind: "reject-input", sequence: message.packet.sequence }),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ping": {
|
||||
const serverReceivedAt = performance.now();
|
||||
sendFrame(
|
||||
socket,
|
||||
playerId,
|
||||
protocol.encodeServer({
|
||||
kind: "pong",
|
||||
pong: { ...message.ping, serverReceivedAt, serverSentAt: performance.now() },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "state-report": {
|
||||
const validation = engine.submitStateReport(playerId, message.report);
|
||||
if (validation) sendValidation(validation);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
socket.end(1003, "Invalid game message");
|
||||
}
|
||||
},
|
||||
close(socket) {
|
||||
const playerId = socketPlayers.get(socket);
|
||||
if (playerId === undefined) return;
|
||||
socketPlayers.delete(socket);
|
||||
playerSockets.delete(playerId);
|
||||
profiler.closeClient(playerId);
|
||||
engine.removePlayer(playerId);
|
||||
},
|
||||
});
|
||||
|
||||
function runStep(): void {
|
||||
const startedAt = performance.now();
|
||||
const result = engine.step();
|
||||
const simulationEndedAt = performance.now();
|
||||
const outbound = new Map<PlayerId, ArrayBuffer[]>();
|
||||
|
||||
for (const acknowledgement of result.acknowledgements) {
|
||||
queueFrame(outbound, acknowledgement.playerId, protocol.encodeServer({
|
||||
kind: "acknowledge",
|
||||
sequence: acknowledgement.sequence,
|
||||
}));
|
||||
}
|
||||
for (const validation of result.validations) {
|
||||
queueFrame(outbound, validation.playerId, protocol.encodeServer({
|
||||
kind: "validation",
|
||||
tick: validation.tick,
|
||||
valid: validation.valid,
|
||||
}));
|
||||
}
|
||||
for (const playerId of engine.playerIds) {
|
||||
for (const event of engine.createPerceptions(playerId, result.events)) {
|
||||
queueFrame(outbound, playerId, protocol.encodeServer({ kind: "event", tick: result.tick, event }));
|
||||
}
|
||||
}
|
||||
const perceptionEndedAt = performance.now();
|
||||
|
||||
if (result.snapshotDue) {
|
||||
const serverTime = performance.now();
|
||||
for (const batch of engine.createSnapshotBatches(serverTime)) {
|
||||
const frame = protocol.encodeServer({
|
||||
kind: "snapshot",
|
||||
snapshot: { tick: result.tick, serverTime, state: batch.state },
|
||||
});
|
||||
for (const playerId of batch.playerIds) queueFrame(outbound, playerId, frame);
|
||||
}
|
||||
}
|
||||
const snapshotEndedAt = performance.now();
|
||||
|
||||
if (engine.drainReplayTickets) {
|
||||
for (const playerId of engine.playerIds) {
|
||||
for (const ticket of engine.drainReplayTickets(playerId)) queueReplay(outbound, playerId, ticket);
|
||||
}
|
||||
}
|
||||
const replayEndedAt = performance.now();
|
||||
|
||||
for (const [playerId, frames] of outbound) {
|
||||
const socket = playerSockets.get(playerId);
|
||||
socket?.cork(() => {
|
||||
for (const frame of frames) sendFrame(socket, playerId, frame);
|
||||
});
|
||||
}
|
||||
const endedAt = performance.now();
|
||||
profiler.recordStep({
|
||||
simulationMs: simulationEndedAt - startedAt,
|
||||
perceptionMs: perceptionEndedAt - simulationEndedAt,
|
||||
snapshotMs: snapshotEndedAt - perceptionEndedAt,
|
||||
replayMs: replayEndedAt - snapshotEndedAt,
|
||||
sendMs: endedAt - replayEndedAt,
|
||||
totalMs: endedAt - startedAt,
|
||||
authorityEvents: result.events.length,
|
||||
snapshotDue: result.snapshotDue,
|
||||
}, endedAt);
|
||||
}
|
||||
|
||||
function sendValidation(validation: ValidationResult): void {
|
||||
const socket = playerSockets.get(validation.playerId);
|
||||
if (!socket) return;
|
||||
sendFrame(
|
||||
socket,
|
||||
validation.playerId,
|
||||
protocol.encodeServer({ kind: "validation", tick: validation.tick, valid: validation.valid }),
|
||||
);
|
||||
}
|
||||
|
||||
function sendFrame(
|
||||
socket: WebSocket<unknown>,
|
||||
playerId: PlayerId,
|
||||
frame: ArrayBuffer,
|
||||
): void {
|
||||
const result = socket.send(frame, true, false);
|
||||
profiler.recordOutbound(
|
||||
playerId,
|
||||
frame.byteLength,
|
||||
result,
|
||||
socket.getBufferedAmount(),
|
||||
);
|
||||
}
|
||||
|
||||
function queueReplay(
|
||||
outbound: Map<PlayerId, ArrayBuffer[]>,
|
||||
playerId: PlayerId,
|
||||
ticket: ReplayTicket<ClientState, PerceptionEvent>,
|
||||
): void {
|
||||
queueFrame(outbound, playerId, protocol.encodeServer({
|
||||
kind: "replay-start",
|
||||
ticketId: ticket.ticketId,
|
||||
perspectiveId: ticket.perspectiveId,
|
||||
fromTick: ticket.fromTick,
|
||||
toTick: ticket.toTick,
|
||||
frameCount: ticket.frames.length,
|
||||
playbackRate: ticket.playbackRate,
|
||||
}));
|
||||
for (const frame of ticket.frames) {
|
||||
queueFrame(outbound, playerId, protocol.encodeServer({
|
||||
kind: "replay-frame",
|
||||
ticketId: ticket.ticketId,
|
||||
tick: frame.tick,
|
||||
state: frame.state,
|
||||
events: frame.events,
|
||||
}));
|
||||
}
|
||||
queueFrame(outbound, playerId, protocol.encodeServer({ kind: "replay-end", ticketId: ticket.ticketId }));
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
advance(now) {
|
||||
clock.advance(now, runStep);
|
||||
},
|
||||
profile(now) {
|
||||
return profiler.snapshot(now);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readRemoteAddress(socket: WebSocket<unknown>): string {
|
||||
const port = socket.getRemotePort();
|
||||
const text = new TextDecoder().decode(socket.getRemoteAddressAsText());
|
||||
if (text) return `${text}:${port}`;
|
||||
const bytes = new Uint8Array(socket.getRemoteAddress());
|
||||
if (bytes.length === 4) return `${[...bytes].join(".")}:${port}`;
|
||||
if (bytes.length === 16) {
|
||||
const groups: string[] = [];
|
||||
for (let index = 0; index < bytes.length; index += 2) {
|
||||
groups.push(((bytes[index] ?? 0) * 256 + (bytes[index + 1] ?? 0)).toString(16));
|
||||
}
|
||||
return `[${groups.join(":")}]:${port}`;
|
||||
}
|
||||
return port > 0 ? `unknown:${port}` : "unknown";
|
||||
}
|
||||
|
||||
function queueFrame(
|
||||
outbound: Map<PlayerId, ArrayBuffer[]>,
|
||||
playerId: PlayerId,
|
||||
frame: ArrayBuffer,
|
||||
): void {
|
||||
const frames = outbound.get(playerId);
|
||||
if (frames) frames.push(frame);
|
||||
else outbound.set(playerId, [frame]);
|
||||
}
|
||||
183
apps/server/src/index.ts
Normal file
183
apps/server/src/index.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
API_PATHS,
|
||||
FLUX_SOCKET_PATH,
|
||||
GAME_SOCKET_PATH,
|
||||
ROYALE_SOCKET_PATH,
|
||||
createShooterGame,
|
||||
fluxGame,
|
||||
royaleGame,
|
||||
type ApiMessage,
|
||||
} from "@syncer/shared";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { extname, join, relative } from "node:path";
|
||||
import { App, type HttpResponse } from "uWebSockets.js";
|
||||
import { ClientProfileStore } from "./client-profile-store.js";
|
||||
import { hostNetworkedGame } from "./host-networked-game.js";
|
||||
import { ServerProcessProfiler } from "./server-profiler.js";
|
||||
|
||||
const port = Number(process.env.PORT ?? 3001);
|
||||
const app = App();
|
||||
const processProfiler = new ServerProcessProfiler();
|
||||
const clientProfiles = new ClientProfileStore();
|
||||
const staticSite = loadStaticSite(process.env.STATIC_ROOT);
|
||||
const gameLoops = [
|
||||
hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })),
|
||||
hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame),
|
||||
hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame),
|
||||
];
|
||||
|
||||
app
|
||||
.post("/api/client-profile", (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
let finished = false;
|
||||
response.onAborted(() => {
|
||||
finished = true;
|
||||
});
|
||||
response.onData((chunk, isLast) => {
|
||||
if (finished) return;
|
||||
bytes += chunk.byteLength;
|
||||
if (bytes > 64 * 1_024) {
|
||||
finished = true;
|
||||
response.writeStatus("413 Payload Too Large").end();
|
||||
return;
|
||||
}
|
||||
chunks.push(Buffer.from(new Uint8Array(chunk)));
|
||||
if (!isLast) return;
|
||||
finished = true;
|
||||
try {
|
||||
const sample: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (!clientProfiles.record(sample, readRemoteAddress(response))) {
|
||||
response.writeStatus("400 Bad Request").end("Invalid profile sample");
|
||||
return;
|
||||
}
|
||||
response
|
||||
.writeStatus("204 No Content")
|
||||
.writeHeader("Access-Control-Allow-Origin", "*")
|
||||
.end();
|
||||
} catch {
|
||||
response.writeStatus("400 Bad Request").end("Invalid JSON");
|
||||
}
|
||||
});
|
||||
})
|
||||
.get("/api/profile", (response) => {
|
||||
const now = performance.now();
|
||||
response
|
||||
.writeHeader("Content-Type", "application/json; charset=utf-8")
|
||||
.writeHeader("Access-Control-Allow-Origin", "*")
|
||||
.end(JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
process: processProfiler.snapshot(),
|
||||
clients: clientProfiles.snapshot(now),
|
||||
games: gameLoops.map((game) => game.profile(now)),
|
||||
}, null, 2));
|
||||
})
|
||||
.get(API_PATHS.message, (response) => {
|
||||
const body: ApiMessage = {
|
||||
message: "Authoritative multiplayer simulations online",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
response
|
||||
.writeHeader("Content-Type", "application/json; charset=utf-8")
|
||||
.end(JSON.stringify(body));
|
||||
})
|
||||
.any("/*", (response, request) => {
|
||||
const url = request.getUrl();
|
||||
const method = request.getMethod().toUpperCase();
|
||||
const asset = staticSite?.files.get(url);
|
||||
const fallback =
|
||||
staticSite &&
|
||||
(method === "GET" || method === "HEAD") &&
|
||||
!url.startsWith("/api/") &&
|
||||
!url.startsWith("/ws/")
|
||||
? staticSite.index
|
||||
: undefined;
|
||||
const file = asset ?? fallback;
|
||||
if (!file || (method !== "GET" && method !== "HEAD")) {
|
||||
response.writeStatus("404 Not Found").end("Not found");
|
||||
return;
|
||||
}
|
||||
response
|
||||
.writeHeader("Content-Type", file.contentType)
|
||||
.writeHeader(
|
||||
"Cache-Control",
|
||||
asset && url.startsWith("/assets/")
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "no-cache",
|
||||
);
|
||||
response.end(method === "HEAD" ? undefined : file.body);
|
||||
})
|
||||
.listen(port, (listenSocket) => {
|
||||
if (!listenSocket) {
|
||||
console.error(`Could not listen on port ${port}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Authoritative server listening on http://localhost:${port} (${gameLoops
|
||||
.map(({ path }) => path)
|
||||
.join(", ")}); profiler: /api/profile`,
|
||||
);
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
for (const game of gameLoops) game.advance(now);
|
||||
}, 4);
|
||||
timer.unref();
|
||||
});
|
||||
|
||||
function readRemoteAddress(response: HttpResponse): string {
|
||||
const port = response.getRemotePort();
|
||||
const text = new TextDecoder().decode(response.getRemoteAddressAsText());
|
||||
return text ? `${text}:${port}` : port > 0 ? `unknown:${port}` : "unknown";
|
||||
}
|
||||
|
||||
interface StaticAsset {
|
||||
body: Buffer;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
interface StaticSite {
|
||||
files: Map<string, StaticAsset>;
|
||||
index: StaticAsset;
|
||||
}
|
||||
|
||||
function loadStaticSite(root: string | undefined): StaticSite | null {
|
||||
if (!root || !existsSync(root)) return null;
|
||||
const files = new Map<string, StaticAsset>();
|
||||
for (const filePath of walkFiles(root)) {
|
||||
const url = `/${relative(root, filePath).split("\\").join("/")}`;
|
||||
files.set(url, {
|
||||
body: readFileSync(filePath),
|
||||
contentType: contentType(filePath),
|
||||
});
|
||||
}
|
||||
const index = files.get("/index.html");
|
||||
if (!index) throw new Error(`STATIC_ROOT has no index.html: ${root}`);
|
||||
console.log(`Serving ${files.size} static files from ${root}`);
|
||||
return { files, index };
|
||||
}
|
||||
|
||||
function walkFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(directory, entry.name);
|
||||
return entry.isDirectory() ? walkFiles(path) : [path];
|
||||
});
|
||||
}
|
||||
|
||||
function contentType(filePath: string): string {
|
||||
switch (extname(filePath)) {
|
||||
case ".html": return "text/html; charset=utf-8";
|
||||
case ".js": return "text/javascript; charset=utf-8";
|
||||
case ".css": return "text/css; charset=utf-8";
|
||||
case ".json": return "application/json; charset=utf-8";
|
||||
case ".svg": return "image/svg+xml";
|
||||
case ".png": return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg": return "image/jpeg";
|
||||
case ".webp": return "image/webp";
|
||||
case ".ico": return "image/x-icon";
|
||||
case ".woff2": return "font/woff2";
|
||||
default: return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
101
apps/server/src/server-profiler.ts
Normal file
101
apps/server/src/server-profiler.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
monitorEventLoopDelay,
|
||||
performance as nodePerformance,
|
||||
type EventLoopUtilization,
|
||||
} from "node:perf_hooks";
|
||||
|
||||
export interface ServerProcessProfile {
|
||||
uptimeSeconds: number;
|
||||
cpuPercent: number;
|
||||
eventLoopUtilization: number;
|
||||
eventLoopDelayMs: {
|
||||
mean: number;
|
||||
maximum: number;
|
||||
p95: number;
|
||||
p99: number;
|
||||
};
|
||||
memoryBytes: {
|
||||
residentSet: number;
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
external: number;
|
||||
arrayBuffers: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Samples Node process pressure independently of any particular game. */
|
||||
export class ServerProcessProfiler {
|
||||
private readonly delay = monitorEventLoopDelay({ resolution: 1 });
|
||||
private previousCpu = process.cpuUsage();
|
||||
private previousSampleAt = nodePerformance.now();
|
||||
private previousUtilization: EventLoopUtilization =
|
||||
nodePerformance.eventLoopUtilization();
|
||||
private recent: ServerProcessProfile;
|
||||
|
||||
constructor() {
|
||||
this.delay.enable();
|
||||
this.recent = this.capture();
|
||||
const timer = setInterval(() => {
|
||||
this.recent = this.capture();
|
||||
}, 1_000);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
snapshot(): ServerProcessProfile {
|
||||
return {
|
||||
...this.recent,
|
||||
uptimeSeconds: round(process.uptime(), 2),
|
||||
memoryBytes: readMemory(),
|
||||
};
|
||||
}
|
||||
|
||||
private capture(): ServerProcessProfile {
|
||||
const now = nodePerformance.now();
|
||||
const elapsedMs = Math.max(1, now - this.previousSampleAt);
|
||||
const cpu = process.cpuUsage();
|
||||
const cpuMicroseconds =
|
||||
cpu.user - this.previousCpu.user + cpu.system - this.previousCpu.system;
|
||||
const utilization = nodePerformance.eventLoopUtilization();
|
||||
const utilizationDelta = nodePerformance.eventLoopUtilization(
|
||||
utilization,
|
||||
this.previousUtilization,
|
||||
);
|
||||
const profile: ServerProcessProfile = {
|
||||
uptimeSeconds: round(process.uptime(), 2),
|
||||
cpuPercent: round((cpuMicroseconds / (elapsedMs * 1_000)) * 100, 2),
|
||||
eventLoopUtilization: round(utilizationDelta.utilization * 100, 2),
|
||||
eventLoopDelayMs: {
|
||||
mean: nanosecondsToMilliseconds(this.delay.mean),
|
||||
maximum: nanosecondsToMilliseconds(this.delay.max),
|
||||
p95: nanosecondsToMilliseconds(this.delay.percentile(95)),
|
||||
p99: nanosecondsToMilliseconds(this.delay.percentile(99)),
|
||||
},
|
||||
memoryBytes: readMemory(),
|
||||
};
|
||||
this.previousCpu = cpu;
|
||||
this.previousSampleAt = now;
|
||||
this.previousUtilization = utilization;
|
||||
this.delay.reset();
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
function readMemory(): ServerProcessProfile["memoryBytes"] {
|
||||
const memory = process.memoryUsage();
|
||||
return {
|
||||
residentSet: memory.rss,
|
||||
heapUsed: memory.heapUsed,
|
||||
heapTotal: memory.heapTotal,
|
||||
external: memory.external,
|
||||
arrayBuffers: memory.arrayBuffers,
|
||||
};
|
||||
}
|
||||
|
||||
function nanosecondsToMilliseconds(value: number): number {
|
||||
return round(Number.isFinite(value) ? value / 1_000_000 : 0, 3);
|
||||
}
|
||||
|
||||
function round(value: number, digits: number): number {
|
||||
const scale = 10 ** digits;
|
||||
return Math.round(value * scale) / scale;
|
||||
}
|
||||
Reference in New Issue
Block a user