openvibers/openvibe.games Commits

Walking no longer rubber-bands: the server simulates every input a client sends, once and in order (the queue dropped all but 6, and an empty queue repeated the last input), and credit, one per tick, keeps a fast sender from gaining speed (#107)

OP OpenVibers committed 25 days ago · 47fb2e282c · 4 files, +142 −18

Changes

ddiff --git a/apps/server/src/game/playerSession.ts b/apps/server/src/game/playerSession.ts
iindex 2345f37..3fd8f8c 100644
---- a/apps/server/src/game/playerSession.ts
++++ b/apps/server/src/game/playerSession.ts
@@@ -102,8 +102,10 @@ export interface PlayerSession {
/** Pending input commands (bounded queue: anti-speedup). */
inputQueue: ClientInput[]
lastInput: ClientInput | null
-- /** Consecutive ticks simulated without a fresh input (jitter bridging). */
++ /** Consecutive ticks with no input to simulate (a late or quiet client). */
starvedTicks: number
++ /** Input credit: one per tick, spent one per simulated input (systems/movement.ts takeInputs). */
++ inputCredit: number
lastProcessedSeq: number
/** Entity ids this client currently knows about (interest management). */
known: Set<EntityId>
@@@ -178,6 +180,7 @@ export function createSession(init: SessionInit): PlayerSession {
inputQueue: [],
lastInput: null,
starvedTicks: 0,
++ inputCredit: 0,
lastProcessedSeq: 0,
known: new Set(),
interestCells: new Set(),
ddiff --git a/apps/server/src/game/systems/movement.ts b/apps/server/src/game/systems/movement.ts
iindex 4b91585..b089a56 100644
---- a/apps/server/src/game/systems/movement.ts
++++ b/apps/server/src/game/systems/movement.ts
@@@ -15,8 +15,47 @@ import type { ServerContext } from './context.js'
import type { HandlerMap, System } from './system.js'
export const MOVE = DEFAULT_MOVEMENT
--/** Bounded input queue: the cap stops a client from buying simulation speed with backlog. */
--export const MAX_INPUT_QUEUE = 6
++
++/*
++ * Every input a client sends is simulated once, in order, and the server never invents one. The client predicts each
++ * input as it sends it and replays the ones the server has not acknowledged, so any input the server drops or adds
++ * is a misprediction, and the next snapshot snaps the player back. The queue used to hold 6 inputs and drop the
++ * rest, and an empty queue repeated the last input: a frame hitch or network jitter (a home connection, OBS on the
++ * same machine) made walking rubber-band (owner, 2026-10-19).
++ *
++ * Speed stays honest through credit: one per tick, banked up to a second's worth, one spent per simulated input.
++ * After a hitch the bank lets the backlog catch up a few inputs a tick; a client sending faster than real time runs
++ * out of credit and its extra inputs only wait.
++ */
++/** Inputs that may wait: a second of play. A client past it is flooding, and its oldest go. */
++export const MAX_INPUT_QUEUE = 30
++/** Credit banked at most: a second of play. */
++export const MAX_INPUT_CREDIT = 30
++/** Inputs simulated in one tick at most, while catching up. */
++export const CATCH_UP_PER_TICK = 4
++/** Ticks a late client is waited for (its player holds still) before its player coasts to a stop. */
++export const STALL_TICKS = 15
++
++/** Queue one input from the client. */
++export function queueInput(session: PlayerSession, input: ClientInput): void {
++ if (session.inputQueue.length >= MAX_INPUT_QUEUE) session.inputQueue.shift()
++ session.inputQueue.push(input)
++}
++
++/** This tick's inputs, in order: earn one credit, then spend one per input, at most CATCH_UP_PER_TICK. */
++export function takeInputs(session: PlayerSession): ClientInput[] {
++ session.inputCredit = Math.min(MAX_INPUT_CREDIT, session.inputCredit + 1)
++ const out: ClientInput[] = []
++ while (
++ out.length < CATCH_UP_PER_TICK &&
++ session.inputCredit >= 1 &&
++ session.inputQueue.length > 0
++ ) {
++ out.push(session.inputQueue.shift()!)
++ session.inputCredit -= 1
++ }
++ return out
++}
const _bodyPosScratch = vec3()
/**
@@@ -55,7 +94,7 @@ export class MovementSystem implements System {
readonly handlers: HandlerMap = {
input: (session, msg: ClientInput, _conn) => {
-- if (session.inputQueue.length < MAX_INPUT_QUEUE) session.inputQueue.push(msg)
++ queueInput(session, msg)
},
editmode: (session, msg: ClientEditMode, _conn) => {
// Rank-gated noclip build mode. The flag lives in the move state so
@@@ -102,13 +141,8 @@ export class MovementSystem implements System {
return
}
this.sweepSelf = ctx.sessions.bodyOf(session.playerId)
-- // Process at most 2 queued inputs per tick (catch-up), else repeat the
-- // last input — the queue bound caps client-driven speedup.
-- const budget = session.inputQueue.length > 2 ? 2 : 1
let simulated = 0
-- for (let i = 0; i < budget; i++) {
-- const input = session.inputQueue.shift()
-- if (!input) break
++ for (const input of takeInputs(session)) {
session.lastInput = input
session.starvedTicks = 0
session.lastProcessedSeq = input.seq
@@@ -118,15 +152,21 @@ export class MovementSystem implements System {
simulated++
}
if (simulated === 0 && session.lastInput) {
-- // Bridge short network jitter by repeating the last command, but only
-- // briefly — a silent client must coast to a stop, not walk forever.
++ // No input this tick. A late client's inputs are on their way: its player holds still and catches up when
++ // they land (simulating anything now would be a step the client never predicted). A client quiet for longer
++ // (a background tab, a dropped connection) coasts to a stop under gravity.
session.starvedTicks++
-- // Zero MOVEMENT only — buttons stay held. Zeroing buttons fabricates
-- // release edges for toggle keys (prone/crouch), so any client hitch
-- // longer than 3 ticks made the server flap stances endlessly.
-- const input =
-- session.starvedTicks <= 3 ? session.lastInput : { ...session.lastInput, moveX: 0, moveZ: 0 }
-- stepMovement(session.move, toMoveInput(session, input), MOVE, this.moveQueries, dt)
++ if (session.starvedTicks > STALL_TICKS) {
++ // Zero MOVEMENT only: buttons stay held. Zeroing buttons fabricates release edges for toggle keys
++ // (prone/crouch), and the server flapped stances.
++ stepMovement(
++ session.move,
++ toMoveInput(session, { ...session.lastInput, moveX: 0, moveZ: 0 }),
++ MOVE,
++ this.moveQueries,
++ dt,
++ )
++ }
}
let bodyId = ctx.sessions.bodyOf(session.playerId)
if (bodyId !== undefined && session.bodyStance !== session.move.stance) {
ddiff --git a/apps/server/src/game/systems/movementQueue.test.ts b/apps/server/src/game/systems/movementQueue.test.ts
nnew file mode 100644
iindex 0000000..27b8c8a
---- /dev/null
++++ b/apps/server/src/game/systems/movementQueue.test.ts
@@@ -0,0 +1,78 @@
++import type { ClientInput } from '@openvibe/protocol'
++import { describe, expect, it } from 'vitest'
++import type { PlayerSession } from '../playerSession.js'
++import {
++ CATCH_UP_PER_TICK,
++ MAX_INPUT_CREDIT,
++ MAX_INPUT_QUEUE,
++ queueInput,
++ takeInputs,
++} from './movement.js'
++
++/*
++ * The client predicts every input it sends and replays the ones not yet acknowledged, so the server must simulate
++ * each input once, in order, and never one the client did not send; otherwise walking rubber-bands (owner,
++ * 2026-10-19: the queue held 6 and dropped the rest, and an empty queue repeated the last input).
++ */
++const session = (): PlayerSession =>
++ ({ inputQueue: [], inputCredit: 0 }) as unknown as PlayerSession
++const input = (seq: number): ClientInput => ({
++ t: 'input',
++ seq,
++ moveX: 0,
++ moveZ: 1,
++ yawQ: 0,
++ pitchQ: 0,
++ intents: 0,
++})
++
++describe('movement input queue', () => {
++ it('simulates a steady client one input a tick', () => {
++ const s = session()
++ const done: number[] = []
++ for (let tick = 1; tick <= 120; tick++) {
++ queueInput(s, input(tick))
++ done.push(...takeInputs(s).map((i) => i.seq))
++ }
++ expect(done).toEqual(Array.from({ length: 120 }, (_, i) => i + 1))
++ expect(s.inputQueue).toHaveLength(0)
++ })
++
++ it('catches up after a hitch without dropping or inventing an input', () => {
++ const s = session()
++ const done: number[] = []
++ let seq = 0
++ for (let tick = 1; tick <= 30; tick++) {
++ if (tick === 20)
++ for (let k = 0; k < 11; k++) queueInput(s, input(++seq)) // ten ticks of inputs land at once
++ else if (tick < 10 || tick > 20) queueInput(s, input(++seq))
++ done.push(...takeInputs(s).map((i) => i.seq))
++ }
++ expect(done).toEqual(Array.from({ length: seq }, (_, i) => i + 1))
++ expect(done.length).toBe(seq)
++ })
++
++ it('keeps a burst bigger than the old cap of 6', () => {
++ const s = session()
++ for (let k = 1; k <= 12; k++) queueInput(s, input(k))
++ const done: number[] = []
++ for (let tick = 0; tick < 12; tick++) done.push(...takeInputs(s).map((i) => i.seq))
++ expect(done).toEqual(Array.from({ length: 12 }, (_, i) => i + 1))
++ })
++
++ it('gives a client sending faster than real time no extra speed', () => {
++ const s = session()
++ let simulated = 0
++ let seq = 0
++ const ticks = 300
++ for (let tick = 0; tick < ticks; tick++) {
++ queueInput(s, input(++seq))
++ queueInput(s, input(++seq))
++ const taken = takeInputs(s)
++ expect(taken.length).toBeLessThanOrEqual(CATCH_UP_PER_TICK)
++ simulated += taken.length
++ }
++ expect(simulated).toBeLessThanOrEqual(ticks + MAX_INPUT_CREDIT)
++ expect(s.inputQueue.length).toBeLessThanOrEqual(MAX_INPUT_QUEUE)
++ })
++})
ddiff --git a/test/apps__server__src__game__systems__movementQueue.test.js b/test/apps__server__src__game__systems__movementQueue.test.js
nnew file mode 100644
iindex 0000000..1f9ff85
---- /dev/null
++++ b/test/apps__server__src__game__systems__movementQueue.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/game/systems/movementQueue.test.ts'
++// covers: 'apps/server/src/game/systems'
++// Generated by node test/run.js --index; do not edit.