This commit is contained in:
Andras Schmelczer 2026-08-22 18:35:53 +01:00
commit 5017ff168a
124 changed files with 1004 additions and 2007 deletions

View file

@ -1,9 +0,0 @@
import { Command } from 'shared';
// Internal request from a game object (e.g. a keystone planet flipping) asking
// the GameServer to broadcast a one-off announcement to every connected client.
export class AnnounceCommand extends Command {
public constructor(public readonly text: string) {
super();
}
}

View file

@ -0,0 +1,31 @@
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,10 +0,0 @@
import { Command } from 'shared';
export class GeneratePointsCommand extends Command {
public constructor(
public readonly blue: number,
public readonly red: number,
) {
super();
}
}

View file

@ -1,7 +0,0 @@
import { Command, GameObject } from 'shared';
export class ReactToCollisionCommand extends Command {
public constructor(public readonly other: GameObject) {
super();
}
}

View file

@ -1,10 +0,0 @@
import { Command, CommandReceiver } from 'shared';
export class StepCommand extends Command {
public constructor(
public readonly deltaTimeInSeconds: number,
public readonly game: CommandReceiver,
) {
super();
}
}

View file

@ -44,9 +44,7 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
) {
const planet =
objects.length === 0
? // The first, central giant is the keystone "Heart": a named,
// always-contested focal objective the whole match orbits.
new PlanetPhysical(
? new PlanetPhysical(
PlanetBase.createPlanetVertices(
position,
Random.getRandomInRange(1600, 2400),
@ -72,11 +70,8 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
console.info(`Generated ${objects.length} planets`);
console.info(`Generated ${lights.length} light`);
// Associate each lamp with its NEAREST planet, so a planet can repaint "its"
// lamps to the owning team's colour when it flips. Lamps are already placed by
// proximity during world-gen, so the nearest planet is the one whose capture
// they should advertise. Distances use the planet SDF (negative inside), which
// is exactly the "closest planet" metric we want.
// Each lamp is associated with its nearest planet via the planet SDF
// (negative inside), so it advertises that planet's capture.
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
lights
.filter((l): l is LampPhysical => l instanceof LampPhysical)

View file

@ -1,12 +0,0 @@
import { Options } from './options';
const isDevelopment = process.env.NODE_ENV !== 'production';
export const defaultOptions: Options = {
port: 3000,
name: isDevelopment ? 'Dev server' : 'Doppler server',
playerLimit: 16,
npcCount: 8,
seed: Math.random(),
scoreLimit: 2500,
};

View file

@ -18,28 +18,18 @@ import {
beginPropertyUpdateGeneration,
} from 'shared';
import { createWorld } from './create-world';
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
import { Options } from './options';
import { PlayerContainer } from './players/player-container';
import { ServerFullError } from './players/server-full-error';
import { Player } from './players/player';
import { StepCommand } from './commands/step';
import { GeneratePointsCommand } from './commands/generate-points';
import { AnnounceCommand } from './commands/announce';
import { StepCommand, GeneratePointsCommand, AnnounceCommand } from './commands/commands';
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
interface JoinedSocket {
player: Player;
onPlayerToServer: (json: string) => void;
onDisconnect: () => void;
}
export class GameServer extends CommandReceiver {
private objects!: PhysicalContainer;
private players!: PlayerContainer;
private deltaTimes!: Array<number>;
private deltaTimeCalculator!: DeltaTimeCalculator;
private lastPhysicsBase: [number, number] = process.hrtime();
private bluePoints = 0;
private redPoints = 0;
@ -47,6 +37,7 @@ export class GameServer extends CommandReceiver {
private isInEndGame = false;
private timeScaling = 1;
private statReportMs = Date.now();
private initialize() {
const previousPlayers = this.players;
@ -59,8 +50,7 @@ export class GameServer extends CommandReceiver {
this.options.playerLimit,
this.options.npcCount,
);
this.deltaTimeCalculator = new DeltaTimeCalculator();
this.deltaTimes = [];
this.lastPhysicsBase = process.hrtime();
this.bluePoints = 0;
this.redPoints = 0;
this.matchPointAnnounced = {};
@ -95,7 +85,7 @@ export class GameServer extends CommandReceiver {
});
}
private readonly joinedSockets = new Map<Socket, JoinedSocket>();
private readonly joinedSockets = new Map<Socket, Player>();
private handleJoin(socket: Socket, playerInfo: PlayerInformation) {
if (this.joinedSockets.has(socket)) {
@ -104,9 +94,6 @@ export class GameServer extends CommandReceiver {
}
if (this.isInEndGame) {
// The round is already over and the world is about to be rebuilt; joining
// now drops the player into an arena running in slow motion with no
// end-of-round card.
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding);
return;
}
@ -133,16 +120,13 @@ export class GameServer extends CommandReceiver {
const onPlayerToServer = (json: string) => {
try {
if (typeof json !== 'string' || json.length > settings.maxInboundMessageBytes) {
if (json.length > settings.maxInboundMessageBytes) {
return;
}
if (!this.allowInboundMessage(socket)) {
return;
}
const commands: Array<Command> = deserialize(json);
if (!Array.isArray(commands)) {
return;
}
commands.forEach((c) => player.handleCommand(c));
} catch (e) {
console.error('Error while processing command', e);
@ -150,18 +134,14 @@ export class GameServer extends CommandReceiver {
};
const onDisconnect = () => {
const record = this.joinedSockets.get(socket);
const p = this.joinedSockets.get(socket);
this.joinedSockets.delete(socket);
// The bucket is keyed by the Socket object, so leaving the entry behind
// pins the socket for the rest of the round.
this.inboundBudget.delete(socket);
if (!record) {
if (!p) {
return;
}
record.player.detachFromSocket();
p.detachFromSocket();
const { kills, deaths } = player.scoreSnapshot;
// Hold the score briefly so a client whose transport blipped can rejoin as
// itself rather than as a blank slate.
this.players.reserveScore(
player.reconnectToken,
player.team,
@ -176,7 +156,7 @@ export class GameServer extends CommandReceiver {
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
socket.on('disconnect', onDisconnect);
this.joinedSockets.set(socket, { player, onPlayerToServer, onDisconnect });
this.joinedSockets.set(socket, player);
player.reconnectToken = this.players.issueToken();
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
@ -184,8 +164,6 @@ export class GameServer extends CommandReceiver {
this.sendServerStateUpdate();
}
// Cheap per-socket token bucket, sized well above what a legitimate client
// produces so it only ever trips on a flood.
private readonly inboundBudget = new Map<Socket, { tokens: number; lastMs: number }>();
private droppedInboundMessages = 0;
private allowInboundMessage(socket: Socket): boolean {
@ -210,13 +188,10 @@ export class GameServer extends CommandReceiver {
return true;
}
// Detach the per-join listeners of every socket still attached to the round
// that is ending, so a client that survives the restart can join the new one.
private releaseJoinedSockets() {
for (const [socket, record] of this.joinedSockets) {
socket.off(TransportEvents.PlayerToServer, record.onPlayerToServer);
socket.off('disconnect', record.onDisconnect);
record.player.detachFromSocket();
for (const [socket, player] of this.joinedSockets) {
socket.removeAllListeners();
player.detachFromSocket();
}
this.joinedSockets.clear();
this.inboundBudget.clear();
@ -226,7 +201,10 @@ export class GameServer extends CommandReceiver {
public sendServerStateUpdate() {
this.io
.to(gameStateSubscribedRoom)
.emit(TransportEvents.ServerInfoUpdate, [this.players.count, this.gameProgress]);
.emit(TransportEvents.ServerInfoUpdate, [
this.players.count,
(Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100,
]);
}
public start() {
@ -269,21 +247,32 @@ export class GameServer extends CommandReceiver {
const endTitleLength = 6;
this.players.endGame(winningTeam);
this.players.queueCommandForEachClient(new GameEndCommand());
// Rebuild the world for the next round once the end card has been shown.
setTimeout(() => this.initialize(), endTitleLength * 1000 * 1.1);
}
private timeSinceLastPointUpdate = 0;
private physicsAccumulator = 0;
// Frames since the last stats report where physics ran over budget (more
// substeps than the cap). Surfaced by handleStats as a saturation signal.
private saturatedFrames = 0;
private handlePhysics() {
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true });
this.deltaTimes.push(delta);
const now = process.hrtime(this.lastPhysicsBase);
const delta = now[0] + now[1] / 1e9;
this.lastPhysicsBase = process.hrtime();
this.handleStats();
if (Date.now() - this.statReportMs > 30000) {
this.statReportMs = Date.now();
const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
console.info(`Memory: ${mem}, Players: ${this.players.count}`);
if (this.droppedInboundMessages > 0) {
console.warn(`Rate limited ${this.droppedInboundMessages} inbound msg(s)`);
this.droppedInboundMessages = 0;
}
if (this.saturatedFrames > 0) {
console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`);
this.saturatedFrames = 0;
}
}
if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
this.timeSinceLastServerStateUpdate = 0;
@ -299,18 +288,11 @@ export class GameServer extends CommandReceiver {
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
const maxSubstepsPerFrame = 5;
// Cap on retained physics backlog when saturated, so a long stall can't
// accumulate an unrecoverable catch-up.
const maxBacklogSeconds = 0.25;
this.physicsAccumulator += delta;
let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
if (substeps > maxSubstepsPerFrame) {
// Saturated: run the cap's worth of substeps but KEEP the remaining
// backlog (clamped) instead of zeroing it. Dropping it silently slowed
// simulated time for everyone — and diverged client prediction, whose
// wall-clock keeps running. Clamping bounds the catch-up so a transient
// spike recovers without a death spiral.
this.saturatedFrames++;
this.physicsAccumulator = Math.min(
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
@ -335,7 +317,8 @@ export class GameServer extends CommandReceiver {
this.players.stepCommunication(delta);
this.objects.resetRemoteCalls();
const physicsDelta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds();
const elapsed = process.hrtime(now);
const physicsDelta = elapsed[0] + elapsed[1] / 1e9;
setTimeout(
this.handlePhysics.bind(this),
@ -343,62 +326,13 @@ export class GameServer extends CommandReceiver {
);
}
private handleStats() {
const framesBetweenDeltaTimeCalculation = 10000;
if (this.deltaTimes.length > framesBetweenDeltaTimeCalculation) {
this.deltaTimes.sort((a, b) => a - b);
console.info(
`Median physics time: ${(
this.deltaTimes[Math.floor(framesBetweenDeltaTimeCalculation / 2)] * 1000
).toFixed(2)} ms`,
);
console.info(
'Tail times: ',
this.deltaTimes.slice(-20).map((v) => `${(v * 1000).toFixed(2)} ms`),
);
console.info(
`Memory used: ${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`,
);
const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0);
if (rtts.length > 0) {
rtts.sort((a, b) => a - b);
console.info(
`Player 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 message(s) since last report — ` +
'client input was discarded',
);
this.droppedInboundMessages = 0;
}
if (this.saturatedFrames > 0) {
console.warn(
`Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`,
);
this.saturatedFrames = 0;
}
this.deltaTimes = [];
}
}
private get gameProgress(): number {
return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100;
}
public get serverInfo(): ServerInformation {
return {
serverName: this.options.name,
playerCount: this.players.count,
playerLimit: this.options.playerLimit,
gameStatePercent: this.gameProgress,
gameStatePercent:
(Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100,
};
}
}

View file

@ -1,13 +0,0 @@
export class DeltaTimeCalculator {
private previousTime: [number, number] = process.hrtime();
public getNextDeltaTimeInSeconds(
{ setAsBase = false }: { setAsBase: boolean } = { setAsBase: false },
): number {
const [seconds, nanoSeconds] = process.hrtime(this.previousTime);
if (setAsBase) {
this.previousTime = process.hrtime();
}
return seconds + nanoSeconds / 1e9;
}
}

View file

@ -6,7 +6,7 @@ import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from '
import minimist from 'minimist';
import { glMatrix } from 'gl-matrix';
import { GameServer } from './game-server';
import { defaultOptions } from './default-options';
import { defaultOptions } from './options';
import parser from 'socket.io-msgpack-parser';
glMatrix.setMatrixArrayType(Array);

View file

@ -4,7 +4,6 @@ import {
settings,
MoveActionCommand,
serializesTo,
last,
Circle,
CharacterBase,
CharacterTeam,
@ -38,12 +37,12 @@ import { ProjectilePhysical } from './projectile-physical';
import { forceAtPosition } from '../physics/functions/force-at-position';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { PlanetPhysical } from './planet-physical';
import { StepCommand } from '../commands/step';
import { ReactToCollisionCommand } from '../commands/react-to-collision';
import { GeneratePointsCommand } from '../commands/generate-points';
import {
StepCommand,
ReactToCollisionCommand,
GeneratePointsCommand,
} from '../commands/commands';
// The three body circles, as a value — used to diff a tick's movement into the
// rate of change the client interpolates on.
interface BodyPose {
head: Circle;
leftFoot: Circle;
@ -69,9 +68,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private killStreak = 0;
// CharacterMovementState, held directly rather than mirrored through an
// adapter: the shared simulation reads and writes these in place, so the
// character simply IS the state it is stepped as.
// Held in place: the shared simulation reads and writes these directly, so
// the character IS the state it is stepped as (client predictor agrees).
public direction = 0;
public currentPlanet: GroundSurface | undefined;
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
@ -127,11 +125,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
container.addObject(this.rightFoot);
}
// The container, presented as the collision/gravity world the shared movement
// queries.
private readonly movementWorld: CharacterWorld = {
// Same set and order forceAtPosition used: planets in the force field, in
// container-traversal order (so the f64 gravity sum is unchanged).
// Same set and order forceAtPosition uses, so the f64 gravity sum matches.
groundsNear: (center, radius) =>
this.container
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
@ -176,15 +171,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.movementActions.push(c);
}
// The world only ever hands back planets, so narrowing the shared
// GroundSurface is safe here.
// The world only ever hands back planets, so narrowing GroundSurface is safe.
public get groundPlanet(): PlanetPhysical | undefined {
return this.currentPlanet as PlanetPhysical | undefined;
}
// The continuous movement state streamed to the owning client, so its
// predictor resumes this simulation instead of guessing at the parts of it
// the pose does not show. See CharacterMovementSnapshot.
// Continuous movement state streamed to the owning client so its predictor
// resumes this simulation instead of guessing. See CharacterMovementSnapshot.
public get movementSnapshot(): CharacterMovementSnapshot {
return new CharacterMovementSnapshot(
this.direction,
@ -242,7 +235,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.remoteCall('setHealth', this.health);
if (this.health <= 0 && this.isAlive) {
// Throw the corpse along the killing shot, harder for charged hits.
vec2.scaleAndAdd(
this.bodyVelocity,
this.bodyVelocity,
@ -303,9 +295,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
c,
);
this.container.addObject(projectile);
// Lag compensation: advance the shot by the time the command spent reaching
// us, so the shooter does not have to lead by their own latency as well as
// by the projectile's travel time.
projectile.fastForward(catchUpSeconds);
if (c > 0) {
@ -374,7 +363,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
vec2.scale(direction, direction, 1 / this.movementActions.length);
this.lastMovementAction = last(this.movementActions)!;
const actions = this.movementActions;
this.lastMovementAction = actions[actions.length - 1]!;
this.movementActions = [];
}
@ -401,9 +391,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
]);
}
// Rate of change of one body part over the tick, which the client's
// interpolator coasts on when a snapshot is late. `previous` is consumed as
// scratch so this allocates nothing beyond the Circle it returns.
// `previous` is consumed as scratch so this allocates nothing beyond the
// Circle it returns.
private static rateOfChange(previous: Circle, current: Circle, deltaTime: number) {
return new Circle(
vec2.scale(
@ -472,15 +461,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.regenerateHealth(deltaTimeInSeconds);
// The planet tallies who is standing on it and resolves capture itself, so
// a contested rock can freeze instead of two squads silently cancelling.
this.groundPlanet?.registerPresence(this);
// The whole walking model — gravity gather, movement force, on/off-planet
// branch, posture springs, body-momentum, and stepping the three parts —
// is the shared simulation the client predicts with, so the two can never
// drift. Server-only concerns (scoring, health, shooting, spawn/death,
// ownership) stay here around it.
// stepCharacterMovement is the shared simulation the client predicts with,
// so the two can never drift.
const direction = this.averageAndResetMovementActions();
stepCharacterMovement(this, this.movementWorld, direction, deltaTimeInSeconds);
@ -502,10 +486,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
grounded = true;
}
}
// Brake with the exact shared model the living body uses: stiff on contact
// so the corpse skids to rest, gentle in the air, plus the constant stop and
// the speed cap — so a flung corpse comes to a definite stop instead of
// sliding forever.
// Brake with the shared model the living body uses, so a flung corpse
// comes to a definite stop instead of sliding forever.
decayMomentum(this.bodyVelocity, grounded, deltaTime);
}

View file

@ -14,7 +14,7 @@ import { PhysicalContainer } from '../physics/containers/physical-container';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { Physical } from '../physics/physicals/physical';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { ReactToCollisionCommand } from '../commands/react-to-collision';
import { ReactToCollisionCommand } from '../commands/commands';
@serializesTo(Circle)
export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical {
@ -35,8 +35,8 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
private _radius: number,
public owner: GameObject,
private readonly container: PhysicalContainer,
// Public + readonly so a CirclePhysical structurally satisfies the shared
// PhysicsBody interface the movement simulation operates on.
// Public + readonly so CirclePhysical satisfies the shared PhysicsBody
// interface the movement simulation operates on.
public readonly restitution = 0,
) {
super();
@ -89,14 +89,10 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
applyForce(this, force, timeInSeconds);
}
// Position-resolution for one tick. Delegates to the shared
// resolveCircleMovement so the server integrates a body with the exact same
// geometry the client predictor runs (shared/physics) — no parallel copy to
// keep in sync. The onHit callback dispatches the collision reactions at the
// same points the old inline move-circle did (both the initial march and the
// post-bounce slide). `possibleIntersectors`, when supplied, lets a caller
// that already broadphased (e.g. a projectile's gravity query) avoid a second
// container query; otherwise it is self-gathered from the swept bounding box.
// Delegates to the shared resolveCircleMovement so the server integrates with
// the exact same geometry the client predictor runs — no parallel copy to
// keep in sync. `possibleIntersectors` lets a caller that already broadphased
// avoid a second container query; otherwise it is self-gathered.
public stepManually(
deltaTimeInSeconds: number,
possibleIntersectors?: Array<Physical>,
@ -119,20 +115,17 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
},
);
// The body has moved; re-register it where it actually is.
this.syncBoundingBox();
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
}
// Query the container with the bounding box grown by this tick's travel, so a
// fast-moving body still sees what it is about to sweep into.
private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
const sweep = vec2.length(
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds),
);
// Grow the query, not the object: inflating and deflating this.radius ran
// the setter (and so re-registered the body's extent) twice mid-tick.
// Grow the query box, not this.radius: mutating the radius would re-register
// the body's extent mid-tick via the setter.
return this.container.findIntersecting(
getBoundingBoxOfCircle(new Circle(this.center, this.radius + sweep)),
);

View file

@ -17,9 +17,11 @@ import {
CommandExecutors,
CommandReceiver,
} from 'shared';
import { GeneratePointsCommand } from '../commands/generate-points';
import { AnnounceCommand } from '../commands/announce';
import { StepCommand } from '../commands/step';
import {
GeneratePointsCommand,
AnnounceCommand,
StepCommand,
} from '../commands/commands';
import { ImmutableBoundingBox } from '../physics/bounding-boxes/immutable-bounding-box';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
@ -31,16 +33,15 @@ import type { CharacterPhysical } from './character-physical';
export class PlanetPhysical extends PlanetBase implements StaticPhysical {
public readonly canCollide = true;
public readonly canMove = false;
// Marks this as standable ground for the shared movement simulation (a body
// landing on it latches it as currentPlanet). See shared GroundSurface.
// Marks this as standable ground for the shared movement simulation.
public readonly isGround = true;
public readonly sizePointMultiplier: number;
// Planets slowly spin. The angle is authoritative here and streamed to the
// client (see getPropertyUpdates), so the rendered outline and this collision
// polygon turn as one rigid body. cos/sin are memoised per angle because
// distance() is called many times per tick by the raymarcher and SDF sampling.
// Authoritative spin angle, streamed to the client so the rendered outline
// and this collision polygon turn as one. cos/sin are memoised: distance()
// is called many times per tick by the raymarcher and the angle rarely
// changes between those calls.
private rotation = 0;
private readonly rotationSpeed: number;
private cachedRotation = Number.NaN;
@ -55,7 +56,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
// Characters standing on the planet this tick. Filled by registerPresence as
// each grounded character steps, drained when the planet resolves capture in
// its own step(). Drives the head-count tug-of-war.
// its own step().
private presentCharacters: Array<CharacterPhysical> = [];
private isContested = false;
@ -81,17 +82,13 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
(0.05 + Random.getRandom() * 0.07) * (Random.getRandom() < 0.5 ? -1 : 1);
}
// A grounded character announces itself each tick so the planet can resolve
// contested capture from the net head-count.
public registerPresence(character: CharacterPhysical) {
this.presentCharacters.push(character);
}
public distance(target: vec2): number {
// The one shared planet outline — the same function the client predictor
// collides against, which is what lets prediction reconcile. Only the
// cos/sin memo lives here, because distance() is called many times per tick
// by the raymarcher and the angle rarely changes between those calls.
// The shared planet outline — the same function the client predictor
// collides against, which is what lets prediction reconcile.
this.syncRotationTrigonometry();
return planetDistance(
target,
@ -110,8 +107,8 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
}
}
// Signed angular velocity in rad/s, exposed so a character standing on the
// planet can ride its spin (see carryWithRotatingPlanet in shared).
// Signed angular velocity in rad/s, exposed so a character can ride the
// planet's spin (see carryWithRotatingPlanet in shared).
public get angularVelocity(): number {
return this.rotationSpeed;
}
@ -145,9 +142,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
this.presentCharacters = [];
}
// One capture step per tick driven by the net team head-count, so grouping up
// pays off and an equal standoff freezes the planet (contested) instead of
// both sides silently cancelling with no feedback.
private resolveCapture(deltaTime: number) {
let blue = 0;
let red = 0;
@ -168,14 +162,11 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
deltaTime * lead,
);
} else if (!occupied) {
// Empty planets drift back to neutral; the keystone drifts much slower so
// it lingers as a live flashpoint.
this.takeControl(
CharacterTeam.neutral,
this.isKeystone ? deltaTime / settings.keystoneLoseControlScale : deltaTime,
);
}
// occupied tie -> frozen tug-of-war: no ownership change, ring pulses.
const contested = occupied && net === 0;
if (contested !== this.isContested) {
@ -184,7 +175,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
}
}
// hysteresis
private get flipTeam(): CharacterTeam {
const control = this.ownership - 0.5;
const enter = settings.planetControlThreshold + settings.planetFlipHysteresis;
@ -292,11 +282,6 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
return planetGravity(this.center, this.radius, position);
}
// forceAtPosition's name for the same thing.
public getForce(position: vec2): vec2 {
return this.gravityAt(position);
}
public get gameObject(): this {
return this;
}

View file

@ -18,8 +18,7 @@ import { PhysicalContainer } from '../physics/containers/physical-container';
import { CharacterPhysical } from './character-physical';
import { forceAtPosition } from '../physics/functions/force-at-position';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { StepCommand } from '../commands/step';
import { ReactToCollisionCommand } from '../commands/react-to-collision';
import { StepCommand, ReactToCollisionCommand } from '../commands/commands';
@serializesTo(ProjectileBase)
export class ProjectilePhysical extends ProjectileBase implements DynamicPhysical {
@ -44,8 +43,8 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
private velocity: vec2,
public readonly originator: CharacterPhysical,
readonly container: PhysicalContainer,
// Normalised charge (0..1) of the shot that fired this, used by the victim
// to scale hit/kill feedback and the death fling.
// Normalised charge (0..1) of the shot that fired this; the victim scales
// hit/kill feedback and the death fling by it.
public readonly charge: number = 0,
) {
super(id(), center, radius, team, strength);
@ -89,11 +88,9 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
}
/**
* Lag compensation. Runs the projectile forward by the time its command spent
* travelling to the server, so a shot lands where the shooter aimed rather
* than forcing them to lead by their own latency on top of the projectile's
* travel time. Capped by settings.maxProjectileCatchUpSeconds so a bad or
* forged timestamp cannot spawn a shot arbitrarily far downrange.
* Lag compensation: runs the projectile forward by the time its command spent
* travelling to the server. Capped by settings.maxProjectileCatchUpSeconds so
* a forged timestamp cannot spawn a shot arbitrarily far downrange.
*/
public fastForward(seconds: number) {
const step = settings.targetPhysicsDeltaTimeInSeconds;
@ -106,9 +103,8 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
}
}
// The circle keeps its own box in sync as it moves, so this is a live view of
// it, not a snapshot — caching it saved nothing and mistyped a mutable box as
// immutable.
// Live view of the circle's box, not a snapshot — the circle keeps it in sync
// as it moves.
public get boundingBox(): BoundingBoxBase {
return this.object.boundingBox;
}
@ -155,9 +151,6 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
return;
}
// Curveball: the same planetary gravity that pulls on a free-falling
// character bends the shot, so slower (charged) shots arc and can be lobbed
// over a planet's horizon. Scale is tiny — near-surface gravity is huge.
// This single broadphase is reused for the step below: the gravity radius
// (maxGravityDistance) dwarfs one tick's travel, so the set is a superset of
// the swept-collision box and the step needn't query the container again.

View file

@ -6,3 +6,14 @@ export interface Options {
npcCount: number;
seed: number;
}
const isDevelopment = process.env.NODE_ENV !== 'production';
export const defaultOptions: Options = {
port: 3000,
name: isDevelopment ? 'Dev server' : 'Doppler server',
playerLimit: 16,
npcCount: 8,
seed: Math.random(),
scoreLimit: 2500,
};

View file

@ -1,4 +1,3 @@
// axis-aligned
export abstract class BoundingBoxBase {
constructor(
protected _xMin: number = 0,

View file

@ -7,6 +7,6 @@ export const forceAtPosition = (position: vec2, objects: Array<Physical>) =>
.filter((o) => o instanceof PlanetPhysical)
.reduce(
(sum: vec2, o: Physical) =>
vec2.add(sum, sum, (o as PlanetPhysical).getForce(position)),
vec2.add(sum, sum, (o as PlanetPhysical).gravityAt(position)),
vec2.create(),
);

View file

@ -1,7 +0,0 @@
import { Circle, evaluateSdf } from 'shared';
import { PhysicalBase } from '../physicals/physical-base';
export const isCircleIntersecting = (
circle: Circle,
intersectors: Array<PhysicalBase>,
): boolean => evaluateSdf(circle.center, intersectors) < circle.radius;

View file

@ -7,7 +7,6 @@ import {
MoveActionCommand,
CharacterTeam,
Id,
rotate90Deg,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { PlayerContainer } from './player-container';
@ -62,8 +61,6 @@ const npcTuning = {
spreadPerDistance: 0.08,
spreadAggressionFalloff: 1.3,
// Per-second chance to hop while grounded and on the move, so bots use the
// leap verb too instead of being grounded targets.
leapChancePerSecond: 0.35,
};
@ -259,7 +256,7 @@ export class NPC extends PlayerBase {
return undefined;
}
const perpendicular = rotate90Deg(threat.direction);
const perpendicular = vec2.fromValues(-threat.direction[1], threat.direction[0]);
const toMe = vec2.subtract(vec2.create(), this.center, threat.center);
if (vec2.dot(perpendicular, toMe) < 0) {
vec2.negate(perpendicular, perpendicular);

View file

@ -11,7 +11,7 @@ import {
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { isCircleIntersecting } from '../physics/functions/is-circle-intersecting';
import { evaluateSdf } from 'shared';
import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical';
import { PlayerContainer } from './player-container';
@ -37,11 +37,9 @@ export abstract class PlayerBase extends CommandReceiver {
protected createCharacter() {
this.character = new CharacterPhysical(
// Coerced, not just truncated. `.slice()` also exists on arrays, so a
// name that is an array passed straight through, was serialized into
// CreateObjects, and was revived as a class on every peer that received
// it — throwing inside the deserializer's reviver and killing that
// client's whole message batch.
// Coerce, don't just truncate: input is untrusted (from deserialize), and
// arrays have `.slice()` too — a non-string name would throw inside the
// reviver and kill the recipient's whole message batch.
sanitizeName(this.playerInfo.name, maximumNameLength),
this.sumKills,
this.sumDeaths,
@ -56,12 +54,8 @@ export abstract class PlayerBase extends CommandReceiver {
public abstract step(deltaTimeInSeconds: number): void;
/**
* The death/respawn lifecycle both a connected player and an NPC go through:
* bank the score off a body that just died, hold the corpse for the respawn
* timeout, then build a new one. Returns whether there is a living character
* to act with this tick — returned as the character itself, so a subclass can
* `const character = this.stepLifecycle(dt); if (!character) return;` and go
* on to use it without re-narrowing.
* Shared death/respawn cycle. Returns the living character to act with this
* tick (or undefined), so a subclass can use it directly without re-narrowing.
*/
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
if (this.character) {
@ -87,8 +81,7 @@ export abstract class PlayerBase extends CommandReceiver {
return undefined;
}
// Hooks for what a subclass wants to do with the body it just lost, and just
// before a new one is built. Both are no-ops by default.
// Override hooks (no-ops by default).
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected onCharacterDied(character: CharacterPhysical) {}
protected onBeforeRespawn() {}
@ -120,8 +113,6 @@ export abstract class PlayerBase extends CommandReceiver {
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
}
// Seed a reconnecting player with the score it held before the drop, so a
// network blip costs you your character but not your match.
public restoreScore(kills: number, deaths: number) {
this.sumKills = kills;
this.sumDeaths = deaths;
@ -141,7 +132,10 @@ export abstract class PlayerBase extends CommandReceiver {
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
const possibleIntersectors =
this.objectContainer.findIntersecting(playerBoundingBox);
if (!isCircleIntersecting(playerBoundingCircle, possibleIntersectors)) {
if (
evaluateSdf(playerBoundingCircle.center, possibleIntersectors) >=
playerBoundingCircle.radius
) {
return playerPosition;
}

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