Improve netcode and simplify #4

Merged
andras merged 15 commits from fix/audit-findings into main 2026-09-15 07:57:58 +01:00
41 changed files with 2086 additions and 205 deletions
Showing only changes of commit 1295828ac8 - Show all commits

Claude fixes

Andras Schmelczer 2026-08-22 11:03:58 +01:00

View file

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

View file

@ -14,17 +14,27 @@ import {
CommandReceiver,
CommandExecutors,
ServerAnnouncement,
JoinRejectionReason,
beginPropertyUpdateGeneration,
} from 'shared';
import { createWorld } from './create-world';
import { DeltaTimeCalculator } from './helper/delta-time-calculator';
import { Options } from './options';
import { PlayerContainer } from './players/player-container';
import { ServerFullError } from './players/server-full-error';
import { Player } from './players/player';
import { StepCommand } from './commands/step';
import { GeneratePointsCommand } from './commands/generate-points';
import { AnnounceCommand } from './commands/announce';
const gameStateSubscribedRoom = 'gameStateSubscribedRoom';
interface JoinedSocket {
player: Player;
onPlayerToServer: (json: string) => void;
onDisconnect: () => void;
}
export class GameServer extends CommandReceiver {
private objects!: PhysicalContainer;
private players!: PlayerContainer;
@ -43,6 +53,7 @@ export class GameServer extends CommandReceiver {
private initialize() {
const previousPlayers = this.players;
this.releaseJoinedSockets();
this.objects = new PhysicalContainer();
createWorld(this.objects);
this.objects.initialize();
@ -80,30 +91,9 @@ export class GameServer extends CommandReceiver {
this.initialize();
io.on('connection', (socket: Socket) => {
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) => {
try {
const player = this.players.createPlayer(playerInfo, socket);
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) {
console.error('Failed to register joining player; disconnecting socket', e);
socket.disconnect();
}
});
socket.on(TransportEvents.PlayerJoining, (playerInfo: PlayerInformation) =>
this.handleJoin(socket, playerInfo),
);
socket.on(TransportEvents.SubscribeForServerInfoUpdates, () => {
socket.join(gameStateSubscribedRoom);
@ -111,6 +101,133 @@ export class GameServer extends CommandReceiver {
});
}
private readonly joinedSockets = new Map<Socket, JoinedSocket>();
private handleJoin(socket: Socket, playerInfo: PlayerInformation) {
if (this.joinedSockets.has(socket)) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.AlreadyJoined);
return;
}
if (this.isInEndGame) {
// The round is already over and the world is about to be rebuilt; joining
// now drops the player into an arena running in slow motion with no
// end-of-round card.
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.RoundEnding);
return;
}
if (this.players.isFull) {
socket.emit(TransportEvents.JoinRejected, JoinRejectionReason.ServerFull);
return;
}
let player: Player;
try {
player = this.players.createPlayer(playerInfo, socket);
} catch (e) {
console.error('Failed to register joining player', e);
socket.emit(
TransportEvents.JoinRejected,
e instanceof ServerFullError
? JoinRejectionReason.ServerFull
: JoinRejectionReason.InvalidRequest,
);
socket.disconnect();
return;
}
const onPlayerToServer = (json: string) => {
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 = () => {
const record = this.joinedSockets.get(socket);
this.joinedSockets.delete(socket);
// The bucket is keyed by the Socket object, so leaving the entry behind
// pins the socket for the rest of the round.
this.inboundBudget.delete(socket);
if (!record) {
return;
}
record.player.detachFromSocket();
const { kills, deaths } = player.scoreSnapshot;
// Hold the score briefly so a client whose transport blipped can rejoin as
// itself rather than as a blank slate.
this.players.reserveScore(
player.reconnectToken,
player.team,
kills,
deaths,
Date.now(),
);
player.destroy();
this.players.deletePlayer(player);
this.sendServerStateUpdate();
};
socket.on(TransportEvents.PlayerToServer, onPlayerToServer);
socket.on('disconnect', onDisconnect);
this.joinedSockets.set(socket, { player, onPlayerToServer, onDisconnect });
player.reconnectToken = this.players.issueToken();
socket.emit(TransportEvents.PlayerJoined, player.reconnectToken);
this.sendServerStateUpdate();
}
// Cheap per-socket token bucket, sized well above what a legitimate client
// produces so it only ever trips on a flood.
private readonly inboundBudget = new Map<Socket, { tokens: number; lastMs: number }>();
private droppedInboundMessages = 0;
private allowInboundMessage(socket: Socket): boolean {
const nowMs = Date.now();
const burst = settings.maxInboundMessageBurst;
const perSecond = settings.maxInboundMessagesPerSecond;
const budget = this.inboundBudget.get(socket) ?? { tokens: burst, lastMs: nowMs };
budget.tokens = Math.min(
burst,
budget.tokens + ((nowMs - budget.lastMs) / 1000) * perSecond,
);
budget.lastMs = nowMs;
this.inboundBudget.set(socket, budget);
if (budget.tokens < 1) {
this.droppedInboundMessages++;
return false;
}
budget.tokens -= 1;
return true;
}
// Detach the per-join listeners of every socket still attached to the round
// that is ending, so a client that survives the restart can join the new one.
private releaseJoinedSockets() {
for (const [socket, record] of this.joinedSockets) {
socket.off(TransportEvents.PlayerToServer, record.onPlayerToServer);
socket.off('disconnect', record.onDisconnect);
record.player.detachFromSocket();
}
this.joinedSockets.clear();
this.inboundBudget.clear();
}
private timeSinceLastServerStateUpdate = 0;
public sendServerStateUpdate() {
this.io
@ -225,6 +342,7 @@ export class GameServer extends CommandReceiver {
this.players.step(scaledDelta);
}
beginPropertyUpdateGeneration();
this.players.stepCommunication(delta);
this.objects.resetRemoteCalls();
@ -259,10 +377,18 @@ export class GameServer extends CommandReceiver {
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})`,
`(min ${rtts[0].toFixed(0)}, max ${rtts[rtts.length - 1].toFixed(0)}, n=${rtts.length})`,
);
}
if (this.droppedInboundMessages > 0) {
console.warn(
`Rate limited ${this.droppedInboundMessages} inbound message(s) since last report — ` +
'client input was discarded',
);
this.droppedInboundMessages = 0;
}
if (this.saturatedFrames > 0) {
console.warn(
`Physics saturated on ${this.saturatedFrames} frame(s) since last report — shedding backlog`,

View file

@ -2,7 +2,7 @@ import { Server as IoServer } from 'socket.io';
import express from 'express';
import { Server } from 'http';
import cors from 'cors';
import { applyArrayPlugins, Random, serverInformationEndpoint } from 'shared';
import { applyArrayPlugins, Random, serverInformationEndpoint, settings } from 'shared';
import minimist from 'minimist';
import { glMatrix } from 'gl-matrix';
import { GameServer } from './game-server';
@ -24,6 +24,7 @@ const app = express();
const server = new Server(app);
const io = new IoServer(server, {
parser,
maxHttpBufferSize: settings.maxInboundMessageBytes,
cors: {
origin: true,
credentials: true,

View file

@ -171,17 +171,25 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
};
}
private hasFiredSinceSpawn = false;
private get isSpawnProtected(): boolean {
return (
!this.hasFiredSinceSpawn &&
this.timeAlive <
settings.spawnDespawnTime + settings.spawnInvulnerabilityExtraSeconds
);
}
private hasGeneratedPoints = false;
private wasKilledInCombat = false;
private getPoints(game: CommandReceiver) {
if (!this.isAlive && !this.hasGeneratedPoints) {
this.hasGeneratedPoints = true;
if (!this.wasKilledInCombat) {
return;
}
const blue = this.team === CharacterTeam.blue ? 0 : settings.playerKillPoint;
const red = this.team === CharacterTeam.red ? 0 : settings.playerKillPoint;
@ -260,7 +268,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
other.direction,
mix(settings.deathImpulseMin, settings.deathImpulseMax, other.charge),
);
this.onDie();
this.onDie(true);
other.originator.addKill(this.name, other.charge);
} else {
other.originator.registerHit(other.charge);
@ -268,7 +276,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
}
}
public shootTowards(position: vec2, charge = 0) {
public shootTowards(position: vec2, charge = 0, catchUpSeconds = 0) {
if (
!this.isAlive ||
this.timeSinceLastShot < settings.projectileCreationInterval ||
@ -277,21 +285,31 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
return;
}
this.timeSinceLastShot = 0;
const direction = vec2.subtract(vec2.create(), position, this.center);
if (vec2.length(direction) === 0) {
return;
}
const c = clamp01(charge);
this.timeSinceLastShot = 0;
this.hasFiredSinceSpawn = true;
const requestedCharge = clamp01(charge);
const desiredStrength = mix(
settings.chargeShotStrengthMin,
settings.chargeShotStrengthMax,
c,
requestedCharge,
);
const strength = Math.min(desiredStrength, this.projectileStrength);
this.projectileStrength -= strength;
const c = clamp01(
(strength - settings.chargeShotStrengthMin) /
(settings.chargeShotStrengthMax - settings.chargeShotStrengthMin),
);
const radius = mix(settings.chargeShotRadiusMin, settings.chargeShotRadiusMax, c);
const speed = mix(settings.chargeShotSpeedMin, settings.chargeShotSpeedMax, c);
const direction = vec2.subtract(vec2.create(), position, this.center);
vec2.normalize(direction, direction);
// Keep the unit direction before vec2.scale repurposes it as the velocity.
const shotDirection = vec2.clone(direction);
@ -307,6 +325,10 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
c,
);
this.container.addObject(projectile);
// Lag compensation: advance the shot by the time the command spent reaching
// us, so the shooter does not have to lead by their own latency as well as
// by the projectile's travel time.
projectile.fastForward(catchUpSeconds);
if (c > 0) {
vec2.scaleAndAdd(
@ -435,8 +457,6 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
),
(this.rightFoot.radius - oldRightFoot.radius) / deltaTime,
);
this.animateScaling(1);
}
private step({ deltaTimeInSeconds, game }: StepCommand) {
@ -467,6 +487,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
if (this.hasJustBorn) {
if ((this.timeSinceBorn += deltaTimeInSeconds) > settings.spawnDespawnTime) {
this.hasJustBorn = false;
this.animateScaling(1);
} else {
this.animateScaling(this.timeSinceBorn / settings.spawnDespawnTime);
}
@ -486,7 +507,7 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
this.projectileStrength = Math.min(
settings.playerMaxStrength,
this.projectileStrength +
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
settings.playerStrengthRegenerationPerSeconds * deltaTimeInSeconds,
);
this.regenerateHealth(deltaTimeInSeconds);
@ -550,7 +571,8 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
}
}
public onDie() {
public onDie(killedInCombat = false) {
this.wasKilledInCombat = killedInCombat;
this.isDestroyed = true;
this.remoteCall('onDie');
}

View file

@ -39,7 +39,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
) {
super();
this._boundingBox = new BoundingBox();
this.recalculateBoundingBox();
this.syncBoundingBox();
}
public get boundingBox(): BoundingBoxBase {
@ -52,7 +52,7 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
public set center(value: vec2) {
this._center = value;
this.recalculateBoundingBox();
this.syncBoundingBox();
}
public onCollision(c: ReactToCollisionCommand) {
@ -69,14 +69,14 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
public set radius(value: number) {
this._radius = value;
this.recalculateBoundingBox();
this.syncBoundingBox();
}
public distance(target: vec2): number {
return vec2.distance(target, this.center) - this.radius;
}
private recalculateBoundingBox() {
public syncBoundingBox() {
this._boundingBox.xMin = this.center.x - this._radius;
this._boundingBox.xMax = this.center.x + this._radius;
this._boundingBox.yMin = this.center.y - this._radius;
@ -121,6 +121,9 @@ export class CirclePhysical extends CommandReceiver implements Circle, DynamicPh
},
);
// The body has moved; re-register it where it actually is.
this.syncBoundingBox();
return { hitObject: (hitObject as Physical | undefined)?.gameObject, velocity };
}

View file

@ -33,6 +33,8 @@ export class LampPhysical extends LampBase implements StaticPhysical {
}
public queueSetLight(color: vec3, lightness: number) {
this.color = vec3.clone(color);
this.lightness = lightness;
this.remoteCall('setLight', color, lightness);
}

View file

@ -69,7 +69,7 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
const sizeClass = clamp01(
(this.radius - settings.planetMinReferenceRadius) /
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
(settings.planetMaxReferenceRadius - settings.planetMinReferenceRadius),
);
this.sizePointMultiplier = mix(1, settings.planetSizePointMultiplierMax, sizeClass);
@ -222,8 +222,25 @@ export class PlanetPhysical extends PlanetBase implements StaticPhysical {
}
}
// hysteresis
private get flipTeam(): CharacterTeam {
const control = this.ownership - 0.5;
const enter = settings.planetControlThreshold + settings.planetFlipHysteresis;
if (control > enter) {
return CharacterTeam.red;
}
if (control < -enter) {
return CharacterTeam.blue;
}
if (Math.abs(control) < settings.planetControlThreshold) {
return CharacterTeam.neutral;
}
return this.lastTeam;
}
private detectFlip(game: CommandReceiver) {
const currentTeam = this.team;
const currentTeam = this.flipTeam;
if (currentTeam === this.lastTeam) {
return;
}

View file

@ -73,15 +73,38 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
vec2.normalize(vec2.create(), this.velocity),
10,
);
while (wasCollision) {
// Bounded: each pass advances by `delta`, but a degenerate direction would
// otherwise spin here forever inside the physics tick.
let passes = 0;
while (wasCollision && passes++ < 32) {
const intersecting = this.container
.findIntersecting(this.boundingBox)
.filter((g) => g instanceof CharacterPhysical && g.team === this.team);
const { hitSurface } = marchCircle(this.object, delta, intersecting, true);
wasCollision = hitSurface;
this.object.syncBoundingBox();
}
vec2.add(this.center, this.center, delta);
vec2.add(this.center, this.center, delta);
this.object.syncBoundingBox();
}
/**
* Lag compensation. Runs the projectile forward by the time its command spent
* travelling to the server, so a shot lands where the shooter aimed rather
* than forcing them to lead by their own latency on top of the projectile's
* travel time. Capped by settings.maxProjectileCatchUpSeconds so a bad or
* forged timestamp cannot spawn a shot arbitrarily far downrange.
*/
public fastForward(seconds: number) {
const step = settings.targetPhysicsDeltaTimeInSeconds;
let remaining = Math.min(Math.max(seconds, 0), settings.maxProjectileCatchUpSeconds);
while (remaining > 0 && this.isAlive) {
const delta = Math.min(step, remaining);
this.advance(delta);
remaining -= delta;
}
}
public get boundingBox(): ImmutableBoundingBox {
@ -123,6 +146,10 @@ export class ProjectilePhysical extends ProjectileBase implements DynamicPhysica
}
private handleStep({ deltaTimeInSeconds }: StepCommand) {
this.advance(deltaTimeInSeconds);
}
private advance(deltaTimeInSeconds: number) {
super.step(deltaTimeInSeconds);
if (this.strength <= 0) {

View file

@ -6,6 +6,7 @@ import {
CharacterTeam,
Random,
settings,
sanitizeName,
} from 'shared';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { getBoundingBoxOfCircle } from '../physics/functions/get-bounding-box-of-circle';
@ -14,6 +15,8 @@ import { CharacterPhysical } from '../objects/character-physical';
import { PlanetPhysical } from '../objects/planet-physical';
import { PlayerContainer } from './player-container';
const maximumNameLength = 40;
export abstract class PlayerBase extends CommandReceiver {
public character?: CharacterPhysical | null;
public center: vec2 = vec2.create();
@ -32,7 +35,12 @@ export abstract class PlayerBase extends CommandReceiver {
protected createCharacter() {
this.character = new CharacterPhysical(
this.playerInfo.name.slice(0, 20),
// Coerced, not just truncated. `.slice()` also exists on arrays, so a
// name that is an array passed straight through, was serialized into
// CreateObjects, and was revived as a class on every peer that received
// it — throwing inside the deserializer's reviver and killing that
// client's whole message batch.
sanitizeName(this.playerInfo.name, maximumNameLength),
this.sumKills,
this.sumDeaths,
this.team,
@ -72,10 +80,17 @@ export abstract class PlayerBase extends CommandReceiver {
return vec2.clone(Random.choose(safe.length ? safe : candidates)!.center);
}
// Seed a reconnecting player with the score it held before the drop, so a
// network blip costs you your character but not your match.
public restoreScore(kills: number, deaths: number) {
this.sumKills = kills;
this.sumDeaths = deaths;
}
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
let rotation = 0;
let radius = 0;
for (;;) {
for (let attempt = 0; attempt < 512; attempt++) {
const playerPosition = vec2.fromValues(
radius * Math.cos(rotation) + preferredCenter.x,
radius * Math.sin(rotation) + preferredCenter.y,
@ -96,6 +111,8 @@ export abstract class PlayerBase extends CommandReceiver {
rotation += Math.PI / 8;
radius += 30;
}
return vec2.clone(preferredCenter);
}
public destroy() {

View file

@ -4,10 +4,22 @@ import { PhysicalContainer } from '../physics/containers/physical-container';
import { NPC } from './npc';
import { Player } from './player';
import { PlayerBase } from './player-base';
import { ServerFullError } from './server-full-error';
import { randomUUID } from 'node:crypto';
// Score held for a player whose socket dropped, so a reconnect inside the grace
// window resumes the match instead of starting from zero.
interface ReservedScore {
team: CharacterTeam;
kills: number;
deaths: number;
expiresAtMs: number;
}
export class PlayerContainer {
private _players: Array<Player> = [];
private _npcs: Array<NPC> = [];
private reservedScores = new Map<string, ReservedScore>();
constructor(
private readonly objects: PhysicalContainer,
@ -31,11 +43,15 @@ export class PlayerContainer {
}
public createPlayer(playerInfo: PlayerInformation, socket: Socket): Player {
if (this._players.length === this.playerMaxCount) {
throw new Error('Too many players');
if (this._players.length >= this.playerMaxCount) {
throw new ServerFullError();
}
const team = this.getTeamOfNextPlayer();
const reserved = this.claimReservedScore(playerInfo.reconnectToken);
const team = reserved ? reserved.team : this.getTeamOfNextPlayer();
const player = new Player(playerInfo, this, this.objects, team, socket);
let npcToReplace = this._npcs.find((n) => n.team === team);
if (!npcToReplace) {
npcToReplace = this._npcs.find((n) => n.team !== team);
@ -43,12 +59,63 @@ export class PlayerContainer {
npcToReplace?.destroy();
this._npcs = this._npcs.filter((n) => n !== npcToReplace);
const player = new Player(playerInfo, this, this.objects, team, socket);
this._players.push(player);
if (reserved) {
player.restoreScore(reserved.kills, reserved.deaths);
}
return player;
}
/**
* A fresh token for a joining player. Handed to the client immediately; it
* only becomes redeemable once the player actually drops (see reserveScore).
*/
public issueToken(): string {
return randomUUID();
}
/**
* Hold a dropped player's team and score against its token for the grace
* window, so a client whose transport blipped rejoins as itself.
*/
public reserveScore(
token: string,
team: CharacterTeam,
kills: number,
deaths: number,
nowMs: number,
) {
this.expireReservations(nowMs);
this.reservedScores.set(token, {
team,
kills,
deaths,
expiresAtMs: nowMs + settings.reconnectGraceSeconds * 1000,
});
}
private claimReservedScore(token?: string): ReservedScore | undefined {
if (!token || typeof token !== 'string') {
return undefined;
}
const reserved = this.reservedScores.get(token);
if (!reserved) {
return undefined;
}
this.reservedScores.delete(token);
return reserved;
}
private expireReservations(nowMs: number) {
for (const [token, reserved] of this.reservedScores) {
if (reserved.expiresAtMs <= nowMs) {
this.reservedScores.delete(token);
}
}
}
public get players(): Array<PlayerBase> {
return [...this._players, ...this._npcs];
}
@ -57,6 +124,10 @@ export class PlayerContainer {
return this._players.length;
}
public get isFull(): boolean {
return this._players.length >= this.playerMaxCount;
}
// Measured round-trip times (ms) of the real connected players, for
// server-side latency stats. NPCs have no socket and are excluded.
public get connectedPlayerRttsMs(): Array<number> {
@ -96,7 +167,13 @@ export class PlayerContainer {
}
public deletePlayer(player: Player) {
const had = this._players.includes(player);
this._players = this._players.filter((p) => p !== player);
this.createNPCs();
// Only refill bots if this player was actually ours. A socket left over from
// a previous round reports its disconnect against the new container, and
// topping up on that would over-fill the roster.
if (had) {
this.createNPCs();
}
}
}

View file

@ -25,16 +25,30 @@ import {
PrimaryActionCommand,
LeapActionCommand,
InputAcknowledgement,
ClientHeartbeatCommand,
finiteInRange,
finiteVec2,
isFiniteNumber,
} from 'shared';
import { Socket } from 'socket.io';
import { BoundingBox } from '../physics/bounding-boxes/bounding-box';
import { PhysicalContainer } from '../physics/containers/physical-container';
import { PlayerContainer } from './player-container';
import { PlayerBase } from './player-base';
import { CharacterPhysical } from '../objects/character-physical';
// How often the server pings each client to measure round-trip time.
const pingIntervalSeconds = 1;
// Aspect ratio is used to size the streamed view area; a zero, negative or
// non-finite value collapses it and the player stops receiving the world.
const minimumAspectRatio = 0.2;
const maximumAspectRatio = 8;
// How much unflushed data on the socket counts as "this client is not keeping
// up". See sendQueuedCommandsToClient.
const maximumBufferedBytes = settings.maxBufferedBytesPerClient;
export class Player extends PlayerBase {
// default, until the clients sends its real value
private aspectRatio: number = 16 / 9;
@ -42,34 +56,98 @@ export class Player extends PlayerBase {
private timeSinceLastMessage = 0;
private objectsPreviouslyInViewArea: Array<GameObject> = [];
private lastInputClientTimeMs = 0;
private lastInputReceiptMs = 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.
// Token this session was issued at join. On a drop it is what the held score
// is filed under, and what the returning client presents to reclaim it.
public reconnectToken = '';
// Measured round-trip time to this client (ms) — drives projectile lag
// compensation as well as the server-side latency stats.
public rttMs = 0;
private timeSinceLastPing = 0;
private lastPingSentMs = 0;
// Nonce of the ping currently awaiting a reply, cleared once answered. Without
// it a Pong was just a bare event: any client could send a stream of them, or
// answer a ping it had sat on for a minute, and the RTT it produced feeds
// projectile lag compensation.
private pendingPingNonce = 0;
private nextPingNonce = 1;
// Nothing below trusts a field it was handed. Every one of these arrives
// straight from `deserialize`, which is a bare JSON.parse — and JSON carries
// Infinity happily as `1e999`. A single non-finite number reaching the
// simulation is unrecoverable: it spreads through the shared SDF and leaves a
// character that never dies and never respawns.
protected commandExecutors: CommandExecutors = {
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) =>
(this.aspectRatio = v.aspectRatio),
[SetAspectRatioActionCommand.type]: (v: SetAspectRatioActionCommand) => {
this.aspectRatio = finiteInRange(
v.aspectRatio,
minimumAspectRatio,
maximumAspectRatio,
this.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);
const direction = finiteVec2(c.direction, 1);
if (!direction) {
return;
}
this.observeClientTime(c.clientTimeMs);
this.character?.handleMovementAction(new MoveActionCommand(direction, 0));
},
[PrimaryActionCommand.type]: (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),
this.catchUpSeconds,
);
},
[PrimaryActionCommand.type]: (c: PrimaryActionCommand) =>
this.character?.shootTowards(c.position, c.charge),
[LeapActionCommand.type]: (c: LeapActionCommand) => {
if (!isFiniteNumber(c.clientTimeMs)) {
return;
}
// 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.
this.lastLeapClientTimeMs = c.clientTimeMs;
this.observeClientTime(c.clientTimeMs);
this.character?.leap();
},
// Closes every batch. Movement is only sent when it changes, so a held key
// used to freeze the acknowledged input time — and with it the window the
// client's predictor is allowed to replay. This advances the acknowledgement
// at the client's send rate regardless of whether the input changed.
[ClientHeartbeatCommand.type]: (c: ClientHeartbeatCommand) =>
this.observeClientTime(c.clientTimeMs),
};
// The newest client-clock instant whose commands have all been applied, and
// the server-clock instant it arrived at. The client's predictor needs both:
// the first tells it which of its inputs are already reflected in the
// snapshot, the second how stale that answer was by the time the snapshot was
// taken (see InputAcknowledgement.ackAgeMs).
private observeClientTime(clientTimeMs: number) {
if (isFiniteNumber(clientTimeMs) && clientTimeMs > this.lastInputClientTimeMs) {
this.lastInputClientTimeMs = clientTimeMs;
this.lastInputReceiptMs = performance.now();
}
}
// How long a command spent reaching us, for projectile lag compensation.
// Deliberately derived from the measured RTT and NOT from the client's own
// timestamp, so a forged stamp cannot buy extra compensation — which is why it
// takes no argument. ProjectilePhysical.fastForward caps the result.
private get catchUpSeconds(): number {
return Math.max(0, this.rttMs / 2 / 1000);
}
constructor(
playerInfo: PlayerInformation,
playerContainer: PlayerContainer,
@ -84,11 +162,29 @@ export class Player extends PlayerBase {
// 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);
}
// Only the outstanding ping counts, and only once: a stale, duplicated or
// invented reply is ignored. A client can still sit on the live ping to
// inflate its measured RTT, so the result is clamped — and the next ping
// retires the nonce, bounding the stall at one ping interval.
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,
);
};
// Fix 7a: the Pong listener outlives the round it belongs to unless it is
// taken off explicitly — the socket survives a restart, so a retired Player
// would stay reachable (and keep updating) through it.
public detachFromSocket() {
this.socket.off(TransportEvents.Pong, this.onPong);
}
protected createCharacter() {
@ -103,6 +199,11 @@ export class Player extends PlayerBase {
this.winnerTeam = winnerTeam;
}
// The corpse a projectile may still credit a kill to. Kept so a shot that was
// already in flight when we died is not thrown away: killCount is re-read at
// respawn instead of being snapshotted the instant the body died.
private dyingCharacter?: CharacterPhysical | null;
public step(deltaTimeInSeconds: number) {
if (this.character) {
this.center = this.character?.center;
@ -111,11 +212,16 @@ export class Player extends PlayerBase {
this.sumDeaths++;
this.sumKills = this.character.killCount;
this.dyingCharacter = this.character;
this.character = null;
this.timeUntilRespawn = settings.playerDiedTimeout;
}
} else {
if ((this.timeUntilRespawn -= deltaTimeInSeconds) < 0) {
if (this.dyingCharacter) {
this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount);
this.dyingCharacter = null;
}
this.createCharacter();
this.center = this.character!.center;
}
@ -164,7 +270,7 @@ export class Player extends PlayerBase {
this.queueCommandSend(
new PropertyUpdatesForObjects(
this.objectsPreviouslyInViewArea
.map((o) => o.getPropertyUpdates())
.map((o) => o.getPropertyUpdatesForFrame())
.filter((u) => u) as Array<PropertyUpdatesForObject>,
performance.now() / 1000,
),
@ -179,6 +285,9 @@ export class Player extends PlayerBase {
this.lastInputClientTimeMs,
this.character.launchMomentum,
this.lastLeapClientTimeMs,
this.lastInputReceiptMs > 0
? Math.max(0, performance.now() - this.lastInputReceiptMs)
: 0,
),
);
}
@ -212,7 +321,8 @@ export class Player extends PlayerBase {
if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) {
this.timeSinceLastPing = 0;
this.lastPingSentMs = performance.now();
this.socket.emit(TransportEvents.Ping);
this.pendingPingNonce = this.nextPingNonce++;
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce);
}
if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
@ -223,11 +333,63 @@ export class Player extends PlayerBase {
}
}
// Bytes written to this client's socket but not yet flushed to the network.
// engine.io's own Socket exposes no such number — it lives on the underlying
// ws socket, and only the websocket transport has one — so reading
// `socket.conn.bufferedAmount` (as this did) always produced undefined and the
// backpressure check below never once ran.
private get bufferedBytes(): number {
const conn = this.socket.conn as
| { transport?: { socket?: { bufferedAmount?: unknown } } }
| undefined;
const buffered = conn?.transport?.socket?.bufferedAmount;
return typeof buffered === 'number' ? buffered : 0;
}
public sendQueuedCommandsToClient() {
if (this.commandsToBeSent.length === 0) {
return;
}
// A client that is not draining gets the cheap half of the snapshot shed:
// piling more state behind it only grows a backlog it will never catch up
// on, and the freshest property update is the only one that matters.
//
// Only the state that next tick regenerates in full may be dropped. Create
// and delete are one-shot: handleViewAreaUpdate has ALREADY advanced
// objectsPreviouslyInViewArea by the time this runs, so dropping them leaves
// this client permanently missing objects it was never told about and
// holding ghosts it was never told to remove. Announcements and the end-game
// card are one-shot for the same reason. They are small; they go out even
// while backed up.
if (this.bufferedBytes > maximumBufferedBytes) {
// Matched by command type rather than `instanceof`, the same way every
// other dispatch in the codebase does it — the identity of a class from a
// bundled shared module is not something to depend on.
const sheddable: ReadonlyArray<string> = [
PropertyUpdatesForObjects.type,
UpdateMinimap.type,
];
this.commandsToBeSent = this.commandsToBeSent.filter(
(c) => !sheddable.includes(c.type),
);
if (this.commandsToBeSent.length === 0) {
return;
}
}
this.socket.emit(TransportEvents.ServerToPlayer, serialize(this.commandsToBeSent));
this.commandsToBeSent = [];
}
/** Score to hold for this player if it reconnects inside the grace window. */
public get scoreSnapshot(): { kills: number; deaths: number } {
return {
kills: Math.max(this.sumKills, this.character?.killCount ?? 0),
deaths: this.sumDeaths,
};
}
private handleAnnouncements() {
let announcement = '';
if (this.winnerTeam) {

View file

@ -0,0 +1,5 @@
export class ServerFullError extends Error {
constructor() {
super('Too many players');
}
}

View file

@ -1,5 +1,6 @@
import { glMatrix } from 'gl-matrix';
import {
applyArrayPlugins,
LampBase,
overrideDeserialization,
PlanetBase,
@ -29,6 +30,7 @@ import { PlanetView } from './scripts/objects/types/planet-view';
import { ProjectileView } from './scripts/objects/types/projectile-view';
glMatrix.setMatrixArrayType(Array);
applyArrayPlugins();
overrideDeserialization(CharacterBase, CharacterView);
overrideDeserialization(PlanetBase, PlanetView);
@ -53,6 +55,11 @@ const enableMusic = document.querySelector('#enable-music') as HTMLInputElement;
const enableVibration = document.querySelector('#enable-vibration') as HTMLInputElement;
const spinner = document.querySelector('#spinner-container') as HTMLElement;
const joinNotice = document.createElement('p');
joinNotice.className = 'join-notice';
joinNotice.style.display = 'none';
joinGameForm.prepend(joinNotice);
const toggleSettings = () => {
settings.className = settings.className === 'open' ? '' : 'open';
SoundHandler.play(Sounds.click);
@ -122,7 +129,7 @@ const main = async () => {
});
window.onpopstate = () => game.destroy();
for (;;) {
for (; ;) {
show(spinner);
hide(logoutButton, true);
show(landingUI, true, 'flex');
@ -150,6 +157,14 @@ const main = async () => {
hide(spinner);
show(logoutButton, true, 'block');
await gameOver;
const reason = game.lastRejectionReason;
if (reason) {
joinNotice.innerText = Game.rejectionText(reason);
joinNotice.style.display = 'block';
} else {
joinNotice.style.display = 'none';
}
}
} catch (e) {
console.error(e);

View file

@ -247,9 +247,8 @@ body {
border-radius: 1000px;
background-color: rgba(0, 0, 0, 0.35);
box-shadow:
inset 0 0 3px 0 rgba(0, 0, 0, 0.4),
0 0 10px rgba(0, 0, 0, 0.3);
box-shadow: inset 0 0 3px 0 rgba(0, 0, 0, 0.4),
0 0 10px rgba(0, 0, 0, 0.3);
border: $border-width solid rgba(255, 255, 255, 0.25);
z-index: 100;
}
@ -937,4 +936,29 @@ body {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.connection-banner {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
padding: 6px 14px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-size: 0.9rem;
letter-spacing: 0.02em;
pointer-events: none;
z-index: 20;
}
.join-notice {
margin: 0 0 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
background: rgba(209, 86, 82, 0.18);
color: #f2c9c7;
font-size: 0.85rem;
text-align: center;
}

View file

@ -1,5 +1,21 @@
import { Command, CommandReceiver, serialize, TransportEvents } from 'shared';
import {
ClientHeartbeatCommand,
Command,
CommandReceiver,
serialize,
settings,
TransportEvents,
} from 'shared';
import { Socket } from 'socket.io-client';
import { predictorNowMs } from '../helper/prediction/local-character-predictor';
const heartbeatIntervalMs = settings.clientSendInterval * 1000;
// A frame that lands a hair short of the deadline still counts as due. At an
// exact ratio of frame rate to interval — 60 Hz against 30 Hz — whether the gap
// compares as reached is decided by floating-point noise, and the heartbeat
// slips a whole frame every third beat, drifting to ~21 Hz instead of 30.
const dueToleranceMs = 1;
export class CommandSocket extends CommandReceiver {
constructor(private readonly socket: Socket) {
@ -7,14 +23,35 @@ export class CommandSocket extends CommandReceiver {
}
private commandQueue: Array<Command> = [];
private lastSendMs = -Infinity;
protected defaultCommandExecutor(command: Command) {
this.commandQueue.push(command);
}
public sendQueuedCommands() {
if (this.commandQueue.length > 0) {
this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue));
this.commandQueue = [];
const nowMs = predictorNowMs();
// Real input goes out on the frame it happens — that is the whole latency
// budget the player feels. An idle client still has to keep the server's
// input acknowledgement moving (a held key generates no fresh command), but
// that heartbeat is paced by the clock rather than by the frame rate.
//
// Sending once per rendered frame instead put every display above
// settings.maxInboundMessagesPerSecond permanently over the server's inbound
// allowance; once the burst was spent it silently discarded whole batches,
// and since movement commands are edge-triggered and never re-sent, a lost
// batch meant the direction change simply never happened.
if (
this.commandQueue.length === 0 &&
nowMs - this.lastSendMs < heartbeatIntervalMs - dueToleranceMs
) {
return;
}
this.lastSendMs = nowMs;
this.commandQueue.push(new ClientHeartbeatCommand(Math.round(nowMs)));
this.socket.emit(TransportEvents.PlayerToServer, serialize(this.commandQueue));
this.commandQueue = [];
}
}

View file

@ -14,20 +14,26 @@ export class KeyboardListener extends CommandGenerator {
}
private keyDownListener = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
// Space leaps (W / ArrowUp already cover walking up). Edge-triggered so a
// held key's auto-repeat doesn't spam leaps.
if ((key === ' ' || key === 'shift') && !this.keysDown.has(key)) {
const key = event.code;
if (
(key === 'Space' || key === 'ShiftLeft' || key === 'ShiftRight') &&
!this.keysDown.has(key)
) {
const clientTimeMs = localCharacterPredictor.recordLeap();
this.sendCommandToSubscribers(new LeapActionCommand(clientTimeMs));
}
if (this.keysDown.has(key)) {
return;
}
this.keysDown.add(key);
this.generateCommands();
};
private keyUpListener = (event: KeyboardEvent) => {
this.keysDown.delete(event.key.toLowerCase());
this.generateCommands();
if (this.keysDown.delete(event.code)) {
this.generateCommands();
}
};
private blurListener = () => {
@ -36,10 +42,10 @@ export class KeyboardListener extends CommandGenerator {
};
private generateCommands() {
const up = ~~(this.keysDown.has('w') || this.keysDown.has('arrowup'));
const down = ~~(this.keysDown.has('s') || this.keysDown.has('arrowdown'));
const left = ~~(this.keysDown.has('a') || this.keysDown.has('arrowleft'));
const right = ~~(this.keysDown.has('d') || this.keysDown.has('arrowright'));
const up = ~~(this.keysDown.has('KeyW') || this.keysDown.has('ArrowUp'));
const down = ~~(this.keysDown.has('KeyS') || this.keysDown.has('ArrowDown'));
const left = ~~(this.keysDown.has('KeyA') || this.keysDown.has('ArrowLeft'));
const right = ~~(this.keysDown.has('KeyD') || this.keysDown.has('ArrowRight'));
const movement = vec2.fromValues(right - left, up - down);
if (vec2.squaredLength(movement) > 0) {

View file

@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix';
import { CommandGenerator, PrimaryActionCommand, holdDurationToCharge } from 'shared';
import { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
import { predictorNowMs } from '../helper/prediction/local-character-predictor';
import { Pointer } from '../helper/pointer';
export class MouseListener extends CommandGenerator {
@ -16,9 +17,13 @@ export class MouseListener extends CommandGenerator {
super();
target.addEventListener('mousedown', this.mouseDownListener);
target.addEventListener('mouseup', this.mouseUpListener);
target.addEventListener('mousemove', this.mouseMoveListener);
target.addEventListener('contextmenu', this.contextMenuListener);
// Release is watched on the window, not the canvas: a charge released over
// the settings gear, the fullscreen icon, or outside the window otherwise
// never fired the shot and left the charge ring up until the next press.
window.addEventListener('mouseup', this.mouseUpListener);
window.addEventListener('blur', this.cancelPrimary);
}
// Only the screen position is stored; it is reprojected to world space each
@ -46,10 +51,23 @@ export class MouseListener extends CommandGenerator {
const charge = holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
this.primaryDownAt = null;
this.sendCommandToSubscribers(
new PrimaryActionCommand(this.positionFromEvent(event), charge),
new PrimaryActionCommand(
this.positionFromEvent(event),
charge,
Math.round(predictorNowMs()),
),
);
};
// Losing the window mid-charge cannot produce a meaningful aim point, so drop
// the charge rather than leaving the indicator running.
private cancelPrimary = () => {
if (this.primaryDownAt !== null) {
this.primaryDownAt = null;
ChargeIndicator.end();
}
};
// Suppress the browser context menu on the canvas; right-click has no action.
private contextMenuListener = (event: MouseEvent) => {
event.preventDefault();
@ -64,8 +82,9 @@ export class MouseListener extends CommandGenerator {
public destroy() {
ChargeIndicator.end();
this.target.removeEventListener('mousedown', this.mouseDownListener);
this.target.removeEventListener('mouseup', this.mouseUpListener);
this.target.removeEventListener('mousemove', this.mouseMoveListener);
this.target.removeEventListener('contextmenu', this.contextMenuListener);
window.removeEventListener('mouseup', this.mouseUpListener);
window.removeEventListener('blur', this.cancelPrimary);
}
}

View file

@ -10,7 +10,10 @@ import {
} from 'shared';
import { Game } from '../game';
import { ChargeIndicator } from '../charge-indicator';
import { localCharacterPredictor } from '../helper/prediction/local-character-predictor';
import {
localCharacterPredictor,
predictorNowMs,
} from '../helper/prediction/local-character-predictor';
export class TouchListener extends CommandGenerator {
private static readonly deadZone = 8;
@ -24,6 +27,7 @@ export class TouchListener extends CommandGenerator {
private isJoystickActive = false;
private touchStartPosition!: vec2;
private primaryDownAt: number | null = null;
private gestureTouchId: number | null = null;
private fireButton: HTMLElement;
private fireStrengthRing: HTMLElement;
@ -69,23 +73,43 @@ export class TouchListener extends CommandGenerator {
target.addEventListener('touchstart', this.touchStartListener);
target.addEventListener('touchmove', this.touchMoveListener);
target.addEventListener('touchend', this.touchEndListener);
target.addEventListener('touchcancel', this.touchCancelListener);
}
// The contact that started this gesture, or undefined if it is not in `list`.
private findGestureTouch(list: TouchList): Touch | undefined {
if (this.gestureTouchId === null) {
return undefined;
}
for (let i = 0; i < list.length; i++) {
if (list[i].identifier === this.gestureTouchId) {
return list[i];
}
}
return undefined;
}
private touchStartListener = (event: TouchEvent) => {
event.preventDefault();
if (this.isJoystickActive) {
const center = vec2.fromValues(
last(event.touches)!.clientX,
last(event.touches)!.clientY,
last(event.changedTouches)!.clientX,
last(event.changedTouches)!.clientY,
);
this.sendCommandToSubscribers(
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center)),
new PrimaryActionCommand(
this.game.displayToWorldCoordinates(center),
0,
Math.round(predictorNowMs()),
),
);
} else {
this.touchStartPosition = vec2.fromValues(
event.touches[0].clientX,
event.touches[0].clientY,
);
const touch = event.changedTouches[0];
if (!touch) {
return;
}
this.gestureTouchId = touch.identifier;
this.touchStartPosition = vec2.fromValues(touch.clientX, touch.clientY);
this.primaryDownAt = performance.now();
ChargeIndicator.begin(this.touchStartPosition.x, this.touchStartPosition.y);
}
@ -94,10 +118,12 @@ export class TouchListener extends CommandGenerator {
private touchMoveListener = (event: TouchEvent) => {
event.preventDefault();
const touchPosition = vec2.fromValues(
event.touches[0].clientX,
event.touches[0].clientY,
);
const touch = this.findGestureTouch(event.touches);
if (!touch || !this.touchStartPosition) {
return;
}
const touchPosition = vec2.fromValues(touch.clientX, touch.clientY);
const delta = vec2.subtract(vec2.create(), touchPosition, this.touchStartPosition);
vec2.scale(delta, delta, TouchListener.deltaScaling);
@ -133,6 +159,14 @@ export class TouchListener extends CommandGenerator {
private touchEndListener = (event: TouchEvent) => {
event.preventDefault();
// Only the contact that owns the gesture ends it — not whichever finger
// happens to lift, and not "the screen is now empty".
const touch = this.findGestureTouch(event.changedTouches);
if (!touch) {
return;
}
this.gestureTouchId = null;
if (!this.isJoystickActive) {
ChargeIndicator.end();
const charge =
@ -140,20 +174,39 @@ export class TouchListener extends CommandGenerator {
? 0
: holdDurationToCharge((performance.now() - this.primaryDownAt) / 1000);
this.primaryDownAt = null;
const center = vec2.fromValues(
event.changedTouches[0].clientX,
event.changedTouches[0].clientY,
);
const center = vec2.fromValues(touch.clientX, touch.clientY);
this.sendCommandToSubscribers(
new PrimaryActionCommand(this.game.displayToWorldCoordinates(center), charge),
new PrimaryActionCommand(
this.game.displayToWorldCoordinates(center),
charge,
Math.round(predictorNowMs()),
),
);
} else if (event.touches.length === 0) {
this.isJoystickActive = false;
this.joystick.parentElement?.removeChild(this.joystick);
this.sendMove(vec2.create());
} else {
this.releaseJoystick();
}
};
// Also reached from touchcancel: a system gesture, an incoming call or a
// palm rejection would otherwise leave the stick latched at full tilt.
private touchCancelListener = (event: TouchEvent) => {
if (!this.findGestureTouch(event.changedTouches)) {
return;
}
this.gestureTouchId = null;
this.primaryDownAt = null;
ChargeIndicator.end();
if (this.isJoystickActive) {
this.releaseJoystick();
}
};
private releaseJoystick() {
this.isJoystickActive = false;
this.joystick.parentElement?.removeChild(this.joystick);
this.sendMove(vec2.create());
}
private swallowTouch = (event: TouchEvent) => {
event.preventDefault();
event.stopPropagation();
@ -237,7 +290,9 @@ export class TouchListener extends CommandGenerator {
direction,
settings.touchAimRange,
);
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
this.sendCommandToSubscribers(
new PrimaryActionCommand(aim, charge, Math.round(predictorNowMs())),
);
};
public update(_deltaTimeInSeconds: number) {
@ -250,9 +305,8 @@ export class TouchListener extends CommandGenerator {
const character = this.game.gameObjects.player;
if (character) {
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${
character.strengthFraction * 360
}deg, transparent 0deg)`;
this.fireStrengthRing.style.background = `conic-gradient(rgba(255, 255, 255, 0.75) ${character.strengthFraction * 360
}deg, transparent 0deg)`;
}
}
@ -261,6 +315,7 @@ export class TouchListener extends CommandGenerator {
this.target.removeEventListener('touchstart', this.touchStartListener);
this.target.removeEventListener('touchmove', this.touchMoveListener);
this.target.removeEventListener('touchend', this.touchEndListener);
this.target.removeEventListener('touchcancel', this.touchCancelListener);
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);

