This commit is contained in:
Andras Schmelczer 2026-08-23 10:27:53 +01:00
commit 82e092ba55
17 changed files with 373 additions and 539 deletions

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');
}
}