This commit is contained in:
Andras Schmelczer 2026-09-14 21:29:18 +01:00
commit 244620105a
7 changed files with 2080 additions and 518 deletions

View file

@ -109,6 +109,9 @@ export class GameServer implements GameEvents {
this.matchPointAnnounced = {};
this.isInEndGame = false;
this.timeScaling = 1;
this.physicsAccumulator = 0;
this.timeSinceLastServerStateUpdate = 0;
this.timeSinceLastPointUpdate = 0;
previousPlayers?.queueCommandForEachClient(new GameStartCommand());
previousPlayers?.sendQueuedCommands();
}
@ -139,15 +142,18 @@ export class GameServer implements GameEvents {
const player = this.players.createPlayer(playerInfo, socket, carried);
const onPlayerToServer = (json: string) => {
const onPlayerToServer = (json: unknown) => {
try {
if (json.length > settings.maxInboundMessageBytes) {
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);
@ -354,14 +360,20 @@ export class GameServer implements GameEvents {
// of noise on every interpolated position and on the client's replay anchor.
const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000;
const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
this.players.stepCommunication(delta, simulatedThroughMs, (object) => {
if (!propertyUpdates.has(object)) {
propertyUpdates.set(object, object.getPropertyUpdates());
}
return propertyUpdates.get(object);
});
this.objects.resetRemoteCalls();
if (substeps > 0) {
const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
this.players.stepCommunication(
substeps * fixedDelta,
simulatedThroughMs,
(object) => {
if (!propertyUpdates.has(object)) {
propertyUpdates.set(object, object.getPropertyUpdates(1 / this.timeScaling));
}
return propertyUpdates.get(object);
},
);
this.objects.resetRemoteCalls();
}
const elapsed = Number(process.hrtime.bigint() - frameStart) / 1e9;
setTimeout(() => this.handlePhysics(), Math.max(0, fixedDelta - elapsed) * 1000);

View file

@ -62,8 +62,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
private lastSyncedHealth = settings.playerMaxHealth;
private killStreak = 0;
private movementDirections: Array<vec2> = [];
private lastMovementDirection = vec2.create();
private readonly movementDirection = vec2.create();
private readonly previousPose = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
private readonly velocities = [0, 1, 2].map(() => new Circle(vec2.create(), 0));
@ -136,6 +135,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
vec2.clone(this.rightFoot.lastNormal),
this.groundPlanet?.id ?? null,
this.secondsSinceOnSurface,
Math.max(0, settings.leapCooldownSeconds - this.timeSinceLastLeap),
);
}
@ -162,7 +162,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
}
public setMoveDirection(direction: vec2) {
this.movementDirections.push(direction);
vec2.normalize(this.movementDirection, direction);
}
public addKill(victimName: string, charge = 0) {
@ -170,11 +170,13 @@ export class CharacterPhysical extends CharacterBase implements Physical {
this.killStreak++;
this.remoteCall('setKillCount', this.killCount);
this.health = Math.min(
settings.playerMaxHealth,
this.health + settings.playerKillHealthReward,
);
this.syncHealth();
if (this.isAlive) {
this.health = Math.min(
settings.playerMaxHealth,
this.health + settings.playerKillHealthReward,
);
this.syncHealth();
}
this.remoteCall('onKillConfirmed', victimName, this.killStreak, charge);
}
@ -293,7 +295,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
this.timeSinceLastLeap = 0;
this.projectileStrength -= settings.leapStrengthCost;
applyLeapImpulse(this, this.lastMovementDirection);
applyLeapImpulse(this, this.movementDirection);
this.remoteCall('onLeap');
}
@ -311,8 +313,14 @@ export class CharacterPhysical extends CharacterBase implements Physical {
}
}
public getPropertyUpdates(): PropertyUpdatesForObject {
const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities;
public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject {
const [headVelocity, leftFootVelocity, rightFootVelocity] = this.velocities.map(
(velocity) =>
new Circle(
vec2.scale(vec2.create(), velocity.center, timeScale),
velocity.radius * timeScale,
),
);
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('head', this.head, headVelocity),
new UpdatePropertyCommand('leftFoot', this.leftFoot, leftFootVelocity),
@ -320,7 +328,7 @@ export class CharacterPhysical extends CharacterBase implements Physical {
new UpdatePropertyCommand(
'strength',
this.projectileStrength,
settings.playerStrengthRegenerationPerSeconds,
settings.playerStrengthRegenerationPerSeconds * timeScale,
),
]);
}
@ -387,27 +395,11 @@ export class CharacterPhysical extends CharacterBase implements Physical {
stepCharacterMovement(
this,
this.movementWorld,
this.averageAndResetMovementDirections(),
this.movementDirection,
deltaTimeInSeconds,
);
}
private averageAndResetMovementDirections(): vec2 {
const direction = vec2.create();
if (this.movementDirections.length === 0) {
vec2.copy(direction, this.lastMovementDirection);
} else {
for (const d of this.movementDirections) {
vec2.add(direction, direction, d);
}
vec2.scale(direction, direction, 1 / this.movementDirections.length);
this.lastMovementDirection =
this.movementDirections[this.movementDirections.length - 1];
this.movementDirections = [];
}
return vec2.normalize(direction, direction);
}
private animateScaling(q: number) {
this.head.radius = headRadius * q;
this.leftFoot.radius = this.rightFoot.radius = feetRadius * q;

View file

@ -95,6 +95,9 @@ export class PlanetPhysical extends PlanetBase implements Physical {
let blue = 0;
let red = 0;
for (const c of this.presentCharacters) {
if (!c.isAlive) {
continue;
}
if (c.team === CharacterTeam.blue) {
blue++;
} else if (c.team === CharacterTeam.red) {
@ -172,10 +175,14 @@ export class PlanetPhysical extends PlanetBase implements Physical {
this.ownership = clamp01(this.ownership);
}
public getPropertyUpdates(): PropertyUpdatesForObject {
public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('ownership', this.ownership, 0),
new UpdatePropertyCommand('rotation', this.rotation, this.angularVelocity),
new UpdatePropertyCommand(
'rotation',
this.rotation,
this.angularVelocity * timeScale,
),
]);
}
}

View file

@ -93,13 +93,25 @@ export class ProjectilePhysical extends ProjectileBase implements Physical {
}
}
public getPropertyUpdates(): PropertyUpdatesForObject {
public getPropertyUpdates(timeScale = 1): PropertyUpdatesForObject {
return new PropertyUpdatesForObject(this.id, [
new UpdatePropertyCommand('center', this.center, this.object.velocity),
new UpdatePropertyCommand(
'center',
this.center,
vec2.scale(vec2.create(), this.object.velocity, timeScale),
),
new UpdatePropertyCommand(
'strength',
this.strength,
-settings.projectileFadeSpeed * timeScale,
),
]);
}
public step(deltaTimeInSeconds: number) {
if (!this.isAlive) {
return;
}
super.step(deltaTimeInSeconds);
if (this.strength <= 0) {

View file

@ -14,6 +14,6 @@ export const defaultOptions: Options = {
name: isDevelopment ? 'Dev server' : 'Doppler server',
playerLimit: 16,
npcCount: 8,
seed: Math.random(),
seed: (Math.random() * 0x100000000) >>> 0,
scoreLimit: 2500,
};

View file

@ -30,13 +30,15 @@ export class PlayerContainer {
}
private createNPCs() {
const newNpcCount = Math.min(
this.playerMaxCount - this._players.length - this.npcs.length,
this.npcMaxCount - this.npcs.length,
);
const newNpcCount =
Math.min(this.playerMaxCount, this.npcMaxCount) -
this._players.length -
this.npcs.length;
for (let i = 0; i < newNpcCount; i++) {
const name = `🤖 ${Random.choose(settings.npcNames)}`;
this.npcs.push(new NPC({ name }, this, this.objects, this.getTeamOfNextNpc()));
this.npcs.push(
new NPC({ name }, this, this.objects, this.teamOfNext(this.players)),
);
}
}
@ -45,7 +47,7 @@ export class PlayerContainer {
socket: Socket,
carried?: CarriedScore,
): Player {
const team = carried ? carried.team : this.getTeamOfNextPlayer();
const team = carried ? carried.team : this.teamOfNext(this._players);
const npcToReplace =
this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team);
@ -100,14 +102,6 @@ export class PlayerContainer {
this._players.forEach((p) => p.sendQueuedCommandsToClient());
}
private getTeamOfNextPlayer(): CharacterTeam {
return this.teamOfNext(this._players);
}
private getTeamOfNextNpc(): CharacterTeam {
return this.teamOfNext(this.players);
}
private teamOfNext(players: Array<PlayerBase>): CharacterTeam {
const blueCount = players.filter((p) => p.team === CharacterTeam.blue).length;
const redCount = players.filter((p) => p.team === CharacterTeam.red).length;

View file

@ -29,6 +29,7 @@ import {
finiteInRange,
finiteVec2,
isFiniteNumber,
boundRadius,
} from 'shared';
import { Socket } from 'socket.io';
import { BoundingBox } from '../physics/bounding-box';
@ -36,6 +37,7 @@ import { PhysicalContainer } from '../physics/physical-container';
import { PlayerContainer } from './player-container';
import { PlayerBase, Score } from './player-base';
import { CharacterPhysical } from '../objects/character-physical';
import { planetsNear } from '../objects/planet-physical';
const pingIntervalSeconds = 1;
const minimumAspectRatio = 0.2;
@ -59,7 +61,7 @@ export class Player extends PlayerBase {
private lastInputClientTimeMs = 0;
private lastInputReceiptMs = 0;
private lastLeapClientTimeMs = 0;
private lastLeapClientTimeMs = -1;
private timeSinceLastPing = 0;
private lastPingSentMs = 0;
@ -178,7 +180,7 @@ export class Player extends PlayerBase {
}
// Subtracted, not zeroed: zeroing would stretch every interval to the next physics frame.
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
if ((this.timeSinceLastMessage += deltaTime) >= settings.updateMessageInterval) {
this.timeSinceLastMessage = Math.min(
this.timeSinceLastMessage - settings.updateMessageInterval,
settings.updateMessageInterval,
@ -206,6 +208,14 @@ export class Player extends PlayerBase {
);
if (this.character) {
inViewArea.add(this.character);
// Prediction needs every gravity source, including planets outside a narrow viewport.
for (const planet of planetsNear(
this.objectContainer,
this.character.center,
boundRadius + settings.maxGravityDistance,
)) {
inViewArea.add(planet);
}
}
const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o));
@ -258,19 +268,45 @@ export class Player extends PlayerBase {
}
private get bufferedBytes(): number {
const conn = this.socket.conn as
| { transport?: { socket?: { bufferedAmount?: unknown } } }
const conn = this.socket.conn as unknown as
| {
transport?: { socket?: { bufferedAmount?: unknown } };
writeBuffer?: Array<{ data?: unknown }>;
}
| undefined;
const buffered = conn?.transport?.socket?.bufferedAmount;
return typeof buffered === 'number' ? buffered : 0;
let bytes = typeof buffered === 'number' ? buffered : 0;
// Engine.IO queues packets here while the transport is waiting for its write callback.
for (const { data } of conn?.writeBuffer ?? []) {
if (typeof data === 'string') {
bytes += Buffer.byteLength(data);
} else if (ArrayBuffer.isView(data) || data instanceof ArrayBuffer) {
bytes += data.byteLength;
}
}
return bytes;
}
public sendQueuedCommandsToClient() {
// Only state the next snapshot regenerates may be dropped; create/delete are one-shot.
if (this.bufferedBytes > settings.maxBufferedBytesPerClient) {
this.commandsToBeSent = this.commandsToBeSent.filter(
const hasReliableCommands = this.commandsToBeSent.some(
(c) => !sheddableWhenBackedUp.includes(c.constructor.name),
);
this.commandsToBeSent = hasReliableCommands
? this.commandsToBeSent.flatMap((c): Array<Command> => {
// Lifecycle events still need their snapshot time to enter the render timeline.
if (c.constructor.name === PropertyUpdatesForObjects.name) {
return [
new PropertyUpdatesForObjects(
[],
(c as PropertyUpdatesForObjects).timestamp,
),
];
}
return sheddableWhenBackedUp.includes(c.constructor.name) ? [] : [c];
})
: [];
}
if (this.commandsToBeSent.length === 0) {
return;