From 5345f01fd0940e16f8e32341deec471dfffb2913 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Thu, 7 May 2026 21:34:33 +0200 Subject: [PATCH 01/10] init network latency testing --- .../src/Networking/Server/LatencySimulator.ts | 48 +++++++++++++++++++ packages/core/src/Networking/Server/Server.ts | 41 ++++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/Networking/Server/LatencySimulator.ts diff --git a/packages/core/src/Networking/Server/LatencySimulator.ts b/packages/core/src/Networking/Server/LatencySimulator.ts new file mode 100644 index 0000000..f30dab7 --- /dev/null +++ b/packages/core/src/Networking/Server/LatencySimulator.ts @@ -0,0 +1,48 @@ +import type winston from 'winston' + +export interface LatencySimulatorOptions { + /** Minimum latency to simulate */ + fixedLatency?: number + + /** How much ms jitter should we include */ + jitter?: number + + /** + * How much percent packet loss to simulate between 0 - 1 + * 1 = 100% + * 0 = 0% + */ + packetLoss?: number +} + +export default class LatencySimulator { + private options: Required + + constructor(options: LatencySimulatorOptions, logger: winston.Logger) { + this.options = { + fixedLatency: 0, + jitter: 0, + packetLoss: 0, + ...options, // Spread incoming over the defaults + } + + logger.warn(`RUNNING WITH LATENCY SIMULATION: latency ${this.options.fixedLatency}ms, jitter: ${this.options.jitter}ms, packet loss: ${this.options.packetLoss}%`) + } + + /** + * Calculate our latency items and then + * run the callback as appropriate + */ + public handle(cb: () => void) { + if (Math.random() < this.options.packetLoss) { + // Drop the packet completely + return + } + + const latency = this.options.fixedLatency + (Math.random() - 0.5) * this.options.jitter + + setTimeout(() => { + cb() + }, latency) + } +} diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index d49cfb6..15a4e9a 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -5,17 +5,23 @@ import type winston from 'winston' import type GameObject from '../../World/GameObject' import type Player from '../Entities/Player' import type NetworkedActor from '../NetworkedActor' +import type { LatencySimulatorOptions } from './LatencySimulator' import { Buffer } from 'node:buffer' import geckos from '@geckos.io/server' import express from 'express' import BaseGame from '../../BaseGame' import { ServerCommand } from './Commands' +import LatencySimulator from './LatencySimulator' import BandwidthTracker from './Stats/BandwidthTracker' import CpuTracker from './Stats/CpuTracker' import ServerWorld from './World' let instance: Server +export interface ServerOptions { + latencySimulation?: LatencySimulatorOptions +} + export default abstract class Server { game: BaseGame @@ -31,11 +37,12 @@ export default abstract class Server { bandwidthTracker = new BandwidthTracker() private cpuTracker: CpuTracker + private latencySimulator?: LatencySimulator protected abstract onConnection(channel: ServerChannel): TClient protected abstract onCommand(command: any, delta: number): void - constructor(logger: winston.Logger, phyicsWorld?: RAPIER.World) { + constructor(logger: winston.Logger, phyicsWorld?: RAPIER.World, options?: ServerOptions) { instance = this as unknown as Server this.game = new BaseGame(logger, phyicsWorld) @@ -45,6 +52,10 @@ export default abstract class Server { this.logger = logger this.cpuTracker = new CpuTracker(this.logger) + if (options?.latencySimulation) { + this.latencySimulator = new LatencySimulator(options.latencySimulation, this.logger) + } + this.gameSocket.onConnection((channel) => { this.logger.info(`Connected: ${channel.id}`) @@ -54,7 +65,14 @@ export default abstract class Server { this.logger.info(`${(this.game.world as ServerWorld).players.length} total players connected`) channel.on('ping', () => { - channel.emit('pong') + const pong = () => channel.emit('pong') + + if (this.latencySimulator) { + this.latencySimulator.handle(pong) + } + else { + pong() + } }) channel.onDisconnect(() => { @@ -175,7 +193,15 @@ export default abstract class Server { } this.bandwidthTracker.recordSent('server', Buffer.byteLength(JSON.stringify(state))) - con.channel.emit(ServerCommand.SV_STATE, state) + + const SendState = () => con.channel.emit(ServerCommand.SV_STATE, state) + + if (this.latencySimulator) { + this.latencySimulator.handle(SendState) + } + else { + SendState() + } }) // Mark entities as synced ONLY if they were actually sent to at least one client @@ -192,7 +218,14 @@ export default abstract class Server { while (this.commandBuffer.length > 0) { const currentCommand = this.commandBuffer[0] - this.onCommand(currentCommand, this.calculateDelta()) + const process = () => this.onCommand(currentCommand, this.calculateDelta()) + + if (this.latencySimulator) { + this.latencySimulator.handle(process) + } + else { + process() + } this.commandBuffer.shift() } From a481d37d9b3c8066b0d514754c43b22e3a0b2ef2 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Thu, 7 May 2026 21:34:49 +0200 Subject: [PATCH 02/10] network latency vitests Co-authored-by: Opus 4.7 --- .../Server/LatencySimulator.test.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 packages/core/tests/Networking/Server/LatencySimulator.test.ts diff --git a/packages/core/tests/Networking/Server/LatencySimulator.test.ts b/packages/core/tests/Networking/Server/LatencySimulator.test.ts new file mode 100644 index 0000000..7a19fff --- /dev/null +++ b/packages/core/tests/Networking/Server/LatencySimulator.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import LatencySimulator from '../../../src/Networking/Server/LatencySimulator' + +function makeLogger() { + return { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() } as any +} + +describe('latencySimulator', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('logs configuration on construction', () => { + const logger = makeLogger() + void new LatencySimulator({ fixedLatency: 100, jitter: 20, packetLoss: 0.1 }, logger) + expect(logger.warn).toHaveBeenCalledWith( + 'RUNNING WITH LATENCY SIMULATION: latency 100ms, jitter: 20ms, packet loss: 0.1%', + ) + }) + + it('fills missing options with zero defaults', () => { + const logger = makeLogger() + void new LatencySimulator({}, logger) + expect(logger.warn).toHaveBeenCalledWith( + 'RUNNING WITH LATENCY SIMULATION: latency 0ms, jitter: 0ms, packet loss: 0%', + ) + }) + + it('invokes the callback synchronously when latency is 0 and no jitter', () => { + const sim = new LatencySimulator({}, makeLogger()) + const cb = vi.fn() + + sim.handle(cb) + expect(cb).not.toHaveBeenCalled() + + vi.advanceTimersByTime(0) + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('delays the callback by fixedLatency ms', () => { + const sim = new LatencySimulator({ fixedLatency: 150 }, makeLogger()) + const cb = vi.fn() + + sim.handle(cb) + + vi.advanceTimersByTime(149) + expect(cb).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('applies jitter within +/- jitter/2 of fixedLatency', () => { + const sim = new LatencySimulator({ fixedLatency: 100, jitter: 40 }, makeLogger()) + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout') + const randomSpy = vi.spyOn(Math, 'random') + + // handle() calls Math.random() twice: once for packet-loss check, once for jitter. + // First sample: 0.5 (no drop, packetLoss = 0 anyway), jitter draw: 0 → 100 + (0 - 0.5) * 40 = 80 + randomSpy.mockReturnValueOnce(0.5).mockReturnValueOnce(0) + sim.handle(vi.fn()) + expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 80) + + // jitter draw: 1 → 100 + (1 - 0.5) * 40 = 120 + randomSpy.mockReturnValueOnce(0.5).mockReturnValueOnce(1) + sim.handle(vi.fn()) + expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 120) + + // jitter draw: 0.5 → 100 (no jitter) + randomSpy.mockReturnValueOnce(0.5).mockReturnValueOnce(0.5) + sim.handle(vi.fn()) + expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 100) + }) + + it('drops the packet when packetLoss is 1', () => { + const sim = new LatencySimulator({ fixedLatency: 100, packetLoss: 1 }, makeLogger()) + const cb = vi.fn() + + sim.handle(cb) + + vi.runAllTimers() + expect(cb).not.toHaveBeenCalled() + }) + + it('never drops the packet when packetLoss is 0', () => { + const sim = new LatencySimulator({ fixedLatency: 50, packetLoss: 0 }, makeLogger()) + const cb = vi.fn() + + // Force Math.random() near 0 — would drop if packetLoss > 0 + vi.spyOn(Math, 'random').mockReturnValue(0) + + sim.handle(cb) + vi.runAllTimers() + expect(cb).toHaveBeenCalledTimes(1) + }) + + it('drops only when Math.random() falls below packetLoss threshold', () => { + const sim = new LatencySimulator({ fixedLatency: 0, packetLoss: 0.3 }, makeLogger()) + + const dropped = vi.fn() + vi.spyOn(Math, 'random').mockReturnValueOnce(0.1) // below 0.3 → dropped + sim.handle(dropped) + vi.runAllTimers() + expect(dropped).not.toHaveBeenCalled() + + const delivered = vi.fn() + vi.spyOn(Math, 'random').mockReturnValueOnce(0.5).mockReturnValueOnce(0.5) + sim.handle(delivered) + vi.runAllTimers() + expect(delivered).toHaveBeenCalledTimes(1) + }) + + it('handles many concurrent callbacks independently', () => { + const sim = new LatencySimulator({ fixedLatency: 100 }, makeLogger()) + const cbs = Array.from({ length: 5 }, () => vi.fn()) + + cbs.forEach(cb => sim.handle(cb)) + + vi.advanceTimersByTime(100) + cbs.forEach(cb => expect(cb).toHaveBeenCalledTimes(1)) + }) +}) From edf7a33676fd3f34713bafa58fe3adb9e585cc22 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Sun, 10 May 2026 09:30:53 +0200 Subject: [PATCH 03/10] send all commands through network manager --- .../client/src/NetworkManager.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/multiplayer-template/client/src/NetworkManager.ts b/packages/multiplayer-template/client/src/NetworkManager.ts index 4549161..42d4336 100644 --- a/packages/multiplayer-template/client/src/NetworkManager.ts +++ b/packages/multiplayer-template/client/src/NetworkManager.ts @@ -1,6 +1,6 @@ import type { SV_CHAT } from '@template/server/Commands/Server' import BaseNetworkManager from '@mavonengine/core/Networking/Client/NetworkManager' -import { ClientCommand, ServerCommand } from '@template/server/Commands' +import { ClientCommand, ServerCommand, type CommandPacket } from '@template/server/Commands' import useStore from './stores/Game' import useChat from './UI/composables/useChat' import useNetworkState from './UI/composables/useNetworkState' @@ -10,6 +10,11 @@ export default class NetworkManager extends BaseNetworkManager { private chat = useChat() private store = useStore().store + private currentSequenceId = 0 + private lastAcknowledgedSequenceId = 0 + + private localCommandQueue: CommandPacket[] = [] + constructor() { super({ /** @ts-expect-error port is null in prod when behind a reverse proxy */ @@ -52,6 +57,17 @@ export default class NetworkManager extends BaseNetworkManager { this.networkState.value.connected = false } + /** + * All commands need to go through here to get the + * sequenceId assigned and add it to the queue for local replay + */ + public sendCommand(commandPacket: CommandPacket) { + commandPacket.sequenceId = this.currentSequenceId++ + + this.localCommandQueue.push(commandPacket) + this.socket.emit('command', commandPacket) + } + sendChat(message: string) { this.socket.emit( ClientCommand.CL_CHAT, From 9b069a3edc5814fb99bd485fab964f6d38f752fc Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Wed, 13 May 2026 19:43:18 +0200 Subject: [PATCH 04/10] better command typing --- .../core/src/Networking/Server/Commands.ts | 20 +++++++++++++------ packages/core/src/Networking/Server/Server.ts | 6 +++--- packages/core/tsconfig.json | 1 + .../client/src/NetworkManager.ts | 2 +- .../server/src/Commands.ts | 19 ++++++++++-------- .../multiplayer-template/server/src/Server.ts | 2 +- 6 files changed, 31 insertions(+), 19 deletions(-) diff --git a/packages/core/src/Networking/Server/Commands.ts b/packages/core/src/Networking/Server/Commands.ts index 3a8f886..358b976 100644 --- a/packages/core/src/Networking/Server/Commands.ts +++ b/packages/core/src/Networking/Server/Commands.ts @@ -1,13 +1,21 @@ +import { ChannelId } from "@geckos.io/client" + export enum ServerCommand { + SV_PONG = 'sv_pong', SV_STATE = 'sv_state', SV_REMOVE_ENTITY = 'sv_remove_entity', } - -export interface CommandPacket { - cmd: T - data: unknown +export enum ClientCommand { + CL_PING = 'cl_ping', } -export type SV_TEST = CommandPacket & { - boo: string +export type Command = ClientCommand | ServerCommand + +export interface CommandPacket { + type: T + sequenceId: number } + +export interface IncomingClientCommandPacket extends CommandPacket { + playerId: ChannelId +} \ No newline at end of file diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index 15a4e9a..6597e9f 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -10,7 +10,7 @@ import { Buffer } from 'node:buffer' import geckos from '@geckos.io/server' import express from 'express' import BaseGame from '../../BaseGame' -import { ServerCommand } from './Commands' +import { ClientCommand, CommandPacket, IncomingClientCommandPacket, ServerCommand } from './Commands' import LatencySimulator from './LatencySimulator' import BandwidthTracker from './Stats/BandwidthTracker' import CpuTracker from './Stats/CpuTracker' @@ -25,7 +25,7 @@ export interface ServerOptions { export default abstract class Server { game: BaseGame - private commandBuffer: object[] = [] + private commandBuffer: IncomingClientCommandPacket[] = [] gameSocket: GeckosServer httpServer: Express = express() @@ -128,7 +128,7 @@ export default abstract class Server { private bufferIncomingCommand(channel: ServerChannel, command: Data) { this.commandBuffer.push({ playerId: channel.id!, - ...(command) as object, + ...(command) as CommandPacket, }) } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 96d3b50..f6d5bdf 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -10,6 +10,7 @@ }, "allowUmdGlobalAccess": true, "strict": true, + "declarationMap": true, "strictNullChecks": true, "noImplicitAny": true, "emitDeclarationOnly": true, diff --git a/packages/multiplayer-template/client/src/NetworkManager.ts b/packages/multiplayer-template/client/src/NetworkManager.ts index 42d4336..1a9e6ab 100644 --- a/packages/multiplayer-template/client/src/NetworkManager.ts +++ b/packages/multiplayer-template/client/src/NetworkManager.ts @@ -1,6 +1,6 @@ import type { SV_CHAT } from '@template/server/Commands/Server' import BaseNetworkManager from '@mavonengine/core/Networking/Client/NetworkManager' -import { ClientCommand, ServerCommand, type CommandPacket } from '@template/server/Commands' +import { ClientCommand, type ServerCommand, type CommandPacket } from '@template/server/Commands' import useStore from './stores/Game' import useChat from './UI/composables/useChat' import useNetworkState from './UI/composables/useNetworkState' diff --git a/packages/multiplayer-template/server/src/Commands.ts b/packages/multiplayer-template/server/src/Commands.ts index 261f882..c21fa19 100644 --- a/packages/multiplayer-template/server/src/Commands.ts +++ b/packages/multiplayer-template/server/src/Commands.ts @@ -1,23 +1,26 @@ -export type Command = ClientCommand | ServerCommand +import { + CommandPacket, + ServerCommand as BaseServerCommand, + ClientCommand as BaseClientCommand +} from "@mavonengine/core/Networking/Server/Commands" -export enum ClientCommand { +export enum LocalClientCommand { CL_INIT = 'cl_init', CL_MOVE = 'cl_move', CL_CHAT = 'cl_chat', } -export enum ServerCommand { +export type ClientCommand = LocalClientCommand | BaseClientCommand + +export enum LocalServerCommand { SV_STATE = 'sv_state', SV_REMOVE_ENTITY = 'sv_remove_entity', SV_CHAT = 'sv_chat', SV_TREES = 'sv_trees', } -export interface CommandPacket { - type: T - sequenceId: number -} +export type ServerCommand = LocalServerCommand | BaseServerCommand -export type IncomingCommandPacket = CommandPacket & { +export type IncomingCommandPacket = CommandPacket & { playerId: string } diff --git a/packages/multiplayer-template/server/src/Server.ts b/packages/multiplayer-template/server/src/Server.ts index 0f527b7..b97dc93 100644 --- a/packages/multiplayer-template/server/src/Server.ts +++ b/packages/multiplayer-template/server/src/Server.ts @@ -7,7 +7,7 @@ import BaseServer from '@mavonengine/core/Networking/Server/Server' import { randRange } from '@mavonengine/core/Utils/Math' import { Vector3 } from 'three' import Tree from './Base/Vegetation/Tree' -import { ClientCommand, ServerCommand } from './Commands' +import { ClientCommand, type ServerCommand } from './Commands' import Player from './Server/Entities/Player' const TREE_COUNT = 15 From 1e8e75dd882481570cd3f6f278d4f0b17f434070 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Wed, 13 May 2026 20:03:44 +0200 Subject: [PATCH 05/10] refactor commands --- .../core/src/Networking/Server/Commands.ts | 4 +-- packages/core/src/Networking/Server/Server.ts | 3 +- packages/core/tsconfig.json | 2 +- .../client/src/GameSyncManager.ts | 3 +- .../client/src/NetworkManager.ts | 5 ++-- .../client/src/PlayerController.ts | 3 +- .../server/src/Commands.ts | 26 ----------------- .../server/src/Commands/Client.ts | 29 ++++++++++++++++--- .../server/src/Commands/Server.ts | 23 +++++++++++++-- .../multiplayer-template/server/src/Server.ts | 3 +- 10 files changed, 58 insertions(+), 43 deletions(-) delete mode 100644 packages/multiplayer-template/server/src/Commands.ts diff --git a/packages/core/src/Networking/Server/Commands.ts b/packages/core/src/Networking/Server/Commands.ts index 358b976..11f5e2b 100644 --- a/packages/core/src/Networking/Server/Commands.ts +++ b/packages/core/src/Networking/Server/Commands.ts @@ -1,4 +1,4 @@ -import { ChannelId } from "@geckos.io/client" +import type { ChannelId } from '@geckos.io/client' export enum ServerCommand { SV_PONG = 'sv_pong', @@ -18,4 +18,4 @@ export interface CommandPacket { export interface IncomingClientCommandPacket extends CommandPacket { playerId: ChannelId -} \ No newline at end of file +} diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index 6597e9f..d30852b 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -5,12 +5,13 @@ import type winston from 'winston' import type GameObject from '../../World/GameObject' import type Player from '../Entities/Player' import type NetworkedActor from '../NetworkedActor' +import type { ClientCommand, CommandPacket, IncomingClientCommandPacket } from './Commands' import type { LatencySimulatorOptions } from './LatencySimulator' import { Buffer } from 'node:buffer' import geckos from '@geckos.io/server' import express from 'express' import BaseGame from '../../BaseGame' -import { ClientCommand, CommandPacket, IncomingClientCommandPacket, ServerCommand } from './Commands' +import { ServerCommand } from './Commands' import LatencySimulator from './LatencySimulator' import BandwidthTracker from './Stats/BandwidthTracker' import CpuTracker from './Stats/CpuTracker' diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index f6d5bdf..7014a6a 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -10,9 +10,9 @@ }, "allowUmdGlobalAccess": true, "strict": true, - "declarationMap": true, "strictNullChecks": true, "noImplicitAny": true, + "declarationMap": true, "emitDeclarationOnly": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, diff --git a/packages/multiplayer-template/client/src/GameSyncManager.ts b/packages/multiplayer-template/client/src/GameSyncManager.ts index f2d8270..176a884 100644 --- a/packages/multiplayer-template/client/src/GameSyncManager.ts +++ b/packages/multiplayer-template/client/src/GameSyncManager.ts @@ -1,12 +1,11 @@ import type GameObjectInterface from '@mavonengine/core/World/GameObjectInterface' -import type { SV_CHAT, SV_TREES } from '@template/server/Commands/Server' +import { ServerCommand, type SV_CHAT, type SV_TREES } from '@template/server/Commands/Server' import type NetworkManager from './NetworkManager' import type PlayerController from './PlayerController' import Game from '@mavonengine/core/Game' import NetworkedActor from '@mavonengine/core/Networking/NetworkedActor' import NetworkedEntityFactory from '@mavonengine/core/Networking/NetworkedEntityFactory' import NetworkedGameObject from '@mavonengine/core/Networking/NetworkedGameObject' -import { ServerCommand } from '@template/server/Commands' import Character from './Entities/Player' import Trees from './World/Trees' diff --git a/packages/multiplayer-template/client/src/NetworkManager.ts b/packages/multiplayer-template/client/src/NetworkManager.ts index 1a9e6ab..2854dc2 100644 --- a/packages/multiplayer-template/client/src/NetworkManager.ts +++ b/packages/multiplayer-template/client/src/NetworkManager.ts @@ -1,9 +1,10 @@ -import type { SV_CHAT } from '@template/server/Commands/Server' +import { ServerCommand, type SV_CHAT } from '@template/server/Commands/Server' import BaseNetworkManager from '@mavonengine/core/Networking/Client/NetworkManager' -import { ClientCommand, type ServerCommand, type CommandPacket } from '@template/server/Commands' import useStore from './stores/Game' import useChat from './UI/composables/useChat' import useNetworkState from './UI/composables/useNetworkState' +import type { CommandPacket, IncomingClientCommandPacket } from '@mavonengine/core/Networking/Server/Commands' +import { ClientCommand } from '@template/server/Commands/Client' export default class NetworkManager extends BaseNetworkManager { private networkState = useNetworkState().networkState diff --git a/packages/multiplayer-template/client/src/PlayerController.ts b/packages/multiplayer-template/client/src/PlayerController.ts index b47ccaa..6d0a4d1 100644 --- a/packages/multiplayer-template/client/src/PlayerController.ts +++ b/packages/multiplayer-template/client/src/PlayerController.ts @@ -1,8 +1,7 @@ -import type { CL_MOVE } from '@template/server/Commands/Client' +import { ClientCommand, type CL_MOVE } from '@template/server/Commands/Client' import type Character from './Entities/Player' import Game from '@mavonengine/core/Game' import GameObject from '@mavonengine/core/World/GameObject' -import { ClientCommand } from '@template/server/Commands' import { Vector3 } from 'three' import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import NetworkManager from './NetworkManager' diff --git a/packages/multiplayer-template/server/src/Commands.ts b/packages/multiplayer-template/server/src/Commands.ts deleted file mode 100644 index c21fa19..0000000 --- a/packages/multiplayer-template/server/src/Commands.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - CommandPacket, - ServerCommand as BaseServerCommand, - ClientCommand as BaseClientCommand -} from "@mavonengine/core/Networking/Server/Commands" - -export enum LocalClientCommand { - CL_INIT = 'cl_init', - CL_MOVE = 'cl_move', - CL_CHAT = 'cl_chat', -} - -export type ClientCommand = LocalClientCommand | BaseClientCommand - -export enum LocalServerCommand { - SV_STATE = 'sv_state', - SV_REMOVE_ENTITY = 'sv_remove_entity', - SV_CHAT = 'sv_chat', - SV_TREES = 'sv_trees', -} - -export type ServerCommand = LocalServerCommand | BaseServerCommand - -export type IncomingCommandPacket = CommandPacket & { - playerId: string -} diff --git a/packages/multiplayer-template/server/src/Commands/Client.ts b/packages/multiplayer-template/server/src/Commands/Client.ts index 49569f2..1bcaf49 100644 --- a/packages/multiplayer-template/server/src/Commands/Client.ts +++ b/packages/multiplayer-template/server/src/Commands/Client.ts @@ -1,14 +1,35 @@ -import type { ClientCommand, CommandPacket } from '../Commands' +import { + ClientCommand as BaseClientCommand, + CommandPacket +} from "@mavonengine/core/Networking/Server/Commands" -export type CL_INIT = CommandPacket & { +/** + * Define all available client commands here that get sent to the server. + */ +export enum LocalClientCommand { + CL_INIT = 'cl_init', + CL_MOVE = 'cl_move', + CL_CHAT = 'cl_chat', +} + +export const ClientCommand = { + ...BaseClientCommand, + ...LocalClientCommand +} as const + + +/** + * Define the structure of the packets below for your defined packet names above + */ +export type CL_INIT = CommandPacket & { name: string } -export type CL_MOVE = CommandPacket & { +export type CL_MOVE = CommandPacket & { keys: string[] yaw: number } -export type CL_CHAT = CommandPacket & { +export type CL_CHAT = CommandPacket & { message: string } diff --git a/packages/multiplayer-template/server/src/Commands/Server.ts b/packages/multiplayer-template/server/src/Commands/Server.ts index 39b39d4..34a527a 100644 --- a/packages/multiplayer-template/server/src/Commands/Server.ts +++ b/packages/multiplayer-template/server/src/Commands/Server.ts @@ -1,6 +1,25 @@ -import type { CommandPacket, ServerCommand } from '../Commands' +import { + ServerCommand as BaseServerCommand, + CommandPacket, +} from "@mavonengine/core/Networking/Server/Commands" -export type SV_REMOVE_ENTITY = CommandPacket & { +/** + * Define all available server commands here that get sent to the client. + */ +export enum LocalServerCommand { + SV_CHAT = 'sv_chat', + SV_TREES = 'sv_trees', +} + +export const ServerCommand = { + ...LocalServerCommand, + ...BaseServerCommand +} + +/** + * Define the structure of the packets below for your defined packet names above + */ +export type SV_REMOVE_ENTITY = CommandPacket & { id: string } diff --git a/packages/multiplayer-template/server/src/Server.ts b/packages/multiplayer-template/server/src/Server.ts index b97dc93..060e356 100644 --- a/packages/multiplayer-template/server/src/Server.ts +++ b/packages/multiplayer-template/server/src/Server.ts @@ -7,8 +7,9 @@ import BaseServer from '@mavonengine/core/Networking/Server/Server' import { randRange } from '@mavonengine/core/Utils/Math' import { Vector3 } from 'three' import Tree from './Base/Vegetation/Tree' -import { ClientCommand, type ServerCommand } from './Commands' import Player from './Server/Entities/Player' +import { ServerCommand } from './Commands/Server' +import { ClientCommand } from './Commands/Client' const TREE_COUNT = 15 const SPAWN_AREA = 80 From 8ac6d696ed4b42b9128e33f9a7034684abd0d074 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Wed, 13 May 2026 20:50:07 +0200 Subject: [PATCH 06/10] move sendCommand to core networkManager and sort packets on server queue --- .../src/Networking/Client/NetworkManager.ts | 21 +++++++++++++++++-- .../core/src/Networking/Server/Commands.ts | 2 +- packages/core/src/Networking/Server/Server.ts | 14 +++++++++++++ packages/core/tests/Networking/Server.test.ts | 19 +++++++++++++++++ .../client/src/NetworkManager.ts | 20 ++---------------- .../client/src/PlayerController.ts | 6 +++--- .../server/src/Commands/Server.ts | 4 ---- 7 files changed, 58 insertions(+), 28 deletions(-) diff --git a/packages/core/src/Networking/Client/NetworkManager.ts b/packages/core/src/Networking/Client/NetworkManager.ts index f4dfb72..618019b 100644 --- a/packages/core/src/Networking/Client/NetworkManager.ts +++ b/packages/core/src/Networking/Client/NetworkManager.ts @@ -4,6 +4,7 @@ import type { ClientOptions } from '@geckos.io/common/lib/types' import { geckos } from '@geckos.io/client' import Game from '../../BaseGame' import EventEmitter from '../../Utils/EventEmitter' +import { CommandPacket } from '../Server/Commands' let instance: NetworkManager | undefined @@ -13,6 +14,11 @@ export default class NetworkManager extends EventEmitter { pingNow = 0 private _connected = false + private currentSequenceId = 0 + private lastAcknowledgedSequenceId = 0 + + private localCommandQueue: CommandPacket[] = [] + constructor(options: ClientOptions) { super() // eslint-disable-next-line ts/no-this-alias @@ -60,9 +66,20 @@ export default class NetworkManager extends EventEmitter { instance = undefined } - protected onPong() {} + /** + * All commands need to go through here to get the + * sequenceId assigned and add it to the queue for local replay + */ + public sendCommand(commandPacket: CommandPacket) { + commandPacket.sequenceId = this.currentSequenceId++ + + this.localCommandQueue.push(commandPacket) + this.socket.emit('command', commandPacket) + } + + protected onPong() { } - protected onDisconnect() {} + protected onDisconnect() { } protected pingCheck() { this.pingNow = performance.now() diff --git a/packages/core/src/Networking/Server/Commands.ts b/packages/core/src/Networking/Server/Commands.ts index 11f5e2b..a96e0b8 100644 --- a/packages/core/src/Networking/Server/Commands.ts +++ b/packages/core/src/Networking/Server/Commands.ts @@ -13,7 +13,7 @@ export type Command = ClientCommand | ServerCommand export interface CommandPacket { type: T - sequenceId: number + sequenceId?: number } export interface IncomingClientCommandPacket extends CommandPacket { diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index d30852b..b3a9027 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -216,6 +216,20 @@ export default abstract class Server { abstract getStateSyncDistance(): number private runThroughBuffer() { + + /** + * Due to network latency packets could come in different orders. First step is sort by sequence Id + * + * This currently will move recently connected clients to the + * front of the tick to be processed. Not really an issue at the moment but if it does + * turn out to be an issue then we need to sort the packets by playerId too. + * + * TODO fix ordering to sort packets only for player ids. + * If in an FPS player1 shoots first but this sorting puts player2 shot to be + * ticked over first because their sequenceId is lower its not really fair + */ + this.commandBuffer.sort((a, b) => a.sequenceId! - b.sequenceId!) + while (this.commandBuffer.length > 0) { const currentCommand = this.commandBuffer[0] diff --git a/packages/core/tests/Networking/Server.test.ts b/packages/core/tests/Networking/Server.test.ts index cc3a8a4..c51202b 100644 --- a/packages/core/tests/Networking/Server.test.ts +++ b/packages/core/tests/Networking/Server.test.ts @@ -155,6 +155,25 @@ describe('server', () => { expect(onCommand).toBeCalledWith(testCommand, randomDelta) }) + it('sorts the commandBuffer by sequenceId before processing', () => { + const server = new TestServer(logger) + + const processed: number[] = [] + ;(server as any).onCommand = (cmd: { sequenceId: number }) => { + processed.push(cmd.sequenceId) + } + ;(server as any).commandBuffer = [ + { sequenceId: 3 }, + { sequenceId: 1 }, + { sequenceId: 4 }, + { sequenceId: 2 }, + ] + + ;(server as any).runThroughBuffer() + + expect(processed).toEqual([1, 2, 3, 4]) + }) + describe('stateSync', () => { function makeConnection(id: string) { const emit = vi.fn() diff --git a/packages/multiplayer-template/client/src/NetworkManager.ts b/packages/multiplayer-template/client/src/NetworkManager.ts index 2854dc2..c03de85 100644 --- a/packages/multiplayer-template/client/src/NetworkManager.ts +++ b/packages/multiplayer-template/client/src/NetworkManager.ts @@ -1,20 +1,15 @@ -import { ServerCommand, type SV_CHAT } from '@template/server/Commands/Server' import BaseNetworkManager from '@mavonengine/core/Networking/Client/NetworkManager' +import { ClientCommand } from '@template/server/Commands/Client' +import { ServerCommand, type SV_CHAT } from '@template/server/Commands/Server' import useStore from './stores/Game' import useChat from './UI/composables/useChat' import useNetworkState from './UI/composables/useNetworkState' -import type { CommandPacket, IncomingClientCommandPacket } from '@mavonengine/core/Networking/Server/Commands' -import { ClientCommand } from '@template/server/Commands/Client' export default class NetworkManager extends BaseNetworkManager { private networkState = useNetworkState().networkState private chat = useChat() private store = useStore().store - private currentSequenceId = 0 - private lastAcknowledgedSequenceId = 0 - - private localCommandQueue: CommandPacket[] = [] constructor() { super({ @@ -58,17 +53,6 @@ export default class NetworkManager extends BaseNetworkManager { this.networkState.value.connected = false } - /** - * All commands need to go through here to get the - * sequenceId assigned and add it to the queue for local replay - */ - public sendCommand(commandPacket: CommandPacket) { - commandPacket.sequenceId = this.currentSequenceId++ - - this.localCommandQueue.push(commandPacket) - this.socket.emit('command', commandPacket) - } - sendChat(message: string) { this.socket.emit( ClientCommand.CL_CHAT, diff --git a/packages/multiplayer-template/client/src/PlayerController.ts b/packages/multiplayer-template/client/src/PlayerController.ts index 6d0a4d1..b754dc2 100644 --- a/packages/multiplayer-template/client/src/PlayerController.ts +++ b/packages/multiplayer-template/client/src/PlayerController.ts @@ -66,14 +66,14 @@ export default class PlayerController extends GameObject { const move: CL_MOVE = { type: ClientCommand.CL_MOVE, - sequenceId: 0, keys, yaw: this.player.rotation.y, } if (JSON.stringify(this.lastMove) !== JSON.stringify(move)) { - this.lastMove = move - NetworkManager.getInstance().socket.emit('command', move) + // Spread so its a different object. Otherwise sendCommand assigns sequenceId which fails the !== check + this.lastMove = {...move} + NetworkManager.getInstance().sendCommand(move) } } diff --git a/packages/multiplayer-template/server/src/Commands/Server.ts b/packages/multiplayer-template/server/src/Commands/Server.ts index 34a527a..935daa9 100644 --- a/packages/multiplayer-template/server/src/Commands/Server.ts +++ b/packages/multiplayer-template/server/src/Commands/Server.ts @@ -19,10 +19,6 @@ export const ServerCommand = { /** * Define the structure of the packets below for your defined packet names above */ -export type SV_REMOVE_ENTITY = CommandPacket & { - id: string -} - export interface SV_CHAT { playerId: string playerName: string From 4cab441a4892ad68544993991b7cd406537d01c1 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Wed, 13 May 2026 20:50:27 +0200 Subject: [PATCH 07/10] lint --- packages/core/src/Networking/Client/NetworkManager.ts | 8 ++++---- packages/core/src/Networking/Server/Server.ts | 11 +++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/core/src/Networking/Client/NetworkManager.ts b/packages/core/src/Networking/Client/NetworkManager.ts index 618019b..be3586e 100644 --- a/packages/core/src/Networking/Client/NetworkManager.ts +++ b/packages/core/src/Networking/Client/NetworkManager.ts @@ -1,10 +1,10 @@ import type { ClientChannel } from '@geckos.io/client' import type { ClientOptions } from '@geckos.io/common/lib/types' +import type { CommandPacket } from '../Server/Commands' import { geckos } from '@geckos.io/client' import Game from '../../BaseGame' import EventEmitter from '../../Utils/EventEmitter' -import { CommandPacket } from '../Server/Commands' let instance: NetworkManager | undefined @@ -67,9 +67,9 @@ export default class NetworkManager extends EventEmitter { } /** - * All commands need to go through here to get the - * sequenceId assigned and add it to the queue for local replay - */ + * All commands need to go through here to get the + * sequenceId assigned and add it to the queue for local replay + */ public sendCommand(commandPacket: CommandPacket) { commandPacket.sequenceId = this.currentSequenceId++ diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index b3a9027..547ff0c 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -216,16 +216,15 @@ export default abstract class Server { abstract getStateSyncDistance(): number private runThroughBuffer() { - /** * Due to network latency packets could come in different orders. First step is sort by sequence Id - * - * This currently will move recently connected clients to the + * + * This currently will move recently connected clients to the * front of the tick to be processed. Not really an issue at the moment but if it does * turn out to be an issue then we need to sort the packets by playerId too. - * - * TODO fix ordering to sort packets only for player ids. - * If in an FPS player1 shoots first but this sorting puts player2 shot to be + * + * TODO fix ordering to sort packets only for player ids. + * If in an FPS player1 shoots first but this sorting puts player2 shot to be * ticked over first because their sequenceId is lower its not really fair */ this.commandBuffer.sort((a, b) => a.sequenceId! - b.sequenceId!) From 22b7573ff33a06bdc641b14000c595778b937a01 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Wed, 13 May 2026 21:04:56 +0200 Subject: [PATCH 08/10] drop packets from local client queue that were acknowledged --- .../src/Networking/Client/NetworkManager.ts | 10 +++++++++- .../core/src/Networking/Entities/Player.ts | 18 ++++++++++++++++++ packages/core/src/Networking/Server/Server.ts | 6 ++++++ .../client/src/PlayerController.ts | 2 ++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/core/src/Networking/Client/NetworkManager.ts b/packages/core/src/Networking/Client/NetworkManager.ts index be3586e..3ca3ec2 100644 --- a/packages/core/src/Networking/Client/NetworkManager.ts +++ b/packages/core/src/Networking/Client/NetworkManager.ts @@ -15,8 +15,12 @@ export default class NetworkManager extends EventEmitter { private _connected = false private currentSequenceId = 0 - private lastAcknowledgedSequenceId = 0 + /** + * This command queue is used for replaying packets locally. + * When ServerCommand.SV_STATE comes in we drop anything thats below + * the lastProcessedSequenceId + */ private localCommandQueue: CommandPacket[] = [] constructor(options: ClientOptions) { @@ -77,6 +81,10 @@ export default class NetworkManager extends EventEmitter { this.socket.emit('command', commandPacket) } + public dropCommandsAtSequenceId(sequenceId: number) { + this.localCommandQueue = this.localCommandQueue.filter(packet => packet.sequenceId! > sequenceId) + } + protected onPong() { } protected onDisconnect() { } diff --git a/packages/core/src/Networking/Entities/Player.ts b/packages/core/src/Networking/Entities/Player.ts index 1bc176e..091fb10 100644 --- a/packages/core/src/Networking/Entities/Player.ts +++ b/packages/core/src/Networking/Entities/Player.ts @@ -8,10 +8,28 @@ export default abstract class Player extends NetworkedLivingActor { trackedEntities = new Set() + private _lastProcessedSequenceId = 0 + + get lastProcessedSequenceId() { + return this._lastProcessedSequenceId + } + + updateLastProcessedSequenceId(sequenceId: number) { + this._lastProcessedSequenceId = sequenceId + } + + networkedFieldCallbacks(): Record void> { + return { + ...super.networkedFieldCallbacks(), + lastProcessedSequenceId: sequenceId => this._lastProcessedSequenceId = sequenceId as number, + } + } + public serialize() { return { ...super.serialize(), $typeName: this.$typeName, + lastProcessedSequenceId: this.lastProcessedSequenceId, } } } diff --git a/packages/core/src/Networking/Server/Server.ts b/packages/core/src/Networking/Server/Server.ts index 547ff0c..cec17ba 100644 --- a/packages/core/src/Networking/Server/Server.ts +++ b/packages/core/src/Networking/Server/Server.ts @@ -241,6 +241,12 @@ export default abstract class Server { process() } + /** + * Update the players lastProcessedSequenceId after the command has executed + */ + const player = this.game.world.entities.items.get(currentCommand.playerId!) as Player + player.updateLastProcessedSequenceId(currentCommand.sequenceId!) + this.commandBuffer.shift() } } diff --git a/packages/multiplayer-template/client/src/PlayerController.ts b/packages/multiplayer-template/client/src/PlayerController.ts index b754dc2..ef52fde 100644 --- a/packages/multiplayer-template/client/src/PlayerController.ts +++ b/packages/multiplayer-template/client/src/PlayerController.ts @@ -89,6 +89,8 @@ export default class PlayerController extends GameObject { } this.player.updateFromNetwork(entityData) + + NetworkManager.getInstance().dropCommandsBeforeSequenceId(this.player.lastProcessedSequenceId) } destroy(): void { From e3189ef7e968b2eb05542fa5946451da002a6e22 Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Wed, 13 May 2026 21:05:01 +0200 Subject: [PATCH 09/10] test packet dropping on clientWorkManager and update server tests Co-authored-by: Claude Opus 4.7 --- .../Networking/Client/NetworkManager.test.ts | 73 +++++++++++++++++++ packages/core/tests/Networking/Server.test.ts | 17 +++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/packages/core/tests/Networking/Client/NetworkManager.test.ts b/packages/core/tests/Networking/Client/NetworkManager.test.ts index 32c6527..f88b958 100644 --- a/packages/core/tests/Networking/Client/NetworkManager.test.ts +++ b/packages/core/tests/Networking/Client/NetworkManager.test.ts @@ -125,4 +125,77 @@ describe('networkManager', () => { expect(mockSocket.emit).toHaveBeenCalledWith('ping') vi.useRealTimers() }) + + describe('command queue', () => { + it('sendCommand assigns sequential sequenceIds starting at 0', () => { + const nm = new NetworkManager({}) + const a = { type: 'cmd_a' as any } + const b = { type: 'cmd_b' as any } + const c = { type: 'cmd_c' as any } + + nm.sendCommand(a) + nm.sendCommand(b) + nm.sendCommand(c) + + expect(a.sequenceId).toBe(0) + expect(b.sequenceId).toBe(1) + expect(c.sequenceId).toBe(2) + }) + + it('sendCommand pushes the packet to the local queue and emits it on the socket', () => { + const nm = new NetworkManager({}) + const packet = { type: 'cmd' as any } + + nm.sendCommand(packet) + + const queue = (nm as any).localCommandQueue + expect(queue).toHaveLength(1) + expect(queue[0]).toBe(packet) + expect(mockSocket.emit).toHaveBeenCalledWith('command', packet) + }) + + it('dropCommandsAtSequenceId removes acknowledged commands with sequenceId <= given', () => { + const nm = new NetworkManager({}) + nm.sendCommand({ type: 'a' as any }) + nm.sendCommand({ type: 'b' as any }) + nm.sendCommand({ type: 'c' as any }) + nm.sendCommand({ type: 'd' as any }) + + nm.dropCommandsAtSequenceId(2) + + const queue = (nm as any).localCommandQueue as { type: string, sequenceId: number }[] + expect(queue.map(p => p.sequenceId)).toEqual([3]) + }) + + it('dropCommandsAtSequenceId keeps commands with sequenceId greater than given', () => { + const nm = new NetworkManager({}) + nm.sendCommand({ type: 'a' as any }) + nm.sendCommand({ type: 'b' as any }) + nm.sendCommand({ type: 'c' as any }) + + nm.dropCommandsAtSequenceId(0) + + const queue = (nm as any).localCommandQueue as { sequenceId: number }[] + expect(queue.map(p => p.sequenceId)).toEqual([1, 2]) + }) + + it('dropCommandsAtSequenceId clears the queue when the ack covers every command', () => { + const nm = new NetworkManager({}) + nm.sendCommand({ type: 'a' as any }) + nm.sendCommand({ type: 'b' as any }) + nm.sendCommand({ type: 'c' as any }) + + nm.dropCommandsAtSequenceId(10) + + const queue = (nm as any).localCommandQueue as unknown[] + expect(queue).toHaveLength(0) + }) + + it('dropCommandsAtSequenceId is a no-op on an empty queue', () => { + const nm = new NetworkManager({}) + + expect(() => nm.dropCommandsAtSequenceId(5)).not.toThrow() + expect((nm as any).localCommandQueue).toHaveLength(0) + }) + }) }) diff --git a/packages/core/tests/Networking/Server.test.ts b/packages/core/tests/Networking/Server.test.ts index c51202b..739714a 100644 --- a/packages/core/tests/Networking/Server.test.ts +++ b/packages/core/tests/Networking/Server.test.ts @@ -141,8 +141,11 @@ describe('server', () => { it('should call onCommand', () => { const server = new TestServer(logger) + const player = new TestClient('p1') + server.game.world.entities.items.set('p1', player) + const onCommand = vi.fn() - const testCommand = { command: 'test-command' } + const testCommand = { command: 'test-command', playerId: 'p1', sequenceId: 0 } const randomDelta = Math.random(); (server as any).onCommand = onCommand; @@ -158,20 +161,24 @@ describe('server', () => { it('sorts the commandBuffer by sequenceId before processing', () => { const server = new TestServer(logger) + const player = new TestClient('p1') + server.game.world.entities.items.set('p1', player) + const processed: number[] = [] ;(server as any).onCommand = (cmd: { sequenceId: number }) => { processed.push(cmd.sequenceId) } ;(server as any).commandBuffer = [ - { sequenceId: 3 }, - { sequenceId: 1 }, - { sequenceId: 4 }, - { sequenceId: 2 }, + { sequenceId: 3, playerId: 'p1' }, + { sequenceId: 1, playerId: 'p1' }, + { sequenceId: 4, playerId: 'p1' }, + { sequenceId: 2, playerId: 'p1' }, ] ;(server as any).runThroughBuffer() expect(processed).toEqual([1, 2, 3, 4]) + expect(player.lastProcessedSequenceId).toBe(4) }) describe('stateSync', () => { From 285af0e933217aa4781eebcee27080b055c7317c Mon Sep 17 00:00:00 2001 From: Matthias von Bargen Date: Wed, 13 May 2026 19:52:31 +0200 Subject: [PATCH 10/10] reset player keys if idle / packets missing due to latency --- .../client/src/GameSyncManager.ts | 3 ++- .../client/src/NetworkManager.ts | 4 +-- .../client/src/PlayerController.ts | 23 +++-------------- .../server/src/Base/Player.ts | 25 +++++++++++++++++++ .../server/src/Commands/Client.ts | 9 ++++--- .../server/src/Commands/Server.ts | 5 ++-- .../multiplayer-template/server/src/Server.ts | 5 ++-- 7 files changed, 43 insertions(+), 31 deletions(-) diff --git a/packages/multiplayer-template/client/src/GameSyncManager.ts b/packages/multiplayer-template/client/src/GameSyncManager.ts index 176a884..5002267 100644 --- a/packages/multiplayer-template/client/src/GameSyncManager.ts +++ b/packages/multiplayer-template/client/src/GameSyncManager.ts @@ -1,11 +1,12 @@ import type GameObjectInterface from '@mavonengine/core/World/GameObjectInterface' -import { ServerCommand, type SV_CHAT, type SV_TREES } from '@template/server/Commands/Server' +import type { SV_CHAT, SV_TREES } from '@template/server/Commands/Server' import type NetworkManager from './NetworkManager' import type PlayerController from './PlayerController' import Game from '@mavonengine/core/Game' import NetworkedActor from '@mavonengine/core/Networking/NetworkedActor' import NetworkedEntityFactory from '@mavonengine/core/Networking/NetworkedEntityFactory' import NetworkedGameObject from '@mavonengine/core/Networking/NetworkedGameObject' +import { ServerCommand } from '@template/server/Commands/Server' import Character from './Entities/Player' import Trees from './World/Trees' diff --git a/packages/multiplayer-template/client/src/NetworkManager.ts b/packages/multiplayer-template/client/src/NetworkManager.ts index c03de85..c8ccc17 100644 --- a/packages/multiplayer-template/client/src/NetworkManager.ts +++ b/packages/multiplayer-template/client/src/NetworkManager.ts @@ -1,6 +1,7 @@ +import type { SV_CHAT } from '@template/server/Commands/Server' import BaseNetworkManager from '@mavonengine/core/Networking/Client/NetworkManager' import { ClientCommand } from '@template/server/Commands/Client' -import { ServerCommand, type SV_CHAT } from '@template/server/Commands/Server' +import { ServerCommand } from '@template/server/Commands/Server' import useStore from './stores/Game' import useChat from './UI/composables/useChat' import useNetworkState from './UI/composables/useNetworkState' @@ -10,7 +11,6 @@ export default class NetworkManager extends BaseNetworkManager { private chat = useChat() private store = useStore().store - constructor() { super({ /** @ts-expect-error port is null in prod when behind a reverse proxy */ diff --git a/packages/multiplayer-template/client/src/PlayerController.ts b/packages/multiplayer-template/client/src/PlayerController.ts index ef52fde..1abb580 100644 --- a/packages/multiplayer-template/client/src/PlayerController.ts +++ b/packages/multiplayer-template/client/src/PlayerController.ts @@ -1,7 +1,8 @@ -import { ClientCommand, type CL_MOVE } from '@template/server/Commands/Client' +import type { CL_MOVE } from '@template/server/Commands/Client' import type Character from './Entities/Player' import Game from '@mavonengine/core/Game' import GameObject from '@mavonengine/core/World/GameObject' +import { ClientCommand } from '@template/server/Commands/Client' import { Vector3 } from 'three' import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import NetworkManager from './NetworkManager' @@ -14,7 +15,6 @@ import NetworkManager from './NetworkManager' export default class PlayerController extends GameObject { player: Character orbitControls: OrbitControls - lerpTo?: Vector3 | null private lastMove?: object constructor(player: Character) { @@ -34,11 +34,6 @@ export default class PlayerController extends GameObject { } update(delta: number): void { - // Lerp toward server-corrected position - if (this.lerpTo && this.lerpTo.distanceTo(this.player.position) > 0.01) { - this.player.position.lerp(this.lerpTo, 0.2) - } - // Smoothly follow player const desiredTarget = new Vector3().copy(this.player.position).add(new Vector3(0, 0.5, 0)) this.orbitControls.target.lerp(desiredTarget, 0.1) @@ -72,25 +67,15 @@ export default class PlayerController extends GameObject { if (JSON.stringify(this.lastMove) !== JSON.stringify(move)) { // Spread so its a different object. Otherwise sendCommand assigns sequenceId which fails the !== check - this.lastMove = {...move} + this.lastMove = { ...move } NetworkManager.getInstance().sendCommand(move) } } updateFromNetwork(entityData: any) { - const serverPosition = new Vector3( - entityData.position.x, - entityData.position.y, - entityData.position.z, - ) - - if (serverPosition.distanceTo(this.player.position) > 0.2) { - this.lerpTo = serverPosition - } - this.player.updateFromNetwork(entityData) - NetworkManager.getInstance().dropCommandsBeforeSequenceId(this.player.lastProcessedSequenceId) + NetworkManager.getInstance().dropCommandsAtSequenceId(this.player.lastProcessedSequenceId) } destroy(): void { diff --git a/packages/multiplayer-template/server/src/Base/Player.ts b/packages/multiplayer-template/server/src/Base/Player.ts index d0d9e88..e1a49ac 100644 --- a/packages/multiplayer-template/server/src/Base/Player.ts +++ b/packages/multiplayer-template/server/src/Base/Player.ts @@ -14,6 +14,13 @@ export default class Player extends BasePlayer { maxHealth = 100 name?: string + /** + * We use this to check when the last movement + * command came in. If nothing comes in we reset the keys + * (incase packets go missing due to network congestion) + */ + lastMovementCommandTimestamp: number | null = null + /** * Current keys held down - updated each tick from client input or from network. */ @@ -43,6 +50,16 @@ export default class Player extends BasePlayer { } } + /** + * How long should movement continue on for (ms) + * if we havent received a movement packet + * + * override this as needed + */ + getMovementTimeout() { + return 400 + } + isDead(): boolean { return this.health <= 0 } @@ -57,6 +74,14 @@ export default class Player extends BasePlayer { update(delta: number): void { this.horizontalIntent.set(0, 0, 0) + + /** + * If we havent received a movement update in a while lets reset. + */ + if (this.lastMovementCommandTimestamp && performance.now() - this.lastMovementCommandTimestamp > this.getMovementTimeout()) { + this.keysPressed.clear() + } + super.update(delta) // states run here and may write to horizontalIntent if (this.isDead() || !this.characterCollider || !this.rigidBody) diff --git a/packages/multiplayer-template/server/src/Commands/Client.ts b/packages/multiplayer-template/server/src/Commands/Client.ts index 1bcaf49..4325feb 100644 --- a/packages/multiplayer-template/server/src/Commands/Client.ts +++ b/packages/multiplayer-template/server/src/Commands/Client.ts @@ -1,7 +1,9 @@ +import type { + CommandPacket, +} from '@mavonengine/core/Networking/Server/Commands' import { ClientCommand as BaseClientCommand, - CommandPacket -} from "@mavonengine/core/Networking/Server/Commands" +} from '@mavonengine/core/Networking/Server/Commands' /** * Define all available client commands here that get sent to the server. @@ -14,10 +16,9 @@ export enum LocalClientCommand { export const ClientCommand = { ...BaseClientCommand, - ...LocalClientCommand + ...LocalClientCommand, } as const - /** * Define the structure of the packets below for your defined packet names above */ diff --git a/packages/multiplayer-template/server/src/Commands/Server.ts b/packages/multiplayer-template/server/src/Commands/Server.ts index 935daa9..c317252 100644 --- a/packages/multiplayer-template/server/src/Commands/Server.ts +++ b/packages/multiplayer-template/server/src/Commands/Server.ts @@ -1,7 +1,6 @@ import { ServerCommand as BaseServerCommand, - CommandPacket, -} from "@mavonengine/core/Networking/Server/Commands" +} from '@mavonengine/core/Networking/Server/Commands' /** * Define all available server commands here that get sent to the client. @@ -13,7 +12,7 @@ export enum LocalServerCommand { export const ServerCommand = { ...LocalServerCommand, - ...BaseServerCommand + ...BaseServerCommand, } /** diff --git a/packages/multiplayer-template/server/src/Server.ts b/packages/multiplayer-template/server/src/Server.ts index 060e356..24342a3 100644 --- a/packages/multiplayer-template/server/src/Server.ts +++ b/packages/multiplayer-template/server/src/Server.ts @@ -7,9 +7,9 @@ import BaseServer from '@mavonengine/core/Networking/Server/Server' import { randRange } from '@mavonengine/core/Utils/Math' import { Vector3 } from 'three' import Tree from './Base/Vegetation/Tree' -import Player from './Server/Entities/Player' -import { ServerCommand } from './Commands/Server' import { ClientCommand } from './Commands/Client' +import { ServerCommand } from './Commands/Server' +import Player from './Server/Entities/Player' const TREE_COUNT = 15 const SPAWN_AREA = 80 @@ -100,6 +100,7 @@ export default class Server extends BaseServer { const cmd = command as unknown as CL_MOVE player.keysPressed.clear() cmd.keys.forEach(key => player.keysPressed.add(key)) + player.lastMovementCommandTimestamp = performance.now() player.rotation.y = cmd.yaw break }