Improve netcode and simplify #4

Merged
andras merged 15 commits from fix/audit-findings into main 2026-09-15 07:57:58 +01:00
51 changed files with 373 additions and 539 deletions
Showing only changes of commit 82e092ba55 - Show all commits
Andras Schmelczer 2026-08-23 10:27:53 +01:00

View file

@ -1,31 +0,0 @@
import { Command, CommandReceiver, GameObject } from 'shared';
export class StepCommand extends Command {
constructor(
public readonly deltaTimeInSeconds: number,
public readonly game: CommandReceiver,
) {
super();
}
}
export class GeneratePointsCommand extends Command {
constructor(
public readonly blue: number,
public readonly red: number,
) {
super();
}
}
export class AnnounceCommand extends Command {
constructor(public readonly text: string) {
super();
}
}
export class ReactToCollisionCommand extends Command {
constructor(public readonly other: GameObject) {
super();
}
}

View file

@ -1,6 +1,7 @@
import { PhysicalContainer } from './physics/containers/physical-container';
import { Server, Socket } from 'socket.io';
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import {
TransportEvents,
deserialize,
@ -27,6 +28,23 @@ import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
// The payload is whatever the client sent: `socket.emit('PlayerJoining')` with
// no argument arrives as undefined. socket.io dispatches listeners inside a
// `process.nextTick` with no try/catch of its own, so a dereference of an
// unchecked payload here would surface as an uncaught exception and take the
// whole match down with the process.
const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => {
if (!payload || typeof payload !== 'object') {
return undefined;
}
const { name, reconnectToken } = payload as Record<string, unknown>;
return {
// Coerced and length-capped downstream, in PlayerBase.createCharacter.
name: name as string,
reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined,
};
};
export class GameServer extends CommandReceiver {
private objects!: PhysicalContainer;
private players!: PlayerContainer;
@ -75,12 +93,27 @@ export class GameServer extends CommandReceiver {
this.initialize();
// Both listeners are metered: they exist before any join, so the limiter
// inside onPlayerToServer cannot see them, and each one costs a reply or an
// allocation an unauthenticated client could otherwise ask for at line rate.
io.on('connection', (socket: Socket) => {
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) =>
this.handleJoin(socket, playerInfo),
);
socket.on(TransportEvents.PlayerJoining, (payload: unknown) => {
if (!this.allowInboundMessage(socket)) {
return;
}
try {
this.handleJoin(socket, payload);
} catch (e) {
console.error('Failed to handle a join request', e);
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest);
socket.disconnect();
}
});
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
if (!this.allowInboundMessage(socket)) {
return;
}
socket.join(gameStateSubscribedRoom);
});
});
@ -91,7 +124,13 @@ export class GameServer extends CommandReceiver {
{ player: Player; release: () => void }
>();
private handleJoin(socket: Socket, playerInfo: PlayerInformation) {
private handleJoin(socket: Socket, payload: unknown) {
const playerInfo = asPlayerInformation(payload);
if (!playerInfo) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest);
return;
}
if (this.joinedSockets.has(socket)) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined);
return;
@ -164,17 +203,22 @@ export class GameServer extends CommandReceiver {
this.sendServerStateUpdate();
}
private readonly inboundBudget = new Map<Socket, { tokens: number; lastMs: number }>();
// A WeakMap, not a Map: sockets that never join are metered too, and they
// have no join-scoped teardown to remove their entry.
private inboundBudget = new WeakMap<Socket, { tokens: number; lastMs: number }>();
private droppedInboundMessages = 0;
private allowInboundMessage(socket: Socket): boolean {
const nowMs = Date.now();
// performance.now(), never Date.now(): a backwards wall-clock step (an NTP
// correction, a VM resync) would make the refill negative and lock the
// client out of sending input for as long as the step was.
const nowMs = performance.now();
const burst = settings.maxInboundMessageBurst;
const perSecond = settings.maxInboundMessagesPerSecond;
const budget = this.inboundBudget.get(socket) ?? { tokens: burst, lastMs: nowMs };
budget.tokens = Math.min(
burst,
budget.tokens + ((nowMs - budget.lastMs) / 1000) * perSecond,
budget.tokens + (Math.max(0, nowMs - budget.lastMs) / 1000) * perSecond,
);
budget.lastMs = nowMs;
this.inboundBudget.set(socket, budget);
@ -219,7 +263,7 @@ export class GameServer extends CommandReceiver {
private releaseJoinedSockets() {
this.joinedSockets.forEach(({ release }) => release());
this.joinedSockets.clear();
this.inboundBudget.clear();
this.inboundBudget = new WeakMap();
}
private timeSinceLastServerStateUpdate = 0;
@ -289,6 +333,15 @@ export class GameServer extends CommandReceiver {
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
console.info(`Memory: ${mem}, Players: ${this.players.count}`);
const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0);
if (rtts.length > 0) {
rtts.sort((a, b) => a - b);
console.info(
`RTT median ${rtts[Math.floor(rtts.length / 2)].toFixed(0)} ms ` +
`(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`,
);
}
if (this.droppedInboundMessages > 0) {
console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`);
this.droppedInboundMessages = 0;

View file

@ -0,0 +1,54 @@
import { vec2 } from 'gl-matrix';
import { BoundingBoxBase } from './bounding-box-base';
export class BoundingBox extends BoundingBoxBase {
public get xMin(): number {
return this._xMin;
}
public set xMin(value: number) {
this._xMin = value;
}
public set xMax(value: number) {
this._xMax = value;
}
public get xMax(): number {
return this._xMax;
}
public set yMin(value: number) {
this._yMin = value;
}
public get yMin(): number {
return this._yMin;
}
public set yMax(value: number) {
this._yMax = value;
}
public get yMax(): number {
return this._yMax;
}
public get topLeft(): vec2 {
return vec2.fromValues(this._xMin, this._yMax);
}
public set topLeft(value: vec2) {
this._xMin = value.x;
this._yMax = value.y;
}
public set size(value: vec2) {
this._xMax = this.xMin + value.x;
this._yMin = this.yMax - value.y;
}
public get size(): vec2 {
return vec2.fromValues(this._xMax - this._xMin, this._yMax - this._yMin);
}
}

View file

@ -1,51 +0,0 @@
export abstract class BoundingBoxBase {
constructor(
protected _xMin: number = 0,
protected _xMax: number = 0,
protected _yMin: number = 0,
protected _yMax: number = 0,
) {}
[key: number]: number | undefined;
public get 0(): number {
return this._xMin;
}
public get 1(): number {
return this._xMax;
}
public get 2(): number {
return this._yMin;
}
public get 3(): number {
return this._yMax;
}
public get xMin(): number {
return this._xMin;
}
public get xMax(): number {
return this._xMax;
}
public get yMin(): number {
return this._yMin;
}
public get yMax(): number {
return this._yMax;
}
public intersects(other: BoundingBoxBase): boolean {
return (
this._xMin < other._xMax &&
this._xMax > other._xMin &&
this._yMin < other._yMax &&
this._yMax > other._yMin
);
}
}

View file

@ -1,3 +0,0 @@
import { BoundingBoxBase } from './bounding-box-base';
export class ImmutableBoundingBox extends BoundingBoxBase {}

View file

@ -1,25 +0,0 @@
import { DynamicPhysical } from '../physicals/dynamic-physical';
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
export class BoundingBoxList {
constructor(private objects: Array<DynamicPhysical> = []) {}
public insert(object: DynamicPhysical) {
this.objects.push(object);
}
public remove(object: DynamicPhysical) {
const index = this.objects.indexOf(object);
if (index >= 0) {
this.objects.splice(index, 1);
}
}
public forEach(func: (object: DynamicPhysical) => unknown) {
this.objects.forEach(func);
}
public findIntersecting(boundingBox: BoundingBoxBase): Array<DynamicPhysical> {
return this.objects.filter((b) => b.boundingBox.intersects(boundingBox));
}
}

View file

@ -1,126 +0,0 @@
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
import { StaticPhysical } from '../physicals/static-physical';
// source: https://github.com/ubilabs/kd-tree-javascript/blob/master/kdTree.js
class Node {
public left: Node | null = null;
public right: Node | null = null;
constructor(
public object: StaticPhysical,
public parent: Node | null,
) {}
}
export class BoundingBoxTree {
root?: Node | null;
constructor(objects: Array<StaticPhysical> = []) {
this.build(objects);
}
public build(objects: Array<StaticPhysical>) {
this.root = this.buildRecursive(objects, 0, null);
}
private buildRecursive(
objects: Array<StaticPhysical>,
depth: number,
parent: Node | null,
): Node | null {
if (objects.length === 0) {
return null;
}
if (objects.length === 1) {
return new Node(objects[0], parent);
}
const dimension = depth % 4;
objects.sort((a, b) => a.boundingBox[dimension]! - b.boundingBox[dimension]!);
const median = Math.floor(objects.length / 2);
const node = new Node(objects[median], parent);
node.left = this.buildRecursive(objects.slice(0, median), depth + 1, node);
node.right = this.buildRecursive(objects.slice(median + 1), depth + 1, node);
return node;
}
public insert(object: StaticPhysical) {
const [insertPosition, depth] = this.findParent(object, this.root, 0, null);
if (insertPosition === null) {
this.root = new Node(object, null);
} else {
const node = new Node(object, insertPosition);
const dimension = depth % 4;
if (
object.boundingBox[dimension]! < insertPosition.object.boundingBox[dimension]!
) {
insertPosition.left = node;
} else {
insertPosition.right = node;
}
}
}
public findIntersecting(boundingBox: BoundingBoxBase): Array<StaticPhysical> {
const maybeResults = this.findMaybeIntersecting(boundingBox, this.root!, 0);
const results = maybeResults.filter((b) => b.boundingBox.intersects(boundingBox));
return results;
}
private findMaybeIntersecting(
boundingBox: BoundingBoxBase,
node: Node | null,
depth: number,
): Array<StaticPhysical> {
if (node === null) {
return [];
}
if (depth % 4 == 0 && boundingBox.xMax < node.object.boundingBox.xMin) {
return this.findMaybeIntersecting(boundingBox, node.left, depth + 1);
}
if (depth % 4 == 1 && boundingBox.xMin > node.object.boundingBox.xMax) {
return this.findMaybeIntersecting(boundingBox, node.right, depth + 1);
}
if (depth % 4 == 2 && boundingBox.yMax < node.object.boundingBox.yMin) {
return this.findMaybeIntersecting(boundingBox, node.left, depth + 1);
}
if (depth % 4 == 3 && boundingBox.yMin > node.object.boundingBox.yMax) {
return this.findMaybeIntersecting(boundingBox, node.right, depth + 1);
}
return [
node.object,
...this.findMaybeIntersecting(boundingBox, node.left, depth + 1),
...this.findMaybeIntersecting(boundingBox, node.right, depth + 1),
];
}
private findParent(
object: StaticPhysical,
node: Node | null | undefined,
depth: number,
parent: Node | null,
): [Node | null, number] {
if (!node) {
return [parent, depth - 1];
}
const dimension = depth % 4;
if (object.boundingBox[dimension]! < node.object.boundingBox[dimension]!) {
return this.findParent(object, node.left, depth + 1, node);
}
return this.findParent(object, node.right, depth + 1, node);
}
}

View file

@ -1,12 +0,0 @@
import { vec2 } from 'gl-matrix';
import { PlanetPhysical } from '../../objects/planet-physical';
import { Physical } from '../physicals/physical';
export const forceAtPosition = (position: vec2, objects: Array<Physical>) =>
objects
.filter((o) => o instanceof PlanetPhysical)
.reduce(
(sum: vec2, o: Physical) =>
vec2.add(sum, sum, (o as PlanetPhysical).gravityAt(position)),
vec2.create(),
);

View file

@ -1,10 +0,0 @@
import { Circle } from 'shared';
import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box';
export const getBoundingBoxOfCircle = (circle: Circle): ImmutableBoundingBox =>
new ImmutableBoundingBox(
circle.center.x - circle.radius,
circle.center.x + circle.radius,
circle.center.y - circle.radius,
circle.center.y + circle.radius,
);

View file

@ -0,0 +1,54 @@
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
import { BoundingBoxList } from './bounding-box-list';
import { BoundingBoxTree } from './bounding-box-tree';
import { Physical } from '../physicals/physical';
import { StaticPhysical } from '../physicals/static-physical';
import { DynamicPhysical } from '../physicals/dynamic-physical';
import { Command, CommandReceiver } from 'shared';
export class PhysicalContainer extends CommandReceiver {
private isTreeInitialized = false;
private staticBoundingBoxesWaitList: Array<StaticPhysical> = [];
private staticBoundingBoxes = new BoundingBoxTree();
private dynamicBoundingBoxes = new BoundingBoxList();
private objects: Array<Physical> = [];
protected defaultCommandExecutor(c: Command) {
this.objects.forEach((o) => o.handleCommand(c));
}
public initialize() {
this.staticBoundingBoxes.build(this.staticBoundingBoxesWaitList);
this.isTreeInitialized = true;
}
public addObject(physical: Physical) {
this.objects.push(physical);
if (physical.canMove) {
this.dynamicBoundingBoxes.insert(physical);
} else {
if (!this.isTreeInitialized) {
this.staticBoundingBoxesWaitList.push(physical);
} else {
this.staticBoundingBoxes.insert(physical);
}
}
}
public removeObject(object: DynamicPhysical) {
this.objects = this.objects.filter((p) => p !== object);
this.dynamicBoundingBoxes.remove(object);
}
public resetRemoteCalls() {
this.objects.forEach((o) => o.gameObject.resetRemoteCalls());
}
public findIntersecting(box: BoundingBoxBase): Array<Physical> {
return [
...this.staticBoundingBoxes.findIntersecting(box),
...this.dynamicBoundingBoxes.findIntersecting(box),
];
}
}

View file

@ -0,0 +1,4 @@
import { DynamicPhysical } from './dynamic-physical';
import { StaticPhysical } from './static-physical';
export type Physical = StaticPhysical | DynamicPhysical;

View file

@ -1,5 +0,0 @@
import { PhysicalBase } from './physical-base';
export interface DynamicPhysical extends PhysicalBase {
readonly canMove: true;
}

View file

@ -1,12 +0,0 @@
import { vec2 } from 'gl-matrix';
import { CommandReceiver, GameObject } from 'shared';
import { BoundingBoxBase } from '../bounding-boxes/bounding-box-base';
export interface PhysicalBase extends CommandReceiver {
readonly canCollide: boolean;
readonly canMove: boolean;
readonly boundingBox: BoundingBoxBase;
readonly gameObject: GameObject;
distance(target: vec2): number;
}

View file

@ -1,7 +0,0 @@
import { PhysicalBase } from './physical-base';
import { ImmutableBoundingBox } from '../bounding-boxes/immutable-bounding-box';
export interface StaticPhysical extends PhysicalBase {
readonly canMove: false;
readonly boundingBox: ImmutableBoundingBox;
}

View file

@ -121,6 +121,12 @@ export abstract class PlayerBase extends CommandReceiver {
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
let rotation = 0;
let radius = 0;
// preferredCenter is a planet centre, the single worst place to give up on:
// depenetrateCircle only runs four passes, and a body left that deep inside
// the rock registers a zero-distance hit every march and never moves again.
// So the roomiest point the spiral saw is kept as the fallback instead.
let roomiestPosition = vec2.clone(preferredCenter);
let roomiestClearance = -Infinity;
for (let attempt = 0; attempt < 512; attempt++) {
const playerPosition = vec2.fromValues(
radius * Math.cos(rotation) + preferredCenter.x,
@ -132,18 +138,20 @@ export abstract class PlayerBase extends CommandReceiver {
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
const possibleIntersectors =
this.objectContainer.findIntersecting(playerBoundingBox);
if (
evaluateSdf(playerBoundingCircle.center, possibleIntersectors) >=
playerBoundingCircle.radius
) {
const clearance = evaluateSdf(playerBoundingCircle.center, possibleIntersectors);
if (clearance >= playerBoundingCircle.radius) {
return playerPosition;
}
if (clearance > roomiestClearance) {
roomiestClearance = clearance;
roomiestPosition = playerPosition;
}
rotation += Math.PI / 8;
radius += 30;
}
return vec2.clone(preferredCenter);
return roomiestPosition;
}
public destroy() {

View file

@ -56,12 +56,12 @@ export class Player extends PlayerBase {
public reconnectToken = '';
// Drives projectile lag compensation as well as the server-side latency stats.
// Server-side latency stat, reported in GameServer's periodic log.
public rttMs = 0;
private timeSinceLastPing = 0;
private lastPingSentMs = 0;
// Nonce of the ping awaiting a reply: prevents a client forging Pongs or
// answering a stale ping to corrupt the RTT that feeds lag compensation.
// answering a stale ping to corrupt the measured RTT.
private pendingPingNonce = 0;
private nextPingNonce = 1;
@ -136,9 +136,10 @@ export class Player extends PlayerBase {
this.socket.on(TransportEvents.Pong, this.onPong);
}
// Only the outstanding ping's nonce counts, once. A client can sit on the
// live ping to inflate measured RTT, so the result is clamped; the next ping
// retires the nonce, bounding any stall at one ping interval.
// Only the outstanding ping's nonce counts, once: a client cannot forge a
// Pong or answer a stale ping to skew the stat. Sitting on the live ping to
// inflate the measurement is bounded by the clamp, and the next ping retires
// the nonce, so any stall costs at most one ping interval.
private readonly onPong = (nonce: unknown) => {
if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) {
return;

View file

@ -1,5 +0,0 @@
export class ServerFullError extends Error {
constructor() {
super('Too many players');
}
}

View file

@ -4,9 +4,9 @@ import { pointer } from './helper/pointer';
let element: HTMLElement | undefined;
let heldSince = 0;
let raf = 0;
let _followPointer = false;
let isFollowingPointer = false;
export function beginChargeIndicator(x: number, y: number, followPointer = false) {
function beginChargeIndicator(x: number, y: number, followPointer = false) {
endChargeIndicator();
const el = document.createElement('div');
@ -17,11 +17,11 @@ export function beginChargeIndicator(x: number, y: number, followPointer = false
element = el;
heldSince = performance.now();
_followPointer = followPointer;
isFollowingPointer = followPointer;
raf = requestAnimationFrame(updateChargeIndicator);
}
export function endChargeIndicator() {
function endChargeIndicator() {
if (element) {
cancelAnimationFrame(raf);
element.parentElement?.removeChild(element);
@ -41,7 +41,7 @@ function updateChargeIndicator() {
}deg, rgba(255, 255, 255, 0.15) 0deg)`;
element.classList.toggle('full', charge >= 1);
if (_followPointer) {
if (isFollowingPointer) {
const cursor = pointer.displayPosition;
if (cursor) {
element.style.left = `${cursor.x}px`;
@ -52,6 +52,7 @@ function updateChargeIndicator() {
raf = requestAnimationFrame(updateChargeIndicator);
}
// The one public surface; the functions above stay module-private.
export const ChargeIndicator = {
begin: beginChargeIndicator,
end: endChargeIndicator,

View file

@ -30,6 +30,17 @@ export class CommandSocket extends CommandReceiver {
}
public sendQueuedCommands() {
// Nothing goes out while the transport is down. socket.io-client would
// otherwise buffer every emit in an uncapped sendBuffer and flush it on
// reconnect BEFORE the `connect` handler re-sends PlayerJoining — the
// server has no PlayerToServer listener attached yet and drops the lot.
// The local queue is dropped with it: these are timestamped inputs, and
// replaying a minute-old shot after the gap would be worse than losing it.
if (!this.socket.connected) {
this.commandQueue = [];
return;
}
const nowMs = predictorNowMs();
// Real input goes out on the frame it happens — that is the latency budget the player feels. An idle client still keeps the server's input acknowledgement moving (a held key generates no fresh command), paced by the clock not the frame rate; sending every rendered frame would exceed settings.maxInboundMessagesPerSecond, and since movement commands are edge-triggered and never re-sent, a discarded batch would drop the direction change for good.

View file

@ -1,7 +0,0 @@
import { Command } from 'shared';
export class BeforeDestroyCommand extends Command {
public constructor() {
super();
}
}

Some files were not shown because too many files have changed in this diff Show more