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)
Changes
@@@ -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: numberlastProcessedSeq: 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(),@@@ -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 : 1let 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 = inputsession.starvedTicks = 0session.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) {nnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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.
@@ -102,8 +102,10 @@ export interface PlayerSession {
@@ -102,8 +102,10 @@ export interface PlayerSession {
/** Pending input commands (bounded queue: anti-speedup). */
/** Pending input commands (bounded queue: anti-speedup). */
inputQueue: ClientInput[]
inputQueue: ClientInput[]
lastInput: ClientInput | null
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
starvedTicks: number
+ /** Input credit: one per tick, spent one per simulated input (systems/movement.ts takeInputs). */
+ inputCredit: number
lastProcessedSeq: number
lastProcessedSeq: number
/** Entity ids this client currently knows about (interest management). */
/** Entity ids this client currently knows about (interest management). */
known: Set<EntityId>
known: Set<EntityId>
@@ -178,6 +180,7 @@ export function createSession(init: SessionInit): PlayerSession {
@@ -178,6 +180,7 @@ export function createSession(init: SessionInit): PlayerSession {
inputQueue: [],
inputQueue: [],
lastInput: null,
lastInput: null,
starvedTicks: 0,
starvedTicks: 0,
+ inputCredit: 0,
lastProcessedSeq: 0,
lastProcessedSeq: 0,
known: new Set(),
known: new Set(),
interestCells: new Set(),
interestCells: new Set(),
@@ -15,8 +15,47 @@ import type { ServerContext } from './context.js'
@@ -15,8 +15,47 @@ import type { ServerContext } from './context.js'
import type { HandlerMap, System } from './system.js'
import type { HandlerMap, System } from './system.js'
export const MOVE = DEFAULT_MOVEMENT
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()
const _bodyPosScratch = vec3()
/**
/**
@@ -55,7 +94,7 @@ export class MovementSystem implements System {
@@ -55,7 +94,7 @@ export class MovementSystem implements System {
readonly handlers: HandlerMap = {
readonly handlers: HandlerMap = {
input: (session, msg: ClientInput, _conn) => {
input: (session, msg: ClientInput, _conn) => {
- if (session.inputQueue.length < MAX_INPUT_QUEUE) session.inputQueue.push(msg)
+ queueInput(session, msg)
},
},
editmode: (session, msg: ClientEditMode, _conn) => {
editmode: (session, msg: ClientEditMode, _conn) => {
// Rank-gated noclip build mode. The flag lives in the move state so
// Rank-gated noclip build mode. The flag lives in the move state so
@@ -102,13 +141,8 @@ export class MovementSystem implements System {
@@ -102,13 +141,8 @@ export class MovementSystem implements System {
return
return
}
}
this.sweepSelf = ctx.sessions.bodyOf(session.playerId)
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
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.lastInput = input
session.starvedTicks = 0
session.starvedTicks = 0
session.lastProcessedSeq = input.seq
session.lastProcessedSeq = input.seq
@@ -118,15 +152,21 @@ export class MovementSystem implements System {
@@ -118,15 +152,21 @@ export class MovementSystem implements System {
simulated++
simulated++
}
}
if (simulated === 0 && session.lastInput) {
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++
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)
let bodyId = ctx.sessions.bodyOf(session.playerId)
if (bodyId !== undefined && session.bodyStance !== session.move.stance) {
if (bodyId !== undefined && session.bodyStance !== session.move.stance) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.