Improve netcode and simplify (#4)
All checks were successful
Build & deploy / Build & deploy website (push) Successful in 32s
Build & deploy / Build & publish server image (push) Successful in 1m2s

- Simplify the logic wherever possible to remove overengineering and simplify the netcode
- Actually improve the netcode
- The new lines are mainly the package locks and tests

Reviewed-on: https://home.schmelczer.dev/git/andras/decla-red/pulls/4
This commit is contained in:
Andras Schmelczer 2026-09-15 07:57:57 +01:00
commit 93a7fe01ab
60 changed files with 15073 additions and 4754 deletions

2196
shared/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
export type CommandExecutors = {
[type: string]: (command: any) => unknown;
};

View file

@ -1,18 +0,0 @@
import { CommandReceiver } from './command-receiver';
import { Command } from './command';
export abstract class CommandGenerator {
private subscribers: Array<CommandReceiver> = [];
public subscribe(subscriber: CommandReceiver): void {
this.subscribers.push(subscriber);
}
public clearSubscribers(): void {
this.subscribers = [];
}
protected sendCommandToSubscribers(command: Command): void {
this.subscribers.forEach((s) => s.handleCommand(command));
}
}

View file

@ -1,13 +1,16 @@
import { CommandExecutors } from './command-executors';
import { Command } from './command';
export type CommandExecutors = {
[type: string]: (command: any) => unknown;
};
export abstract class CommandReceiver {
protected commandExecutors: CommandExecutors = {};
protected defaultCommandExecutor(_: Command) {}
public handleCommand(command: Command) {
const commandType = command.type;
const commandType = command.constructor.name;
if (Object.prototype.hasOwnProperty.call(this.commandExecutors, commandType)) {
this.commandExecutors[commandType]!(command);

View file

@ -1,9 +1 @@
export abstract class Command {
public static get type(): string {
return (this as any).name;
}
public get type(): string {
return (this as any).constructor.name;
}
}
export abstract class Command {}

View file

@ -0,0 +1,13 @@
import { serializable } from '../../../serialization/serializable';
import { Command } from '../../command';
@serializable
export class ClientHeartbeatCommand extends Command {
public constructor(public readonly clientTimeMs: number) {
super();
}
public toArray(): Array<any> {
return [this.clientTimeMs];
}
}

View file

@ -1,15 +1,9 @@
import { serializable } from '../../../serialization/serializable';
import { Command } from '../../command';
// Sent client -> server when the player triggers a leap (Space / leap button).
// The launch direction is derived server-side from the character's surface
// normal and current movement input. clientTimeMs is the client's wall-clock at
// the press, echoed back via InputAcknowledgement so the predictor knows which
// leaps the server has already folded into the streamed launch momentum and
// must not replay again.
@serializable
export class LeapActionCommand extends Command {
public constructor(public readonly clientTimeMs: number = 0) {
public constructor(public readonly clientTimeMs: number) {
super();
}

View file

@ -4,14 +4,9 @@ import { Command } from '../../command';
@serializable
export class MoveActionCommand extends Command {
// clientTimeMs is the client's wall-clock (integer ms, survives the
// serializer's toFixed(3)) when the input was generated. The server echoes
// the latest one back via InputAcknowledgement so the client knows how much
// of its input timeline is already baked into a snapshot and can replay the
// rest for prediction. Defaults to 0 for inputs the server itself synthesises.
public constructor(
public readonly direction: vec2,
public readonly clientTimeMs: number = 0,
public readonly clientTimeMs: number,
) {
super();
}

View file

@ -6,12 +6,13 @@ import { Command } from '../../command';
export class PrimaryActionCommand extends Command {
public constructor(
public readonly position: vec2,
public readonly charge: number = 0,
public readonly charge: number,
public readonly clientTimeMs: number,
) {
super();
}
public toArray(): Array<any> {
return [this.position, this.charge];
return [this.position, this.charge, this.clientTimeMs];
}
}

View file

@ -1,4 +1,4 @@
import { Id } from '../../communication/id';
import { Id } from '../../communication/communication';
import { serializable } from '../../serialization/serializable';
import { Command } from '../command';

View file

@ -1,18 +1,9 @@
import { CharacterTeam } from '../../objects/types/character-base';
import { serializable } from '../../serialization/serializable';
import { Command } from '../command';
@serializable
export class GameEndCommand extends Command {
constructor(
public readonly winningTeam: CharacterTeam,
public readonly endCardLengthInSeconds: number,
public readonly shouldReconnect: boolean,
) {
super();
}
public toArray(): Array<any> {
return [this.winningTeam, this.endCardLengthInSeconds, this.shouldReconnect];
return [];
}
}

View file

@ -3,10 +3,6 @@ import { Command } from '../command';
@serializable
export class GameStartCommand extends Command {
constructor() {
super();
}
public toArray(): Array<any> {
return [];
}

View file

@ -1,28 +1,46 @@
import { vec2 } from 'gl-matrix';
import { Id } from '../../communication/communication';
import { serializable } from '../../serialization/serializable';
import { Command } from '../command';
// Sent server -> owning client only, alongside that client's own character
// snapshot. Carries the clientTimeMs of the most recent movement input the
// server had received when the snapshot was taken (so the client's predictor
// can reset to the snapshot and replay just the inputs the server hasn't seen
// yet) and the authoritative launch momentum (so the predictor reproduces a
// leap/slingshot/recoil flight rather than only snapping to it). See
// LocalCharacterPredictor.
// `direction` is not recoverable from the pose: the body lags the posture it is sprung towards.
@serializable
export class CharacterMovementSnapshot {
public constructor(
public readonly direction: number,
public readonly bodyVelocity: vec2,
public readonly leftFootNormal: vec2,
public readonly rightFootNormal: vec2,
public readonly groundPlanetId: Id | null,
public readonly secondsSinceOnSurface: number,
public readonly leapCooldownRemaining: number = 0,
) {}
public toArray(): Array<any> {
return [
this.direction,
this.bodyVelocity,
this.leftFootNormal,
this.rightFootNormal,
this.groundPlanetId,
this.secondsSinceOnSurface,
this.leapCooldownRemaining,
];
}
}
@serializable
export class InputAcknowledgement extends Command {
public constructor(
public readonly clientTimeMs: number,
public readonly bodyVelocity: vec2,
// clientTimeMs of the most recent leap the server has received: any leap at
// or before it is already reflected in bodyVelocity, so the predictor must
// not replay it.
public readonly movement: CharacterMovementSnapshot,
public readonly lastLeapClientTimeMs: number,
public readonly ackAgeMs: number = 0,
) {
super();
}
public toArray(): Array<any> {
return [this.clientTimeMs, this.bodyVelocity, this.lastLeapClientTimeMs];
return [this.clientTimeMs, this.movement, this.lastLeapClientTimeMs, this.ackAgeMs];
}
}

View file

@ -6,8 +6,6 @@ import { Command } from '../command';
export class PropertyUpdatesForObjects extends Command {
constructor(
public readonly updates: Array<PropertyUpdatesForObject>,
// Seconds on the server's monotonic clock at the moment this state was
// captured. Only differences between timestamps are meaningful.
public readonly timestamp: number,
) {
super();

View file

@ -1,4 +1,5 @@
import { Id, RemoteCall } from '../../main';
import { Id } from '../../communication/communication';
import { RemoteCall } from '../../objects/game-object';
import { serializable } from '../../serialization/serializable';
import { Command } from '../command';

View file

@ -1,5 +1,5 @@
import { vec2 } from 'gl-matrix';
import { Id } from '../../communication/id';
import { Id } from '../../communication/communication';
import { CharacterTeam } from '../../objects/types/character-base';
import { serializable } from '../../serialization/serializable';
import { Command } from '../command';

View file

@ -0,0 +1,18 @@
export type Id = number;
let currentId = 0;
export const id = (): Id => currentId++;
export interface PlayerInformation {
name: string;
reconnectToken?: string;
}
export interface ServerInformation {
playerLimit: number;
playerCount: number;
serverName: string;
gameStatePercent: number;
}
export const serverInformationEndpoint = '/state';

View file

@ -1,7 +0,0 @@
export type Id = number | null;
let currentId = 0;
export const id = (): number => {
return currentId++;
};

View file

@ -1,3 +0,0 @@
export interface PlayerInformation {
name: string;
}

View file

@ -6,4 +6,13 @@ export enum TransportEvents {
ServerInfoUpdate = 'ServerInfoUpdate',
Ping = 'Ping',
Pong = 'Pong',
PlayerJoined = 'PlayerJoined',
JoinRejected = 'JoinRejected',
}
export enum JoinRejectionReason {
ServerFull = 'ServerFull',
RoundEnding = 'RoundEnding',
AlreadyJoined = 'AlreadyJoined',
InvalidRequest = 'InvalidRequest',
}

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