Claude fixes

This commit is contained in:
Andras Schmelczer 2026-08-22 11:03:58 +01:00
commit 1295828ac8
11 changed files with 2086 additions and 205 deletions

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();
}

View file

@ -25,10 +25,16 @@ export const setPredictorClockForTesting = (clock: () => number): void => {
nowMs = clock;
};
// The same clock the predictor stamps input with. Anything that tells the server
// "this is how far my input timeline has got" must read it from here, or the
// acknowledgement comes back in a different time base than the replay window.
export const predictorNowMs = (): number => nowMs();
// Don't replay more than this far back: if the last acknowledged input is older
// (a stall, or a backgrounded tab catching up) snap to the authoritative pose
// instead of grinding through hundreds of steps.
const maxReplayMs = 300;
// (a stall, or a backgrounded tab catching up) fall back to a shorter window
// instead of grinding through hundreds of steps. Comfortably covers the replay
// a 300 ms round trip needs; beyond that the body simply trails a little.
const maxReplayMs = 400;
// Render-side easing of the correction the reconciliation produces each frame,
// so a snapshot that disagrees with the prediction is smoothed out instead of
@ -60,15 +66,33 @@ export class LocalCharacterPredictor {
private readonly world = new ClientCharacterWorld();
private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle };
private lastAckClientTimeMs?: number;
// Wall-clock (client) time the latest authoritative snapshot was received. The
// replay predicts forward from HERE by the snapshot's age, so the local pose
// advances smoothly with real time between the 25 Hz snapshots. Anchoring to
// the last *acked input* time instead breaks when input is sent only on change
// (a held key sends nothing): that time freezes, the window pins to the
// maxReplayMs clamp, the replay displacement goes constant, and the pose
// stair-steps at the snapshot rate.
private authReceiptMs = 0;
// Client-clock instant the authoritative pose below belongs to, less one
// one-way trip — which is exactly the lead the local body should have, since
// the input the player is giving right now will not reach the server for that
// long. Replaying from here reproduces every input the server has not applied
// yet, so the body responds immediately at any latency.
//
// It is built from BOTH halves of the acknowledgement: the client-clock time
// of the newest input the server had applied, plus how long after that the
// snapshot was actually taken. Both halves are needed.
//
// Anchoring on the acknowledged input time ALONE beats against the send
// cadence: input is sent once per frame, so at snapshot time the newest input
// the server holds is 0..1 frame old depending on where the client's frames
// fell, and that age lands directly in the replay window. Measured on
// localhost it walked a sawtooth — 17, 10, 6, 1, 11, 6, 2, 12 ms — so the
// window jumped by up to a frame of travel every snapshot and the body
// stuttered at 25 Hz. ackAgeMs cancels it exactly.
//
// Anchoring on the snapshot's ARRIVAL is free of that beat, but it under-
// advances by a one-way trip, so the local body trails its own input at any
// real latency. This anchor degenerates to arrival on a zero-latency link and
// grows into full compensation as latency rises.
//
// A held key sends no fresh movement, which would freeze the acknowledgement
// altogether; that is fixed at the source, by closing every outgoing batch
// with a ClientHeartbeatCommand.
private replayAnchorMs?: number;
// Authoritative launch momentum at the last snapshot — seeds each replay so a
// leap/slingshot/recoil flight is reproduced and continuously corrected.
private authoritativeBodyVelocity = vec2.create();
@ -123,14 +147,13 @@ export class LocalCharacterPredictor {
clientTimeMs: number,
bodyVelocity: vec2,
lastLeapClientTimeMs: number,
ackAgeMs = 0,
): void {
// Inputs only advance the acknowledgement forward; the launch momentum and
// leap boundary always adopt the latest authoritative values.
if (
this.lastAckClientTimeMs === undefined ||
clientTimeMs > this.lastAckClientTimeMs
) {
this.lastAckClientTimeMs = clientTimeMs;
const anchor = clientTimeMs + Math.max(0, ackAgeMs);
if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) {
this.replayAnchorMs = anchor;
}
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
this.lastLeapAckMs = lastLeapClientTimeMs;
@ -138,7 +161,6 @@ export class LocalCharacterPredictor {
public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void {
this.authoritative = { head, leftFoot, rightFoot };
this.authReceiptMs = Math.round(nowMs());
}
// The player pressed leap; remember when, so the replay applies the same
@ -168,8 +190,7 @@ export class LocalCharacterPredictor {
this.leapHistory = [];
this.lastLeapAckMs = -Infinity;
this.authoritative = undefined;
this.lastAckClientTimeMs = undefined;
this.authReceiptMs = 0;
this.replayAnchorMs = undefined;
vec2.zero(this.authoritativeBodyVelocity);
this.currentStrength = settings.playerMaxStrength;
this.carriedPlanetId = undefined;
@ -179,7 +200,7 @@ export class LocalCharacterPredictor {
}
public get canPredict(): boolean {
return this.authoritative !== undefined && this.lastAckClientTimeMs !== undefined;
return this.authoritative !== undefined && this.replayAnchorMs !== undefined;
}
// During spawn-in and death the server freezes walking and only scales the
@ -232,11 +253,10 @@ export class LocalCharacterPredictor {
private simulate(): CharacterMovementState {
const auth = this.authoritative!;
const now = Math.round(nowMs());
// Predict forward from the latest snapshot by its age, clamped so a stall
// (or a backgrounded tab catching up) snaps instead of grinding hundreds of
// steps. This advances with wall-clock time even while a held key sends no
// fresh input, so the pose no longer pins to the 25 Hz snapshot cadence.
const startMs = Math.max(this.authReceiptMs, now - maxReplayMs);
// Replay every input the server has not confirmed yet: from the anchor up to
// now, clamped so a stall (or a backgrounded tab catching up) cannot grind
// through hundreds of steps.
const startMs = Math.min(now, Math.max(this.replayAnchorMs!, now - maxReplayMs));
const windowMs = Math.max(0, now - startMs);
const steps = Math.floor(windowMs / stepMs);
const remainderSeconds = (windowMs - steps * stepMs) / 1000;

View file

@ -15,6 +15,7 @@ import {
UpdatePropertyCommand,
} from 'shared';
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
import { RenderCommand } from '../commands/types/render';
import { StepCommand } from '../commands/types/step';
import { FeedbackHud } from '../feedback-hud';
import { Game } from '../game';
@ -31,6 +32,19 @@ export class GameObjectContainer extends CommandReceiver {
public camera: Camera = new Camera(this.game);
private wasLocalPlayerAlive = false;
// Create and delete arrive on the wire, but positions are drawn
// interpolationDelaySeconds in the past — so applying them the instant a
// packet lands made every projectile pop into existence and hang at its muzzle
// for 100 ms, then disappear a few hundred units before its rendered impact.
// Objects are therefore admitted and retired on the render timeline instead:
// they exist (and accumulate interpolation frames) from the moment they
// arrive, but only draw once the cursor reaches the snapshot that introduced
// them, and keep drawing until it reaches the one that removed them.
private visibleFrom = new Map<Id, number>();
private deleteAt = new Map<Id, number>();
private awaitingCreateStamp: Array<Id> = [];
private awaitingDeleteStamp: Array<Id> = [];
protected commandExecutors: CommandExecutors = {
[CreatePlayerCommand.type]: (c: CreatePlayerCommand) => {
this.player = c.character as CharacterView;
@ -45,9 +59,17 @@ export class GameObjectContainer extends CommandReceiver {
},
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
c.objects.forEach((o) => this.addObject(o as GameObject)),
c.objects.forEach((o) => {
this.addObject(o as GameObject);
this.awaitingCreateStamp.push((o as GameObject).id);
}),
[StepCommand.type]: (c: StepCommand) => {
// A batch carrying no property updates (a bare announcement, say) leaves
// entries unstamped; fall back to the newest known snapshot so nothing is
// stranded invisible.
this.stampPending(serverTimeline.snapshotTime);
this.retireDueObjects();
this.defaultCommandExecutor(c);
// The local body is alive only while its object still exists (the server
@ -91,6 +113,9 @@ export class GameObjectContainer extends CommandReceiver {
[PropertyUpdatesForObjects.type]: (c: PropertyUpdatesForObjects) => {
serverTimeline.onSnapshot(c.timestamp);
// Create/delete precede the property updates inside a batch, so this is
// where the batch's server timestamp becomes known to them.
this.stampPending(c.timestamp);
c.updates.forEach((u) => {
u.updates.forEach((au) => this.objects.get(u.id)?.handleCommand(au));
if (this.player && u.id === this.player.id) {
@ -100,17 +125,36 @@ export class GameObjectContainer extends CommandReceiver {
},
[DeleteObjectsCommand.type]: (c: DeleteObjectsCommand) =>
c.ids.forEach((id: Id) => this.deleteObject(id)),
c.ids.forEach((id: Id) => this.awaitingDeleteStamp.push(id)),
};
constructor(private game: Game) {
super();
}
// The local player's world position, but only while the body is alive. On
// death the server deletes the character object (yet `player` keeps pointing
// at the now-stale view), so gate on the object still being present — otherwise
// the minimap would pin the "you" dot at the death spot for the whole respawn.
/**
* Forget everything the previous connection was told about.
*
* A reconnect is a brand-new server-side player whose view-area bookkeeping
* starts empty, so it re-announces what is in view NOW and never sends a
* delete for anything the dropped session held. Without this, every object
* that has since moved out of view — the old character included — stays in the
* map and on screen forever, as a frozen ghost.
*
* `player` is deliberately left pointing at the stale view: every read of it
* is gated on the object still being in `objects`, so clearing the map is what
* makes it inert, and the next CreatePlayerCommand replaces it.
*/
public reset() {
this.objects.forEach((o) => o.handleCommand(new BeforeDestroyCommand()));
this.objects.clear();
this.visibleFrom.clear();
this.deleteAt.clear();
this.awaitingCreateStamp = [];
this.awaitingDeleteStamp = [];
this.wasLocalPlayerAlive = false;
}
public get localPlayerPosition(): vec2 | undefined {
return this.player && this.objects.has(this.player.id)
? this.player.position
@ -128,10 +172,48 @@ export class GameObjectContainer extends CommandReceiver {
}
protected defaultCommandExecutor(c: Command) {
this.objects.forEach((o) => o.handleCommand(c));
const isRender = c.type === RenderCommand.type;
this.objects.forEach((o) => {
// Stepping always runs, so an object that has not appeared yet is still
// accumulating interpolation frames and enters the scene already moving.
if (isRender && !this.isVisible(o.id)) {
return;
}
o.handleCommand(c);
});
this.camera.handleCommand(c);
}
private isVisible(id: Id): boolean {
const from = this.visibleFrom.get(id);
return from === undefined || serverTimeline.renderTime >= from;
}
private stampPending(timestamp: number) {
for (const id of this.awaitingCreateStamp) {
this.visibleFrom.set(id, timestamp);
}
this.awaitingCreateStamp = [];
for (const id of this.awaitingDeleteStamp) {
this.deleteAt.set(id, timestamp);
}
this.awaitingDeleteStamp = [];
}
private retireDueObjects() {
if (this.deleteAt.size === 0) {
return;
}
const renderTime = serverTimeline.renderTime;
for (const [id, at] of [...this.deleteAt]) {
if (renderTime >= at) {
this.deleteAt.delete(id);
this.deleteObject(id);
}
}
}
// Hand the local player's raw authoritative pose to the predictor (the
// interpolated pose would already be ~100 ms stale). The three body parts
// arrive together in one snapshot.
@ -168,5 +250,7 @@ export class GameObjectContainer extends CommandReceiver {
const object = this.objects.get(id);
object?.handleCommand(new BeforeDestroyCommand());
this.objects.delete(id);
this.visibleFrom.delete(id);
this.deleteAt.delete(id);
}
}

View file

@ -57,13 +57,20 @@ export abstract class SoundHandler {
return;
}
const audio =
this.sounds[sound].currentTime > 0
? (this.sounds[sound].cloneNode(true) as HTMLAudioElement)
: this.sounds[sound];
// Reuse the pooled element unless it is still mid-playback; only overlapping
// sounds need a clone. Testing `currentTime > 0` instead stayed true forever
// after the first play, so every later effect allocated a fresh element.
const pooled = this.sounds[sound];
const isBusy = !pooled.paused && !pooled.ended;
const audio = isBusy ? (pooled.cloneNode(true) as HTMLAudioElement) : pooled;
if (!isBusy) {
audio.currentTime = 0;
}
audio.volume = Math.max(0, Math.min(1, volume));
audio.playbackRate = playbackRate;
audio.play();
// A rejected play() (autoplay policy, or a clone GC'd mid-play) is expected
// and must not surface as an unhandled rejection.
void audio.play().catch(() => undefined);
}
public static playAmbient() {