diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts
index 2d145e8..11990d9 100644
--- a/backend/src/objects/character-physical.ts
+++ b/backend/src/objects/character-physical.ts
@@ -126,6 +126,9 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical
}
private initMovementBridge() {
+ // The movementState object-literal getters/setters below can't use `this`
+ // (it would bind to the literal), so alias the character instance.
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
this.movementState = {
head: this.head,
diff --git a/backend/src/players/npc.ts b/backend/src/players/npc.ts
index 23177b6..655ade7 100644
--- a/backend/src/players/npc.ts
+++ b/backend/src/players/npc.ts
@@ -315,7 +315,7 @@ export class NPC extends PlayerBase {
const charge =
distance > npcTuning.chargeRangeThreshold &&
- Random.getRandom() <
+ Random.getRandom() <
npcTuning.chargeBaseChance + npcTuning.chargeAggressionChance * this.aggression
? Random.getRandomInRange(npcTuning.chargeMin, 1)
: 0;
diff --git a/backend/src/players/player-base.ts b/backend/src/players/player-base.ts
index 3d6a561..6de34cd 100644
--- a/backend/src/players/player-base.ts
+++ b/backend/src/players/player-base.ts
@@ -75,7 +75,7 @@ export abstract class PlayerBase extends CommandReceiver {
protected findEmptyPositionForPlayer(preferredCenter: vec2): vec2 {
let rotation = 0;
let radius = 0;
- for (; ;) {
+ for (;;) {
const playerPosition = vec2.fromValues(
radius * Math.cos(rotation) + preferredCenter.x,
radius * Math.sin(rotation) + preferredCenter.y,
diff --git a/frontend/src/main.scss b/frontend/src/main.scss
index 8d2e613..3ab46d2 100644
--- a/frontend/src/main.scss
+++ b/frontend/src/main.scss
@@ -332,6 +332,26 @@ body {
&::after {
content: '\25C9';
}
+
+ // Twin-stick aim indicator: drawn from the button centre toward the
+ // current drag direction while aiming a shot (see TouchListener).
+ .aim-line {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 130px;
+ height: 3px;
+ border-radius: 2px;
+ transform-origin: left center;
+ transform: translateY(-50%) rotate(0rad);
+ background: linear-gradient(90deg,
+ rgba(255, 255, 255, 0.85),
+ rgba(255, 255, 255, 0));
+ box-shadow: 0 0 6px rgba(0, 0, 0, 0.6);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 80ms;
+ }
}
&.leap {
@@ -717,6 +737,34 @@ body {
}
}
}
+
+ // Centred "Eliminated" overlay shown while the local player is dead; the
+ // respawn countdown is the server-driven "Reviving in N…" announcement.
+ .elimination {
+ position: absolute;
+ top: 42%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ animation: elimination-in 220ms ease-out;
+
+ .elimination-title {
+ font-weight: 800;
+ font-size: 3.4rem;
+ letter-spacing: 0.04em;
+ color: $accent;
+ text-shadow:
+ 0 0 12px rgba(0, 0, 0, 0.9),
+ 0 0 28px rgba($accent, 0.6);
+ }
+
+ .elimination-sub {
+ margin-top: 6px;
+ font-size: 1.3rem;
+ opacity: 0.85;
+ text-shadow: 0 0 8px rgba(0, 0, 0, 0.9);
+ }
+ }
}
@keyframes contested-pulse {
@@ -877,4 +925,16 @@ body {
100% {
opacity: 0;
}
+}
+
+@keyframes elimination-in {
+ 0% {
+ opacity: 0;
+ transform: translate(-50%, -42%) scale(0.9);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
}
\ No newline at end of file
diff --git a/frontend/src/scripts/charge-indicator.ts b/frontend/src/scripts/charge-indicator.ts
index 7b44718..239e33b 100644
--- a/frontend/src/scripts/charge-indicator.ts
+++ b/frontend/src/scripts/charge-indicator.ts
@@ -1,7 +1,6 @@
import { holdDurationToCharge } from 'shared';
import { Pointer } from './helper/pointer';
-
export abstract class ChargeIndicator {
private static element?: HTMLElement;
private static heldSince = 0;
@@ -41,8 +40,9 @@ export abstract class ChargeIndicator {
(performance.now() - ChargeIndicator.heldSince) / 1000,
);
element.style.opacity = charge < 0.12 ? '0' : '1';
- element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${charge * 360
- }deg, rgba(255, 255, 255, 0.15) 0deg)`;
+ element.style.background = `conic-gradient(rgba(255, 255, 255, 0.85) ${
+ charge * 360
+ }deg, rgba(255, 255, 255, 0.15) 0deg)`;
element.classList.toggle('full', charge >= 1);
if (ChargeIndicator.followPointer) {
diff --git a/frontend/src/scripts/commands/touch-listener.ts b/frontend/src/scripts/commands/touch-listener.ts
index 9dd2cfa..ab91ce8 100644
--- a/frontend/src/scripts/commands/touch-listener.ts
+++ b/frontend/src/scripts/commands/touch-listener.ts
@@ -15,6 +15,9 @@ import { localCharacterPredictor } from '../helper/prediction/local-character-pr
export class TouchListener extends CommandGenerator {
private static readonly deadZone = 8;
private static readonly deltaScaling = 0.4;
+ // Min screen drag (px) from the fire button before a shot is aimed by the
+ // drag direction instead of firing straight ahead.
+ private static readonly aimDeadZone = 18;
private joystick: HTMLElement;
private joystickButton: HTMLElement;
@@ -24,9 +27,12 @@ export class TouchListener extends CommandGenerator {
private fireButton: HTMLElement;
private fireStrengthRing: HTMLElement;
+ private fireAimLine!: HTMLElement;
private leapButton: HTMLElement;
private fireDownAt: number | null = null;
+ private fireButtonCenter: vec2 | null = null;
+ private fireAimScreen: vec2 | null = null;
constructor(
private target: HTMLElement,
@@ -45,8 +51,12 @@ export class TouchListener extends CommandGenerator {
this.fireStrengthRing = document.createElement('div');
this.fireStrengthRing.className = 'strength-ring';
this.fireButton.appendChild(this.fireStrengthRing);
+ this.fireAimLine = document.createElement('div');
+ this.fireAimLine.className = 'aim-line';
+ this.fireButton.appendChild(this.fireAimLine);
this.fireButton.addEventListener('touchstart', this.fireButtonDownListener);
+ this.fireButton.addEventListener('touchmove', this.fireButtonMoveListener);
this.fireButton.addEventListener('touchend', this.fireButtonUpListener);
this.leapButton = document.createElement('div');
@@ -159,12 +169,41 @@ export class TouchListener extends CommandGenerator {
this.swallowTouch(event);
this.fireDownAt = performance.now();
const rect = this.fireButton.getBoundingClientRect();
- ChargeIndicator.begin(rect.left + rect.width / 2, rect.top + rect.height / 2);
+ this.fireButtonCenter = vec2.fromValues(
+ rect.left + rect.width / 2,
+ rect.top + rect.height / 2,
+ );
+ this.fireAimScreen = null;
+ ChargeIndicator.begin(this.fireButtonCenter[0], this.fireButtonCenter[1]);
+ };
+
+ // Dragging from the fire button aims the shot: the drag vector sets the
+ // direction, decoupling aim from movement so a touch player can fire one way
+ // while walking another. A tap with no meaningful drag fires straight ahead.
+ private fireButtonMoveListener = (event: TouchEvent) => {
+ this.swallowTouch(event);
+ if (this.fireDownAt === null || !this.fireButtonCenter) {
+ return;
+ }
+ const touch = event.targetTouches[0] ?? event.changedTouches[0];
+ if (!touch) {
+ return;
+ }
+ this.fireAimScreen = vec2.fromValues(touch.clientX, touch.clientY);
+ const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
+ const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
+ if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
+ this.fireAimLine.style.opacity = '1';
+ this.fireAimLine.style.transform = `translateY(-50%) rotate(${Math.atan2(dy, dx)}rad)`;
+ } else {
+ this.fireAimLine.style.opacity = '0';
+ }
};
private fireButtonUpListener = (event: TouchEvent) => {
this.swallowTouch(event);
ChargeIndicator.end();
+ this.fireAimLine.style.opacity = '0';
if (this.fireDownAt === null) {
return;
}
@@ -174,12 +213,28 @@ export class TouchListener extends CommandGenerator {
const character = this.game.gameObjects.player;
if (!character) {
+ this.fireButtonCenter = null;
+ this.fireAimScreen = null;
return;
}
+
+ // Screen drag → world aim direction (flip Y: screen +y is down). Below the
+ // dead-zone it's a tap, so fall back to firing along the facing direction.
+ let direction = character.facingDirection;
+ if (this.fireButtonCenter && this.fireAimScreen) {
+ const dx = this.fireAimScreen[0] - this.fireButtonCenter[0];
+ const dy = this.fireAimScreen[1] - this.fireButtonCenter[1];
+ if (dx * dx + dy * dy > TouchListener.aimDeadZone * TouchListener.aimDeadZone) {
+ direction = vec2.normalize(vec2.create(), vec2.fromValues(dx, -dy));
+ }
+ }
+ this.fireButtonCenter = null;
+ this.fireAimScreen = null;
+
const aim = vec2.scaleAndAdd(
vec2.create(),
character.bodyCenter,
- character.facingDirection,
+ direction,
settings.touchAimRange,
);
this.sendCommandToSubscribers(new PrimaryActionCommand(aim, charge));
@@ -195,8 +250,9 @@ 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)`;
}
}
@@ -207,6 +263,7 @@ export class TouchListener extends CommandGenerator {
this.target.removeEventListener('touchend', this.touchEndListener);
this.fireButton.removeEventListener('touchstart', this.fireButtonDownListener);
+ this.fireButton.removeEventListener('touchmove', this.fireButtonMoveListener);
this.fireButton.removeEventListener('touchend', this.fireButtonUpListener);
this.leapButton.removeEventListener('touchstart', this.leapButtonListener);
diff --git a/frontend/src/scripts/feedback-hud.ts b/frontend/src/scripts/feedback-hud.ts
index d6a7ea0..adbf068 100644
--- a/frontend/src/scripts/feedback-hud.ts
+++ b/frontend/src/scripts/feedback-hud.ts
@@ -4,6 +4,7 @@ import { Pointer } from './helper/pointer';
export abstract class FeedbackHud {
private static root?: HTMLElement;
private static killfeed?: HTMLElement;
+ private static elimination?: HTMLElement;
private static ensureRoot(): { root: HTMLElement; killfeed: HTMLElement } {
if (!this.root || !this.killfeed) {
@@ -75,6 +76,29 @@ export abstract class FeedbackHud {
}
}
+ // Persistent centred overlay shown while the local player is dead and waiting
+ // to respawn. The countdown itself is the server-driven "Reviving in N…"
+ // announcement; this makes the death state unmistakable and stays up until
+ // hideElimination() is called on respawn.
+ public static showElimination(): void {
+ if (this.elimination) {
+ return;
+ }
+ const { root } = this.ensureRoot();
+ const el = document.createElement('div');
+ el.className = 'elimination';
+ el.innerHTML =
+ '
Eliminated
' +
+ 'Respawning…
';
+ root.appendChild(el);
+ this.elimination = el;
+ }
+
+ public static hideElimination(): void {
+ this.elimination?.parentElement?.removeChild(this.elimination);
+ this.elimination = undefined;
+ }
+
private static streakName(streak: number): string | undefined {
switch (streak) {
case 2:
diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts
index c147477..5d23c1c 100644
--- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts
+++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts
@@ -17,6 +17,14 @@ import { InputHistory } from './input-history';
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
const stepSeconds = 1 / 200;
+// Clock source for the predictor. Injectable so deterministic reconciliation
+// tests can drive prediction without real time passing; defaults to the
+// browser wall clock in production.
+let nowMs: () => number = () => performance.now();
+export const setPredictorClockForTesting = (clock: () => number): void => {
+ nowMs = clock;
+};
+
// 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.
@@ -74,6 +82,12 @@ export class LocalCharacterPredictor {
// Latest streamed shooting-strength, to gate predicted leaps as the server does.
private currentStrength = settings.playerMaxStrength;
+ // Whether the local body is alive on the server. While dead (awaiting respawn)
+ // prediction is suppressed so the corpse/ghost can't keep walking in response
+ // to input — the server ignores a dead player's movement, so a predicted body
+ // that still moved would be a pure client-side desync.
+ private alive = true;
+
// Continuous state carried between replays (the snapshot carries only poses).
// The facing direction is NOT carried — it is re-derived from the pose each
// frame (see directionFromPose / simulate); only the latched planet and the
@@ -100,7 +114,7 @@ export class LocalCharacterPredictor {
// Stamp a movement command and record it for replay. Returns the wall-clock
// time the command should carry so the server can echo it back.
public recordInput(direction: vec2): number {
- const timeMs = Math.round(performance.now());
+ const timeMs = Math.round(nowMs());
this.inputHistory.record(direction, timeMs);
return timeMs;
}
@@ -112,7 +126,10 @@ export class LocalCharacterPredictor {
): 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) {
+ if (
+ this.lastAckClientTimeMs === undefined ||
+ clientTimeMs > this.lastAckClientTimeMs
+ ) {
this.lastAckClientTimeMs = clientTimeMs;
}
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
@@ -121,7 +138,7 @@ export class LocalCharacterPredictor {
public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void {
this.authoritative = { head, leftFoot, rightFoot };
- this.authReceiptMs = Math.round(performance.now());
+ this.authReceiptMs = Math.round(nowMs());
}
// The player pressed leap; remember when, so the replay applies the same
@@ -129,7 +146,7 @@ export class LocalCharacterPredictor {
// it — a rejected leap (no strength/cooldown) self-corrects via the streamed
// authoritative momentum.
public recordLeap(): number {
- const timeMs = Math.round(performance.now());
+ const timeMs = Math.round(nowMs());
this.leapHistory.push(timeMs);
const cutoff = timeMs - 1500;
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
@@ -142,6 +159,10 @@ export class LocalCharacterPredictor {
this.currentStrength = strength;
}
+ public setAlive(alive: boolean): void {
+ this.alive = alive;
+ }
+
public reset(): void {
this.inputHistory.reset();
this.leapHistory = [];
@@ -154,6 +175,7 @@ export class LocalCharacterPredictor {
this.carriedPlanetId = undefined;
this.carriedSecondsSinceSurface = 1;
this.hasRender = false;
+ this.alive = true;
}
public get canPredict(): boolean {
@@ -175,7 +197,7 @@ export class LocalCharacterPredictor {
// caller should use (otherwise fall back to interpolation). `planets` is the
// current collision world; `frameSeconds` is the render delta.
public update(planets: Array, frameSeconds: number): boolean {
- if (!this.canPredict || this.isAnimatingInOrOut) {
+ if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) {
// Resume from the authoritative pose with a snap rather than gliding from
// a stale rendered one.
this.hasRender = false;
@@ -209,7 +231,7 @@ export class LocalCharacterPredictor {
private simulate(): CharacterMovementState {
const auth = this.authoritative!;
- const now = Math.round(performance.now());
+ 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
diff --git a/frontend/src/scripts/landing-page-background.ts b/frontend/src/scripts/landing-page-background.ts
index 6371e62..bafe559 100644
--- a/frontend/src/scripts/landing-page-background.ts
+++ b/frontend/src/scripts/landing-page-background.ts
@@ -11,10 +11,11 @@ import {
import { settings, rgb, PlanetBase, Random } from 'shared';
import { PlanetShape } from './shapes/planet-shape';
-// colorMixQ ends of PlanetShape's blue<->red gradient, so the two backdrop
-// planets read as the two in-game teams.
+// PlanetShape colours by mixing blue (0) -> red (1). The two backdrop planets
+// read as the two in-game teams; the red planet is pulled a little off the
+// pure-red end so it shows as a more muted, less saturated red.
const bluePlanet = 0;
-const redPlanet = 1;
+const redPlanet = 0.85;
export class LandingPageBackground {
private isActive = true;
diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts
index fa48f08..2b03836 100644
--- a/frontend/src/scripts/objects/game-object-container.ts
+++ b/frontend/src/scripts/objects/game-object-container.ts
@@ -16,6 +16,7 @@ import {
} from 'shared';
import { BeforeDestroyCommand } from '../commands/types/before-destroy';
import { StepCommand } from '../commands/types/step';
+import { FeedbackHud } from '../feedback-hud';
import { Game } from '../game';
import { serverTimeline } from '../helper/server-timeline';
import { PredictablePlanet } from '../helper/prediction/client-character-world';
@@ -28,6 +29,7 @@ export class GameObjectContainer extends CommandReceiver {
protected objects: Map = new Map();
public player!: CharacterView;
public camera: Camera = new Camera(this.game);
+ private wasLocalPlayerAlive = false;
protected commandExecutors: CommandExecutors = {
[CreatePlayerCommand.type]: (c: CreatePlayerCommand) => {
@@ -37,6 +39,9 @@ export class GameObjectContainer extends CommandReceiver {
// Fresh character (first spawn or respawn at a far planet): drop any
// prediction state so it snaps to the new body instead of gliding across.
localCharacterPredictor.reset();
+ // Respawned — clear the elimination overlay.
+ FeedbackHud.hideElimination();
+ this.wasLocalPlayerAlive = true;
},
[CreateObjectsCommand.type]: (c: CreateObjectsCommand) =>
@@ -45,15 +50,32 @@ export class GameObjectContainer extends CommandReceiver {
[StepCommand.type]: (c: StepCommand) => {
this.defaultCommandExecutor(c);
- if (this.player) {
+ // The local body is alive only while its object still exists (the server
+ // deletes it on death) and its health is above zero — `player` keeps
+ // pointing at the now-stale view after death, so both checks are needed.
+ const bodyPresent = !!this.player && this.objects.has(this.player.id);
+ const alive = bodyPresent && this.player.health > 0;
+
+ // Show the elimination overlay on the alive→dead edge; CreatePlayerCommand
+ // clears it on respawn.
+ if (this.wasLocalPlayerAlive && !alive) {
+ FeedbackHud.showElimination();
+ }
+ this.wasLocalPlayerAlive = alive;
+
+ if (bodyPresent) {
// Override the interpolated pose of the local player with the predicted
- // one so it responds to input immediately. Remote objects keep
- // interpolating. A large correction (respawn / death) snaps inside the
- // predictor rather than rubber-banding.
+ // one so it responds to input immediately. Suppressed while dead so the
+ // corpse can't be walked around (the server ignores a dead player's
+ // input — a moving predicted body would be a pure client-side desync).
+ // A large correction (respawn / death) snaps inside the predictor.
+ localCharacterPredictor.setAlive(alive);
localCharacterPredictor.setStrength(
this.player.strengthFraction * settings.playerMaxStrength,
);
- if (localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)) {
+ if (
+ localCharacterPredictor.update(this.predictablePlanets(), c.deltaTimeInSeconds)
+ ) {
this.player.head = localCharacterPredictor.head;
this.player.leftFoot = localCharacterPredictor.leftFoot;
this.player.rightFoot = localCharacterPredictor.rightFoot;
diff --git a/frontend/src/scripts/shapes/planet-shape.ts b/frontend/src/scripts/shapes/planet-shape.ts
index 1d1ec9e..ceb2e35 100644
--- a/frontend/src/scripts/shapes/planet-shape.ts
+++ b/frontend/src/scripts/shapes/planet-shape.ts
@@ -103,8 +103,8 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
if (dist < minDistance) {
minDistance = dist;
color = mix(${colorToString(settings.bluePlanetColor)}, ${colorToString(
- settings.redPlanetColor,
- )}, planetColorMixQ[j]);
+ settings.redPlanetColor,
+ )}, planetColorMixQ[j]);
}
}
@@ -141,10 +141,7 @@ export class PlanetShape extends PolygonFactory(settings.planetEdgeCount, 0) {
) {
super(vertices);
- this.cullCenter = vertices.reduce(
- (sum, v) => vec2.add(sum, sum, v),
- vec2.create(),
- );
+ this.cullCenter = vertices.reduce((sum, v) => vec2.add(sum, sum, v), vec2.create());
vec2.scale(this.cullCenter, this.cullCenter, 1 / vertices.length);
this.cullRadius = vertices.reduce(
diff --git a/shared/src/objects/game-object.ts b/shared/src/objects/game-object.ts
index 4793d2f..f851137 100644
--- a/shared/src/objects/game-object.ts
+++ b/shared/src/objects/game-object.ts
@@ -15,10 +15,24 @@ export class RemoteCall {
}
}
+// Every object property streamed via UpdatePropertyCommand. A single union means
+// a typo or rename on the producing (server *-physical) or consuming (client
+// *-view) side is a compile error instead of a silently dropped update that
+// just stops a body interpolating. The wire format is unchanged — these remain
+// the same strings, only now compiler-checked at both ends.
+export type SyncPropertyKey =
+ | 'head'
+ | 'leftFoot'
+ | 'rightFoot'
+ | 'strength'
+ | 'center'
+ | 'ownership'
+ | 'rotation';
+
@serializable
export class UpdatePropertyCommand extends Command {
constructor(
- public readonly propertyKey: string,
+ public readonly propertyKey: SyncPropertyKey,
public readonly propertyValue: any,
public readonly rateOfChange: any,
) {
diff --git a/shared/src/objects/types/character-base.ts b/shared/src/objects/types/character-base.ts
index b461127..f6cad5c 100644
--- a/shared/src/objects/types/character-base.ts
+++ b/shared/src/objects/types/character-base.ts
@@ -1,6 +1,7 @@
import { Id } from '../../communication/id';
import { Circle } from '../../helper/circle';
import { serializable } from '../../serialization/serializable';
+import { toArrayFromFields } from '../../serialization/serialized-fields';
import { GameObject } from '../game-object';
export enum CharacterTeam {
@@ -11,6 +12,18 @@ export enum CharacterTeam {
@serializable
export class CharacterBase extends GameObject {
+ private static readonly serializedFields = [
+ 'id',
+ 'name',
+ 'killCount',
+ 'deathCount',
+ 'team',
+ 'health',
+ 'head',
+ 'leftFoot',
+ 'rightFoot',
+ ] as const;
+
constructor(
id: Id,
public name: string,
@@ -26,37 +39,27 @@ export class CharacterBase extends GameObject {
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
- public onShoot(strength: number) { }
+ public onShoot(strength: number) {}
- public onLeap() { }
+ public onLeap() {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
- public onHitConfirmed(charge?: number) { }
+ public onHitConfirmed(charge?: number) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
- public onKillConfirmed(victimName?: string, streak?: number, charge?: number) { }
+ public onKillConfirmed(victimName?: string, streak?: number, charge?: number) {}
public setHealth(health: number) {
this.health = health;
}
- public onDie() { }
+ public onDie() {}
public setKillCount(killCount: number) {
this.killCount = killCount;
}
public toArray(): Array {
- return [
- this.id,
- this.name,
- this.killCount,
- this.deathCount,
- this.team,
- this.health,
- this.head,
- this.leftFoot,
- this.rightFoot,
- ];
+ return toArrayFromFields(this, CharacterBase.serializedFields);
}
}
diff --git a/shared/src/objects/types/lamp-base.ts b/shared/src/objects/types/lamp-base.ts
index 794e75b..9f86ca6 100644
--- a/shared/src/objects/types/lamp-base.ts
+++ b/shared/src/objects/types/lamp-base.ts
@@ -1,9 +1,17 @@
import { vec2, vec3 } from 'gl-matrix';
import { Id, serializable } from '../../main';
+import { toArrayFromFields } from '../../serialization/serialized-fields';
import { GameObject } from '../game-object';
@serializable
export class LampBase extends GameObject {
+ private static readonly serializedFields = [
+ 'id',
+ 'center',
+ 'color',
+ 'lightness',
+ ] as const;
+
constructor(
id: Id,
public center: vec2,
@@ -19,7 +27,6 @@ export class LampBase extends GameObject {
public setLight(color: vec3, lightness: number) {}
public toArray(): Array {
- const { id, center, color, lightness } = this;
- return [id, center, color, lightness];
+ return toArrayFromFields(this, LampBase.serializedFields);
}
}
diff --git a/shared/src/objects/types/planet-base.ts b/shared/src/objects/types/planet-base.ts
index e3835e1..bafea38 100644
--- a/shared/src/objects/types/planet-base.ts
+++ b/shared/src/objects/types/planet-base.ts
@@ -2,12 +2,22 @@ import { vec2 } from 'gl-matrix';
import { Random } from '../../helper/random';
import { settings } from '../../settings';
import { serializable } from '../../serialization/serializable';
+import { toArrayFromFields } from '../../serialization/serialized-fields';
import { GameObject } from '../game-object';
import { Id } from '../../communication/id';
import { CharacterTeam } from './character-base';
@serializable
export class PlanetBase extends GameObject {
+ // centre/radius are derived from vertices in the constructor, so they are not
+ // serialized — only the constructor parameters are.
+ private static readonly serializedFields = [
+ 'id',
+ 'vertices',
+ 'ownership',
+ 'isKeystone',
+ ] as const;
+
public readonly center: vec2;
public readonly radius: number;
@@ -57,6 +67,6 @@ export class PlanetBase extends GameObject {
}
public toArray(): Array {
- return [this.id, this.vertices, this.ownership, this.isKeystone];
+ return toArrayFromFields(this, PlanetBase.serializedFields);
}
}
diff --git a/shared/src/objects/types/projectile-base.ts b/shared/src/objects/types/projectile-base.ts
index c334cb8..f96fd94 100644
--- a/shared/src/objects/types/projectile-base.ts
+++ b/shared/src/objects/types/projectile-base.ts
@@ -1,12 +1,21 @@
import { vec2 } from 'gl-matrix';
import { settings } from '../../settings';
import { serializable } from '../../serialization/serializable';
+import { toArrayFromFields } from '../../serialization/serialized-fields';
import { GameObject } from '../game-object';
import { Id } from '../../communication/id';
import { CharacterTeam } from './character-base';
@serializable
export class ProjectileBase extends GameObject {
+ private static readonly serializedFields = [
+ 'id',
+ 'center',
+ 'radius',
+ 'team',
+ 'strength',
+ ] as const;
+
constructor(
id: Id,
public center: vec2,
@@ -23,6 +32,6 @@ export class ProjectileBase extends GameObject {
}
public toArray(): Array {
- return [this.id, this.center, this.radius, this.team, this.strength];
+ return toArrayFromFields(this, ProjectileBase.serializedFields);
}
}
diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts
index a5744b9..21355a5 100644
--- a/shared/src/physics/character-movement.ts
+++ b/shared/src/physics/character-movement.ts
@@ -107,7 +107,13 @@ const springMove = (
const keepPosture = (state: CharacterMovementState) => {
const center = characterCenter(state);
- springMove(state, state.leftFoot, center, leftFootOffset, settings.postureFeetStiffness);
+ springMove(
+ state,
+ state.leftFoot,
+ center,
+ leftFootOffset,
+ settings.postureFeetStiffness,
+ );
springMove(
state,
state.rightFoot,
@@ -133,7 +139,12 @@ const carryWithRotatingPlanet = (
const angle = -planet.angularVelocity * deltaTimeInSeconds;
const center = planet.center;
state.head.center = vec2.rotate(vec2.create(), state.head.center, center, angle);
- state.leftFoot.center = vec2.rotate(vec2.create(), state.leftFoot.center, center, angle);
+ state.leftFoot.center = vec2.rotate(
+ vec2.create(),
+ state.leftFoot.center,
+ center,
+ angle,
+ );
state.rightFoot.center = vec2.rotate(
vec2.create(),
state.rightFoot.center,
@@ -148,10 +159,7 @@ const carryWithRotatingPlanet = (
// server's leap() and the client's prediction apply the exact same impulse.
// The caller does the gating (strength, cooldown, alive); this is a no-op when
// not on a surface.
-export const applyLeapImpulse = (
- state: CharacterMovementState,
- moveDirection: vec2,
-) => {
+export const applyLeapImpulse = (state: CharacterMovementState, moveDirection: vec2) => {
const planet = state.currentPlanet;
if (!planet) {
return;
@@ -206,7 +214,9 @@ export const tickPlanetDetachment = (
state: CharacterMovementState,
deltaTimeInSeconds: number,
) => {
- if ((state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds) {
+ if (
+ (state.secondsSinceOnSurface += deltaTimeInSeconds) > settings.planetDetachmentSeconds
+ ) {
state.currentPlanet = undefined;
}
};
@@ -255,10 +265,7 @@ export const decayMomentum = (
}
};
-const decayBodyMomentum = (
- state: CharacterMovementState,
- deltaTimeInSeconds: number,
-) => {
+const decayBodyMomentum = (state: CharacterMovementState, deltaTimeInSeconds: number) => {
decayMomentum(state.bodyVelocity, !!state.currentPlanet, deltaTimeInSeconds);
};
@@ -293,7 +300,11 @@ export const stepCharacterMovement = (
const center = characterCenter(state);
const grounds = world.groundsNear(center, boundRadius + settings.maxGravityDistance);
- const movementForce = vec2.scale(inputDirection, inputDirection, settings.maxAcceleration);
+ const movementForce = vec2.scale(
+ inputDirection,
+ inputDirection,
+ settings.maxAcceleration,
+ );
applyForce(state.leftFoot, movementForce, deltaTimeInSeconds);
applyForce(state.rightFoot, movementForce, deltaTimeInSeconds);
diff --git a/shared/src/serialization/serialized-fields.ts b/shared/src/serialization/serialized-fields.ts
new file mode 100644
index 0000000..48733e8
--- /dev/null
+++ b/shared/src/serialization/serialized-fields.ts
@@ -0,0 +1,13 @@
+// Derive a serializable object's positional wire array from a single declared
+// list of field names, so toArray() and the constructor share ONE source of
+// truth for field order instead of a hand-written array that can silently drift
+// out of step with the parameters.
+//
+// deserialize reconstructs via `new Ctor(...array.slice(1))`, so the field list
+// MUST name the constructor's parameters in order. Fields a constructor
+// recomputes from others (e.g. a planet's centre/radius from its vertices) are
+// derived, not serialized, and so are deliberately absent from the list.
+export const toArrayFromFields = (
+ object: any,
+ fields: ReadonlyArray,
+): Array => fields.map((field) => object[field]);
diff --git a/test/__snapshots__/physics-determinism.test.mjs.snap b/test/__snapshots__/physics-determinism.test.mjs.snap
new file mode 100644
index 0000000..50ab1c3
--- /dev/null
+++ b/test/__snapshots__/physics-determinism.test.mjs.snap
@@ -0,0 +1,3 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`shared character simulation determinism > matches the pinned reference pose (changes only with intentional physics edits) 1`] = `"{"head":[76.14,123.412],"leftFoot":[83.214,105.036],"rightFoot":[79.974,123.01],"bodyVelocity":[0,0]}"`;
diff --git a/test/physics-determinism.test.mjs b/test/physics-determinism.test.mjs
new file mode 100644
index 0000000..d0fee1a
--- /dev/null
+++ b/test/physics-determinism.test.mjs
@@ -0,0 +1,83 @@
+// Determinism guard for the shared character simulation. The client predictor
+// and the authoritative server run the EXACT same stepCharacterMovement; if it
+// were non-deterministic (a stray Math.random / Date, or an order-dependent
+// reduce) prediction would rubber-band and could never reconcile. This is also
+// the regression net for the upcoming GC/scratch-pool perf rewrites: the pinned
+// hash must not change unless physics behaviour is intentionally changed.
+import { describe, it, expect } from 'vitest';
+import { createRequire } from 'node:module';
+
+const require = createRequire(import.meta.url);
+const shared = require('../shared/lib/main.js');
+const { vec2 } = require('../shared/node_modules/gl-matrix');
+
+const {
+ stepCharacterMovement,
+ resolveCircleMovement,
+ headRadius,
+ feetRadius,
+ headOffset,
+ leftFootOffset,
+ rightFootOffset,
+} = shared;
+
+const makeBody = (center, radius) => ({
+ center: vec2.clone(center),
+ radius,
+ velocity: vec2.create(),
+ lastNormal: vec2.fromValues(0, 1),
+ restitution: 0,
+});
+
+// A free-space world (no planets): the body is driven purely by the movement
+// input force, posture springs and momentum decay — enough to exercise the
+// deterministic core without coupling the test to planet SDF geometry.
+const emptyWorld = {
+ groundsNear: () => [],
+ stepBody: (body, dt) => {
+ resolveCircleMovement(body, dt, []);
+ return undefined;
+ },
+};
+
+const stepSeconds = 1 / 200;
+
+// Run a fixed, scripted input sequence through the shared sim and return a
+// stable string snapshot of the final pose + carried momentum.
+const runSimulation = () => {
+ const start = vec2.fromValues(100, 100);
+ const state = {
+ head: makeBody(vec2.add(vec2.create(), start, headOffset), headRadius),
+ leftFoot: makeBody(vec2.add(vec2.create(), start, leftFootOffset), feetRadius),
+ rightFoot: makeBody(vec2.add(vec2.create(), start, rightFootOffset), feetRadius),
+ direction: 0,
+ currentPlanet: undefined,
+ secondsSinceOnSurface: 1,
+ bodyVelocity: vec2.create(),
+ };
+
+ for (let i = 0; i < 300; i++) {
+ const angle = i * 0.1;
+ const input = vec2.fromValues(Math.cos(angle), Math.sin(angle));
+ stepCharacterMovement(state, emptyWorld, input, stepSeconds);
+ }
+
+ const round = (v) => Math.round(v * 1000) / 1000;
+ return JSON.stringify({
+ head: [round(state.head.center[0]), round(state.head.center[1])],
+ leftFoot: [round(state.leftFoot.center[0]), round(state.leftFoot.center[1])],
+ rightFoot: [round(state.rightFoot.center[0]), round(state.rightFoot.center[1])],
+ bodyVelocity: [round(state.bodyVelocity[0]), round(state.bodyVelocity[1])],
+ });
+};
+
+describe('shared character simulation determinism', () => {
+ it('produces identical output across independent runs', () => {
+ expect(runSimulation()).toBe(runSimulation());
+ });
+
+ it('matches the pinned reference pose (changes only with intentional physics edits)', () => {
+ // Regression pin — update deliberately when physics behaviour changes.
+ expect(runSimulation()).toMatchSnapshot();
+ });
+});
diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts
new file mode 100644
index 0000000..98ffc1d
--- /dev/null
+++ b/test/prediction-reconciliation.test.ts
@@ -0,0 +1,96 @@
+// Reconciliation guard for the REAL client predictor. It exercises
+// LocalCharacterPredictor end-to-end with an injected clock, verifying the two
+// properties reconciliation depends on:
+// 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly
+// (no spurious drift on top of server truth), and
+// 2. un-acknowledged input is replayed forward deterministically.
+// This is the net for prediction-touching changes (the death-while-dead fix,
+// future netcode work) — a regression shows up as drift or non-determinism.
+import { describe, it, expect } from 'vitest';
+import {
+ LocalCharacterPredictor,
+ setPredictorClockForTesting,
+} from '../frontend/src/scripts/helper/prediction/local-character-predictor';
+
+const HEAD_RADIUS = 50;
+const FEET_RADIUS = 20;
+
+// A Circle-shaped pose; the predictor only reads .center / .radius, so plain
+// objects with array centres are sufficient (and avoid importing gl-matrix).
+const poseAt = (cx: number, cy: number) => ({
+ head: { center: [cx, cy + 37], radius: HEAD_RADIUS },
+ leftFoot: { center: [cx - 33, cy - 18], radius: FEET_RADIUS },
+ rightFoot: { center: [cx + 33, cy - 18], radius: FEET_RADIUS },
+});
+
+describe('local prediction reconciliation', () => {
+ it('reproduces the authoritative pose exactly when all input is acknowledged', () => {
+ let clock = 1000;
+ setPredictorClockForTesting(() => clock);
+
+ const predictor = new LocalCharacterPredictor();
+ const auth = poseAt(500, 500);
+
+ const t = predictor.recordInput([1, 0]);
+ predictor.acknowledge(t, [0, 0], -Infinity); // server has consumed this input
+ predictor.setStrength(80);
+ predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
+
+ // No clock advance → zero replay window → predicted pose == authoritative.
+ const used = predictor.update([], 1 / 60);
+
+ expect(used).toBe(true);
+ expect(predictor.head.center[0]).toBeCloseTo(auth.head.center[0], 5);
+ expect(predictor.head.center[1]).toBeCloseTo(auth.head.center[1], 5);
+ expect(predictor.leftFoot.center[0]).toBeCloseTo(auth.leftFoot.center[0], 5);
+ expect(predictor.rightFoot.center[0]).toBeCloseTo(auth.rightFoot.center[0], 5);
+ });
+
+ it('replays un-acknowledged input deterministically', () => {
+ const drive = () => {
+ let clock = 0;
+ setPredictorClockForTesting(() => clock);
+
+ const predictor = new LocalCharacterPredictor();
+ clock = 1000;
+ predictor.acknowledge(900, [0, 0], -Infinity); // baseline ack (older than the input below)
+ predictor.setStrength(80);
+ const auth = poseAt(0, 0);
+ predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
+ predictor.recordInput([1, 0]); // unacked rightward input at t=1000
+
+ clock = 1100; // replay ~100 ms forward
+ predictor.update([], 1 / 60);
+ return [
+ predictor.head.center[0],
+ predictor.head.center[1],
+ predictor.leftFoot.center[0],
+ predictor.rightFoot.center[0],
+ ];
+ };
+
+ const a = drive();
+ const b = drive();
+ expect(a).toEqual(b); // deterministic replay
+ expect(a.every((n) => Number.isFinite(n))).toBe(true);
+ expect(a[0]).toBeGreaterThan(0.5); // rightward input actually moved the body
+ });
+
+ it('suppresses prediction while the local player is dead', () => {
+ let clock = 5000;
+ setPredictorClockForTesting(() => clock);
+
+ const predictor = new LocalCharacterPredictor();
+ const auth = poseAt(200, 200);
+ const t = predictor.recordInput([1, 0]);
+ predictor.acknowledge(t, [0, 0], -Infinity);
+ predictor.setStrength(80);
+ predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never);
+ predictor.setAlive(false); // dead, awaiting respawn
+
+ clock = 5200; // input + elapsed time that would otherwise be replayed forward
+ // Even with a valid authoritative pose and pending input, a dead body must
+ // not be predicted/moved — that was the "move while dead" bug.
+ expect(predictor.update([], 1 / 60)).toBe(false);
+ });
+});
diff --git a/test/serialization.test.mjs b/test/serialization.test.mjs
index 4387661..9c7bc11 100644
--- a/test/serialization.test.mjs
+++ b/test/serialization.test.mjs
@@ -17,6 +17,11 @@ const {
MoveActionCommand,
UpdatePropertyCommand,
PropertyUpdatesForObject,
+ // networked entity bases — their toArray() must mirror their constructor order
+ CharacterBase,
+ PlanetBase,
+ ProjectileBase,
+ LampBase,
// geometry — single source of truth shared by server & client prediction
headRadius,
feetRadius,
@@ -77,6 +82,67 @@ describe('serialization round-trip (built shared lib)', () => {
});
});
+describe('networked entity round-trips (toArray ↔ constructor contract)', () => {
+ it('round-trips a ProjectileBase', () => {
+ const out = deserialize(serialize(new ProjectileBase(7, [10, 20], 30, 'blue', 40)));
+ expect(out).toBeInstanceOf(ProjectileBase);
+ expect(out.id).toBe(7);
+ expect(out.center[0]).toBeCloseTo(10, 5);
+ expect(out.center[1]).toBeCloseTo(20, 5);
+ expect(out.radius).toBe(30);
+ expect(out.team).toBe('blue');
+ expect(out.strength).toBe(40);
+ });
+
+ it('round-trips a CharacterBase with its nested body Circles', () => {
+ const out = deserialize(
+ serialize(
+ new CharacterBase(
+ 8,
+ 'Bob',
+ 2,
+ 1,
+ 'red',
+ 100,
+ new Circle([1, 2], 50),
+ new Circle([3, 4], 20),
+ new Circle([5, 6], 20),
+ ),
+ ),
+ );
+ expect(out).toBeInstanceOf(CharacterBase);
+ expect(out.id).toBe(8);
+ expect(out.name).toBe('Bob');
+ expect(out.killCount).toBe(2);
+ expect(out.deathCount).toBe(1);
+ expect(out.team).toBe('red');
+ expect(out.health).toBe(100);
+ expect(out.head).toBeInstanceOf(Circle);
+ expect(out.head.center[0]).toBeCloseTo(1, 5);
+ expect(out.head.radius).toBe(50);
+ });
+
+ it('round-trips a LampBase', () => {
+ const out = deserialize(serialize(new LampBase(9, [7, 8], [1, 0.5, 0.2], 0.8)));
+ expect(out).toBeInstanceOf(LampBase);
+ expect(out.center[1]).toBeCloseTo(8, 5);
+ expect(out.color[2]).toBeCloseTo(0.2, 5);
+ expect(out.lightness).toBeCloseTo(0.8, 5);
+ });
+
+ it('round-trips a PlanetBase (derived centre/radius recomputed from vertices)', () => {
+ const out = deserialize(
+ serialize(new PlanetBase(10, [[0, 0], [100, 0], [50, 100]], 0.7, true)),
+ );
+ expect(out).toBeInstanceOf(PlanetBase);
+ expect(out.id).toBe(10);
+ expect(out.vertices).toHaveLength(3);
+ expect(out.ownership).toBeCloseTo(0.7, 5);
+ expect(out.isKeystone).toBe(true);
+ expect(out.center[0]).toBeCloseTo(50, 5); // recomputed by the constructor
+ });
+});
+
describe('shared character geometry — single source of truth', () => {
it('matches the known body layout', () => {
expect(headRadius).toBe(50);