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
4 changed files with 15073 additions and 4754 deletions

View file

@ -2,22 +2,21 @@ import { vec2 } from 'gl-matrix';
import {
PlayerInformation,
settings,
Circle,
Random,
MoveActionCommand,
CharacterTeam,
Id,
evaluateSdf,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { PhysicalContainer } from '../physics/physical-container';
import { BoundingBox } from '../physics/bounding-box';
import { PlayerContainer } from './player-container';
import { PlayerBase } from './player-base';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical';
import { PlanetPhysical, planetsIn } from '../objects/planet-physical';
import { ProjectilePhysical } from '../objects/projectile-physical';
import { Physical } from '../physics/physicals/physical';
import { Physical } from '../physics/physical';
const npcTuning = {
const tuning = {
planIntervalSeconds: 1,
shootIntervalSeconds: 1.5,
reactionIntervalSeconds: 1 / 10,
@ -40,7 +39,6 @@ const npcTuning = {
dodgeThreatRange: 450,
dodgeApproachDot: 0.6,
dodgeBaseChance: 0.25,
dodgeAggressionChance: 0.35,
dodgeCommitSeconds: 0.35,
@ -61,32 +59,36 @@ 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,
};
interface Enemy {
character: CharacterPhysical;
distance: number;
}
export class NPC extends PlayerBase {
private direction = vec2.fromValues(Random.getRandom() - 0.5, Random.getRandom() - 0.5);
private timeSinceLastPlan = 10000;
private timeSinceLastShoot = 10000;
private isWandering = false;
private movement = vec2.create();
private timeSinceLastPlan = Infinity;
private timeSinceLastShoot = Infinity;
private timeSinceObserve = Infinity;
private timeSinceLastWanderingConsideration = 0;
private isWandering = false;
private isComingBack = false;
private readonly aggression = Random.getRandomInRange(
npcTuning.aggressionMin,
npcTuning.aggressionMax,
tuning.aggressionMin,
tuning.aggressionMax,
);
private aimTargetId: Id = null;
private aimTargetId: Id | null = null;
private readonly aimTargetLastPosition = vec2.create();
private readonly dodgeDirection = vec2.create();
private dodgeCommitRemaining = 0;
private dodgeCooldownRemaining = 0;
private timeSinceObserve = npcTuning.reactionIntervalSeconds;
private nearObjects: Array<Physical> = [];
constructor(
@ -97,69 +99,50 @@ export class NPC extends PlayerBase {
) {
super(playerInfo, playerContainer, objectContainer, team);
this.createCharacter();
this.step(0);
}
private timeUntilRespawn = 0;
public step(deltaTimeInSeconds: number) {
if (this.character) {
this.center = this.character.center;
if (!this.character.isAlive) {
this.sumDeaths++;
this.sumKills = this.character.killCount;
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
return;
}
} else {
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.createCharacter();
this.center = this.character!.center;
}
const character = this.stepLifecycle(deltaTimeInSeconds);
if (!character) {
return;
}
if (
(this.timeSinceLastWanderingConsideration += deltaTimeInSeconds) >
npcTuning.wanderReconsiderSeconds
tuning.wanderReconsiderSeconds
) {
this.timeSinceLastWanderingConsideration = 0;
this.isWandering = Random.getRandom() < npcTuning.wanderProbability;
this.isWandering = Random.getRandom() < tuning.wanderProbability;
}
if ((this.timeSinceLastPlan += deltaTimeInSeconds) > npcTuning.planIntervalSeconds) {
if ((this.timeSinceLastPlan += deltaTimeInSeconds) > tuning.planIntervalSeconds) {
this.timeSinceLastPlan = 0;
this.plan();
}
if (
(this.timeSinceObserve += deltaTimeInSeconds) > npcTuning.reactionIntervalSeconds
) {
this.timeSinceObserve = 0;
this.nearObjects = this.observe(npcTuning.reactionObserveRadius);
}
this.dodgeCommitRemaining -= deltaTimeInSeconds;
this.dodgeCooldownRemaining -= deltaTimeInSeconds;
const movement = this.decideMovement(this.nearObjects);
this.character.handleMovementAction(new MoveActionCommand(movement));
if ((this.timeSinceObserve += deltaTimeInSeconds) > tuning.reactionIntervalSeconds) {
this.timeSinceObserve = 0;
this.nearObjects = this.observe(tuning.reactionObserveRadius);
this.movement = this.decideMovement(character, this.nearObjects);
character.setMoveDirection(this.movement);
}
if (
!this.isComingBack &&
this.character.groundPlanet &&
vec2.length(movement) > 0 &&
character.groundPlanet &&
vec2.length(this.movement) > 0 &&
Random.getRandom() <
npcTuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
tuning.leapChancePerSecond * this.aggression * deltaTimeInSeconds
) {
this.character.leap();
character.leap();
}
if (
(this.timeSinceLastShoot += deltaTimeInSeconds) > npcTuning.shootIntervalSeconds
) {
if ((this.timeSinceLastShoot += deltaTimeInSeconds) > tuning.shootIntervalSeconds) {
this.timeSinceLastShoot = 0;
this.tryShoot(this.nearObjects);
this.tryShoot(character, this.nearObjects);
}
}
@ -175,23 +158,18 @@ export class NPC extends PlayerBase {
}
this.isComingBack = false;
const nearObjects = this.observe(npcTuning.planScanRadius);
const enemies = this.enemiesByDistance(nearObjects);
if (enemies.length > 0) {
const nearest = enemies[0];
const nearObjects = this.observe(tuning.planScanRadius);
const nearest = this.enemiesByDistance(nearObjects)[0];
if (nearest) {
const fleeRange =
npcTuning.fleeBaseRange * (npcTuning.fleeAggressionFalloff - this.aggression);
tuning.fleeBaseRange * (tuning.fleeAggressionFalloff - this.aggression);
if (nearest.distance < fleeRange) {
vec2.subtract(this.direction, this.center, nearest.character.center);
return;
}
}
if (enemies.length > 0) {
const nearest = enemies[0];
const chaseRange =
npcTuning.chaseBaseRange + npcTuning.chaseAggressionRange * this.aggression;
tuning.chaseBaseRange + tuning.chaseAggressionRange * this.aggression;
if (nearest.distance < chaseRange) {
vec2.subtract(this.direction, nearest.character.center, this.center);
return;
@ -201,7 +179,7 @@ export class NPC extends PlayerBase {
if (!this.isWandering) {
const planet = this.capturablePlanetsByDistance(nearObjects)[0];
if (planet) {
vec2.subtract(this.direction, planet.planet.center, this.center);
vec2.subtract(this.direction, planet.center, this.center);
return;
}
}
@ -210,11 +188,14 @@ export class NPC extends PlayerBase {
this.direction,
this.direction,
vec2.create(),
Random.getRandomInRange(-npcTuning.wanderTurn, npcTuning.wanderTurn),
Random.getRandomInRange(-tuning.wanderTurn, tuning.wanderTurn),
);
}
private decideMovement(nearObjects: Array<Physical>): vec2 {
private decideMovement(
character: CharacterPhysical,
nearObjects: Array<Physical>,
): vec2 {
if (this.dodgeCommitRemaining > 0) {
return vec2.clone(this.dodgeDirection);
}
@ -223,23 +204,20 @@ export class NPC extends PlayerBase {
if (dodge) {
if (
Random.getRandom() <
npcTuning.dodgeBaseChance + npcTuning.dodgeAggressionChance * this.aggression
tuning.dodgeBaseChance + tuning.dodgeAggressionChance * this.aggression
) {
vec2.copy(this.dodgeDirection, dodge);
this.dodgeCommitRemaining = npcTuning.dodgeCommitSeconds;
this.dodgeCommitRemaining = tuning.dodgeCommitSeconds;
return vec2.clone(this.dodgeDirection);
}
this.dodgeCooldownRemaining = npcTuning.dodgeCooldownSeconds;
this.dodgeCooldownRemaining = tuning.dodgeCooldownSeconds;
}
}
const planet = this.character!.groundPlanet;
const planet = character.groundPlanet;
if (planet && planet.team !== this.team) {
const enemies = this.enemiesByDistance(nearObjects);
if (
enemies.length === 0 ||
enemies[0].distance > npcTuning.captureHoldEnemyDistance
) {
if (enemies.length === 0 || enemies[0].distance > tuning.captureHoldEnemyDistance) {
return vec2.create();
}
}
@ -258,12 +236,12 @@ export class NPC extends PlayerBase {
}
const toMe = vec2.subtract(vec2.create(), this.center, p.center);
const distance = vec2.length(toMe);
if (distance > npcTuning.dodgeThreatRange || distance === 0) {
if (distance > tuning.dodgeThreatRange || distance === 0) {
continue;
}
vec2.normalize(toMe, toMe);
if (
vec2.dot(p.direction, toMe) > npcTuning.dodgeApproachDot &&
vec2.dot(p.direction, toMe) > tuning.dodgeApproachDot &&
distance < threatDistance
) {
threatDistance = distance;
@ -275,19 +253,16 @@ export class NPC extends PlayerBase {
return undefined;
}
const perpendicular = vec2.fromValues(-threat.direction.y, threat.direction.x);
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);
}
vec2.normalize(perpendicular, perpendicular);
return perpendicular;
return vec2.normalize(perpendicular, perpendicular);
}
private tryShoot(nearObjects: Array<Physical>) {
const enemies = this.enemiesByDistance(nearObjects);
const visible = enemies.find((e) =>
private tryShoot(character: CharacterPhysical, nearObjects: Array<Physical>) {
const visible = this.enemiesByDistance(nearObjects).find((e) =>
this.hasLineOfSightTo(e.character.center, nearObjects),
);
if (!visible) {
@ -295,71 +270,66 @@ export class NPC extends PlayerBase {
return;
}
const target = visible.character;
const distance = visible.distance;
const { character: target, distance } = visible;
const velocity = vec2.create();
if (this.aimTargetId === target.id) {
vec2.subtract(velocity, target.center, this.aimTargetLastPosition);
vec2.scale(velocity, velocity, 1 / npcTuning.shootIntervalSeconds);
vec2.scale(velocity, velocity, 1 / tuning.shootIntervalSeconds);
}
this.aimTargetId = target.id;
vec2.copy(this.aimTargetLastPosition, target.center);
if (
Random.getRandom() >
npcTuning.fireBaseChance + npcTuning.fireAggressionChance * this.aggression
tuning.fireBaseChance + tuning.fireAggressionChance * this.aggression
) {
return;
}
const charge =
distance > npcTuning.chargeRangeThreshold &&
distance > tuning.chargeRangeThreshold &&
Random.getRandom() <
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression
? Random.getRandomInRange(npcTuning.chargeMin, 1)
tuning.chargeBaseChance + tuning.chargeAggressionChance * this.aggression
? Random.getRandomInRange(tuning.chargeMin, 1)
: 0;
const projectileSpeed =
charge > 0 ? settings.chargeShotSpeedMax : settings.chargeShotSpeedMin;
const leadTime = distance / projectileSpeed;
const aim = vec2.scaleAndAdd(vec2.create(), target.center, velocity, leadTime);
const aim = vec2.scaleAndAdd(
vec2.create(),
target.center,
velocity,
distance / projectileSpeed,
);
const spread =
(npcTuning.spreadBase + distance * npcTuning.spreadPerDistance) *
(npcTuning.spreadAggressionFalloff - this.aggression);
aim.x += Random.getRandomInRange(-spread, spread);
aim.y += Random.getRandomInRange(-spread, spread);
(tuning.spreadBase + distance * tuning.spreadPerDistance) *
(tuning.spreadAggressionFalloff - this.aggression);
aim[0] += Random.getRandomInRange(-spread, spread);
aim[1] += Random.getRandomInRange(-spread, spread);
this.character!.shootTowards(aim, charge);
character.shootTowards(aim, charge);
}
private hasLineOfSightTo(target: vec2, nearObjects: Array<Physical>): boolean {
const planets: Array<PlanetPhysical> = [];
for (const o of nearObjects) {
if (o.gameObject instanceof PlanetPhysical) {
planets.push(o.gameObject);
}
}
const planets = planetsIn(nearObjects);
if (planets.length === 0) {
return true;
}
const direction = vec2.subtract(vec2.create(), target, this.center);
const totalDistance = vec2.length(direction);
if (totalDistance <= npcTuning.lineOfSightStartOffset) {
if (totalDistance <= tuning.lineOfSightStartOffset) {
return true;
}
vec2.normalize(direction, direction);
let traveled = npcTuning.lineOfSightStartOffset;
let traveled = tuning.lineOfSightStartOffset;
const position = vec2.scaleAndAdd(vec2.create(), this.center, direction, traveled);
while (traveled < totalDistance) {
let sdf = Infinity;
for (const planet of planets) {
sdf = Math.min(sdf, planet.distance(position));
}
if (sdf < npcTuning.lineOfSightClearance) {
const sdf = evaluateSdf(position, planets);
if (sdf < tuning.lineOfSightClearance) {
return false;
}
traveled += sdf;
@ -370,45 +340,34 @@ export class NPC extends PlayerBase {
private observe(radius: number): Array<Physical> {
return this.objectContainer.findIntersecting(
getBoundingBoxOfCircle(new Circle(this.center, radius)),
BoundingBox.ofCircle(this.center, radius),
);
}
private enemiesByDistance(
nearObjects: Array<Physical>,
): Array<{ character: CharacterPhysical; distance: number }> {
const seen = new Set<CharacterPhysical>();
const enemies: Array<{ character: CharacterPhysical; distance: number }> = [];
private enemiesByDistance(nearObjects: Array<Physical>): Array<Enemy> {
const enemies: Array<Enemy> = [];
for (const o of nearObjects) {
const c = o.gameObject;
if (
c instanceof CharacterPhysical &&
c !== this.character &&
c.isAlive &&
c.team !== this.team &&
!seen.has(c)
c.team !== this.team
) {
seen.add(c);
enemies.push({ character: c, distance: vec2.distance(this.center, c.center) });
}
}
enemies.sort((a, b) => a.distance - b.distance);
return enemies;
return enemies.sort((a, b) => a.distance - b.distance);
}
private capturablePlanetsByDistance(
nearObjects: Array<Physical>,
): Array<{ planet: PlanetPhysical; distance: number }> {
const planets = nearObjects
.filter(
(o): o is Physical =>
o.gameObject instanceof PlanetPhysical && o.gameObject.team !== this.team,
)
.map((o) => ({
planet: o.gameObject as PlanetPhysical,
distance: vec2.distance(this.center, (o.gameObject as PlanetPhysical).center),
}));
planets.sort((a, b) => a.distance - b.distance);
return planets;
): Array<PlanetPhysical> {
return planetsIn(nearObjects)
.filter((p) => p.team !== this.team)
.sort(
(a, b) =>
vec2.distance(this.center, a.center) - vec2.distance(this.center, b.center),
);
}
}

View file

@ -1,56 +1,101 @@
import { vec2 } from 'gl-matrix';
import {
CommandReceiver,
Circle,
PlayerInformation,
CharacterTeam,
Random,
settings,
sanitizeName,
boundRadius,
evaluateSdf,
} 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 { PhysicalContainer } from '../physics/physical-container';
import { BoundingBox } from '../physics/bounding-box';
import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical';
import { PlanetPhysical, planetsNear } from '../objects/planet-physical';
import { PlayerContainer } from './player-container';
const maximumNameLength = 40;
export interface Score {
kills: number;
deaths: number;
}
export abstract class PlayerBase extends CommandReceiver {
public character?: CharacterPhysical | null;
public character: CharacterPhysical | null = null;
public center: vec2 = vec2.create();
protected sumKills = 0;
protected sumDeaths = 0;
protected lastCharacter?: CharacterPhysical;
protected timeUntilRespawn = 0;
private kills: number;
private deaths: number;
constructor(
protected readonly playerInfo: PlayerInformation,
protected readonly playerContainer: PlayerContainer,
protected readonly objectContainer: PhysicalContainer,
public readonly team: CharacterTeam,
score: Score = { kills: 0, deaths: 0 },
) {
super();
this.kills = score.kills;
this.deaths = score.deaths;
}
protected createCharacter() {
public get score(): Score {
return {
kills: (this.character ?? this.lastCharacter)?.killCount ?? this.kills,
deaths: this.deaths,
};
}
protected createCharacter(): CharacterPhysical {
this.kills = this.lastCharacter?.killCount ?? this.kills;
this.character = new CharacterPhysical(
this.playerInfo.name.slice(0, 20),
this.sumKills,
this.sumDeaths,
sanitizeName(this.playerInfo.name, maximumNameLength),
this.kills,
this.deaths,
this.team,
this.objectContainer,
this.findEmptyPositionForPlayer(this.findSpawnCenter()),
);
this.objectContainer.addObject(this.character);
this.center = this.character.center;
return this.character;
}
public abstract step(deltaTimeInSeconds: number): void;
public step(deltaTimeInSeconds: number) {
this.stepLifecycle(deltaTimeInSeconds);
}
protected stepLifecycle(deltaTimeInSeconds: number): CharacterPhysical | undefined {
if (this.character) {
this.center = this.character.center;
if (this.character.isAlive) {
return this.character;
}
this.deaths++;
this.lastCharacter = this.character;
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
return undefined;
}
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.createCharacter();
}
return undefined;
}
private findSpawnCenter(): vec2 {
const planets = this.objectContainer
.findIntersecting(
getBoundingBoxOfCircle(new Circle(vec2.create(), settings.worldRadius * 2)),
)
.filter((o): o is PlanetPhysical => o instanceof PlanetPhysical);
const planets = planetsNear(
this.objectContainer,
vec2.create(),
settings.worldRadius * 2,
);
const friendly = planets.filter((p) => p.team === this.team);
const neutral = planets.filter((p) => p.team === CharacterTeam.neutral);
@ -68,34 +113,39 @@ export abstract class PlayerBase extends CommandReceiver {
);
const safe = candidates.filter((p) => !isContested(p));
// candidates is non-empty here, so choose() always returns a planet.
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
}
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
private findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
let rotation = 0;
let radius = 0;
for (;;) {
const playerPosition = vec2.fromValues(
radius * Math.cos(rotation) + preferredCenter.x,
radius * Math.sin(rotation) + preferredCenter.y,
let roomiestPosition = vec2.clone(preferredCenter);
let roomiestClearance = -Infinity;
for (let attempt = 0; attempt < 512; attempt++) {
const position = vec2.fromValues(
radius * Math.cos(rotation) + preferredCenter[0],
radius * Math.sin(rotation) + preferredCenter[1],
);
const playerBoundingCircle = new Circle(
playerPosition,
CharacterPhysical.boundRadius,
const clearance = evaluateSdf(
position,
this.objectContainer.findIntersecting(
BoundingBox.ofCircle(position, boundRadius),
),
);
const playerBoundingBox = getBoundingBoxOfCircle(playerBoundingCircle);
const possibleIntersectors =
this.objectContainer.findIntersecting(playerBoundingBox);
if (!isCircleIntersecting(playerBoundingCircle, possibleIntersectors)) {
return playerPosition;
if (clearance >= boundRadius) {
return position;
}
if (clearance > roomiestClearance) {
roomiestClearance = clearance;
roomiestPosition = position;
}
rotation += Math.PI / 8;
radius += 30;
}
return roomiestPosition;
}
public destroy() {

View file

@ -1,13 +1,25 @@
import { CharacterTeam, PlayerInformation, Random, settings, Command } from 'shared';
import {
CharacterTeam,
PlayerInformation,
Random,
settings,
Command,
GameObject,
PropertyUpdatesForObject,
} from 'shared';
import { Socket } from 'socket.io';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { PhysicalContainer } from '../physics/physical-container';
import { NPC } from './npc';
import { Player } from './player';
import { PlayerBase } from './player-base';
import { PlayerBase, Score } from './player-base';
export interface CarriedScore extends Score {
team: CharacterTeam;
}
export class PlayerContainer {
private _players: Array<Player> = [];
private _npcs: Array<NPC> = [];
private npcs: Array<NPC> = [];
constructor(
private readonly objects: PhysicalContainer,
@ -17,58 +29,65 @@ export class PlayerContainer {
this.createNPCs();
}
public createNPCs() {
const newNpcCount = Math.min(
this.playerMaxCount - this._players.length - this._npcs.length,
this.npcMaxCount - this._npcs.length,
);
private createNPCs() {
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.getTeamOfNextPlayer(true)),
this.npcs.push(
new NPC({ name }, this, this.objects, this.teamOfNext(this.players)),
);
}
}
public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player {
if (this._players.length === this.playerMaxCount) {
throw new Error('Too many players');
}
public createPlayer(
playerInfo: PlayerInformation,
socket: Socket,
carried?: CarriedScore,
): Player {
const team = carried ? carried.team : this.teamOfNext(this._players);
const team = this.getTeamOfNextPlayer();
let npcToReplace = this._npcs.find((n) => n.team === team);
if (!npcToReplace) {
npcToReplace = this._npcs.find((n) => n.team !== team);
}
const npcToReplace =
this.npcs.find((n) => n.team === team) ?? this.npcs.find((n) => n.team !== team);
npcToReplace?.destroy();
this._npcs = this._npcs.filter((n) => n !== npcToReplace);
this.npcs = this.npcs.filter((n) => n !== npcToReplace);
const player = new Player(playerInfo, this, this.objects, team, socket);
const player = new Player(playerInfo, this, this.objects, team, socket, carried);
this._players.push(player);
return player;
}
public get players(): Array<PlayerBase> {
return [...this._players, ...this._npcs];
return [...this._players, ...this.npcs];
}
public get count(): number {
return this._players.length;
}
// Measured round-trip times (ms) of the real connected players, for
// server-side latency stats. NPCs have no socket and are excluded.
public get isFull(): boolean {
return this._players.length >= this.playerMaxCount;
}
public get connectedPlayerRttsMs(): Array<number> {
return this._players.map((p) => p.rttMs);
}
public step(deltaTimeInSeconds: number) {
this.players.forEach((p) => p.step(deltaTimeInSeconds));
this._players.forEach((p) => p.step(deltaTimeInSeconds));
this.npcs.forEach((p) => p.step(deltaTimeInSeconds));
}
public stepCommunication(deltaTimeInSeconds: number) {
this._players.forEach((p) => p.stepCommunications(deltaTimeInSeconds));
public stepCommunication(
deltaTimeInSeconds: number,
simulatedThroughMs: number,
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
) {
this._players.forEach((p) =>
p.stepCommunications(deltaTimeInSeconds, simulatedThroughMs, propertyUpdatesOf),
);
}
public endGame(winner: CharacterTeam) {
@ -83,8 +102,7 @@ export class PlayerContainer {
this._players.forEach((p) => p.sendQueuedCommandsToClient());
}
private getTeamOfNextPlayer(isNpc = false): CharacterTeam {
const players = isNpc ? this.players : 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;
@ -96,7 +114,9 @@ export class PlayerContainer {
}
public deletePlayer(player: Player) {
this._players = this._players.filter((p) => p !== player);
this.createNPCs();
if (this._players.includes(player)) {
this._players = this._players.filter((p) => p !== player);
this.createNPCs();
}
}
}

View file

@ -25,49 +25,85 @@ import {
PrimaryActionCommand,
LeapActionCommand,
InputAcknowledgement,
ClientHeartbeatCommand,
finiteInRange,
finiteVec2,
isFiniteNumber,
boundRadius,
} from 'shared';
import { Socket } from 'socket.io';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { BoundingBox } from '../physics/bounding-box';
import { PhysicalContainer } from '../physics/physical-container';
import { PlayerContainer } from './player-container';
import { PlayerBase } from './player-base';
import { PlayerBase, Score } from './player-base';
import { CharacterPhysical } from '../objects/character-physical';
import { planetsNear } from '../objects/planet-physical';
// How often the server pings each client to measure round-trip time.
const pingIntervalSeconds = 1;
const minimumAspectRatio = 0.2;
const maximumAspectRatio = 8;
const sheddableWhenBackedUp: ReadonlyArray<string> = [
PropertyUpdatesForObjects.name,
UpdateMinimap.name,
InputAcknowledgement.name,
];
export class Player extends PlayerBase {
// default, until the clients sends its real value
private aspectRatio: number = 16 / 9;
private timeUntilRespawn = 0;
private timeSinceLastMessage = 0;
private objectsPreviouslyInViewArea: Array<GameObject> = [];
private lastInputClientTimeMs = 0;
private lastLeapClientTimeMs = 0;
// Measured round-trip time to this client (ms) — the latency primitive that
// lag compensation, a latency HUD, and adaptive interpolation build on.
public reconnectToken = '';
public rttMs = 0;
private aspectRatio = 16 / 9;
private timeSinceLastMessage = 0;
private objectsInViewArea = new Set<GameObject>();
private commandsToBeSent: Array<Command> = [];
private winnerTeam?: CharacterTeam;
private lastInputClientTimeMs = 0;
private lastInputReceiptMs = 0;
private lastLeapClientTimeMs = -1;
private timeSinceLastPing = 0;
private lastPingSentMs = 0;
private pendingPingNonce = 0;
private nextPingNonce = 1;
// Input is untrusted: one non-finite value reaching the simulation leaves a character that never dies.
protected commandExecutors: CommandExecutors = {
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
(this.aspectRatio = v.aspectRatio),
[MoveActionCommand.type]: (c: MoveActionCommand) => {
// Remember how far into this client's input timeline we've consumed, to
// echo back for client-side prediction reconciliation.
this.lastInputClientTimeMs = c.clientTimeMs;
this.character?.handleMovementAction(c);
[SetAspectRatioActionCommand.name]: (v: SetAspectRatioActionCommand) => {
this.aspectRatio = finiteInRange(
v.aspectRatio,
minimumAspectRatio,
maximumAspectRatio,
this.aspectRatio,
);
},
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
this.character?.shootTowards(c.position, c.charge),
[LeapActionCommand.type]: (c: LeapActionCommand) => {
// Record receipt (whether or not leap() accepts it): either way its effect
// on bodyVelocity is now reflected in the streamed launch momentum, so the
// predictor must stop replaying this leap.
[MoveActionCommand.name]: (c: MoveActionCommand) => {
const direction = finiteVec2(c.direction, 1);
if (!direction) {
return;
}
this.observeClientTime(c.clientTimeMs);
this.character?.setMoveDirection(direction);
},
[PrimaryActionCommand.name]: (c: PrimaryActionCommand) => {
const position = finiteVec2(c.position, settings.maxClientPositionMagnitude);
if (!position) {
return;
}
this.observeClientTime(c.clientTimeMs);
this.character?.shootTowards(position, finiteInRange(c.charge, 0, 1, 0));
},
[LeapActionCommand.name]: (c: LeapActionCommand) => {
if (!isFiniteNumber(c.clientTimeMs)) {
return;
}
this.lastLeapClientTimeMs = c.clientTimeMs;
this.observeClientTime(c.clientTimeMs);
this.character?.leap();
},
[ClientHeartbeatCommand.name]: (c: ClientHeartbeatCommand) =>
this.observeClientTime(c.clientTimeMs),
};
constructor(
@ -76,116 +112,152 @@ export class Player extends PlayerBase {
objectContainer: PhysicalContainer,
team: CharacterTeam,
private readonly socket: Socket,
score?: Score,
) {
super(playerInfo, playerContainer, objectContainer, team);
super(playerInfo, playerContainer, objectContainer, team, score);
this.createCharacter();
this.step(0);
// The client already echoes a Pong for every Ping (see game.ts). Only one
// ping is ever in flight, so RTT is simply now − send-time; no payload
// needed and no client change required.
this.socket.on(TransportEvents.Pong, () => {
if (this.lastPingSentMs > 0) {
this.rttMs = performance.now() - this.lastPingSentMs;
}
});
this.socket.on(TransportEvents.Pong, this.onPong);
}
protected createCharacter() {
super.createCharacter();
private observeClientTime(clientTimeMs: number) {
if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) {
this.lastInputClientTimeMs = clientTimeMs;
this.lastInputReceiptMs = performance.now();
}
}
this.objectsPreviouslyInViewArea.push(this.character!);
this.queueCommandSend(new CreatePlayerCommand(this.character!));
private readonly onPong = (nonce: unknown) => {
if (this.pendingPingNonce === 0 || nonce !== this.pendingPingNonce) {
return;
}
this.pendingPingNonce = 0;
this.rttMs = Math.min(
performance.now() - this.lastPingSentMs,
settings.maxMeasuredRttMs,
);
};
public detachFromSocket() {
this.socket.off(TransportEvents.Pong, this.onPong);
}
protected createCharacter(): CharacterPhysical {
const character = super.createCharacter();
this.objectsInViewArea.add(character);
this.queueCommandSend(new CreatePlayerCommand(character));
return character;
}
private winnerTeam?: CharacterTeam;
public onGameEnded(winnerTeam: CharacterTeam) {
this.winnerTeam = winnerTeam;
}
public step(deltaTimeInSeconds: number) {
if (this.character) {
this.center = this.character?.center;
public queueCommandSend(command: Command) {
this.commandsToBeSent.push(command);
}
if (!this.character.isAlive) {
this.sumDeaths++;
this.sumKills = this.character.killCount;
public stepCommunications(
deltaTime: number,
simulatedThroughMs: number,
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
) {
const remoteCalls: Array<RemoteCallsForObject> = [];
for (const object of this.objectsInViewArea) {
const calls = object.getRemoteCalls();
if (calls.length > 0) {
remoteCalls.push(new RemoteCallsForObject(object.id, calls));
}
}
if (remoteCalls.length > 0) {
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
}
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
}
} else {
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
this.createCharacter();
this.center = this.character!.center;
}
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
this.timeSinceLastPing = 0;
this.lastPingSentMs = performance.now();
this.pendingPingNonce = this.nextPingNonce++;
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
}
// Subtracted, not zeroed: zeroing would stretch every interval to the next physics frame.
if ((this.timeSinceLastMessage += deltaTime) >= settings.updateMessageInterval) {
this.timeSinceLastMessage = Math.min(
this.timeSinceLastMessage - settings.updateMessageInterval,
settings.updateMessageInterval,
);
this.queueAnnouncement();
this.queueSnapshot(simulatedThroughMs, propertyUpdatesOf);
this.sendQueuedCommandsToClient();
}
}
private handleViewAreaUpdate() {
const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2);
const bb = new BoundingBox();
bb.topLeft = viewArea.topLeft;
bb.size = viewArea.size;
const objectsInViewArea = Array.from(
new Set(this.objectContainer.findIntersecting(bb).map((o) => o.gameObject)),
private queueSnapshot(
simulatedThroughMs: number,
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
) {
const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2);
const viewBox = new BoundingBox(
topLeft[0],
topLeft[0] + size[0],
topLeft[1] - size[1],
topLeft[1],
);
// The owning character must always be in its own snapshot, regardless of the
// view-area query, so the client predictor never loses its authoritative
// anchor (the body can ride a fast spinner to the very edge of the box).
if (this.character && !objectsInViewArea.includes(this.character)) {
objectsInViewArea.push(this.character);
const inViewArea = new Set(
this.objectContainer.findIntersecting(viewBox).map((o) => o.gameObject),
);
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 newlyIntersecting = objectsInViewArea.filter(
(o) => !this.objectsPreviouslyInViewArea.includes(o),
);
const created = [...inViewArea].filter((o) => !this.objectsInViewArea.has(o));
const deleted = [...this.objectsInViewArea].filter((o) => !inViewArea.has(o));
this.objectsInViewArea = inViewArea;
const noLongerIntersecting = this.objectsPreviouslyInViewArea.filter(
(o) => !objectsInViewArea.includes(o),
);
this.objectsPreviouslyInViewArea = objectsInViewArea;
if (noLongerIntersecting.length > 0) {
this.queueCommandSend(
new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)),
);
if (deleted.length > 0) {
this.queueCommandSend(new DeleteObjectsCommand(deleted.map((g) => g.id)));
}
if (newlyIntersecting.length > 0) {
this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting));
if (created.length > 0) {
this.queueCommandSend(new CreateObjectsCommand(created));
}
this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers()));
const propertyUpdates: Array<PropertyUpdatesForObject> = [];
for (const object of this.objectsInViewArea) {
const update = propertyUpdatesOf(object);
if (update) {
propertyUpdates.push(update);
}
}
this.queueCommandSend(
new PropertyUpdatesForObjects(
this.objectsPreviouslyInViewArea
.map((o) => o.getPropertyUpdates())
.filter((u) => u) as Array<PropertyUpdatesForObject>,
performance.now() / 1000,
),
new PropertyUpdatesForObjects(propertyUpdates, simulatedThroughMs / 1000),
);
// Tell the client how much of its own input is reflected in the snapshot it
// just received, so its predictor can replay the rest. Only while alive —
// a dead player isn't predicting.
if (this.character) {
this.queueCommandSend(
new InputAcknowledgement(
this.lastInputClientTimeMs,
this.character.launchMomentum,
this.character.movementSnapshot,
this.lastLeapClientTimeMs,
// Aged against the instant the pose is from, not the send time: the difference is
// the un-simulated remainder, and it would land straight in the client's replay.
this.lastInputReceiptMs > 0
? Math.max(0, simulatedThroughMs - this.lastInputReceiptMs)
: 0,
),
);
}
}
// Every living player except this one, reported by absolute world position so
// the client can plot the whole circular arena on its minimap.
private getMinimapPlayers(): Array<MinimapPlayer> {
return this.playerContainer.players
.filter((p) => p !== this && p.character?.isAlive)
@ -195,49 +267,66 @@ export class Player extends PlayerBase {
);
}
private commandsToBeSent: Array<Command> = [];
public queueCommandSend(command: Command) {
this.commandsToBeSent.push(command);
}
public stepCommunications(deltaTime: number) {
const remoteCalls = this.objectsPreviouslyInViewArea
.map((g) => new RemoteCallsForObject(g.id, g.getRemoteCalls()))
.filter((c) => c.calls.length > 0);
if (remoteCalls.length > 0) {
this.queueCommandSend(new RemoteCallsForObjects(remoteCalls));
}
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
this.timeSinceLastPing = 0;
this.lastPingSentMs = performance.now();
this.socket.emit(TransportEvents.Ping);
}
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
this.handleAnnouncements();
this.handleViewAreaUpdate();
this.sendQueuedCommandsToClient();
this.timeSinceLastMessage = 0;
private get bufferedBytes(): number {
const conn = this.socket.conn as unknown as
| {
transport?: { socket?: { bufferedAmount?: unknown } };
writeBuffer?: Array<{ data?: unknown }>;
}
| undefined;
const buffered = conn?.transport?.socket?.bufferedAmount;
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) {
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;
}
this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent));
this.commandsToBeSent = [];
}
private handleAnnouncements() {
let announcement = '';
private queueAnnouncement() {
if (this.winnerTeam) {
announcement = `Team <span class="${this.winnerTeam}">${this.winnerTeam}</span> won 🎉`;
this.queueCommandSend(
new ServerAnnouncement(
`Team <span class="${this.winnerTeam}">${this.winnerTeam}</span> won 🎉`,
),
);
} else if (!this.character) {
announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`;
}
if (announcement) {
this.queueCommandSend(new ServerAnnouncement(announcement));
this.queueCommandSend(
new ServerAnnouncement(`Reviving in ${Math.round(this.timeUntilRespawn)}…`),
);
}
}
}