View file

@ -99,6 +99,18 @@ export abstract class FeedbackHud {
this.elimination = undefined;
}
/**
* Tear the HUD down entirely. Its root lives on document.body rather than the
* game overlay, so leaving a match while dead used to leave "Eliminated /
* Respawning…" painted over the landing page.
*/
public static reset(): void {
this.hideElimination();
this.root?.parentElement?.removeChild(this.root);
this.root = undefined;
this.killfeed = undefined;
}
private static streakName(streak: number): string | undefined {
switch (streak) {
case 2:

View file

@ -21,6 +21,7 @@ import {
Command,
settings,
InputAcknowledgement,
JoinRejectionReason,
} from 'shared';
import { io, Socket } from 'socket.io-client';
import { KeyboardListener } from './commands/keyboard-listener';
@ -40,6 +41,7 @@ import { Tutorial } from './tutorial';
import { Scoreboard } from './scoreboard';
import { Minimap } from './minimap';
import { ScreenShake } from './screen-shake';
import { FeedbackHud } from './feedback-hud';
export class Game extends CommandReceiver {
public gameObjects = new GameObjectContainer(this);
@ -60,6 +62,11 @@ export class Game extends CommandReceiver {
private keystoneArrow?: HTMLElement;
private socketReceiver!: CommandSocket;
private tutorial!: Tutorial;
// Issued by the server on join; presented on reconnect to reclaim this
// player's team and score instead of coming back as a blank slate.
private reconnectToken?: string;
private connectionBanner?: HTMLElement;
private rejectionReason?: JoinRejectionReason;
constructor(
private readonly playerDecision: PlayerDecision,
@ -110,19 +117,76 @@ export class Game extends CommandReceiver {
this.socket.io.opts.transports = ['polling', 'websocket'];
});
// A transport drop is not the end of the match. Previously this tore the
// game down on the first `disconnect`, which stopped the render loop, which
// closed the socket — cancelling the reconnection the client is configured
// for before it could ever run, and dumping the player on the server list.
this.socket.on('disconnect', () => {
if (!this.isBetweenGames) {
this.destroy();
if (this.isBetweenGames) {
return;
}
this.showConnectionBanner('Reconnecting…');
});
this.socket.on(TransportEvents.Ping, () => {
this.socket.emit(TransportEvents.Pong);
this.socket.on('connect', () => {
if (this.isBetweenGames) {
return;
}
// A reconnect is a brand-new server-side connection that has never seen a
// join, so the join has to be re-sent or the client sits connected and
// invisible forever.
this.hideConnectionBanner();
serverTimeline.reset();
localCharacterPredictor.reset();
// ...and it is a brand-new server-side *player*, whose view-area
// bookkeeping starts empty. Anything the dropped session was told about
// will never be retracted, so the old world has to go here.
this.gameObjects.reset();
this.socket.emit(TransportEvents.PlayerJoining, {
...this.playerDecision,
reconnectToken: this.reconnectToken,
});
});
this.socket.io.on('reconnect_failed', () => {
this.showConnectionBanner('Connection lost');
this.destroy();
});
this.socket.on(TransportEvents.PlayerJoined, (token: string) => {
this.reconnectToken = typeof token === 'string' ? token : undefined;
this.hideConnectionBanner();
});
this.socket.on(TransportEvents.JoinRejected, (reason: JoinRejectionReason) => {
this.rejectionReason = reason;
this.showConnectionBanner(Game.rejectionText(reason));
this.destroy();
});
// Echo the nonce: the server only accepts a reply that matches the ping
// still outstanding, so a stale or duplicated Pong cannot move its RTT.
this.socket.on(TransportEvents.Ping, (nonce: unknown) => {
this.socket.emit(TransportEvents.Pong, nonce);
});
this.socket.on(TransportEvents.ServerToPlayer, (serializedCommands: string) => {
const commands: Array<Command> = deserialize(serializedCommands);
commands.forEach((c) => this.handleCommand(c));
// One malformed object must not take down the message pump. deserialize
// revives classes by name from the payload, so a hostile or corrupt field
// can throw inside JSON.parse's reviver — and every later batch would be
// lost with it.
try {
const commands: Array<Command> = deserialize(serializedCommands);
commands.forEach((c) => {
try {
this.handleCommand(c);
} catch (e) {
console.error('Failed to apply a server command', e);
}
});
} catch (e) {
console.error('Dropped an undecodable server message', e);
}
});
this.socketReceiver = new CommandSocket(this.socket);
@ -138,9 +202,10 @@ export class Game extends CommandReceiver {
this.touchListener.subscribe(this.socketReceiver);
this.touchListener.subscribe(this.tutorial);
// The join is emitted from the socket's `connect` handler above, which fires
// for the first connection and for every reconnection alike — so one code
// path covers both, and a reconnect can never be left unjoined.
this.isBetweenGames = false;
this.socket.emit(TransportEvents.PlayerJoining, this.playerDecision);
}
protected defaultCommandExecutor(c: Command) {
@ -163,6 +228,7 @@ export class Game extends CommandReceiver {
c.clientTimeMs,
c.bodyVelocity,
c.lastLeapClientTimeMs,
c.ackAgeMs,
),
[GameEndCommand.type]: () => (this.isEnding = true),
[UpdateMinimap.type]: (c: UpdateMinimap) => (this.lastMinimap = c),
@ -208,11 +274,49 @@ export class Game extends CommandReceiver {
);
this.socket.close();
this.overlay.innerHTML = '';
this.hideConnectionBanner();
// The HUD root lives on document.body, not the overlay, so it has to be torn
// down explicitly or it stays painted over the landing page.
FeedbackHud.reset();
this.keyboardListener.destroy();
this.mouseListener.destroy();
this.touchListener.destroy();
}
/** Why the player was sent back to the server list, if they were. */
public get lastRejectionReason(): JoinRejectionReason | undefined {
return this.rejectionReason;
}
public static rejectionText(reason: JoinRejectionReason): string {
switch (reason) {
case JoinRejectionReason.ServerFull:
return 'That server is full — pick another';
case JoinRejectionReason.RoundEnding:
return 'That round is just finishing — try again in a moment';
case JoinRejectionReason.AlreadyJoined:
return 'Already joined on this connection';
default:
return 'The server refused the connection';
}
}
private showConnectionBanner(text: string) {
if (!this.connectionBanner) {
this.connectionBanner = document.createElement('div');
this.connectionBanner.className = 'connection-banner';
this.overlay.appendChild(this.connectionBanner);
}
this.connectionBanner.innerText = text;
this.connectionBanner.style.display = 'block';
}
private hideConnectionBanner() {
if (this.connectionBanner) {
this.connectionBanner.style.display = 'none';
}
}
public displayToWorldCoordinates(p: vec2): vec2 {
return this.renderer?.displayToWorldCoordinates(p) ?? vec2.create();
}

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