import { performance } from 'perf_hooks'; import { vec2 } from 'gl-matrix'; import { CommandExecutors, CreateObjectsCommand, CreatePlayerCommand, DeleteObjectsCommand, MoveActionCommand, serialize, TransportEvents, SetAspectRatioActionCommand, calculateViewArea, settings, PlayerInformation, CharacterTeam, UpdateMinimap, GameObject, Command, MinimapPlayer, RemoteCallsForObject, RemoteCallsForObjects, ServerAnnouncement, PropertyUpdatesForObjects, PropertyUpdatesForObject, 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; private timeSinceLastMessage = 0; // What this client has already been told about, in the order it was told. // A Set because the per-snapshot diff below is membership-heavy; iteration // order is insertion order, so the streamed object order is unchanged. private objectsInViewArea = new Set(); private lastInputClientTimeMs = 0; private lastInputReceiptMs = 0; private lastLeapClientTimeMs = 0; // 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 = finiteInRange( v.aspectRatio, minimumAspectRatio, maximumAspectRatio, this.aspectRatio, ); }, [MoveActionCommand.type]: (c: MoveActionCommand) => { 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, ); }, [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, objectContainer: PhysicalContainer, team: CharacterTeam, private readonly socket: Socket, ) { super(playerInfo, playerContainer, objectContainer, team); 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, 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() { super.createCharacter(); this.objectsInViewArea.add(this.character!); this.queueCommandSend(new CreatePlayerCommand(this.character!)); } private winnerTeam?: CharacterTeam; public onGameEnded(winnerTeam: CharacterTeam) { 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) { this.stepLifecycle(deltaTimeInSeconds); } protected onCharacterDied(character: CharacterPhysical) { this.dyingCharacter = character; } protected onBeforeRespawn() { if (this.dyingCharacter) { this.sumKills = Math.max(this.sumKills, this.dyingCharacter.killCount); this.dyingCharacter = null; } } private handleViewAreaUpdate() { const viewArea = calculateViewArea(this.center, this.aspectRatio, 1.2); const bb = new BoundingBox(); bb.topLeft = viewArea.topLeft; bb.size = viewArea.size; const inViewArea = new Set( this.objectContainer.findIntersecting(bb).map((o) => o.gameObject), ); // 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) { inViewArea.add(this.character); } // Set membership rather than Array.includes: this diff runs per player per // snapshot over everything on their screen, and was quadratic in that. // Set membership rather than Array.includes: this diff runs per player per // snapshot over everything on their screen, and was quadratic in that. const newlyIntersecting = [...inViewArea].filter( (o) => !this.objectsInViewArea.has(o), ); const noLongerIntersecting = [...this.objectsInViewArea].filter( (o) => !inViewArea.has(o), ); this.objectsInViewArea = inViewArea; if (noLongerIntersecting.length > 0) { this.queueCommandSend( new DeleteObjectsCommand(noLongerIntersecting.map((g) => g.id)), ); } if (newlyIntersecting.length > 0) { this.queueCommandSend(new CreateObjectsCommand(newlyIntersecting)); } this.queueCommandSend(new UpdateMinimap(this.getMinimapPlayers())); const propertyUpdates: Array = []; for (const object of this.objectsInViewArea) { const update = object.getPropertyUpdatesForFrame(); if (update) { propertyUpdates.push(update); } } this.queueCommandSend( new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 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.movementSnapshot, this.lastLeapClientTimeMs, this.lastInputReceiptMs > 0 ? Math.max(0, performance.now() - 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 { return this.playerContainer.players .filter((p) => p !== this && p.character?.isAlive) .map( (p) => new MinimapPlayer(p.character!.id, vec2.clone(p.character!.center), p.team), ); } private commandsToBeSent: Array = []; public queueCommandSend(command: Command) { this.commandsToBeSent.push(command); } public stepCommunications(deltaTime: number) { // Runs at the physics rate, and on most ticks nothing has fired — so test // first and only build the command when there is something to send. let remoteCalls: Array | undefined; for (const object of this.objectsInViewArea) { const calls = object.getRemoteCalls(); if (calls.length > 0) { (remoteCalls ??= []).push(new RemoteCallsForObject(object.id, calls)); } } if (remoteCalls) { this.queueCommandSend(new RemoteCallsForObjects(remoteCalls)); } if ((this.timeSinceLastPing += deltaTime) > pingIntervalSeconds) { this.timeSinceLastPing = 0; this.lastPingSentMs = performance.now(); this.pendingPingNonce = this.nextPingNonce++; this.socket.emit(TransportEvents.Ping, this.pendingPingNonce); } if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { this.handleAnnouncements(); this.handleViewAreaUpdate(); this.sendQueuedCommandsToClient(); this.timeSinceLastMessage = 0; } } // 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 = [ PropertyUpdatesForObjects.type, UpdateMinimap.type, // Only meaningful together with the pose it describes, and regenerated // in full next tick — so it is shed with it rather than advancing the // client's replay anchor past a snapshot it never received. InputAcknowledgement.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) { announcement = `Team ${this.winnerTeam} won 🎉`; } else if (!this.character) { announcement = `Reviving in ${Math.round(this.timeUntilRespawn)}…`; } if (announcement) { this.queueCommandSend(new ServerAnnouncement(announcement)); } } }