Improve netcode and simplify (#4)
All checks were successful
Build & deploy / Build & deploy website (push) Successful in 32s
Build & deploy / Build & publish server image (push) Successful in 1m2s

- Simplify the logic wherever possible to remove overengineering and simplify the netcode
- Actually improve the netcode
- The new lines are mainly the package locks and tests

Reviewed-on: https://home.schmelczer.dev/git/andras/decla-red/pulls/4
This commit is contained in:
Andras Schmelczer 2026-09-15 07:57:57 +01:00
commit 93a7fe01ab
176 changed files with 15073 additions and 4754 deletions

View file

@ -1,4 +1,3 @@
# The server image only needs `shared/` and `backend/`.
**/node_modules **/node_modules
**/dist **/dist
**/lib **/lib

View file

@ -13,7 +13,6 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# ---- Static website -> /pages mount on the runner host --------------------
website: website:
name: Build & deploy website name: Build & deploy website
runs-on: docker runs-on: docker
@ -26,8 +25,11 @@ jobs:
node-version-file: .nvmrc node-version-file: .nvmrc
- name: Install dependencies - name: Install dependencies
# No lockfiles are committed (see .gitignore), so use `npm install`. run: |
run: npm install && npm run init npm ci
npm --prefix shared ci
npm --prefix frontend ci
npm --prefix backend ci
- name: Build (shared -> frontend -> backend) - name: Build (shared -> frontend -> backend)
run: npm run build run: npm run build
@ -45,11 +47,9 @@ jobs:
source: frontend/dist source: frontend/dist
target: declared target: declared
# ---- Server Docker image -> Forgejo container registry --------------------
server-image: server-image:
name: Build & publish server image name: Build & publish server image
runs-on: docker runs-on: docker
# No registry push on PRs; build validation still happens in the website job.
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
steps: steps:
- name: Checkout - name: Checkout

1
.gitignore vendored
View file

@ -4,5 +4,4 @@ node_modules
.firebase .firebase
yarm-error.log yarm-error.log
yarn.lock yarn.lock
package-lock.json
.DS_Store .DS_Store

3
.vscode/launch.json vendored
View file

@ -1,7 +1,4 @@
{ {
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {

View file

@ -1,38 +1,29 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# doppler game server (the `doppler-server` package). # The full image carries the build tools for socket.io's optional native dependencies.
# The frontend is a static site and is NOT built here — see .forgejo/workflows.
# ---- Stage 1: build the shared lib, then bundle the server -------------------
# Node 22 (current LTS) matches the project toolchain (webpack 5 / TypeScript 6,
# see .devcontainer). The full (non-slim) image carries the build tools that
# socket.io's optional native deps (bufferutil/utf-8-validate) compile against.
FROM node:22-bookworm AS build FROM node:22-bookworm AS build
WORKDIR /app WORKDIR /app
# `shared` is consumed by the backend as `file:../shared` and is bundled into # `shared` is consumed by the backend as `file:../shared` and is bundled into
# the server bundle by webpack, so it must be installed and built first. # the server bundle by webpack, so it must be installed and built first.
COPY shared/package.json shared/ COPY shared/package.json shared/package-lock.json shared/
RUN cd shared && npm install RUN npm --prefix shared ci
COPY shared/ shared/ COPY shared/ shared/
RUN cd shared && npm run build RUN npm --prefix shared run build
# Install the backend deps. `file:../shared` now resolves to the built /app/shared. COPY backend/package.json backend/package-lock.json backend/
COPY backend/package.json backend/ RUN npm --prefix backend ci
RUN cd backend && npm install
COPY backend/ backend/ COPY backend/ backend/
RUN cd backend && npm run build RUN npm --prefix backend run build
# Drop devDependencies; the runtime only needs the production deps that webpack # Drop devDependencies; the runtime only needs the production deps that webpack
# left external (express, socket.io, cors, gl-matrix, minimist, msgpack parser). # left external (express, socket.io, cors, gl-matrix, minimist, msgpack parser).
RUN cd backend && npm prune --production RUN npm --prefix backend prune --production
# ---- Stage 2: minimal runtime ----------------------------------------------
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# Run as an unprivileged user.
RUN groupadd -r app && useradd -r -g app -d /app app RUN groupadd -r app && useradd -r -g app -d /app app
COPY --from=build /app/backend/dist ./dist COPY --from=build /app/backend/dist ./dist
@ -41,7 +32,6 @@ COPY --from=build /app/backend/node_modules ./node_modules
USER app USER app
EXPOSE 3000 EXPOSE 3000
# Hits the same /state endpoint the website polls (see serverInformationEndpoint).
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" CMD node -e "require('http').get('http://localhost:3000/state',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"

View file

@ -9,19 +9,11 @@ A 2-dimensional multiplayer game utilising ray tracing.
For optimised 2D ray tracing, [SDF-2D](https://github.com/schmelczerandras/sdf-2d) is used. For optimised 2D ray tracing, [SDF-2D](https://github.com/schmelczerandras/sdf-2d) is used.
## Deployment ## Development
CI/CD runs on Forgejo Actions (`.forgejo/workflows/deploy.yml`). On a push to Run `npm install && npm run init` once, then `npm run dev`. Run `npm test` for
`main` it: regressions, `npm run lint:check` for formatting and lint, and `npm run build` for
all production bundles. Tests rebuild the shared library before running.
- builds the static frontend and rsyncs `frontend/dist/` to the `/pages/declared`
mount on the runner host (the mount keeps its pre-rebrand name), and
- builds the server image from the root `Dockerfile` and pushes it to the Forgejo
container registry as `<registry>/<owner>/<repo>-server`.
The registry job needs a `FORGEJO_PACKAGE_TOKEN` secret (with package write
scope) and, optionally, a `CONTAINER_REGISTRY_HOST` variable to override the
registry host.
The website's server list is hardcoded in The website's server list is hardcoded in
[`frontend/src/scripts/configuration.ts`](frontend/src/scripts/configuration.ts) — [`frontend/src/scripts/configuration.ts`](frontend/src/scripts/configuration.ts) —

3635
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@
}, },
"scripts": { "scripts": {
"build": "npx webpack --mode production", "build": "npx webpack --mode production",
"dev": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch dist/main.js\"", "dev": "concurrently --kill-others-on-fail \"webpack --mode development -w\" \"nodemon --legacy-watch --delay 500ms dist/main.js\"",
"try-build": "npm run build && cd dist && node main.js && cd -" "try-build": "npm run build && cd dist && node main.js && cd -"
}, },
"dependencies": { "dependencies": {

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

@ -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

@ -2,16 +2,14 @@ import { vec2 } from 'gl-matrix';
import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared'; import { Random, PlanetBase, hsl, settings, evaluateSdf } from 'shared';
import { LampPhysical } from './objects/lamp-physical'; import { LampPhysical } from './objects/lamp-physical';
import { PlanetPhysical } from './objects/planet-physical'; import { PlanetPhysical } from './objects/planet-physical';
import { PhysicalContainer } from './physics/containers/physical-container'; import { PhysicalContainer } from './physics/physical-container';
import { Physical } from './physics/physicals/physical';
export const createWorld = (objectContainer: PhysicalContainer) => { export const createWorld = (objectContainer: PhysicalContainer) => {
const objects: Array<Physical> = []; const planets: Array<PlanetPhysical> = [];
const lights: Array<Physical> = []; const lamps: Array<LampPhysical> = [];
for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) { for (let r = 0; r < settings.worldRadius; r += settings.radiusSteps) {
const circumference = 2 * Math.PI * r; const stepCount = 2 * Math.PI * r * settings.objectsOnCircleLength;
const stepCount = circumference * settings.objectsOnCircleLength;
for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) { for (let rad = 0; rad < 2 * Math.PI; rad += (2 * Math.PI) / stepCount) {
const position = vec2.rotate( const position = vec2.rotate(
vec2.create(), vec2.create(),
@ -20,12 +18,12 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
rad, rad,
); );
if (objects.length !== 0 && Random.getRandom() > 0.5) { if (planets.length !== 0 && Random.getRandom() > 0.5) {
if ( if (
evaluateSdf(position, objects) > 200 && evaluateSdf(position, planets) > 200 &&
!lights.find((l) => l.distance(position) < 2500) !lamps.find((l) => l.distance(position) < 2500)
) { ) {
lights.push( lamps.push(
new LampPhysical( new LampPhysical(
position, position,
hsl( hsl(
@ -37,61 +35,43 @@ export const createWorld = (objectContainer: PhysicalContainer) => {
), ),
); );
} }
} else { } else if (
if ( evaluateSdf(position, planets) > 1400 &&
evaluateSdf(position, objects) > 1400 && !lamps.find((l) => l.distance(position) < 1700)
!lights.find((l) => l.distance(position) < 1700) ) {
) { const isKeystone = planets.length === 0;
const planet = const [sizeMin, sizeMax, roughMin, roughMax] = isKeystone
objects.length === 0 ? [1600, 2400, 80, 300]
? // The first, central giant is the keystone "Heart": a named, : [300, 1600, 20, 100];
// always-contested focal objective the whole match orbits. planets.push(
new PlanetPhysical( new PlanetPhysical(
PlanetBase.createPlanetVertices( PlanetBase.createPlanetVertices(
position, position,
Random.getRandomInRange(1600, 2400), Random.getRandomInRange(sizeMin, sizeMax),
Random.getRandomInRange(1600, 2400), Random.getRandomInRange(sizeMin, sizeMax),
Random.getRandomInRange(80, 300), Random.getRandomInRange(roughMin, roughMax),
), ),
true, isKeystone,
) objectContainer.game,
: new PlanetPhysical( ),
PlanetBase.createPlanetVertices( );
position,
Random.getRandomInRange(300, 1600),
Random.getRandomInRange(300, 1600),
Random.getRandomInRange(20, 100),
),
);
objects.push(planet);
}
} }
} }
} }
console.info(`Generated ${objects.length} planets`); console.info(`Generated ${planets.length} planets and ${lamps.length} lamps`);
console.info(`Generated ${lights.length} light`);
// Associate each lamp with its NEAREST planet, so a planet can repaint "its" for (const lamp of lamps) {
// lamps to the owning team's colour when it flips. Lamps are already placed by let nearest: PlanetPhysical | undefined;
// proximity during world-gen, so the nearest planet is the one whose capture let nearestDistance = Infinity;
// they should advertise. Distances use the planet SDF (negative inside), which for (const planet of planets) {
// is exactly the "closest planet" metric we want. const distance = planet.distance(lamp.center);
const planets = objects.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical); if (distance < nearestDistance) {
lights nearestDistance = distance;
.filter((l): l is LampPhysical => l instanceof LampPhysical) nearest = planet;
.forEach((lamp) => { }
let nearest: PlanetPhysical | undefined; }
let nearestDistance = Infinity; nearest?.addLamp(lamp);
planets.forEach((planet) => { }
const distance = planet.distance(lamp.center);
if (distance < nearestDistance) {
nearestDistance = distance;
nearest = planet;
}
});
nearest?.addLamp(lamp);
});
[...objects, ...lights].forEach((o) => objectContainer.addObject(o)); [...planets, ...lamps].forEach((o) => objectContainer.addObject(o));
}; };

View file

@ -1,10 +0,0 @@
import { Options } from './options';
export const defaultOptions: Options = {
port: 3000,
name: 'Test server',
playerLimit: 16,
npcCount: 8,
seed: Math.random(),
scoreLimit: 2500,
};

View file

@ -0,0 +1,17 @@
import { CharacterTeam } from 'shared';
export interface GameEvents {
addPoints(blue: number, red: number): void;
announce(text: string): void;
}
export const addPointsForTeam = (
game: GameEvents,
team: CharacterTeam,
points: number,
): void => {
game.addPoints(
team === CharacterTeam.blue ? points : 0,
team === CharacterTeam.red ? points : 0,
);
};

View file

@ -1,5 +1,6 @@
import { PhysicalContainer } from './physics/containers/physical-container';
import { Server, Socket } from 'socket.io'; import { Server, Socket } from 'socket.io';
import { randomUUID } from 'node:crypto';
import { performance } from 'node:perf_hooks';
import { import {
TransportEvents, TransportEvents,
deserialize, deserialize,
@ -11,25 +12,36 @@ import {
GameEndCommand, GameEndCommand,
GameStartCommand, GameStartCommand,
Command, Command,
CommandReceiver,
CommandExecutors,
ServerAnnouncement, ServerAnnouncement,
JoinRejectionReason,
GameObject,
PropertyUpdatesForObject,
} from 'shared'; } from 'shared';
import { PhysicalContainer } from './physics/physical-container';
import { createWorld } from './create-world'; import { createWorld } from './create-world';
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
import { Options } from './options'; import { Options } from './options';
import { PlayerContainer } from './players/player-container'; import { GameEvents } from './game-events';
import { StepCommand } from './commands/step'; import { CarriedScore, PlayerContainer } from './players/player-container';
import { GeneratePointsCommand } from './commands/generate-points'; import { Player } from './players/player';
import { AnnounceCommand } from './commands/announce';
const gameStateSubscribedRoom = 'gameStateSubscribedRoom'; const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
const endTitleSeconds = 6;
export class GameServer extends CommandReceiver { const asPlayerInformation = (payload: unknown): PlayerInformation | undefined => {
if (!payload || typeof payload !== 'object') {
return undefined;
}
const { name, reconnectToken } = payload as Record<string, unknown>;
return {
name: name as string,
reconnectToken: typeof reconnectToken === 'string' ? reconnectToken : undefined,
};
};
export class GameServer implements GameEvents {
private objects!: PhysicalContainer; private objects!: PhysicalContainer;
private players!: PlayerContainer; private players!: PlayerContainer;
private deltaTimes!: Array<number>; private lastPhysicsBase = process.hrtime.bigint();
private deltaTimeCalculator!: DeltaTimeCalculator;
private bluePoints = 0; private bluePoints = 0;
private redPoints = 0; private redPoints = 0;
@ -37,92 +49,202 @@ export class GameServer extends CommandReceiver {
private isInEndGame = false; private isInEndGame = false;
private timeScaling = 1; private timeScaling = 1;
private statReportMs = Date.now();
private timeSinceLastServerStateUpdate = 0;
private timeSinceLastPointUpdate = 0;
private physicsAccumulator = 0;
private saturatedFrames = 0;
private droppedInboundMessages = 0;
private serverName: string; private readonly joinedSockets = new Map<
private playerLimit: number; Socket,
{ player: Player; release: () => void }
private initialize() { >();
const previousPlayers = this.players; private inboundBudget = new WeakMap<Socket, { tokens: number; lastMs: number }>();
this.objects = new PhysicalContainer();
createWorld(this.objects);
this.objects.initialize();
this.players = new PlayerContainer(
this.objects,
this.options.playerLimit,
this.options.npcCount,
);
this.deltaTimeCalculator = new DeltaTimeCalculator();
this.deltaTimes = [];
this.bluePoints = 0;
this.redPoints = 0;
this.matchPointAnnounced = {};
this.isInEndGame = false;
this.timeScaling = 1;
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
previousPlayers?.sendQueuedCommands();
}
protected commandExecutors: CommandExecutors = {
[GeneratePointsCommand.type]: this.addPoints.bind(this),
[AnnounceCommand.type]: ({ text }: AnnounceCommand) =>
this.players.queueCommandForEachClient(new ServerAnnouncement(text)),
};
constructor( constructor(
private readonly io: Server, private readonly io: Server,
private options: Options, private options: Options,
) { ) {
super();
this.serverName = options.name;
this.playerLimit = options.playerLimit;
this.initialize(); this.initialize();
io.on('connection', (socket: Socket) => { io.on('connection', (socket: Socket) => {
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => { socket.on(TransportEvents.PlayerJoining, (payload: unknown) => {
if (!this.allowInboundMessage(socket)) {
return;
}
try { try {
const player = this.players.createPlayer(playerInfo, socket); this.handleJoin(socket, payload);
socket.on(TransportEvents.PlayerToServer, (json: string) => {
try {
const commands: Array<Command> = deserialize(json);
commands.forEach((c) => player.handleCommand(c));
} catch (e) {
console.error('Error while processing command', e);
}
});
this.sendServerStateUpdate();
socket.on('disconnect', () => {
player.destroy();
this.players.deletePlayer(player);
this.sendServerStateUpdate();
});
} catch (e) { } catch (e) {
console.error('Failed to register joining player; disconnecting socket', e); console.error('Failed to handle a join request', e);
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.InvalidRequest);
socket.disconnect(); socket.disconnect();
} }
}); });
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => { socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
socket.join(gameStateSubscribedRoom); if (this.allowInboundMessage(socket)) {
socket.join(gameStateSubscribedRoom);
}
}); });
}); });
} }
private timeSinceLastServerStateUpdate = 0; private initialize() {
const previousPlayers = this.players;
this.joinedSockets.forEach(({ release }) => release());
this.joinedSockets.clear();
this.inboundBudget = new WeakMap();
this.objects = new PhysicalContainer(this);
createWorld(this.objects);
this.players = new PlayerContainer(
this.objects,
this.options.playerLimit,
this.options.npcCount,
);
this.lastPhysicsBase = process.hrtime.bigint();
this.bluePoints = 0;
this.redPoints = 0;
this.matchPointAnnounced = {};
this.isInEndGame = false;
this.timeScaling = 1;
this.physicsAccumulator = 0;
this.timeSinceLastServerStateUpdate = 0;
this.timeSinceLastPointUpdate = 0;
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
previousPlayers?.sendQueuedCommands();
}
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;
}
if (this.isInEndGame) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding);
return;
}
const carried = this.retireGhost(playerInfo.reconnectToken);
if (this.players.isFull) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull);
return;
}
const player = this.players.createPlayer(playerInfo, socket, carried);
const onPlayerToServer = (json: unknown) => {
try {
if (typeof json !== 'string' || 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);
}
};
const onDisconnect = () => {
this.removeJoined(socket);
this.sendServerStateUpdate();
};
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
socket.on('disconnect', onDisconnect);
this.joinedSockets.set(socket, {
player,
release: () => {
socket.off(TransportEvents.PlayerToServer, onPlayerToServer);
socket.off('disconnect', onDisconnect);
player.detachFromSocket();
},
});
player.reconnectToken = randomUUID();
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
this.sendServerStateUpdate();
}
private allowInboundMessage(socket: Socket): boolean {
// performance.now(), not Date.now(): a backwards wall-clock step would lock the client out.
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 + (Math.max(0, nowMs - budget.lastMs) / 1000) * perSecond,
);
budget.lastMs = nowMs;
this.inboundBudget.set(socket, budget);
if (budget.tokens < 1) {
this.droppedInboundMessages++;
return false;
}
budget.tokens -= 1;
return true;
}
private retireGhost(token?: string): CarriedScore | undefined {
if (!token) {
return undefined;
}
for (const [socket, { player }] of this.joinedSockets) {
if (player.reconnectToken === token) {
const carried = { team: player.team, ...player.score };
this.removeJoined(socket);
return carried;
}
}
return undefined;
}
private removeJoined(socket: Socket) {
const joined = this.joinedSockets.get(socket);
if (!joined) {
return;
}
this.joinedSockets.delete(socket);
this.inboundBudget.delete(socket);
joined.release();
joined.player.destroy();
this.players.deletePlayer(joined.player);
}
public sendServerStateUpdate() { public sendServerStateUpdate() {
this.io this.io
.to(gameStateSubscribedRoom) .to(gameStateSubscribedRoom)
.emit(TransportEvents.ServerInfoUpdate, [this.players.count, this.gameProgress]); .emit(TransportEvents.ServerInfoUpdate, [
this.players.count,
this.serverInfo.gameStatePercent,
]);
} }
public start() { public start() {
this.handlePhysics(); this.handlePhysics();
} }
private addPoints({ blue, red }: GeneratePointsCommand) { public addPoints(blue: number, red: number) {
if (this.isInEndGame) { if (this.isInEndGame) {
return; return;
} }
@ -139,45 +261,60 @@ export class GameServer extends CommandReceiver {
} }
} }
public announce(text: string) {
this.players.queueCommandForEachClient(new ServerAnnouncement(text));
}
private announceMatchPointOnce(team: CharacterTeam, points: number) { private announceMatchPointOnce(team: CharacterTeam, points: number) {
if ( if (
!this.matchPointAnnounced[team] && !this.matchPointAnnounced[team] &&
points >= this.options.scoreLimit * settings.matchPointScoreRatio points >= this.options.scoreLimit * settings.matchPointScoreRatio
) { ) {
this.matchPointAnnounced[team] = true; this.matchPointAnnounced[team] = true;
this.players.queueCommandForEachClient( this.announce(`Match point — team <span class="${team}">${team}</span>!`);
new ServerAnnouncement(
`Match point — team <span class="${team}">${team}</span>!`,
),
);
} }
} }
private endGame(winningTeam: CharacterTeam) { private endGame(winningTeam: CharacterTeam) {
this.isInEndGame = true; this.isInEndGame = true;
const endTitleLength = 6;
this.players.endGame(winningTeam); this.players.endGame(winningTeam);
this.players.queueCommandForEachClient( this.players.queueCommandForEachClient(new GameEndCommand());
new GameEndCommand(winningTeam, endTitleLength, true), setTimeout(() => this.initialize(), endTitleSeconds * 1000 * 1.1);
);
setTimeout(() => this.destroy(), endTitleLength * 1000 * 1.1);
} }
private destroy() { private reportStats() {
this.initialize(); const mem = `${(process.memoryUsage().rss / 1024 / 1024).toFixed(2)} MB`;
} console.info(`Memory: ${mem}, Players: ${this.players.count}`);
private timeSinceLastPointUpdate = 0; const rtts = this.players.connectedPlayerRttsMs.filter((r) => r > 0);
private physicsAccumulator = 0; if (rtts.length > 0) {
// Frames since the last stats report where physics ran over budget (more rtts.sort((a, b) => a - b);
// substeps than the cap). Surfaced by handleStats as a saturation signal. console.info(
private saturatedFrames = 0; `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;
}
if (this.saturatedFrames > 0) {
console.warn(`Physics saturated on ${this.saturatedFrames} frame(s)`);
this.saturatedFrames = 0;
}
}
private handlePhysics() { private handlePhysics() {
const delta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds({ setAsBase: true }); const frameStart = process.hrtime.bigint();
this.deltaTimes.push(delta); const frameStartMs = performance.now();
const delta = Number(frameStart - this.lastPhysicsBase) / 1e9;
this.lastPhysicsBase = frameStart;
this.handleStats(); if (Date.now() - this.statReportMs > 30000) {
this.statReportMs = Date.now();
this.reportStats();
}
if ((this.timeSinceLastServerStateUpdate += delta) > 4) { if ((this.timeSinceLastServerStateUpdate += delta) > 4) {
this.timeSinceLastServerStateUpdate = 0; this.timeSinceLastServerStateUpdate = 0;
@ -193,18 +330,11 @@ export class GameServer extends CommandReceiver {
const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds; const fixedDelta = settings.targetPhysicsDeltaTimeInSeconds;
const maxSubstepsPerFrame = 5; 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; const maxBacklogSeconds = 0.25;
this.physicsAccumulator += delta; this.physicsAccumulator += delta;
let substeps = Math.floor(this.physicsAccumulator / fixedDelta); let substeps = Math.floor(this.physicsAccumulator / fixedDelta);
if (substeps > maxSubstepsPerFrame) { 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.saturatedFrames++;
this.physicsAccumulator = Math.min( this.physicsAccumulator = Math.min(
this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta, this.physicsAccumulator - maxSubstepsPerFrame * fixedDelta,
@ -221,69 +351,41 @@ export class GameServer extends CommandReceiver {
this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta); this.timeScaling *= Math.pow(settings.endGameDeltaScaling, fixedDelta);
scaledDelta /= this.timeScaling; scaledDelta /= this.timeScaling;
} }
this.objects.handleCommand(new StepCommand(scaledDelta, this)); this.objects.step(scaledDelta);
this.players.step(scaledDelta); this.players.step(scaledDelta);
} }
this.players.stepCommunication(delta); // Physics has only advanced to the start of this frame less whatever is still in the
this.objects.resetRemoteCalls(); // accumulator. Timing anything off the send time instead would put up to one physics step
// of noise on every interpolated position and on the client's replay anchor.
const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000;
const physicsDelta = this.deltaTimeCalculator.getNextDeltaTimeInSeconds(); if (substeps > 0) {
const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
setTimeout( this.players.stepCommunication(
this.handlePhysics.bind(this), substeps * fixedDelta,
Math.max(0, fixedDelta - physicsDelta) * 1000, simulatedThroughMs,
); (object) => {
} if (!propertyUpdates.has(object)) {
propertyUpdates.set(object, object.getPropertyUpdates(1 / this.timeScaling));
private handleStats() { }
const framesBetweenDeltaTimeCalculation = 10000; return propertyUpdates.get(object);
},
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( this.objects.resetRemoteCalls();
'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.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 { const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
return (Math.max(this.bluePoints, this.redPoints) / this.options.scoreLimit) * 100; setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000);
} }
public get serverInfo(): ServerInformation { public get serverInfo(): ServerInformation {
return { return {
serverName: this.serverName, serverName: this.options.name,
playerCount: this.players.count, playerCount: this.players.count,
playerLimit: this.playerLimit, 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

@ -2,15 +2,14 @@ import { Server as IoServer } from 'socket.io';
import express from 'express'; import express from 'express';
import { Server } from 'http'; import { Server } from 'http';
import cors from 'cors'; import cors from 'cors';
import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared'; import { Random, serverInformationEndpoint, settings } from 'shared';
import minimist from 'minimist'; import minimist from 'minimist';
import { glMatrix } from 'gl-matrix'; import { glMatrix } from 'gl-matrix';
import { GameServer } from './game-server'; import { GameServer } from './game-server';
import { defaultOptions } from './default-options'; import { defaultOptions } from './options';
import parser from 'socket.io-msgpack-parser'; import parser from 'socket.io-msgpack-parser';
glMatrix.setMatrixArrayType(Array); glMatrix.setMatrixArrayType(Array);
applyArrayPlugins();
const optionOverrides = minimist(process.argv.slice(2)); const optionOverrides = minimist(process.argv.slice(2));
const options = { const options = {
@ -24,6 +23,7 @@ const app = express();
const server = new Server(app); const server = new Server(app);
const io = new IoServer(server, { const io = new IoServer(server, {
parser, parser,
maxHttpBufferSize: settings.maxInboundMessageBytes,
cors: { cors: {
origin: true, origin: true,
credentials: true, credentials: true,
@ -32,14 +32,7 @@ const io = new IoServer(server, {
const gameServer = new GameServer(io, options); const gameServer = new GameServer(io, options);
app.use( app.use(cors({ origin: true, credentials: true }));
cors({
origin: (_, callback) => {
callback(null, true);
},
credentials: true,
}),
);
app.get(serverInformationEndpoint, (_, res) => { app.get(serverInformationEndpoint, (_, res) => {
res.json(gameServer.serverInfo); res.json(gameServer.serverInfo);

View file

@ -2,22 +2,23 @@ import { vec2 } from 'gl-matrix';
import { import {
id, id,
settings, settings,
MoveActionCommand,
serializesTo,
last,
Circle, Circle,
CharacterBase, CharacterBase,
CharacterTeam, CharacterTeam,
PropertyUpdatesForObject, PropertyUpdatesForObject,
UpdatePropertyCommand, UpdatePropertyCommand,
CommandExecutors, CharacterMovementSnapshot,
CommandReceiver, GameObject,
mix, mix,
clamp01, clamp01,
strengthToCharge,
stepCharacterMovement, stepCharacterMovement,
applyForce,
applyLeapImpulse, applyLeapImpulse,
decayMomentum, decayMomentum,
CharacterMovementState, tickPlanetDetachment,
characterCenter,
sumGravity,
CharacterWorld, CharacterWorld,
GroundSurface, GroundSurface,
headRadius, headRadius,
@ -27,69 +28,45 @@ import {
rightFootOffset, rightFootOffset,
boundRadius, boundRadius,
} from 'shared'; } from 'shared';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical'; import { addPointsForTeam } from '../game-events';
import { Physical } from '../physics/physical';
import { CirclePhysical } from './circle-physical'; import { CirclePhysical } from './circle-physical';
import { PhysicalContainer } from '../physics/containers/physical-container'; import { PhysicalContainer } from '../physics/physical-container';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base'; import { BoundingBox } from '../physics/bounding-box';
import { ProjectilePhysical } from './projectile-physical'; import { ProjectilePhysical } from './projectile-physical';
import { forceAtPosition } from '../physics/functions/force-at-position'; import { PlanetPhysical, planetsNear } from './planet-physical';
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';
@serializesTo(CharacterBase) const placeholder = new Circle(vec2.create(), 0);
export class CharacterPhysical extends CharacterBase implements DynamicPhysical {
export class CharacterPhysical extends CharacterBase implements Physical {
public readonly canCollide = true; public readonly canCollide = true;
public readonly canMove = true;
private projectileStrength = settings.playerMaxStrength;
// Body geometry (head/foot radii, posture offsets, bound radius) is defined
// once in the shared movement module and imported here, so the authoritative
// body and the client's predicted body are bit-identical by construction
// instead of by a hand-synced "copied verbatim" duplicate. Re-exposed as a
// static only because external callers reference CharacterPhysical.boundRadius.
public static readonly boundRadius = boundRadius;
private timeSinceDying = 0;
private isDestroyed = false;
private timeSinceBorn = 0;
private hasJustBorn = true;
private timeAlive = 0;
private timeSinceLastShot = settings.projectileCreationInterval;
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
private lastSyncedHealth = settings.playerMaxHealth;
private killStreak = 0;
private direction = 0;
private currentPlanet?: PlanetPhysical;
private secondsSinceOnSurface = settings.planetDetachmentSeconds;
private bodyVelocity = vec2.create();
private timeSinceLastLeap = settings.leapCooldownSeconds;
public head: CirclePhysical; public head: CirclePhysical;
public leftFoot: CirclePhysical; public leftFoot: CirclePhysical;
public rightFoot: CirclePhysical; public rightFoot: CirclePhysical;
private movementState!: CharacterMovementState; public direction = 0;
private movementWorld!: CharacterWorld; public currentPlanet: GroundSurface | undefined;
public secondsSinceOnSurface = settings.planetDetachmentSeconds;
public readonly bodyVelocity = vec2.create();
private movementActions: Array<MoveActionCommand> = []; private projectileStrength = settings.playerMaxStrength;
private lastMovementAction: MoveActionCommand = new MoveActionCommand(vec2.create()); private timeAlive = 0;
private timeSinceDying = 0;
private isDestroyed = false;
private hasJustBorn = true;
private hasFiredSinceSpawn = false;
private timeSinceLastShot = settings.projectileCreationInterval;
private timeSinceLastLeap = settings.leapCooldownSeconds;
private timeSinceLastDamage = settings.playerOutOfCombatDelaySeconds;
private lastSyncedHealth = settings.playerMaxHealth;
private killStreak = 0;
private headVelocity = new Circle(vec2.create(), 0); private readonly movementDirection = vec2.create();
private leftFootVelocity = new Circle(vec2.create(), 0);
private rightFootVelocity = new Circle(vec2.create(), 0);
protected commandExecutors: CommandExecutors = { private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
[StepCommand.type]: this.step.bind(this), private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
[ReactToCollisionCommand.type]: this.onCollision.bind(this), private readonly box = new BoundingBox();
};
constructor( constructor(
name: string, name: string,
@ -99,112 +76,93 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
private readonly container: PhysicalContainer, private readonly container: PhysicalContainer,
startPosition: vec2, startPosition: vec2,
) { ) {
super(id(), name, killCount, deathCount, team, settings.playerMaxHealth); super(
this.head = new CirclePhysical( id(),
vec2.add(vec2.create(), startPosition, headOffset), name,
headRadius, killCount,
this, deathCount,
container, team,
settings.playerMaxHealth,
placeholder,
placeholder,
placeholder,
); );
this.leftFoot = new CirclePhysical( const part = (offset: vec2, radius: number) =>
vec2.add(vec2.create(), startPosition, leftFootOffset), new CirclePhysical(
feetRadius, vec2.add(vec2.create(), startPosition, offset),
this, radius,
container, this,
); container,
this.rightFoot = new CirclePhysical( );
vec2.add(vec2.create(), startPosition, rightFootOffset), this.head = part(headOffset, headRadius);
feetRadius, this.leftFoot = part(leftFootOffset, feetRadius);
this, this.rightFoot = part(rightFootOffset, feetRadius);
container,
);
container.addObject(this.head);
container.addObject(this.leftFoot);
container.addObject(this.rightFoot);
this.initMovementBridge();
} }
private initMovementBridge() { private readonly movementWorld: CharacterWorld = {
// The movementState object-literal getters/setters below can't use `this` groundsNear: (center, radius) => planetsNear(this.container, center, radius),
// (it would bind to the literal), so alias the character instance. stepBody: (body, deltaTimeInSeconds) => {
// eslint-disable-next-line @typescript-eslint/no-this-alias const hit = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
const self = this; return hit instanceof PlanetPhysical ? hit : undefined;
this.movementState = { },
head: this.head, };
leftFoot: this.leftFoot,
rightFoot: this.rightFoot,
get direction() {
return self.direction;
},
set direction(value: number) {
self.direction = value;
},
get currentPlanet() {
return self.currentPlanet;
},
set currentPlanet(value: GroundSurface | undefined) {
// On the server every ground is a PlanetPhysical (the world only ever
// hands back planets), so this narrowing is safe.
self.currentPlanet = value as PlanetPhysical | undefined;
},
get secondsSinceOnSurface() {
return self.secondsSinceOnSurface;
},
set secondsSinceOnSurface(value: number) {
self.secondsSinceOnSurface = value;
},
bodyVelocity: this.bodyVelocity,
};
this.movementWorld = { private get parts(): Array<CirclePhysical> {
// Same set and order forceAtPosition used: planets in the force field, return [this.head, this.leftFoot, this.rightFoot];
// in container-traversal order (so the f64 gravity sum is unchanged).
groundsNear: (center, radius) =>
self.container
.findIntersecting(getBoundingBoxOfCircle(new Circle(center, radius)))
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical),
stepBody: (body, deltaTimeInSeconds) => {
const { hitObject } = (body as CirclePhysical).stepManually(deltaTimeInSeconds);
return hitObject instanceof PlanetPhysical ? hitObject : undefined;
},
};
} }
private get isSpawnProtected(): boolean { private get isSpawnProtected(): boolean {
return ( return (
!this.hasFiredSinceSpawn &&
this.timeAlive < this.timeAlive <
settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
); );
} }
private hasGeneratedPoints = false;
private getPoints(game: CommandReceiver) {
if (!this.isAlive && !this.hasGeneratedPoints) {
this.hasGeneratedPoints = true;
const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint;
const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint;
game.handleCommand(new GeneratePointsCommand(blue, red));
}
}
public get isAlive(): boolean { public get isAlive(): boolean {
return !this.isDestroyed; return !this.isDestroyed;
} }
public handleMovementAction(c: MoveActionCommand) {
this.movementActions.push(c);
}
public get groundPlanet(): PlanetPhysical | undefined { public get groundPlanet(): PlanetPhysical | undefined {
return this.currentPlanet; return this.currentPlanet as PlanetPhysical | undefined;
} }
// Persistent launch momentum, streamed to the owning client so its predictor public get movementSnapshot(): CharacterMovementSnapshot {
// can reproduce a leap/slingshot/recoil flight instead of only snapping to it. return new CharacterMovementSnapshot(
public get launchMomentum(): vec2 { this.direction,
return this.bodyVelocity; vec2.clone(this.bodyVelocity),
vec2.clone(this.leftFoot.lastNormal),
vec2.clone(this.rightFoot.lastNormal),
this.groundPlanet?.id ?? null,
this.secondsSinceOnSurface,
Math.max(0, settings.leapCooldownSeconds - this.timeSinceLastLeap),
);
}
public get boundingBox(): BoundingBox {
return this.box.setCircle(this.center, boundRadius);
}
public get gameObject(): this {
return this;
}
public get center(): vec2 {
return characterCenter(this.head, this.leftFoot, this.rightFoot);
}
public distance(target: vec2): number {
return (
Math.min(
this.head.distance(target),
this.leftFoot.distance(target),
this.rightFoot.distance(target),
) - 5
);
}
public setMoveDirection(direction: vec2) {
vec2.normalize(this.movementDirection, direction);
} }
public addKill(victimName: string, charge = 0) { public addKill(victimName: string, charge = 0) {
@ -212,11 +170,13 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.killStreak++; this.killStreak++;
this.remoteCall('setKillCount', this.killCount); this.remoteCall('setKillCount', this.killCount);
this.health = Math.min( if (this.isAlive) {
settings.playerMaxHealth, this.health = Math.min(
this.health + settings.playerKillHealthReward, settings.playerMaxHealth,
); this.health + settings.playerKillHealthReward,
this.syncHealth(); );
this.syncHealth();
}
this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge); this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge);
} }
@ -232,39 +192,38 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
} }
} }
public onCollision({ other }: ReactToCollisionCommand) { public onCollision(other: GameObject) {
if ( if (
// A corpse keeps its collidable circles for the despawn animation; the !this.isAlive ||
// isAlive guard stops a flying corpse from eating shots aimed past it. !(other instanceof ProjectilePhysical) ||
this.isAlive && other.team === this.team ||
other instanceof ProjectilePhysical && !other.isAlive
other.team !== this.team &&
other.isAlive
) { ) {
other.destroy(); return;
}
if (this.isSpawnProtected) { other.destroy();
return;
}
this.timeSinceLastDamage = 0; if (this.isSpawnProtected) {
this.health -= other.strength; return;
this.lastSyncedHealth = Math.round(this.health); }
this.remoteCall('setHealth', this.health);
if (this.health <= 0 && this.isAlive) { this.timeSinceLastDamage = 0;
// Throw the corpse along the killing shot, harder for charged hits. this.health -= other.strength;
vec2.scaleAndAdd( this.lastSyncedHealth = Math.round(this.health);
this.bodyVelocity, this.remoteCall('setHealth', this.health);
this.bodyVelocity,
other.direction, if (this.health <= 0) {
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge), vec2.scaleAndAdd(
); this.bodyVelocity,
this.onDie(); this.bodyVelocity,
other.originator.addKill(this.name, other.charge); other.direction,
} else { mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
other.originator.registerHit(other.charge); );
} this.onDie(true);
other.originator.addKill(this.name, other.charge);
} else {
other.originator.registerHit(other.charge);
} }
} }
@ -277,42 +236,45 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
return; return;
} }
const direction = vec2.subtract(vec2.create(), position, this.center);
if (vec2.length(direction) === 0) {
return;
}
this.timeSinceLastShot = 0; this.timeSinceLastShot = 0;
this.hasFiredSinceSpawn = true;
const c = clamp01(charge);
const desiredStrength = mix( const desiredStrength = mix(
settings.chargeShotStrengthMin, settings.chargeShotStrengthMin,
settings.chargeShotStrengthMax, settings.chargeShotStrengthMax,
c, clamp01(charge),
); );
const strength = Math.min(desiredStrength, this.projectileStrength); const strength = Math.min(desiredStrength, this.projectileStrength);
this.projectileStrength -= strength; this.projectileStrength -= strength;
const c = strengthToCharge(strength);
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c); const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c); const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
const direction = vec2.subtract(vec2.create(), position, this.center);
vec2.normalize(direction, direction); vec2.normalize(direction, direction);
// Keep the unit direction before vec2.scale repurposes it as the velocity. this.container.addObject(
const shotDirection = vec2.clone(direction); new ProjectilePhysical(
const velocity = vec2.scale(direction, direction, speed); vec2.clone(this.center),
const projectile = new ProjectilePhysical( radius,
vec2.clone(this.center), strength,
radius, this.team,
strength, vec2.scale(vec2.create(), direction, speed),
this.team, this,
velocity, this.container,
this, c,
this.container, ),
c,
); );
this.container.addObject(projectile);
if (c > 0) { if (c > 0) {
vec2.scaleAndAdd( vec2.scaleAndAdd(
this.bodyVelocity, this.bodyVelocity,
this.bodyVelocity, this.bodyVelocity,
shotDirection, direction,
-settings.chargeShotRecoilMax * c, -settings.chargeShotRecoilMax * c,
); );
} }
@ -333,210 +295,89 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.timeSinceLastLeap = 0; this.timeSinceLastLeap = 0;
this.projectileStrength -= settings.leapStrengthCost; this.projectileStrength -= settings.leapStrengthCost;
applyLeapImpulse(this, this.movementDirection);
// Same impulse the client predicts with (shared), so a leap launches
// identically on both sides.
applyLeapImpulse(this.movementState, this.lastMovementAction.direction);
this.remoteCall('onLeap'); this.remoteCall('onLeap');
} }
public get boundingBox(): BoundingBoxBase { public onDie(killedInCombat = false) {
return getBoundingBoxOfCircle(new Circle(this.center, CharacterPhysical.boundRadius)); if (this.isDestroyed) {
} return;
public get gameObject(): this {
return this;
}
public get center(): vec2 {
const bodyCenter = vec2.add(vec2.create(), this.head.center, this.leftFoot.center);
vec2.add(bodyCenter, bodyCenter, this.rightFoot.center);
return vec2.scale(bodyCenter, bodyCenter, 1 / 3);
}
public distance(target: vec2): number {
return (
Math.min(
this.head.distance(target),
this.leftFoot.distance(target),
this.rightFoot.distance(target),
) - 5
);
}
private averageAndResetMovementActions(): vec2 {
let direction: vec2;
if (this.movementActions.length === 0) {
direction = vec2.clone(this.lastMovementAction.direction);
} else {
direction = this.movementActions.reduce(
(sum, current) => vec2.add(sum, sum, current.direction),
vec2.create(),
);
vec2.scale(direction, direction, 1 / this.movementActions.length);
this.lastMovementAction = last(this.movementActions)!;
this.movementActions = [];
} }
this.isDestroyed = true;
this.remoteCall('onDie');
return vec2.length(direction) > 0 if (killedInCombat) {
? vec2.normalize(direction, direction) const opposingTeam =
: vec2.create(); this.team === CharacterTeam.blue ? CharacterTeam.red : CharacterTeam.blue;
addPointsForTeam(this.container.game, opposingTeam, settings.playerKillPoint);
}
} }
private animateScaling(q: number) { public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject {
this.head.radius = headRadius * q; const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities.map(
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q; (velocity) =>
} new Circle(
vec2.scale(vec2.create(), velocity.center, timeScale),
public getPropertyUpdates(): PropertyUpdatesForObject { velocity.radius * timeScale,
),
);
return new PropertyUpdatesForObject(this.id, [ return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('head', this.head, this.headVelocity), new UpdatePropertyCommand('head', this.head, headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, this.leftFootVelocity), new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity),
new UpdatePropertyCommand('rightFoot', this.rightFoot, this.rightFootVelocity), new UpdatePropertyCommand('rightFoot', this.rightFoot, rightFootVelocity),
new UpdatePropertyCommand( new UpdatePropertyCommand(
'strength', 'strength',
this.projectileStrength, this.projectileStrength,
settings.playerStrengthRegenerationPerSeconds, settings.playerStrengthRegenerationPerSeconds * timeScale,
), ),
]); ]);
} }
private setPropertyUpdates( public step(deltaTimeInSeconds: number) {
oldHead: Circle,
oldLeftFoot: Circle,
oldRightFoot: Circle,
deltaTime: number,
) {
this.headVelocity = new Circle(
vec2.scale(
oldHead.center,
vec2.subtract(oldHead.center, this.head.center, oldHead.center),
1 / deltaTime,
),
(this.head.radius - oldHead.radius) / deltaTime,
);
this.leftFootVelocity = new Circle(
vec2.scale(
oldLeftFoot.center,
vec2.subtract(oldLeftFoot.center, this.leftFoot.center, oldLeftFoot.center),
1 / deltaTime,
),
(this.leftFoot.radius - oldLeftFoot.radius) / deltaTime,
);
this.rightFootVelocity = new Circle(
vec2.scale(
oldRightFoot.center,
vec2.subtract(oldRightFoot.center, this.rightFoot.center, oldRightFoot.center),
1 / deltaTime,
),
(this.rightFoot.radius - oldRightFoot.radius) / deltaTime,
);
this.animateScaling(1);
}
private step({ deltaTimeInSeconds, game }: StepCommand) {
this.getPoints(game);
this.timeAlive += deltaTimeInSeconds; this.timeAlive += deltaTimeInSeconds;
this.timeSinceLastLeap += deltaTimeInSeconds; this.timeSinceLastLeap += deltaTimeInSeconds;
const oldHead = new Circle(vec2.clone(this.head.center), this.head.radius); this.parts.forEach((part, i) => {
const oldLeftFoot = new Circle( vec2.copy(this.previousPose[i].center, part.center);
vec2.clone(this.leftFoot.center), this.previousPose[i].radius = part.radius;
this.leftFoot.radius, });
);
const oldRightFoot = new Circle(
vec2.clone(this.rightFoot.center),
this.rightFoot.radius,
);
if (this.isDestroyed) { if (this.isDestroyed) {
if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) { if ((this.timeSinceDying += deltaTimeInSeconds) > settings.spawnDespawnTime) {
this.destroy(); this.container.removeObject(this);
} else { } else {
this.freeFallCorpse(deltaTimeInSeconds); this.freeFallCorpse(deltaTimeInSeconds);
this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime); this.animateScaling(1 - this.timeSinceDying / settings.spawnDespawnTime);
} }
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); } else if (this.hasJustBorn) {
return; if (this.timeAlive > settings.spawnDespawnTime) {
}
if (this.hasJustBorn) {
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
this.hasJustBorn = false; this.hasJustBorn = false;
this.animateScaling(1);
} else { } else {
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime); this.animateScaling(this.timeAlive / settings.spawnDespawnTime);
} }
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds); } else {
return; this.stepAlive(deltaTimeInSeconds);
} }
if ( this.parts.forEach((part, i) => {
(this.secondsSinceOnSurface += deltaTimeInSeconds) > const velocity = this.velocities[i];
settings.planetDetachmentSeconds const previous = this.previousPose[i];
) { vec2.subtract(velocity.center, part.center, previous.center);
this.currentPlanet = undefined; vec2.scale(velocity.center, velocity.center, 1 / deltaTimeInSeconds);
} velocity.radius = (part.radius - previous.radius) / deltaTimeInSeconds;
});
}
private stepAlive(deltaTimeInSeconds: number) {
tickPlanetDetachment(this, deltaTimeInSeconds);
this.timeSinceLastShot += deltaTimeInSeconds; this.timeSinceLastShot += deltaTimeInSeconds;
this.projectileStrength = Math.min( this.projectileStrength = Math.min(
settings.playerMaxStrength, settings.playerMaxStrength,
this.projectileStrength + this.projectileStrength +
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds, settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
); );
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.currentPlanet?.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.
const direction = this.averageAndResetMovementActions();
stepCharacterMovement(
this.movementState,
this.movementWorld,
direction,
deltaTimeInSeconds,
);
this.setPropertyUpdates(oldHead, oldLeftFoot, oldRightFoot, deltaTimeInSeconds);
}
private freeFallCorpse(deltaTime: number) {
const intersecting = this.container.findIntersecting(
getBoundingBoxOfCircle(
new Circle(
this.center,
CharacterPhysical.boundRadius + settings.maxGravityDistance,
),
),
);
let grounded = false;
for (const part of [this.leftFoot, this.rightFoot, this.head]) {
part.applyForce(forceAtPosition(part.center, intersecting), deltaTime);
vec2.add(part.velocity, part.velocity, this.bodyVelocity);
const { hitObject } = part.stepManually(deltaTime);
if (hitObject instanceof PlanetPhysical) {
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.
decayMomentum(this.bodyVelocity, grounded, deltaTime);
}
private regenerateHealth(deltaTimeInSeconds: number) {
this.timeSinceLastDamage += deltaTimeInSeconds; this.timeSinceLastDamage += deltaTimeInSeconds;
if ( if (
this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds && this.timeSinceLastDamage > settings.playerOutOfCombatDelaySeconds &&
@ -548,17 +389,35 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
); );
this.syncHealth(); this.syncHealth();
} }
this.groundPlanet?.registerPresence(this);
stepCharacterMovement(
this,
this.movementWorld,
this.movementDirection,
deltaTimeInSeconds,
);
} }
public onDie() { private animateScaling(q: number) {
this.isDestroyed = true; this.head.radius = headRadius * q;
this.remoteCall('onDie'); this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;
} }
private destroy() { private freeFallCorpse(deltaTime: number) {
this.container.removeObject(this); const planets = this.movementWorld.groundsNear(
this.container.removeObject(this.head); this.center,
this.container.removeObject(this.leftFoot); boundRadius + settings.maxGravityDistance,
this.container.removeObject(this.rightFoot); );
let grounded = false;
for (const part of this.parts) {
vec2.copy(part.velocity, this.bodyVelocity);
applyForce(part, sumGravity(planets, part.center), deltaTime);
if (part.stepManually(deltaTime) instanceof PlanetPhysical) {
grounded = true;
}
}
decayMomentum(this.bodyVelocity, grounded, deltaTime);
} }
} }

View file

@ -1,143 +1,64 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { import { Circle, GameObject, resolveCircleMovement } from 'shared';
Circle, import { BoundingBox } from '../physics/bounding-box';
CommandExecutors, import { PhysicalContainer } from '../physics/physical-container';
CommandReceiver, import { Physical } from '../physics/physical';
GameObject,
resolveCircleMovement,
serializesTo,
} from 'shared';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
import { BoundingBoxBase } from '../physics/bounding-boxes/bounding-box-base';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { DynamicPhysical } from '../physics/physicals/dynamic-physical';
import { Physical } from '../physics/physicals/physical';
import { ReactToCollisionCommand } from '../commands/react-to-collision';
@serializesTo(Circle)
export class CirclePhysical extends CommandReceiver implements Circle, DynamicPhysical {
readonly canCollide = true;
readonly canMove = true;
export class CirclePhysical extends Circle implements Physical {
public readonly canCollide = true;
public velocity = vec2.create(); public velocity = vec2.create();
public lastNormal = vec2.fromValues(0, 1); public lastNormal = vec2.fromValues(0, 1);
private _boundingBox: BoundingBox; private readonly box = new BoundingBox();
protected commandExecutors: CommandExecutors = {
[ReactToCollisionCommand.type]: this.onCollision.bind(this),
};
constructor( constructor(
private _center: vec2, center: vec2,
private _radius: number, radius: number,
public owner: GameObject, public readonly owner: Physical,
private readonly container: PhysicalContainer, private readonly container: PhysicalContainer,
// Public + readonly so a CirclePhysical structurally satisfies the shared
// PhysicsBody interface the movement simulation operates on.
public readonly restitution = 0, public readonly restitution = 0,
) { ) {
super(); super(center, radius);
this._boundingBox = new BoundingBox();
this.recalculateBoundingBox();
} }
public get boundingBox(): BoundingBoxBase { public get boundingBox(): BoundingBox {
return this._boundingBox; return this.box.setCircle(this.center, this.radius);
}
public get center(): vec2 {
return this._center;
}
public set center(value: vec2) {
this._center = value;
this.recalculateBoundingBox();
}
public onCollision(c: ReactToCollisionCommand) {
this.owner.handleCommand(c);
} }
public get gameObject(): GameObject { public get gameObject(): GameObject {
return this.owner; return this.owner.gameObject;
} }
public get radius(): number { public onCollision(other: GameObject) {
return this._radius; this.owner.onCollision?.(other);
} }
public set radius(value: number) {
this._radius = value;
this.recalculateBoundingBox();
}
public distance(target: vec2): number {
return vec2.distance(target, this.center) - this.radius;
}
private recalculateBoundingBox() {
this._boundingBox.xMin = this.center.x - this._radius;
this._boundingBox.xMax = this.center.x + this._radius;
this._boundingBox.yMin = this.center.y - this._radius;
this._boundingBox.yMax = this.center.y + this._radius;
}
public applyForce(force: vec2, timeInSeconds: number) {
vec2.add(
this.velocity,
this.velocity,
vec2.scale(vec2.create(), 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.
public stepManually( public stepManually(
deltaTimeInSeconds: number, deltaTimeInSeconds: number,
possibleIntersectors?: Array<Physical>, possibleIntersectors?: Array<Physical>,
): { ): GameObject | undefined {
hitObject: GameObject | undefined;
velocity: vec2;
} {
const intersecting = ( const intersecting = (
possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds) possibleIntersectors ?? this.sweptBroadphase(deltaTimeInSeconds)
).filter((b) => b.gameObject !== this.gameObject && b.canCollide); ).filter((b) => b.gameObject !== this.gameObject && b.canCollide);
const { hitObject, velocity } = resolveCircleMovement( const hit = resolveCircleMovement(
this, this,
deltaTimeInSeconds, deltaTimeInSeconds,
intersecting, intersecting,
(intersected) => { (intersected) => {
const physical = intersected as Physical; const physical = intersected as Physical;
physical.handleCommand(new ReactToCollisionCommand(this.gameObject)); physical.onCollision?.(this.gameObject);
this.handleCommand(new ReactToCollisionCommand(physical.gameObject)); this.onCollision(physical.gameObject);
}, },
); ) as Physical | undefined;
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity }; return hit?.gameObject;
} }
// 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> { private sweptBroadphase(deltaTimeInSeconds: number): Array<Physical> {
const sweep = vec2.length( const sweep = vec2.length(this.velocity) * deltaTimeInSeconds;
vec2.scale(vec2.create(), this.velocity, deltaTimeInSeconds), return this.container.findIntersecting(
BoundingBox.ofCircle(this.center, this.radius + sweep),
); );
this.radius += sweep;
const intersecting = this.container.findIntersecting(this.boundingBox);
this.radius -= sweep;
return intersecting;
}
public toArray(): Array<any> {
const { center, radius } = this;
return [center, radius];
} }
} }

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