openvibers/openvibe.games Commits
Surf places run at 100 Hz with Source's numbers: air cap 0.762 m/s per tick, gravity, jump, stop speed, step height and the 72/36-unit hull; other rooms stay at 30 Hz on a per-room timestep (#194)
Changes
@@@ -4,6 +4,7 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'import type { Scene } from '@babylonjs/core/scene.js'import {DEFAULT_MOVEMENT,++ Stance,createMoveState,eyeOffsetFor,stepMovement,@@@ -75,13 +76,13 @@ export class LocalPlayer {private readonly state: ClientState,spawn: { x: number; y: number; z: number },/** The place's movement preset, resolved on both sides (surf vs default). */-- private readonly params: MovementParams = DEFAULT_MOVEMENT,++ readonly params: MovementParams = DEFAULT_MOVEMENT,) {this.move = createMoveState(spawn)-- this.eyeSmooth = params.eyeOffset++ this.eyeSmooth = eyeOffsetFor(Stance.Stand, params)this.camera = new UniversalCamera('cam',-- new Vector3(spawn.x, spawn.y + params.eyeOffset, spawn.z),++ new Vector3(spawn.x, spawn.y + this.eyeSmooth, spawn.z),scene,)this.camera.minZ = 0.05@@@ -231,7 +232,7 @@ export class LocalPlayer {this.corr.scaleInPlace(decay)this.stepOffset *= Math.exp(-dt * 14)this.renderPos.set(x + this.corr.x, y + this.corr.y + this.stepOffset, z + this.corr.z)-- const targetEye = eyeOffsetFor(this.move.stance)++ const targetEye = eyeOffsetFor(this.move.stance, this.params)this.eyeSmooth += (targetEye - this.eyeSmooth) * Math.min(1, dt * 9)this.eyePos.set(this.renderPos.x, this.renderPos.y + this.eyeSmooth, this.renderPos.z)if (this.topDown === null) {@@@ -819,8 +819,13 @@ async function start(): Promise<void> {let wasSubmerged = false-- // Fixed-timestep prediction; interpolated rendering.-- const timestep = new FixedTimestep(1 / state.tickRate)++ // Fixed-timestep prediction; interpolated rendering. A room's welcome carries++ // its own rate (30 default, 100 surf), so a transfer/reset rebuilds the++ // accumulator instead of keeping the previous room's step.++ let timestep = new FixedTimestep(1 / state.tickRate)++ state.events.on('welcome', () => {++ if (timestep.dt !== 1 / state.tickRate) timestep = new FixedTimestep(1 / state.tickRate)++ })gameLoop = () => {const now = performance.now()const elapsed = Math.min((now - last) / 1000, 0.25)@@@ -10,7 +10,12 @@ import { CreateSphere } from '@babylonjs/core/Meshes/Builders/sphereBuilder.js'import { Mesh } from '@babylonjs/core/Meshes/mesh.js'import type { Scene } from '@babylonjs/core/scene.js'import type { ContentRegistry, WorldShape } from '@openvibe/content'--import { DEFAULT_MOVEMENT, hullHeightFor } from '@openvibe/gameplay'++import {++ hullHeightFor,++ movementParamsFor,++ movementStyleFor,++ type MovementParams,++} from '@openvibe/gameplay'import { CollisionLayer, type BodyId, type PhysicsWorld, type ShapeDesc } from '@openvibe/physics'import type { ServerSnapshot, WireEntity } from '@openvibe/protocol'import { quat, vec3, wrapAngle } from '@openvibe/shared'@@@ -67,6 +72,8 @@ export class EntityView {/** EMA of (serverTime - localTime) for the interpolation clock. */private clockOffset: number | null = nullprivate lastUpdateTime = 0++ /** The room's movement preset, for remote-player mirror hulls and feet. */++ private readonly movement: MovementParamsconstructor(private readonly scene: Scene,@@@ -74,6 +81,7 @@ export class EntityView {private readonly content: ContentRegistry,private readonly state: ClientState,) {++ this.movement = movementParamsFor(movementStyleFor(content.world))state.events.on('entityAdded', (e) => this.add(e))state.events.on('entityRemoved', (id) => this.remove(id))state.events.on('entityUpdated', (e) => this.applyAuthoritative(e))@@@ -206,11 +214,11 @@ export class EntityView {/** Static capsule matching the server's player body (short + lifted). */private addPlayerBody(pos: { x: number; y: number; z: number }, stance: number): BodyId {-- const hull = hullHeightFor(stance as 0 | 1 | 2)++ const hull = hullHeightFor(stance as 0 | 1 | 2, this.movement)return this.physics.addBody({shape: {type: 'capsule',-- radius: DEFAULT_MOVEMENT.capsuleRadius,++ radius: this.movement.capsuleRadius,height: Math.max(hull - 0.3, 0.4),},motion: 'static',@@@ -371,8 +379,12 @@ export class EntityView {// Blend FEET height, not capsule-center height: the center jumps// when the hull shrinks (crouch/prone) and blending centers sinks// the avatar through the floor mid-transition.-- const feetA = a.pos[1] - (v.feetOffset ?? hullHeightFor((a.stance ?? 0) as 0 | 1 | 2) / 2)-- const feetB = b.pos[1] - (v.feetOffset ?? hullHeightFor((b.stance ?? 0) as 0 | 1 | 2) / 2)++ const feetA =++ a.pos[1] -++ (v.feetOffset ?? hullHeightFor((a.stance ?? 0) as 0 | 1 | 2, this.movement) / 2)++ const feetB =++ b.pos[1] -++ (v.feetOffset ?? hullHeightFor((b.stance ?? 0) as 0 | 1 | 2, this.movement) / 2)const feetY = feetA + (feetB - feetA) * alpha// Keep the player collision mirror on the smooth pose; swap the hull// when their stance changes so crouched players are shorter to walk on.@@@ -66,7 +66,7 @@ export class FirstPersonBody {const speed = Math.hypot(move.vel.x, move.vel.z)const yaw = this.player.viewYawconst pitch = this.player.viewPitch-- const feetY = renderPos.y - hullHeightFor(this.player.move.stance) / 2++ const feetY = renderPos.y - hullHeightFor(this.player.move.stance, this.player.params) / 2// The camera pivots at the eyes; slide the body backward as the view// pitches down so looking down shows your chest and legs from above// instead of the inside of your own collar.@@@ -89,7 +89,11 @@ export class TopDownMarkers {update(): void {const me = this.markerFor('self', this.selfMat)const pos = this.player.renderPos-- me.position.set(pos.x, pos.y - hullHeightFor(this.player.move.stance) / 2 + LIFT, pos.z)++ me.position.set(++ pos.x,++ pos.y - hullHeightFor(this.player.move.stance, this.player.params) / 2 + LIFT,++ pos.z,++ )me.rotation.y = this.player.viewYawfor (const [id, e] of this.state.entities) {if (e.kind !== 'player' || id === this.state.myEntityId) continue@@@ -1,4 +1,5 @@import { MIGRATIONS_DIR } from '@openvibe/persistence'++import { SIM_TICK_RATE, SURF_SNAPSHOT_EVERY, SURF_TICK_RATE } from '@openvibe/shared'import { parseProxyTrust, type ProxyTrust } from './net/clientAddress.js'export interface DbConfig {@@@ -53,6 +54,10 @@ export interface ServerConfig {* is off until OV_OAUTH_CLIENT_SECRET (the same OAuth client as SSO) is set.*/platform: PlatformConfig++ /**++ * The room's simulation rate. A room carries its own value: surf rooms use++ * SURF_ROOM_TIMING, every other place keeps the default (and creator worlds).++ */tickRate: number/** Send a snapshot every N ticks. */snapshotEvery: number@@@ -143,6 +148,24 @@ export interface PlatformConfig {eventsEndpoint: string | null}++/** One room's fixed simulation cadence. */++export interface RoomTiming {++ readonly tickRate: number++ readonly snapshotEvery: number++}++++/** Scraplandia, Salvage Rush, creator worlds: 30 Hz, a snapshot every other tick (15 Hz). */++export const DEFAULT_ROOM_TIMING: RoomTiming = {++ tickRate: SIM_TICK_RATE,++ snapshotEvery: 2,++}++++/** Native CS:S surf: 100 Hz simulation, a snapshot every 4 ticks (25 Hz). */++export const SURF_ROOM_TIMING: RoomTiming = {++ tickRate: SURF_TICK_RATE,++ snapshotEvery: SURF_SNAPSHOT_EVERY,++}++function trimSlash(v: string): string {return v.replace(/\/+$/, '')}@@@ -217,8 +240,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {platform: loadPlatformConfig(env),/** IndexNow is off until a key is configured; an empty key never mounts the key file. */indexnowKey: String(env.INDEXNOW_KEY ?? '').trim(),-- tickRate: 30,-- snapshotEvery: 2,++ ...DEFAULT_ROOM_TIMING,interestRadius: positiveIntEnv(env,'GAMES_INTEREST_RADIUS',@@@ -272,9 +272,14 @@ export class GameServer {return nearbyWorkstationKinds(session, this.ctx.world)}++ /** This room's fixed simulation rate (100 surf, 30 everywhere else). */++ get tickRate(): number {++ return this.ctx.config.tickRate++ }++/**-- * The simulation tick, 30 Hz. This is the ONE place the order is written down; the systems'-- * internal cadence (1 Hz sweeps, every-2-tick snapshots) is their own.++ * One simulation tick at the room's own rate. This is the ONE place the order is written down;++ * the systems' internal cadence (1 Hz sweeps, every-N-tick snapshots) is their own.*/step(): void {const ctx = this.ctx@@@ -333,7 +338,7 @@ export class GameServer {// 10. Once a second: resource respawn sweep.s.interaction.respawnResources()-- // 11. Replication: interest diff + snapshot (every `snapshotEvery` ticks → 15 Hz).++ // 11. Replication: interest diff + snapshot (every `snapshotEvery` ticks → 15 Hz default, 25 Hz surf).s.replication.tick(ctx, dt)// 12. Mods: reconcile installs (a revoked or disabled mod's effects are@@@ -1,6 +1,6 @@import { encodeServerMessage, type ClientHello, type ClientMessage } from '@openvibe/protocol'import type { InstanceLease, InstanceRegistry } from '@openvibe/persistence'--import type { Logger } from '@openvibe/shared'++import { FixedTimestep, type Logger } from '@openvibe/shared'import type { GameConnection } from './systems/context.js'import type { GameServer } from './gameServer.js'import type { GameWorld } from './gameWorld.js'@@@ -15,6 +15,8 @@ export interface InstanceRoom {/** One-process room router. The socket's upgrade identity remains attached across transfers. */export class InstanceHub {private readonly rooms = new Map<number, InstanceRoom>()++ /** One accumulator per room: 100 Hz surf and 30 Hz everything else can share a hub. */++ private readonly clocks = new Map<number, FixedTimestep>()private readonly connections = new Map<GameConnection, { slot: number; hello: ClientHello }>()private readonly reservations = new Map<number, number>()private readonly pending = new Map<GameConnection, Promise<void>>()@@@ -126,8 +128,30 @@ export class InstanceHub {await this.pending.get(conn)}-- step(): void {-- for (const room of this.rooms.values()) room.game.step()++ /**++ * Advance every room by its own fixed steps. `elapsedSeconds` is the scheduler++ * slice (one 100 Hz slot in main.ts); each room's accumulator turns it into++ * 0–N steps of its configured rate, so a 30 Hz room takes one step roughly++ * every third slice while a 100 Hz surf room takes one every slice.++ *++ * Without an argument this keeps the old test/manual contract: exactly one++ * step per room per call, regardless of its rate.++ */++ step(elapsedSeconds?: number): void {++ if (elapsedSeconds === undefined) {++ for (const room of this.rooms.values()) room.game.step()++ return++ }++ for (const room of this.rooms.values()) {++ const rate = room.game.tickRate++ let clock = this.clocks.get(room.lease.slot)++ if (!clock || clock.dt !== 1 / rate) {++ clock = new FixedTimestep(1 / rate)++ this.clocks.set(room.lease.slot, clock)++ }++ const steps = clock.consume(elapsedSeconds)++ for (let i = 0; i < steps; i++) room.game.step()++ }}async heartbeat(now = Date.now()): Promise<void> {@@@ -147,6 +171,7 @@ export class InstanceHub {}this.log.error('instance lease lost', { id: room.lease.id })this.rooms.delete(room.lease.slot)++ this.clocks.delete(room.lease.slot)for (const [conn, current] of this.connections) {if (current.slot === room.lease.slot) conn.close(1012, 'instance_lease_lost')}@@@ -170,6 +195,7 @@ export class InstanceHub {await room.dispose()}this.rooms.clear()++ this.clocks.clear()return drained.every(Boolean)}@@@ -124,8 +124,9 @@ export class PlaceRouter {return this.allHubs().reduce((total, hub) => total + hub.onlineCount, 0)}-- step(): void {-- for (const hub of this.allHubs()) hub.step()++ /** `elapsedSeconds` is one scheduler slice; every hub advances its rooms at their own rates. */++ step(elapsedSeconds?: number): void {++ for (const hub of this.allHubs()) hub.step(elapsedSeconds)}async heartbeat(): Promise<void> {@@@ -5,6 +5,8 @@ import {CraftQueue,createMoveState,eyeOffsetFor,++ movementParamsFor,++ movementStyleFor,normalizeStats,type PlayerMoveState,type SurfRunState,@@@ -207,8 +209,9 @@ export function createSession(init: SessionInit): PlayerSession {/** Eye position for view rays — stance-aware, matches the client camera. */export function eyePosition(session: PlayerSession, out: Vec3): Vec3 {++ const params = movementParamsFor(movementStyleFor(session.content.world))out.x = session.move.pos.x-- out.y = session.move.pos.y + eyeOffsetFor(session.move.stance)++ out.y = session.move.pos.y + eyeOffsetFor(session.move.stance, params)out.z = session.move.pos.zreturn out}@@@ -109,12 +109,12 @@ describe.each(SURF_MAPS)('$name', (info) => {const compiled = compileMapFileV2(map)const run = new SurfRunState(surfRunMapOf(compiled.surf, compiled.zones ?? [], compiled.spawn, compiled.spawnYaw),-- 30,++ 100,)const move = createMoveState(vec3(run.map.spawn.x, run.map.spawn.y, run.map.spawn.z))const params = movementParamsFor('surf')let finished = false-- for (let tick = 0; tick < 60 * 30 && !finished; tick++) {++ for (let tick = 0; tick < 60 * 100 && !finished; tick++) {const next =run.reached < run.map.checkpoints.length? run.map.checkpoints[run.reached]!@@@ -128,8 +128,8 @@ describe.each(SURF_MAPS)('$name', (info) => {const from = { x: move.pos.x, y: move.pos.y, z: move.pos.z }// A hop pulse clears the gaps between landings; the surf line itself// is a held forward run down the 50–60° ramps.-- const buttons = tick % 20 < 2 ? Buttons.Jump : 0-- stepMovement(move, { moveX: 0, moveZ: 1, yaw, pitch: 0, buttons }, params, queries, 1 / 30)++ const buttons = tick % 67 < 2 ? Buttons.Jump : 0++ stepMovement(move, { moveX: 0, moveZ: 1, yaw, pitch: 0, buttons }, params, queries, 1 / 100)const events = run.observe(tick, from, move.pos, yaw)for (const event of events) {if (event.type === 'failed' || event.type === 'restarted') {@@@ -2,6 +2,7 @@import { describe, expect, it } from 'vitest'import { createMoveState, normalizeStats } from '@openvibe/gameplay'import { vec3 } from '@openvibe/shared'++import { DEFAULT_ROOM_TIMING, SURF_ROOM_TIMING } from '../config.js'import { loadSurfOverride } from './surfMaps.js'import { setMapOverride } from '@openvibe/content'import { EnvironmentSystem } from './systems/environment.js'@@@ -54,8 +55,8 @@ function sessionFor(override: ReturnType<typeof loadSurfOverride>) {function ctxFor(session: any, respawns: string[], sent: unknown[]) {return {-- clock: { tick: 30 },-- config: { tickRate: 30, snapshotEvery: 2, placeId: 'surf-lagoon', instanceSlot: 0 },++ clock: { tick: 0 },++ config: { ...SURF_ROOM_TIMING, placeId: 'surf-lagoon', instanceSlot: 0 },env: {},regions: { update() {} },sessions: {@@@ -158,3 +159,32 @@ describe('surf place rules', () => {expect(state).toMatchObject({ running: false, stage: 0 })})})++++describe('surf room timing', () => {++ it('is 100 Hz with 20–30 Hz snapshots; the default room stays at 30/15', () => {++ expect(SURF_ROOM_TIMING.tickRate).toBe(100)++ const surfSnapshotRate = SURF_ROOM_TIMING.tickRate / SURF_ROOM_TIMING.snapshotEvery++ expect(surfSnapshotRate).toBeGreaterThanOrEqual(20)++ expect(surfSnapshotRate).toBeLessThanOrEqual(30)++ expect(surfSnapshotRate).toBe(25)++++ expect(DEFAULT_ROOM_TIMING.tickRate).toBe(30)++ expect(DEFAULT_ROOM_TIMING.tickRate / DEFAULT_ROOM_TIMING.snapshotEvery).toBe(15)++ })++++ it('emits surf_state at the room snapshot cadence, not every tick', () => {++ const { override } = surfWorld()++ const session = sessionFor(override)++ const sent: any[] = []++ const ctx = ctxFor(session, [], sent) as any++ const surf = new SurfSystem(ctx)++ surf.onJoin(session as any)++ sent.length = 0++++ for (let tick = 1; tick <= 8; tick++) {++ ctx.clock.tick = tick++ surf.tick(ctx, 1 / SURF_ROOM_TIMING.tickRate)++ }++ expect(sent.filter((m) => m.t === 'surf_state')).toHaveLength(2)++ })++})@@@ -11,7 +11,7 @@ import {} from '@openvibe/gameplay'import { CollisionLayer, type BodyId } from '@openvibe/physics'import type { ClientEditMode, ClientInput } from '@openvibe/protocol'--import { clamp, dequantiseAngle, qfromYaw, vec3, wrapAngle } from '@openvibe/shared'++import { SIM_TICK_RATE, clamp, dequantiseAngle, qfromYaw, vec3, wrapAngle } from '@openvibe/shared'import { canEditMap } from '../../net/networkAuth.js'import type { PlayerSession } from '../playerSession.js'import type { ServerContext } from './context.js'@@@ -28,26 +28,34 @@ export const MOVE = DEFAULT_MOVEMENT** 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.++ * out of credit and its extra inputs only wait. The one-second budgets scale with the room's tick rate: 30 at++ * the sandbox's 30 Hz, 100 in a 100 Hz surf room.*/--/** 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 that may wait at 30 Hz: a second of play. A client past it is flooding, and its oldest go. */++export const MAX_INPUT_QUEUE = SIM_TICK_RATE++/** Credit banked at most at 30 Hz: a second of play. */++export const MAX_INPUT_CREDIT = SIM_TICK_RATE/** 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++/** Ticks a late client is waited for at 30 Hz (its player holds still) before it coasts to a stop. */++export const STALL_TICKS = SIM_TICK_RATE / 2--/** Queue one input from the client. */--export function queueInput(session: PlayerSession, input: ClientInput): void {-- if (session.inputQueue.length >= MAX_INPUT_QUEUE) session.inputQueue.shift()++/** Queue one input from the client; the cap is one second at the room's rate. */++export function queueInput(++ session: PlayerSession,++ input: ClientInput,++ tickRate: number = SIM_TICK_RATE,++): void {++ if (session.inputQueue.length >= tickRate) 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)++export function takeInputs(++ session: PlayerSession,++ tickRate: number = SIM_TICK_RATE,++): ClientInput[] {++ session.inputCredit = Math.min(tickRate, session.inputCredit + 1)const out: ClientInput[] = []while (out.length < CATCH_UP_PER_TICK &&@@@ -97,7 +105,7 @@ export class MovementSystem implements System {readonly handlers: HandlerMap = {input: (session, msg: ClientInput, _conn) => {-- queueInput(session, msg)++ queueInput(session, msg, this.ctx.config.tickRate)},editmode: (session, msg: ClientEditMode, _conn) => {// Rank-gated noclip build mode. The flag lives in the move state so@@@ -157,7 +165,7 @@ export class MovementSystem implements System {this.sweepSelf = ctx.sessions.bodyOf(session.playerId)session.moveSegments.length = 0let simulated = 0-- for (const input of takeInputs(session)) {++ for (const input of takeInputs(session, ctx.config.tickRate)) {session.lastInput = inputsession.starvedTicks = 0session.lastProcessedSeq = input.seq@@@ -180,7 +188,7 @@ export class MovementSystem implements System {// 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++-- if (session.starvedTicks > STALL_TICKS) {++ if (session.starvedTicks > ctx.config.tickRate / 2) {// Zero MOVEMENT only: buttons stay held. Zeroing buttons fabricates release edges for toggle keys// (prone/crouch), and the server flapped stances.const from = surfPlace@@@ -205,7 +213,7 @@ export class MovementSystem implements System {if (bodyId !== undefined && session.bodyStance !== session.move.stance) {// Stance changed: swap the kinematic hull to match the new posture.ctx.world.physics.removeBody(bodyId)-- const hull = hullHeightFor(session.move.stance)++ const hull = hullHeightFor(session.move.stance, params)bodyId = ctx.world.physics.addBody({shape: {type: 'capsule',@@@ -26,8 +26,8 @@ import {type InstanceLease,} from '@openvibe/persistence'import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'--import { createConsoleLogger } from '@openvibe/shared'--import { loadConfig } from './config.js'++import { SURF_TICK_RATE, createConsoleLogger } from '@openvibe/shared'++import { SURF_ROOM_TIMING, loadConfig } from './config.js'import { openDb } from './db.js'import { telemetry } from 'openvibe-sdk/service'import { createPgTelemetrySink, startTelemetryRetention } from 'openvibe-sdk/telemetry'@@@ -381,7 +381,7 @@ async function main(): Promise<void> {scriptLog.info('script mods off for this place', { mods: content.allMods().length })}const game = new GameServer(-- { ...config, instanceSlot: 0 },++ { ...config, instanceSlot: 0, ...(primarySurf ? SURF_ROOM_TIMING : {}) },world,store,metrics,@@@ -452,7 +452,7 @@ async function main(): Promise<void> {roomWorld.reconcileMapProps()const roomScripts = await createScriptHost(slot)const roomGame = new GameServer(-- { ...config, instanceSlot: slot },++ { ...config, instanceSlot: slot, ...(primarySurf ? SURF_ROOM_TIMING : {}) },roomWorld,roomStore,metrics,@@@ -648,6 +648,7 @@ async function main(): Promise<void> {const roomGame = new GameServer({...config,++ ...SURF_ROOM_TIMING,placeId: info.placeId,instanceSlot: slot,// A surf room has no survival, NPC population or world events; the@@@ -1341,15 +1342,19 @@ async function main(): Promise<void> {log.info('listening', { host: config.host, port: config.port })})-- // Fixed-tick loop with drift correction — never tied to timers' jitter.-- const tickMs = 1000 / config.tickRate++ // Scheduler loop with drift correction — never tied to timers' jitter. It++ // runs at the fastest room rate; each room's accumulator in InstanceHub++ // turns a slice into its own 30/100 Hz fixed steps.++ const schedulerRate = Math.max(config.tickRate, SURF_TICK_RATE)++ const tickMs = 1000 / schedulerRate++ const sliceSeconds = tickMs / 1000let nextTick = performance.now()let running = trueconst loop = (): void => {if (!running) returnconst now = performance.now()while (now >= nextTick) {-- router.step()++ router.step(sliceSeconds)nextTick += tickMs// If we fell far behind (debugger pause, EL stall), resync instead of// spiraling through hundreds of catch-up ticks.@@@ -13,11 +13,12 @@ import type { IncomingMessage, ServerResponse } from 'node:http'*//**-- * No completed tick for this long means the simulation is stalled. The loop-- * runs at `tickRate` (30/s, one tick every ~33 ms) and resyncs itself after-- * falling more than a second behind, so a healthy server never goes a second-- * without a tick; 5 s leaves room for a GC pause, a persistence flush or a-- * live map rebuild, and still flags a frozen world within one deploy poll.++ * No completed tick for this long means the simulation is stalled. The++ * scheduler runs at the fastest hosted room rate (100/s with surf, 30/s++ * otherwise, one slice every ~10–33 ms) and resyncs itself after falling more++ * than a second behind, so a healthy server never goes a second without a++ * tick; 5 s leaves room for a GC pause, a persistence flush or a live map++ * rebuild, and still flags a frozen world within one deploy poll.*/export const TICK_STALL_MS = 5_000@@@ -1,3 +1,6 @@++/** [stand, crouch, prone] values, indexed by the Stance enum. */++export type StanceProfile = readonly [number, number, number]++/*** Source/Quake-inspired movement tuning. All values in meters/seconds.* Both server simulation and client prediction read the same instance —@@@ -27,6 +30,10 @@ export interface MovementParams {readonly capsuleRadius: number/** Eye height above capsule center. */readonly eyeOffset: number++ /** Total capsule height per stance, [stand, crouch, prone]; standing = capsuleHeight. */++ readonly stanceHull: StanceProfile++ /** Eye offset above the capsule center per stance, [stand, crouch, prone]. */++ readonly stanceEye: StanceProfile/** Collision skin to keep the capsule from touching surfaces exactly. */readonly skin: number/** Horizontal speed ceiling (m/s); Infinity means the engine's old unbounded rule. */@@@ -50,6 +57,8 @@ export const DEFAULT_MOVEMENT: MovementParams = {capsuleHeight: 1.8,capsuleRadius: 0.4,eyeOffset: 0.65,++ stanceHull: [1.8, 1.2, 0.7],++ stanceEye: [0.65, 0.38, 0.14],skin: 0.02,maxHorizontalSpeed: Number.POSITIVE_INFINITY,autoBhop: true,@@@ -1,7 +1,7 @@import { Buttons } from './buttons.js'import { clamp, v3addScaled, v3copy, v3dot, v3lengthSq, vec3, type Vec3 } from '@openvibe/shared'import type { CollisionQueries, SweepHit } from './collision.js'--import type { MovementParams } from './params.js'++import { DEFAULT_MOVEMENT, type MovementParams } from './params.js'/*** Source/Quake-style kinematic character movement with stances.@@@ -26,10 +26,10 @@ export enum Stance {Prone = 2,}--/** Hull heights per stance (capsule total height, m). */--export const STANCE_HULL: readonly number[] = [1.8, 1.2, 0.7]--/** Eye offset above the capsule CENTER per stance. */--export const STANCE_EYE: readonly number[] = [0.65, 0.38, 0.14]++/** Default hull heights per stance (capsule total height, m). */++export const STANCE_HULL = DEFAULT_MOVEMENT.stanceHull++/** Default eye offset above the capsule CENTER per stance. */++export const STANCE_EYE = DEFAULT_MOVEMENT.stanceEye/** Ground-speed multiplier per stance. */export const STANCE_SPEED: readonly number[] = [1, 0.45, 0.22]/** Transition durations [from][to] in seconds. */@@@ -41,12 +41,14 @@ const STANCE_DUR: readonly (readonly number[])[] = [/** Lockout after a completed transition (anti spam/peek abuse). */const STANCE_COOLDOWN = 0.4--export function hullHeightFor(stance: Stance): number {-- return STANCE_HULL[stance] ?? 1.8++/** A stance's collision hull height under these params (defaults preserve the sandbox). */++export function hullHeightFor(stance: Stance, params: MovementParams = DEFAULT_MOVEMENT): number {++ return params.stanceHull[stance] ?? params.capsuleHeight}--export function eyeOffsetFor(stance: Stance): number {-- return STANCE_EYE[stance] ?? 0.65++/** A stance's eye offset above the capsule center under these params. */++export function eyeOffsetFor(stance: Stance, params: MovementParams = DEFAULT_MOVEMENT): number {++ return params.stanceEye[stance] ?? params.eyeOffset}export interface MoveInput {@@@ -398,8 +400,8 @@ function updateStance(: Stance.Standif (desired === state.stance || state.stanceT > 0 || state.stanceCooldown > 0) return-- const oldHull = hullHeightFor(state.stance)-- const newHull = hullHeightFor(desired)++ const oldHull = hullHeightFor(state.stance, params)++ const newHull = hullHeightFor(desired, params)if (newHull > oldHull) {// Growing needs headroom: sweep the current hull upward by the delta.v3copy(_end, state.pos)@@@ -437,7 +439,7 @@ export function stepMovement(return}updateStance(state, input, params, world, dt)-- const hull = hullHeightFor(state.stance)++ const hull = hullHeightFor(state.stance, params)const groundHit = checkGround(state, world, params, hull)state.grounded = groundHit !== null@@@ -5,34 +5,47 @@ import { DEFAULT_MOVEMENT, type MovementParams } from './params.js'* CS:S surf tuning, converted from Source units into this engine's metres.** Unit scale: Source is authored in hammer units where **1 unit = 1 inch =-- * 0.0254 m** (a 72-unit player is 1.8288 m, the same 1.8 m capsule this engine-- * uses). Converting every velocity/length by 0.0254 keeps the map and the-- * player the same physical size as CS.++ * 0.0254 m** (a 72-unit player is 1.8288 m tall). Converting every++ * velocity/length by 0.0254 keeps the map and the player the same physical++ * size as CS.** sv_gravity 800 u/s² × 0.0254 = 20.32 m/s²* sv_maxvelocity 3500 u/s × 0.0254 = 88.9 m/s* air wish cap 30 u/s × 0.0254 = 0.762 m/s* walk speed 250 u/s × 0.0254 = 6.35 m/s* jump 301.993 u/s × 0.0254 = 7.6706 m/s-- * stop speed 80 u/s × 0.0254 = 2.032 m/s++ * stop speed 75 u/s × 0.0254 = 1.905 m/s++ * step height 18 u × 0.0254 = 0.4572 m++ * stand hull 72 u × 0.0254 = 1.8288 m, radius 16 u = 0.4064 m++ * duck hull 36 u × 0.0254 = 0.9144 m++ * eye (stand) 64 u × 0.0254 = 1.6256 m above feet++ * eye (duck) 28 u × 0.0254 = 0.7112 m above feet*-- * The cap is applied PER TICK, so the per-second gain is `cap × tickRate`.-- * CS:S surf is played at 64 tick; this server keeps 30 Hz, so the cap is-- * scaled by 64/30 to preserve the per-second acceleration:++ * The air cap is applied PER TICK, so the per-second gain is `cap × tickRate`++ * and that difference is intentional:*-- * 0.762 × 64 / 30 = 1.6256 m/s at 30 Hz-- * 1.6256 × 30 = 48.768 m/s² (same as 0.762 × 64)++ * 0.762 × 64 = 48.768 m/s² cap equivalent at 64 Hz++ * 0.762 × 100 = 76.20 m/s² cap equivalent at 100 Hz (native CS:S surf)++ *++ * There is no 64/30 scaling here: surf rooms simulate at 100 Hz, so the cap++ * is Source's own 0.762. A 30 Hz room would need 1.6256 to preserve the++ * per-second rate, which is exactly why the old value was wrong once surf++ * moved off 30 Hz.** `airAccel` is `sv_airaccelerate 150` unchanged: it is already a per-second* rate (the kernel multiplies it by dt), so it does not need a tick-rate-- * scale. At 30 Hz its term is 150 × 6.35 × (1/30) = 31.75 m/s per tick,-- * well above the 1.6256 cap — the cap is what limits surf, exactly as in CS.++ * scale. At 100 Hz its term is 150 × 6.35 × (1/100) = 9.525 m/s per tick,++ * well above the 0.762 cap — the cap is what limits surf, exactly as in CS.* `sv_airaccelerate` only becomes visible when a very small wish speed makes* the rate term smaller than the cap.** `groundNormalY` stays at 0.7 (cos 45.57°), CS's standable plane. Ride ramps* are authored at 50–60°, so cos(50°)=0.643 < 0.7 and the controller never* treats them as ground; the 45° edge stays exactly where CS puts it.++ *++ * Eye offsets are above the capsule CENTER: standing (1.6256 − 0.9144) = 0.7112,++ * ducking (0.7112 − 0.4572) = 0.254. Prone is not a Source stance; keep the++ * engine's existing low profile.*/export const SURF_MOVEMENT: MovementParams = {maxGroundSpeed: 6.35,@@@ -40,16 +53,18 @@ export const SURF_MOVEMENT: MovementParams = {maxSprintSpeed: 6.35,groundAccel: 10,airAccel: 150,-- airSpeedCap: 1.6256,++ airSpeedCap: 0.762,friction: 4,-- stopSpeed: 2.032,++ stopSpeed: 1.905,gravity: 20.32,jumpSpeed: 7.6706,groundNormalY: 0.7,-- stepHeight: 0.45,-- capsuleHeight: 1.8,-- capsuleRadius: 0.4,-- eyeOffset: 0.65,++ stepHeight: 0.4572,++ capsuleHeight: 1.8288,++ capsuleRadius: 0.4064,++ eyeOffset: 0.7112,++ stanceHull: [1.8288, 0.9144, 0.7],++ stanceEye: [0.7112, 0.254, 0.14],skin: 0.02,maxHorizontalSpeed: 88.9,autoBhop: false,@@@ -68,7 +83,6 @@ export function movementParamsFor(style: MovementStyle): MovementParams {return params}--/*** The movement style in force for a world: the loaded map's choice overrides* the content world's. Both sides call this with their own (identical) map@@@ -2,15 +2,22 @@ import { describe, expect, it } from 'vitest'import { vec3 } from '@openvibe/shared'import type { CollisionQueries, SweepHit } from './collision.js'import { DEFAULT_MOVEMENT } from './params.js'--import { createMoveState, stepMovement, type PlayerMoveState } from './simulate.js'++import {++ Stance,++ createMoveState,++ eyeOffsetFor,++ hullHeightFor,++ stepMovement,++ type PlayerMoveState,++} from './simulate.js'import { SURF_MOVEMENT } from './styles.js'/*** The feel proof for Ride. A 55° ramp is steeper than cos⁻¹(0.7) = 45.57°, so* NEITHER style ever grounds on it; the difference is the air controller.-- * Surf's CS:S cap (scaled to 30 Hz) lets a strafe + turn keep or gain-- * horizontal speed; the sandbox default's 0.85 m/s cap gives the ramp the-- * speed instead. A 40° slope is ground in both styles.++ * Surf runs at 100 Hz with CS:S's native 0.762 m/s wish cap; the sandbox runs++ * at 30 Hz with its own 0.85 cap and gives the ramp the speed instead. A 40°++ * slope is ground in both styles.** The collision world is an analytic half-space rather than Rapier: the test* is about the movement rule, and the half-space has no box edges, so the@@@ -25,8 +32,7 @@ function planeQueries(angleDeg: number): CollisionQueries {sweepCapsule(from, to, radius): SweepHit | null {const da = from.x * n.x + from.y * n.y + from.z * n.z - radiusconst db = to.x * n.x + to.y * n.y + to.z * n.z - radius-- if (da <= 0)-- return { fraction: 0, normal: { ...n }, point: { ...from } }++ if (da <= 0) return { fraction: 0, normal: { ...n }, point: { ...from } }if (db >= 0) return nullconst f = da / (da - db)return {@@@ -42,67 +48,121 @@ function planeQueries(angleDeg: number): CollisionQueries {}}++/** Open air: no ground, no ramp, so only the air rule changes velocity. */++const FLAT_AIR: CollisionQueries = { sweepCapsule: () => null }++function spawnOnPlane(angleDeg: number, above = 1.2): PlayerMoveState {const a = angleDeg * RADconst n = { x: 0, y: Math.cos(a), z: Math.sin(a) }return createMoveState(vec3(n.x * above, n.y * above, n.z * above))}--/** 3 s of a strafe whose yaw turns 45°, the surf line. */--function runLine(params: typeof DEFAULT_MOVEMENT, angle: number): number[] {++/** 3 s of a strafe whose yaw turns 45°, the surf line, at the room's own rate. */++function runLine(params: typeof DEFAULT_MOVEMENT, angle: number, tickRate: number): number[] {const state = spawnOnPlane(angle)state.vel.x = 0state.vel.y = 0state.vel.z = 10const speeds: number[] = []-- for (let tick = 0; tick < 90; tick++) {-- const yaw = (tick / 30) * 45 * RAD++ const ticks = 3 * tickRate++ for (let tick = 0; tick < ticks; tick++) {++ const yaw = (tick / tickRate) * 45 * RADstepMovement(state,{ moveX: 1, moveZ: 0, yaw, pitch: 0, buttons: 0 },params,planeQueries(angle),-- 1 / 30,++ 1 / tickRate,)-- if (tick % 30 === 29) speeds.push(Math.hypot(state.vel.x, state.vel.z))++ if ((tick + 1) % tickRate === 0) speeds.push(Math.hypot(state.vel.x, state.vel.z))}return speeds}++/** The horizontal speed a single airborne tick adds toward the wish direction. */++function firstAirTickGain(tickRate: number): number {++ const state = createMoveState(vec3(0, 100, 0))++ stepMovement(++ state,++ { moveX: 1, moveZ: 0, yaw: 0, pitch: 0, buttons: 0 },++ SURF_MOVEMENT,++ FLAT_AIR,++ 1 / tickRate,++ )++ return Math.hypot(state.vel.x, state.vel.z)++}++describe('surf feel on a steep ramp', () => {-- it('keeps or gains horizontal speed over 3 s in the surf style', () => {-- const speeds = runLine(SURF_MOVEMENT, 55)++ it('keeps or gains horizontal speed over 3 s in the surf style at 100 Hz', () => {++ const speeds = runLine(SURF_MOVEMENT, 55, 100)expect(speeds[0]).toBeGreaterThan(9)expect(speeds[2]).toBeGreaterThanOrEqual(10 * 0.99)})-- it('loses horizontal speed in the default style on the same ramp and line', () => {-- const speeds = runLine(DEFAULT_MOVEMENT, 55)++ it('loses horizontal speed in the default style on the same ramp and line at 30 Hz', () => {++ const speeds = runLine(DEFAULT_MOVEMENT, 55, 30)expect(speeds[2]).toBeLessThan(9)-- expect(speeds[2]).toBeLessThan(runLine(SURF_MOVEMENT, 55)[2]! * 0.75)++ expect(speeds[2]).toBeLessThan(runLine(SURF_MOVEMENT, 55, 100)[2]! * 0.75)})-- it('a 40° slope is ground in both styles', () => {-- for (const params of [SURF_MOVEMENT, DEFAULT_MOVEMENT]) {++ it('a 40° slope is ground in both styles at their native rates', () => {++ for (const [params, tickRate] of [++ [SURF_MOVEMENT, 100],++ [DEFAULT_MOVEMENT, 30],++ ] as const) {const state = spawnOnPlane(40, 0.1)stepMovement(state,{ moveX: 0, moveZ: 0, yaw: 0, pitch: 0, buttons: 0 },params,planeQueries(40),-- 1 / 30,++ 1 / tickRate,)expect(state.grounded).toBe(true)}})++})++++describe('source air cap', () => {++ it('adds the same 0.762 m/s per airborne tick at 64 and 100 Hz', () => {++ const at64 = firstAirTickGain(64)++ const at100 = firstAirTickGain(100)++ expect(at64).toBeCloseTo(30 * 0.0254, 10)++ expect(at100).toBeCloseTo(at64, 10)++ })-- it('CS:S constants are the converted Source values, scaled at 30 Hz', () => {++ it('is 48.768 m/s² at 64 Hz and 76.20 m/s² at 100 Hz — the rate difference is intentional', () => {++ expect(firstAirTickGain(64) * 64).toBeCloseTo(48.768, 8)++ expect(firstAirTickGain(100) * 100).toBeCloseTo(76.2, 8)++ })++})++++describe('CS:S constants', () => {++ it('uses the converted Source values and the native 100 Hz cap', () => {expect(SURF_MOVEMENT.groundNormalY).toBe(0.7)expect(SURF_MOVEMENT.gravity).toBeCloseTo(800 * 0.0254)expect(SURF_MOVEMENT.maxHorizontalSpeed).toBeCloseTo(3500 * 0.0254)-- expect(SURF_MOVEMENT.airSpeedCap).toBeCloseTo(30 * 0.0254 * (64 / 30))++ expect(SURF_MOVEMENT.airSpeedCap).toBeCloseTo(30 * 0.0254)expect(SURF_MOVEMENT.airAccel).toBe(150)++ expect(SURF_MOVEMENT.maxGroundSpeed).toBeCloseTo(250 * 0.0254)++ expect(SURF_MOVEMENT.jumpSpeed).toBeCloseTo(301.993 * 0.0254)++ expect(SURF_MOVEMENT.stopSpeed).toBeCloseTo(75 * 0.0254)expect(SURF_MOVEMENT.autoBhop).toBe(false)expect(SURF_MOVEMENT.maxSprintSpeed).toBe(SURF_MOVEMENT.maxGroundSpeed)})++++ it('uses the exact hull, crouch, eye and step heights without changing the sandbox', () => {++ expect(SURF_MOVEMENT.capsuleHeight).toBeCloseTo(72 * 0.0254)++ expect(SURF_MOVEMENT.capsuleRadius).toBeCloseTo(16 * 0.0254)++ expect(SURF_MOVEMENT.eyeOffset).toBeCloseTo(64 * 0.0254 - 36 * 0.0254)++ expect(SURF_MOVEMENT.stepHeight).toBeCloseTo(18 * 0.0254)++ expect(hullHeightFor(Stance.Stand, SURF_MOVEMENT)).toBeCloseTo(1.8288)++ expect(hullHeightFor(Stance.Crouch, SURF_MOVEMENT)).toBeCloseTo(36 * 0.0254)++ expect(eyeOffsetFor(Stance.Stand, SURF_MOVEMENT)).toBeCloseTo(64 * 0.0254 - 36 * 0.0254)++ expect(eyeOffsetFor(Stance.Crouch, SURF_MOVEMENT)).toBeCloseTo(28 * 0.0254 - 18 * 0.0254)++++ // Source values are opt-in by style: the sandbox keeps its current hull.++ expect(DEFAULT_MOVEMENT.stanceHull).toEqual([1.8, 1.2, 0.7])++ expect(DEFAULT_MOVEMENT.stanceEye).toEqual([0.65, 0.38, 0.14])++ })})@@@ -9,9 +9,10 @@ import type { PlayerMoveState } from '../movement/simulate.js'* simulated input, and it returns what happened. No client time, no client* position and no wall clock participate in a run.*-- * Timing: the server ticks at 30 Hz, so a tick count alone is 33.3 ms coarse.-- * Every crossing is a slab intersection against the zone AABB, giving the-- * fraction of the segment at which it happened:++ * Timing: the room ticks at its configured rate (100 Hz for surf, 30 Hz for++ * the sandbox), and the run carries that rate. Every crossing is a slab++ * intersection against the zone AABB, giving the fraction of the segment at++ * which it happened:** timeMs = (endTick - startTick + fracEnd - fracStart) * (1000 / tickRate)*@@@ -6,6 +6,16 @@export const SIM_TICK_RATE = 30export const SIM_DT = 1 / SIM_TICK_RATE++/**++ * Native CS:S surf rate. Surf rooms simulate at this rate with the unscaled++ * Source air cap (0.762 m/s per tick); other rooms stay at SIM_TICK_RATE.++ * The process scheduler runs at least this fast, and each room's accumulator++ * decides how many of its own fixed steps to take.++ */++export const SURF_TICK_RATE = 100++/** Snapshots every N surf ticks → 25 Hz, inside Source's 20–30 Hz range. */++export const SURF_SNAPSHOT_EVERY = 4++export class FixedTimestep {private accumulator = 0constructor(
@@ -4,6 +4,7 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
@@ -4,6 +4,7 @@ import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { Scene } from '@babylonjs/core/scene.js'
import {
import {
DEFAULT_MOVEMENT,
DEFAULT_MOVEMENT,
+ Stance,
createMoveState,
createMoveState,
eyeOffsetFor,
eyeOffsetFor,
stepMovement,
stepMovement,
@@ -75,13 +76,13 @@ export class LocalPlayer {
@@ -75,13 +76,13 @@ export class LocalPlayer {
private readonly state: ClientState,
private readonly state: ClientState,
spawn: { x: number; y: number; z: number },
spawn: { x: number; y: number; z: number },
/** The place's movement preset, resolved on both sides (surf vs default). */
/** The place's movement preset, resolved on both sides (surf vs default). */
- private readonly params: MovementParams = DEFAULT_MOVEMENT,
+ readonly params: MovementParams = DEFAULT_MOVEMENT,
) {
) {
this.move = createMoveState(spawn)
this.move = createMoveState(spawn)
- this.eyeSmooth = params.eyeOffset
+ this.eyeSmooth = eyeOffsetFor(Stance.Stand, params)
this.camera = new UniversalCamera(
this.camera = new UniversalCamera(
'cam',
'cam',
- new Vector3(spawn.x, spawn.y + params.eyeOffset, spawn.z),
+ new Vector3(spawn.x, spawn.y + this.eyeSmooth, spawn.z),
scene,
scene,
)
)
this.camera.minZ = 0.05
this.camera.minZ = 0.05
@@ -231,7 +232,7 @@ export class LocalPlayer {
@@ -231,7 +232,7 @@ export class LocalPlayer {
this.corr.scaleInPlace(decay)
this.corr.scaleInPlace(decay)
this.stepOffset *= Math.exp(-dt * 14)
this.stepOffset *= Math.exp(-dt * 14)
this.renderPos.set(x + this.corr.x, y + this.corr.y + this.stepOffset, z + this.corr.z)
this.renderPos.set(x + this.corr.x, y + this.corr.y + this.stepOffset, z + this.corr.z)
- const targetEye = eyeOffsetFor(this.move.stance)
+ const targetEye = eyeOffsetFor(this.move.stance, this.params)
this.eyeSmooth += (targetEye - this.eyeSmooth) * Math.min(1, dt * 9)
this.eyeSmooth += (targetEye - this.eyeSmooth) * Math.min(1, dt * 9)
this.eyePos.set(this.renderPos.x, this.renderPos.y + this.eyeSmooth, this.renderPos.z)
this.eyePos.set(this.renderPos.x, this.renderPos.y + this.eyeSmooth, this.renderPos.z)
if (this.topDown === null) {
if (this.topDown === null) {
@@ -819,8 +819,13 @@ async function start(): Promise<void> {
@@ -819,8 +819,13 @@ async function start(): Promise<void> {
let wasSubmerged = false
let wasSubmerged = false
- // Fixed-timestep prediction; interpolated rendering.
- const timestep = new FixedTimestep(1 / state.tickRate)
+ // Fixed-timestep prediction; interpolated rendering. A room's welcome carries
+ // its own rate (30 default, 100 surf), so a transfer/reset rebuilds the
+ // accumulator instead of keeping the previous room's step.
+ let timestep = new FixedTimestep(1 / state.tickRate)
+ state.events.on('welcome', () => {
+ if (timestep.dt !== 1 / state.tickRate) timestep = new FixedTimestep(1 / state.tickRate)
+ })
gameLoop = () => {
gameLoop = () => {
const now = performance.now()
const now = performance.now()
const elapsed = Math.min((now - last) / 1000, 0.25)
const elapsed = Math.min((now - last) / 1000, 0.25)
@@ -10,7 +10,12 @@ import { CreateSphere } from '@babylonjs/core/Meshes/Builders/sphereBuilder.js'
@@ -10,7 +10,12 @@ import { CreateSphere } from '@babylonjs/core/Meshes/Builders/sphereBuilder.js'
import { Mesh } from '@babylonjs/core/Meshes/mesh.js'
import { Mesh } from '@babylonjs/core/Meshes/mesh.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { ContentRegistry, WorldShape } from '@openvibe/content'
import type { ContentRegistry, WorldShape } from '@openvibe/content'
-import { DEFAULT_MOVEMENT, hullHeightFor } from '@openvibe/gameplay'
+import {
+ hullHeightFor,
+ movementParamsFor,
+ movementStyleFor,
+ type MovementParams,
+} from '@openvibe/gameplay'
import { CollisionLayer, type BodyId, type PhysicsWorld, type ShapeDesc } from '@openvibe/physics'
import { CollisionLayer, type BodyId, type PhysicsWorld, type ShapeDesc } from '@openvibe/physics'
import type { ServerSnapshot, WireEntity } from '@openvibe/protocol'
import type { ServerSnapshot, WireEntity } from '@openvibe/protocol'
import { quat, vec3, wrapAngle } from '@openvibe/shared'
import { quat, vec3, wrapAngle } from '@openvibe/shared'
@@ -67,6 +72,8 @@ export class EntityView {
@@ -67,6 +72,8 @@ export class EntityView {
/** EMA of (serverTime - localTime) for the interpolation clock. */
/** EMA of (serverTime - localTime) for the interpolation clock. */
private clockOffset: number | null = null
private clockOffset: number | null = null
private lastUpdateTime = 0
private lastUpdateTime = 0
+ /** The room's movement preset, for remote-player mirror hulls and feet. */
+ private readonly movement: MovementParams
constructor(
constructor(
private readonly scene: Scene,
private readonly scene: Scene,
@@ -74,6 +81,7 @@ export class EntityView {
@@ -74,6 +81,7 @@ export class EntityView {
private readonly content: ContentRegistry,
private readonly content: ContentRegistry,
private readonly state: ClientState,
private readonly state: ClientState,
) {
) {
+ this.movement = movementParamsFor(movementStyleFor(content.world))
state.events.on('entityAdded', (e) => this.add(e))
state.events.on('entityAdded', (e) => this.add(e))
state.events.on('entityRemoved', (id) => this.remove(id))
state.events.on('entityRemoved', (id) => this.remove(id))
state.events.on('entityUpdated', (e) => this.applyAuthoritative(e))
state.events.on('entityUpdated', (e) => this.applyAuthoritative(e))
@@ -206,11 +214,11 @@ export class EntityView {
@@ -206,11 +214,11 @@ export class EntityView {
/** Static capsule matching the server's player body (short + lifted). */
/** Static capsule matching the server's player body (short + lifted). */
private addPlayerBody(pos: { x: number; y: number; z: number }, stance: number): BodyId {
private addPlayerBody(pos: { x: number; y: number; z: number }, stance: number): BodyId {
- const hull = hullHeightFor(stance as 0 | 1 | 2)
+ const hull = hullHeightFor(stance as 0 | 1 | 2, this.movement)
return this.physics.addBody({
return this.physics.addBody({
shape: {
shape: {
type: 'capsule',
type: 'capsule',
- radius: DEFAULT_MOVEMENT.capsuleRadius,
+ radius: this.movement.capsuleRadius,
height: Math.max(hull - 0.3, 0.4),
height: Math.max(hull - 0.3, 0.4),
},
},
motion: 'static',
motion: 'static',
@@ -371,8 +379,12 @@ export class EntityView {
@@ -371,8 +379,12 @@ export class EntityView {
// Blend FEET height, not capsule-center height: the center jumps
// Blend FEET height, not capsule-center height: the center jumps
// when the hull shrinks (crouch/prone) and blending centers sinks
// when the hull shrinks (crouch/prone) and blending centers sinks
// the avatar through the floor mid-transition.
// the avatar through the floor mid-transition.
- const feetA = a.pos[1] - (v.feetOffset ?? hullHeightFor((a.stance ?? 0) as 0 | 1 | 2) / 2)
- const feetB = b.pos[1] - (v.feetOffset ?? hullHeightFor((b.stance ?? 0) as 0 | 1 | 2) / 2)
+ const feetA =
+ a.pos[1] -
+ (v.feetOffset ?? hullHeightFor((a.stance ?? 0) as 0 | 1 | 2, this.movement) / 2)
+ const feetB =
+ b.pos[1] -
+ (v.feetOffset ?? hullHeightFor((b.stance ?? 0) as 0 | 1 | 2, this.movement) / 2)
const feetY = feetA + (feetB - feetA) * alpha
const feetY = feetA + (feetB - feetA) * alpha
// Keep the player collision mirror on the smooth pose; swap the hull
// Keep the player collision mirror on the smooth pose; swap the hull
// when their stance changes so crouched players are shorter to walk on.
// when their stance changes so crouched players are shorter to walk on.
@@ -66,7 +66,7 @@ export class FirstPersonBody {
@@ -66,7 +66,7 @@ export class FirstPersonBody {
const speed = Math.hypot(move.vel.x, move.vel.z)
const speed = Math.hypot(move.vel.x, move.vel.z)
const yaw = this.player.viewYaw
const yaw = this.player.viewYaw
const pitch = this.player.viewPitch
const pitch = this.player.viewPitch
- const feetY = renderPos.y - hullHeightFor(this.player.move.stance) / 2
+ const feetY = renderPos.y - hullHeightFor(this.player.move.stance, this.player.params) / 2
// The camera pivots at the eyes; slide the body backward as the view
// The camera pivots at the eyes; slide the body backward as the view
// pitches down so looking down shows your chest and legs from above
// pitches down so looking down shows your chest and legs from above
// instead of the inside of your own collar.
// instead of the inside of your own collar.
@@ -89,7 +89,11 @@ export class TopDownMarkers {
@@ -89,7 +89,11 @@ export class TopDownMarkers {
update(): void {
update(): void {
const me = this.markerFor('self', this.selfMat)
const me = this.markerFor('self', this.selfMat)
const pos = this.player.renderPos
const pos = this.player.renderPos
- me.position.set(pos.x, pos.y - hullHeightFor(this.player.move.stance) / 2 + LIFT, pos.z)
+ me.position.set(
+ pos.x,
+ pos.y - hullHeightFor(this.player.move.stance, this.player.params) / 2 + LIFT,
+ pos.z,
+ )
me.rotation.y = this.player.viewYaw
me.rotation.y = this.player.viewYaw
for (const [id, e] of this.state.entities) {
for (const [id, e] of this.state.entities) {
if (e.kind !== 'player' || id === this.state.myEntityId) continue
if (e.kind !== 'player' || id === this.state.myEntityId) continue
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
import { MIGRATIONS_DIR } from '@openvibe/persistence'
import { MIGRATIONS_DIR } from '@openvibe/persistence'
+import { SIM_TICK_RATE, SURF_SNAPSHOT_EVERY, SURF_TICK_RATE } from '@openvibe/shared'
import { parseProxyTrust, type ProxyTrust } from './net/clientAddress.js'
import { parseProxyTrust, type ProxyTrust } from './net/clientAddress.js'
export interface DbConfig {
export interface DbConfig {
@@ -53,6 +54,10 @@ export interface ServerConfig {
@@ -53,6 +54,10 @@ export interface ServerConfig {
* is off until OV_OAUTH_CLIENT_SECRET (the same OAuth client as SSO) is set.
* is off until OV_OAUTH_CLIENT_SECRET (the same OAuth client as SSO) is set.
*/
*/
platform: PlatformConfig
platform: PlatformConfig
+ /**
+ * The room's simulation rate. A room carries its own value: surf rooms use
+ * SURF_ROOM_TIMING, every other place keeps the default (and creator worlds).
+ */
tickRate: number
tickRate: number
/** Send a snapshot every N ticks. */
/** Send a snapshot every N ticks. */
snapshotEvery: number
snapshotEvery: number
@@ -143,6 +148,24 @@ export interface PlatformConfig {
@@ -143,6 +148,24 @@ export interface PlatformConfig {
eventsEndpoint: string | null
eventsEndpoint: string | null
}
}
+/** One room's fixed simulation cadence. */
+export interface RoomTiming {
+ readonly tickRate: number
+ readonly snapshotEvery: number
+}
+
+/** Scraplandia, Salvage Rush, creator worlds: 30 Hz, a snapshot every other tick (15 Hz). */
+export const DEFAULT_ROOM_TIMING: RoomTiming = {
+ tickRate: SIM_TICK_RATE,
+ snapshotEvery: 2,
+}
+
+/** Native CS:S surf: 100 Hz simulation, a snapshot every 4 ticks (25 Hz). */
+export const SURF_ROOM_TIMING: RoomTiming = {
+ tickRate: SURF_TICK_RATE,
+ snapshotEvery: SURF_SNAPSHOT_EVERY,
+}
+
function trimSlash(v: string): string {
function trimSlash(v: string): string {
return v.replace(/\/+$/, '')
return v.replace(/\/+$/, '')
}
}
@@ -217,8 +240,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -217,8 +240,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
platform: loadPlatformConfig(env),
platform: loadPlatformConfig(env),
/** IndexNow is off until a key is configured; an empty key never mounts the key file. */
/** IndexNow is off until a key is configured; an empty key never mounts the key file. */
indexnowKey: String(env.INDEXNOW_KEY ?? '').trim(),
indexnowKey: String(env.INDEXNOW_KEY ?? '').trim(),
- tickRate: 30,
- snapshotEvery: 2,
+ ...DEFAULT_ROOM_TIMING,
interestRadius: positiveIntEnv(
interestRadius: positiveIntEnv(
env,
env,
'GAMES_INTEREST_RADIUS',
'GAMES_INTEREST_RADIUS',
@@ -272,9 +272,14 @@ export class GameServer {
@@ -272,9 +272,14 @@ export class GameServer {
return nearbyWorkstationKinds(session, this.ctx.world)
return nearbyWorkstationKinds(session, this.ctx.world)
}
}
+ /** This room's fixed simulation rate (100 surf, 30 everywhere else). */
+ get tickRate(): number {
+ return this.ctx.config.tickRate
+ }
+
/**
/**
- * The simulation tick, 30 Hz. This is the ONE place the order is written down; the systems'
- * internal cadence (1 Hz sweeps, every-2-tick snapshots) is their own.
+ * One simulation tick at the room's own rate. This is the ONE place the order is written down;
+ * the systems' internal cadence (1 Hz sweeps, every-N-tick snapshots) is their own.
*/
*/
step(): void {
step(): void {
const ctx = this.ctx
const ctx = this.ctx
@@ -333,7 +338,7 @@ export class GameServer {
@@ -333,7 +338,7 @@ export class GameServer {
// 10. Once a second: resource respawn sweep.
// 10. Once a second: resource respawn sweep.
s.interaction.respawnResources()
s.interaction.respawnResources()
- // 11. Replication: interest diff + snapshot (every `snapshotEvery` ticks → 15 Hz).
+ // 11. Replication: interest diff + snapshot (every `snapshotEvery` ticks → 15 Hz default, 25 Hz surf).
s.replication.tick(ctx, dt)
s.replication.tick(ctx, dt)
// 12. Mods: reconcile installs (a revoked or disabled mod's effects are
// 12. Mods: reconcile installs (a revoked or disabled mod's effects are
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
import { encodeServerMessage, type ClientHello, type ClientMessage } from '@openvibe/protocol'
import { encodeServerMessage, type ClientHello, type ClientMessage } from '@openvibe/protocol'
import type { InstanceLease, InstanceRegistry } from '@openvibe/persistence'
import type { InstanceLease, InstanceRegistry } from '@openvibe/persistence'
-import type { Logger } from '@openvibe/shared'
+import { FixedTimestep, type Logger } from '@openvibe/shared'
import type { GameConnection } from './systems/context.js'
import type { GameConnection } from './systems/context.js'
import type { GameServer } from './gameServer.js'
import type { GameServer } from './gameServer.js'
import type { GameWorld } from './gameWorld.js'
import type { GameWorld } from './gameWorld.js'
@@ -15,6 +15,8 @@ export interface InstanceRoom {
@@ -15,6 +15,8 @@ export interface InstanceRoom {
/** One-process room router. The socket's upgrade identity remains attached across transfers. */
/** One-process room router. The socket's upgrade identity remains attached across transfers. */
export class InstanceHub {
export class InstanceHub {
private readonly rooms = new Map<number, InstanceRoom>()
private readonly rooms = new Map<number, InstanceRoom>()
+ /** One accumulator per room: 100 Hz surf and 30 Hz everything else can share a hub. */
+ private readonly clocks = new Map<number, FixedTimestep>()
private readonly connections = new Map<GameConnection, { slot: number; hello: ClientHello }>()
private readonly connections = new Map<GameConnection, { slot: number; hello: ClientHello }>()
private readonly reservations = new Map<number, number>()
private readonly reservations = new Map<number, number>()
private readonly pending = new Map<GameConnection, Promise<void>>()
private readonly pending = new Map<GameConnection, Promise<void>>()
@@ -126,8 +128,30 @@ export class InstanceHub {
@@ -126,8 +128,30 @@ export class InstanceHub {
await this.pending.get(conn)
await this.pending.get(conn)
}
}
- step(): void {
- for (const room of this.rooms.values()) room.game.step()
+ /**
+ * Advance every room by its own fixed steps. `elapsedSeconds` is the scheduler
+ * slice (one 100 Hz slot in main.ts); each room's accumulator turns it into
+ * 0–N steps of its configured rate, so a 30 Hz room takes one step roughly
+ * every third slice while a 100 Hz surf room takes one every slice.
+ *
+ * Without an argument this keeps the old test/manual contract: exactly one
+ * step per room per call, regardless of its rate.
+ */
+ step(elapsedSeconds?: number): void {
+ if (elapsedSeconds === undefined) {
+ for (const room of this.rooms.values()) room.game.step()
+ return
+ }
+ for (const room of this.rooms.values()) {
+ const rate = room.game.tickRate
+ let clock = this.clocks.get(room.lease.slot)
+ if (!clock || clock.dt !== 1 / rate) {
+ clock = new FixedTimestep(1 / rate)
+ this.clocks.set(room.lease.slot, clock)
+ }
+ const steps = clock.consume(elapsedSeconds)
+ for (let i = 0; i < steps; i++) room.game.step()
+ }
}
}
async heartbeat(now = Date.now()): Promise<void> {
async heartbeat(now = Date.now()): Promise<void> {
@@ -147,6 +171,7 @@ export class InstanceHub {
@@ -147,6 +171,7 @@ export class InstanceHub {
}
}
this.log.error('instance lease lost', { id: room.lease.id })
this.log.error('instance lease lost', { id: room.lease.id })
this.rooms.delete(room.lease.slot)
this.rooms.delete(room.lease.slot)
+ this.clocks.delete(room.lease.slot)
for (const [conn, current] of this.connections) {
for (const [conn, current] of this.connections) {
if (current.slot === room.lease.slot) conn.close(1012, 'instance_lease_lost')
if (current.slot === room.lease.slot) conn.close(1012, 'instance_lease_lost')
}
}
@@ -170,6 +195,7 @@ export class InstanceHub {
@@ -170,6 +195,7 @@ export class InstanceHub {
await room.dispose()
await room.dispose()
}
}
this.rooms.clear()
this.rooms.clear()
+ this.clocks.clear()
return drained.every(Boolean)
return drained.every(Boolean)
}
}
@@ -124,8 +124,9 @@ export class PlaceRouter {
@@ -124,8 +124,9 @@ export class PlaceRouter {
return this.allHubs().reduce((total, hub) => total + hub.onlineCount, 0)
return this.allHubs().reduce((total, hub) => total + hub.onlineCount, 0)
}
}
- step(): void {
- for (const hub of this.allHubs()) hub.step()
+ /** `elapsedSeconds` is one scheduler slice; every hub advances its rooms at their own rates. */
+ step(elapsedSeconds?: number): void {
+ for (const hub of this.allHubs()) hub.step(elapsedSeconds)
}
}
async heartbeat(): Promise<void> {
async heartbeat(): Promise<void> {
@@ -5,6 +5,8 @@ import {
@@ -5,6 +5,8 @@ import {
CraftQueue,
CraftQueue,
createMoveState,
createMoveState,
eyeOffsetFor,
eyeOffsetFor,
+ movementParamsFor,
+ movementStyleFor,
normalizeStats,
normalizeStats,
type PlayerMoveState,
type PlayerMoveState,
type SurfRunState,
type SurfRunState,
@@ -207,8 +209,9 @@ export function createSession(init: SessionInit): PlayerSession {
@@ -207,8 +209,9 @@ export function createSession(init: SessionInit): PlayerSession {
/** Eye position for view rays — stance-aware, matches the client camera. */
/** Eye position for view rays — stance-aware, matches the client camera. */
export function eyePosition(session: PlayerSession, out: Vec3): Vec3 {
export function eyePosition(session: PlayerSession, out: Vec3): Vec3 {
+ const params = movementParamsFor(movementStyleFor(session.content.world))
out.x = session.move.pos.x
out.x = session.move.pos.x
- out.y = session.move.pos.y + eyeOffsetFor(session.move.stance)
+ out.y = session.move.pos.y + eyeOffsetFor(session.move.stance, params)
out.z = session.move.pos.z
out.z = session.move.pos.z
return out
return out
}
}
@@ -109,12 +109,12 @@ describe.each(SURF_MAPS)('$name', (info) => {
@@ -109,12 +109,12 @@ describe.each(SURF_MAPS)('$name', (info) => {
const compiled = compileMapFileV2(map)
const compiled = compileMapFileV2(map)
const run = new SurfRunState(
const run = new SurfRunState(
surfRunMapOf(compiled.surf, compiled.zones ?? [], compiled.spawn, compiled.spawnYaw),
surfRunMapOf(compiled.surf, compiled.zones ?? [], compiled.spawn, compiled.spawnYaw),
- 30,
+ 100,
)
)
const move = createMoveState(vec3(run.map.spawn.x, run.map.spawn.y, run.map.spawn.z))
const move = createMoveState(vec3(run.map.spawn.x, run.map.spawn.y, run.map.spawn.z))
const params = movementParamsFor('surf')
const params = movementParamsFor('surf')
let finished = false
let finished = false
- for (let tick = 0; tick < 60 * 30 && !finished; tick++) {
+ for (let tick = 0; tick < 60 * 100 && !finished; tick++) {
const next =
const next =
run.reached < run.map.checkpoints.length
run.reached < run.map.checkpoints.length
? run.map.checkpoints[run.reached]!
? run.map.checkpoints[run.reached]!
@@ -128,8 +128,8 @@ describe.each(SURF_MAPS)('$name', (info) => {
@@ -128,8 +128,8 @@ describe.each(SURF_MAPS)('$name', (info) => {
const from = { x: move.pos.x, y: move.pos.y, z: move.pos.z }
const from = { x: move.pos.x, y: move.pos.y, z: move.pos.z }
// A hop pulse clears the gaps between landings; the surf line itself
// A hop pulse clears the gaps between landings; the surf line itself
// is a held forward run down the 50–60° ramps.
// is a held forward run down the 50–60° ramps.
- const buttons = tick % 20 < 2 ? Buttons.Jump : 0
- stepMovement(move, { moveX: 0, moveZ: 1, yaw, pitch: 0, buttons }, params, queries, 1 / 30)
+ const buttons = tick % 67 < 2 ? Buttons.Jump : 0
+ stepMovement(move, { moveX: 0, moveZ: 1, yaw, pitch: 0, buttons }, params, queries, 1 / 100)
const events = run.observe(tick, from, move.pos, yaw)
const events = run.observe(tick, from, move.pos, yaw)
for (const event of events) {
for (const event of events) {
if (event.type === 'failed' || event.type === 'restarted') {
if (event.type === 'failed' || event.type === 'restarted') {
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { createMoveState, normalizeStats } from '@openvibe/gameplay'
import { createMoveState, normalizeStats } from '@openvibe/gameplay'
import { vec3 } from '@openvibe/shared'
import { vec3 } from '@openvibe/shared'
+import { DEFAULT_ROOM_TIMING, SURF_ROOM_TIMING } from '../config.js'
import { loadSurfOverride } from './surfMaps.js'
import { loadSurfOverride } from './surfMaps.js'
import { setMapOverride } from '@openvibe/content'
import { setMapOverride } from '@openvibe/content'
import { EnvironmentSystem } from './systems/environment.js'
import { EnvironmentSystem } from './systems/environment.js'
@@ -54,8 +55,8 @@ function sessionFor(override: ReturnType<typeof loadSurfOverride>) {
@@ -54,8 +55,8 @@ function sessionFor(override: ReturnType<typeof loadSurfOverride>) {
function ctxFor(session: any, respawns: string[], sent: unknown[]) {
function ctxFor(session: any, respawns: string[], sent: unknown[]) {
return {
return {
- clock: { tick: 30 },
- config: { tickRate: 30, snapshotEvery: 2, placeId: 'surf-lagoon', instanceSlot: 0 },
+ clock: { tick: 0 },
+ config: { ...SURF_ROOM_TIMING, placeId: 'surf-lagoon', instanceSlot: 0 },
env: {},
env: {},
regions: { update() {} },
regions: { update() {} },
sessions: {
sessions: {
@@ -158,3 +159,32 @@ describe('surf place rules', () => {
@@ -158,3 +159,32 @@ describe('surf place rules', () => {
expect(state).toMatchObject({ running: false, stage: 0 })
expect(state).toMatchObject({ running: false, stage: 0 })
})
})
})
})
+
+describe('surf room timing', () => {
+ it('is 100 Hz with 20–30 Hz snapshots; the default room stays at 30/15', () => {
+ expect(SURF_ROOM_TIMING.tickRate).toBe(100)
+ const surfSnapshotRate = SURF_ROOM_TIMING.tickRate / SURF_ROOM_TIMING.snapshotEvery
+ expect(surfSnapshotRate).toBeGreaterThanOrEqual(20)
+ expect(surfSnapshotRate).toBeLessThanOrEqual(30)
+ expect(surfSnapshotRate).toBe(25)
+
+ expect(DEFAULT_ROOM_TIMING.tickRate).toBe(30)
+ expect(DEFAULT_ROOM_TIMING.tickRate / DEFAULT_ROOM_TIMING.snapshotEvery).toBe(15)
+ })
+
+ it('emits surf_state at the room snapshot cadence, not every tick', () => {
+ const { override } = surfWorld()
+ const session = sessionFor(override)
+ const sent: any[] = []
+ const ctx = ctxFor(session, [], sent) as any
+ const surf = new SurfSystem(ctx)
+ surf.onJoin(session as any)
+ sent.length = 0
+
+ for (let tick = 1; tick <= 8; tick++) {
+ ctx.clock.tick = tick
+ surf.tick(ctx, 1 / SURF_ROOM_TIMING.tickRate)
+ }
+ expect(sent.filter((m) => m.t === 'surf_state')).toHaveLength(2)
+ })
+})
@@ -11,7 +11,7 @@ import {
@@ -11,7 +11,7 @@ import {
} from '@openvibe/gameplay'
} from '@openvibe/gameplay'
import { CollisionLayer, type BodyId } from '@openvibe/physics'
import { CollisionLayer, type BodyId } from '@openvibe/physics'
import type { ClientEditMode, ClientInput } from '@openvibe/protocol'
import type { ClientEditMode, ClientInput } from '@openvibe/protocol'
-import { clamp, dequantiseAngle, qfromYaw, vec3, wrapAngle } from '@openvibe/shared'
+import { SIM_TICK_RATE, clamp, dequantiseAngle, qfromYaw, vec3, wrapAngle } from '@openvibe/shared'
import { canEditMap } from '../../net/networkAuth.js'
import { canEditMap } from '../../net/networkAuth.js'
import type { PlayerSession } from '../playerSession.js'
import type { PlayerSession } from '../playerSession.js'
import type { ServerContext } from './context.js'
import type { ServerContext } from './context.js'
@@ -28,26 +28,34 @@ export const MOVE = DEFAULT_MOVEMENT
@@ -28,26 +28,34 @@ export const MOVE = DEFAULT_MOVEMENT
*
*
* Speed stays honest through credit: one per tick, banked up to a second's worth, one spent per simulated input.
* 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
* 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.
+ * out of credit and its extra inputs only wait. The one-second budgets scale with the room's tick rate: 30 at
+ * the sandbox's 30 Hz, 100 in a 100 Hz surf room.
*/
*/
-/** 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 that may wait at 30 Hz: a second of play. A client past it is flooding, and its oldest go. */
+export const MAX_INPUT_QUEUE = SIM_TICK_RATE
+/** Credit banked at most at 30 Hz: a second of play. */
+export const MAX_INPUT_CREDIT = SIM_TICK_RATE
/** Inputs simulated in one tick at most, while catching up. */
/** Inputs simulated in one tick at most, while catching up. */
export const CATCH_UP_PER_TICK = 4
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
+/** Ticks a late client is waited for at 30 Hz (its player holds still) before it coasts to a stop. */
+export const STALL_TICKS = SIM_TICK_RATE / 2
-/** Queue one input from the client. */
-export function queueInput(session: PlayerSession, input: ClientInput): void {
- if (session.inputQueue.length >= MAX_INPUT_QUEUE) session.inputQueue.shift()
+/** Queue one input from the client; the cap is one second at the room's rate. */
+export function queueInput(
+ session: PlayerSession,
+ input: ClientInput,
+ tickRate: number = SIM_TICK_RATE,
+): void {
+ if (session.inputQueue.length >= tickRate) session.inputQueue.shift()
session.inputQueue.push(input)
session.inputQueue.push(input)
}
}
/** This tick's inputs, in order: earn one credit, then spend one per input, at most CATCH_UP_PER_TICK. */
/** 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)
+export function takeInputs(
+ session: PlayerSession,
+ tickRate: number = SIM_TICK_RATE,
+): ClientInput[] {
+ session.inputCredit = Math.min(tickRate, session.inputCredit + 1)
const out: ClientInput[] = []
const out: ClientInput[] = []
while (
while (
out.length < CATCH_UP_PER_TICK &&
out.length < CATCH_UP_PER_TICK &&
@@ -97,7 +105,7 @@ export class MovementSystem implements System {
@@ -97,7 +105,7 @@ export class MovementSystem implements System {
readonly handlers: HandlerMap = {
readonly handlers: HandlerMap = {
input: (session, msg: ClientInput, _conn) => {
input: (session, msg: ClientInput, _conn) => {
- queueInput(session, msg)
+ queueInput(session, msg, this.ctx.config.tickRate)
},
},
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
@@ -157,7 +165,7 @@ export class MovementSystem implements System {
@@ -157,7 +165,7 @@ export class MovementSystem implements System {
this.sweepSelf = ctx.sessions.bodyOf(session.playerId)
this.sweepSelf = ctx.sessions.bodyOf(session.playerId)
session.moveSegments.length = 0
session.moveSegments.length = 0
let simulated = 0
let simulated = 0
- for (const input of takeInputs(session)) {
+ for (const input of takeInputs(session, ctx.config.tickRate)) {
session.lastInput = input
session.lastInput = input
session.starvedTicks = 0
session.starvedTicks = 0
session.lastProcessedSeq = input.seq
session.lastProcessedSeq = input.seq
@@ -180,7 +188,7 @@ export class MovementSystem implements System {
@@ -180,7 +188,7 @@ export class MovementSystem implements System {
// they land (simulating anything now would be a step the client never predicted). A client quiet for longer
// 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.
// (a background tab, a dropped connection) coasts to a stop under gravity.
session.starvedTicks++
session.starvedTicks++
- if (session.starvedTicks > STALL_TICKS) {
+ if (session.starvedTicks > ctx.config.tickRate / 2) {
// Zero MOVEMENT only: buttons stay held. Zeroing buttons fabricates release edges for toggle keys
// Zero MOVEMENT only: buttons stay held. Zeroing buttons fabricates release edges for toggle keys
// (prone/crouch), and the server flapped stances.
// (prone/crouch), and the server flapped stances.
const from = surfPlace
const from = surfPlace
@@ -205,7 +213,7 @@ export class MovementSystem implements System {
@@ -205,7 +213,7 @@ export class MovementSystem implements System {
if (bodyId !== undefined && session.bodyStance !== session.move.stance) {
if (bodyId !== undefined && session.bodyStance !== session.move.stance) {
// Stance changed: swap the kinematic hull to match the new posture.
// Stance changed: swap the kinematic hull to match the new posture.
ctx.world.physics.removeBody(bodyId)
ctx.world.physics.removeBody(bodyId)
- const hull = hullHeightFor(session.move.stance)
+ const hull = hullHeightFor(session.move.stance, params)
bodyId = ctx.world.physics.addBody({
bodyId = ctx.world.physics.addBody({
shape: {
shape: {
type: 'capsule',
type: 'capsule',
@@ -26,8 +26,8 @@ import {
@@ -26,8 +26,8 @@ import {
type InstanceLease,
type InstanceLease,
} from '@openvibe/persistence'
} from '@openvibe/persistence'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
-import { createConsoleLogger } from '@openvibe/shared'
-import { loadConfig } from './config.js'
+import { SURF_TICK_RATE, createConsoleLogger } from '@openvibe/shared'
+import { SURF_ROOM_TIMING, loadConfig } from './config.js'
import { openDb } from './db.js'
import { openDb } from './db.js'
import { telemetry } from 'openvibe-sdk/service'
import { telemetry } from 'openvibe-sdk/service'
import { createPgTelemetrySink, startTelemetryRetention } from 'openvibe-sdk/telemetry'
import { createPgTelemetrySink, startTelemetryRetention } from 'openvibe-sdk/telemetry'
@@ -381,7 +381,7 @@ async function main(): Promise<void> {
@@ -381,7 +381,7 @@ async function main(): Promise<void> {
scriptLog.info('script mods off for this place', { mods: content.allMods().length })
scriptLog.info('script mods off for this place', { mods: content.allMods().length })
}
}
const game = new GameServer(
const game = new GameServer(
- { ...config, instanceSlot: 0 },
+ { ...config, instanceSlot: 0, ...(primarySurf ? SURF_ROOM_TIMING : {}) },
world,
world,
store,
store,
metrics,
metrics,
@@ -452,7 +452,7 @@ async function main(): Promise<void> {
@@ -452,7 +452,7 @@ async function main(): Promise<void> {
roomWorld.reconcileMapProps()
roomWorld.reconcileMapProps()
const roomScripts = await createScriptHost(slot)
const roomScripts = await createScriptHost(slot)
const roomGame = new GameServer(
const roomGame = new GameServer(
- { ...config, instanceSlot: slot },
+ { ...config, instanceSlot: slot, ...(primarySurf ? SURF_ROOM_TIMING : {}) },
roomWorld,
roomWorld,
roomStore,
roomStore,
metrics,
metrics,
@@ -648,6 +648,7 @@ async function main(): Promise<void> {
@@ -648,6 +648,7 @@ async function main(): Promise<void> {
const roomGame = new GameServer(
const roomGame = new GameServer(
{
{
...config,
...config,
+ ...SURF_ROOM_TIMING,
placeId: info.placeId,
placeId: info.placeId,
instanceSlot: slot,
instanceSlot: slot,
// A surf room has no survival, NPC population or world events; the
// A surf room has no survival, NPC population or world events; the
@@ -1341,15 +1342,19 @@ async function main(): Promise<void> {
@@ -1341,15 +1342,19 @@ async function main(): Promise<void> {
log.info('listening', { host: config.host, port: config.port })
log.info('listening', { host: config.host, port: config.port })
})
})
- // Fixed-tick loop with drift correction — never tied to timers' jitter.
- const tickMs = 1000 / config.tickRate
+ // Scheduler loop with drift correction — never tied to timers' jitter. It
+ // runs at the fastest room rate; each room's accumulator in InstanceHub
+ // turns a slice into its own 30/100 Hz fixed steps.
+ const schedulerRate = Math.max(config.tickRate, SURF_TICK_RATE)
+ const tickMs = 1000 / schedulerRate
+ const sliceSeconds = tickMs / 1000
let nextTick = performance.now()
let nextTick = performance.now()
let running = true
let running = true
const loop = (): void => {
const loop = (): void => {
if (!running) return
if (!running) return
const now = performance.now()
const now = performance.now()
while (now >= nextTick) {
while (now >= nextTick) {
- router.step()
+ router.step(sliceSeconds)
nextTick += tickMs
nextTick += tickMs
// If we fell far behind (debugger pause, EL stall), resync instead of
// If we fell far behind (debugger pause, EL stall), resync instead of
// spiraling through hundreds of catch-up ticks.
// spiraling through hundreds of catch-up ticks.
@@ -13,11 +13,12 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
@@ -13,11 +13,12 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
*/
*/
/**
/**
- * No completed tick for this long means the simulation is stalled. The loop
- * runs at `tickRate` (30/s, one tick every ~33 ms) and resyncs itself after
- * falling more than a second behind, so a healthy server never goes a second
- * without a tick; 5 s leaves room for a GC pause, a persistence flush or a
- * live map rebuild, and still flags a frozen world within one deploy poll.
+ * No completed tick for this long means the simulation is stalled. The
+ * scheduler runs at the fastest hosted room rate (100/s with surf, 30/s
+ * otherwise, one slice every ~10–33 ms) and resyncs itself after falling more
+ * than a second behind, so a healthy server never goes a second without a
+ * tick; 5 s leaves room for a GC pause, a persistence flush or a live map
+ * rebuild, and still flags a frozen world within one deploy poll.
*/
*/
export const TICK_STALL_MS = 5_000
export const TICK_STALL_MS = 5_000
@@ -1,3 +1,6 @@
@@ -1,3 +1,6 @@
+/** [stand, crouch, prone] values, indexed by the Stance enum. */
+export type StanceProfile = readonly [number, number, number]
+
/**
/**
* Source/Quake-inspired movement tuning. All values in meters/seconds.
* Source/Quake-inspired movement tuning. All values in meters/seconds.
* Both server simulation and client prediction read the same instance —
* Both server simulation and client prediction read the same instance —
@@ -27,6 +30,10 @@ export interface MovementParams {
@@ -27,6 +30,10 @@ export interface MovementParams {
readonly capsuleRadius: number
readonly capsuleRadius: number
/** Eye height above capsule center. */
/** Eye height above capsule center. */
readonly eyeOffset: number
readonly eyeOffset: number
+ /** Total capsule height per stance, [stand, crouch, prone]; standing = capsuleHeight. */
+ readonly stanceHull: StanceProfile
+ /** Eye offset above the capsule center per stance, [stand, crouch, prone]. */
+ readonly stanceEye: StanceProfile
/** Collision skin to keep the capsule from touching surfaces exactly. */
/** Collision skin to keep the capsule from touching surfaces exactly. */
readonly skin: number
readonly skin: number
/** Horizontal speed ceiling (m/s); Infinity means the engine's old unbounded rule. */
/** Horizontal speed ceiling (m/s); Infinity means the engine's old unbounded rule. */
@@ -50,6 +57,8 @@ export const DEFAULT_MOVEMENT: MovementParams = {
@@ -50,6 +57,8 @@ export const DEFAULT_MOVEMENT: MovementParams = {
capsuleHeight: 1.8,
capsuleHeight: 1.8,
capsuleRadius: 0.4,
capsuleRadius: 0.4,
eyeOffset: 0.65,
eyeOffset: 0.65,
+ stanceHull: [1.8, 1.2, 0.7],
+ stanceEye: [0.65, 0.38, 0.14],
skin: 0.02,
skin: 0.02,
maxHorizontalSpeed: Number.POSITIVE_INFINITY,
maxHorizontalSpeed: Number.POSITIVE_INFINITY,
autoBhop: true,
autoBhop: true,
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
import { Buttons } from './buttons.js'
import { Buttons } from './buttons.js'
import { clamp, v3addScaled, v3copy, v3dot, v3lengthSq, vec3, type Vec3 } from '@openvibe/shared'
import { clamp, v3addScaled, v3copy, v3dot, v3lengthSq, vec3, type Vec3 } from '@openvibe/shared'
import type { CollisionQueries, SweepHit } from './collision.js'
import type { CollisionQueries, SweepHit } from './collision.js'
-import type { MovementParams } from './params.js'
+import { DEFAULT_MOVEMENT, type MovementParams } from './params.js'
/**
/**
* Source/Quake-style kinematic character movement with stances.
* Source/Quake-style kinematic character movement with stances.
@@ -26,10 +26,10 @@ export enum Stance {
@@ -26,10 +26,10 @@ export enum Stance {
Prone = 2,
Prone = 2,
}
}
-/** Hull heights per stance (capsule total height, m). */
-export const STANCE_HULL: readonly number[] = [1.8, 1.2, 0.7]
-/** Eye offset above the capsule CENTER per stance. */
-export const STANCE_EYE: readonly number[] = [0.65, 0.38, 0.14]
+/** Default hull heights per stance (capsule total height, m). */
+export const STANCE_HULL = DEFAULT_MOVEMENT.stanceHull
+/** Default eye offset above the capsule CENTER per stance. */
+export const STANCE_EYE = DEFAULT_MOVEMENT.stanceEye
/** Ground-speed multiplier per stance. */
/** Ground-speed multiplier per stance. */
export const STANCE_SPEED: readonly number[] = [1, 0.45, 0.22]
export const STANCE_SPEED: readonly number[] = [1, 0.45, 0.22]
/** Transition durations [from][to] in seconds. */
/** Transition durations [from][to] in seconds. */
@@ -41,12 +41,14 @@ const STANCE_DUR: readonly (readonly number[])[] = [
@@ -41,12 +41,14 @@ const STANCE_DUR: readonly (readonly number[])[] = [
/** Lockout after a completed transition (anti spam/peek abuse). */
/** Lockout after a completed transition (anti spam/peek abuse). */
const STANCE_COOLDOWN = 0.4
const STANCE_COOLDOWN = 0.4
-export function hullHeightFor(stance: Stance): number {
- return STANCE_HULL[stance] ?? 1.8
+/** A stance's collision hull height under these params (defaults preserve the sandbox). */
+export function hullHeightFor(stance: Stance, params: MovementParams = DEFAULT_MOVEMENT): number {
+ return params.stanceHull[stance] ?? params.capsuleHeight
}
}
-export function eyeOffsetFor(stance: Stance): number {
- return STANCE_EYE[stance] ?? 0.65
+/** A stance's eye offset above the capsule center under these params. */
+export function eyeOffsetFor(stance: Stance, params: MovementParams = DEFAULT_MOVEMENT): number {
+ return params.stanceEye[stance] ?? params.eyeOffset
}
}
export interface MoveInput {
export interface MoveInput {
@@ -398,8 +400,8 @@ function updateStance(
@@ -398,8 +400,8 @@ function updateStance(
: Stance.Stand
: Stance.Stand
if (desired === state.stance || state.stanceT > 0 || state.stanceCooldown > 0) return
if (desired === state.stance || state.stanceT > 0 || state.stanceCooldown > 0) return
- const oldHull = hullHeightFor(state.stance)
- const newHull = hullHeightFor(desired)
+ const oldHull = hullHeightFor(state.stance, params)
+ const newHull = hullHeightFor(desired, params)
if (newHull > oldHull) {
if (newHull > oldHull) {
// Growing needs headroom: sweep the current hull upward by the delta.
// Growing needs headroom: sweep the current hull upward by the delta.
v3copy(_end, state.pos)
v3copy(_end, state.pos)
@@ -437,7 +439,7 @@ export function stepMovement(
@@ -437,7 +439,7 @@ export function stepMovement(
return
return
}
}
updateStance(state, input, params, world, dt)
updateStance(state, input, params, world, dt)
- const hull = hullHeightFor(state.stance)
+ const hull = hullHeightFor(state.stance, params)
const groundHit = checkGround(state, world, params, hull)
const groundHit = checkGround(state, world, params, hull)
state.grounded = groundHit !== null
state.grounded = groundHit !== null
@@ -5,34 +5,47 @@ import { DEFAULT_MOVEMENT, type MovementParams } from './params.js'
@@ -5,34 +5,47 @@ import { DEFAULT_MOVEMENT, type MovementParams } from './params.js'
* CS:S surf tuning, converted from Source units into this engine's metres.
* CS:S surf tuning, converted from Source units into this engine's metres.
*
*
* Unit scale: Source is authored in hammer units where **1 unit = 1 inch =
* Unit scale: Source is authored in hammer units where **1 unit = 1 inch =
- * 0.0254 m** (a 72-unit player is 1.8288 m, the same 1.8 m capsule this engine
- * uses). Converting every velocity/length by 0.0254 keeps the map and the
- * player the same physical size as CS.
+ * 0.0254 m** (a 72-unit player is 1.8288 m tall). Converting every
+ * velocity/length by 0.0254 keeps the map and the player the same physical
+ * size as CS.
*
*
* sv_gravity 800 u/s² × 0.0254 = 20.32 m/s²
* sv_gravity 800 u/s² × 0.0254 = 20.32 m/s²
* sv_maxvelocity 3500 u/s × 0.0254 = 88.9 m/s
* sv_maxvelocity 3500 u/s × 0.0254 = 88.9 m/s
* air wish cap 30 u/s × 0.0254 = 0.762 m/s
* air wish cap 30 u/s × 0.0254 = 0.762 m/s
* walk speed 250 u/s × 0.0254 = 6.35 m/s
* walk speed 250 u/s × 0.0254 = 6.35 m/s
* jump 301.993 u/s × 0.0254 = 7.6706 m/s
* jump 301.993 u/s × 0.0254 = 7.6706 m/s
- * stop speed 80 u/s × 0.0254 = 2.032 m/s
+ * stop speed 75 u/s × 0.0254 = 1.905 m/s
+ * step height 18 u × 0.0254 = 0.4572 m
+ * stand hull 72 u × 0.0254 = 1.8288 m, radius 16 u = 0.4064 m
+ * duck hull 36 u × 0.0254 = 0.9144 m
+ * eye (stand) 64 u × 0.0254 = 1.6256 m above feet
+ * eye (duck) 28 u × 0.0254 = 0.7112 m above feet
*
*
- * The cap is applied PER TICK, so the per-second gain is `cap × tickRate`.
- * CS:S surf is played at 64 tick; this server keeps 30 Hz, so the cap is
- * scaled by 64/30 to preserve the per-second acceleration:
+ * The air cap is applied PER TICK, so the per-second gain is `cap × tickRate`
+ * and that difference is intentional:
*
*
- * 0.762 × 64 / 30 = 1.6256 m/s at 30 Hz
- * 1.6256 × 30 = 48.768 m/s² (same as 0.762 × 64)
+ * 0.762 × 64 = 48.768 m/s² cap equivalent at 64 Hz
+ * 0.762 × 100 = 76.20 m/s² cap equivalent at 100 Hz (native CS:S surf)
+ *
+ * There is no 64/30 scaling here: surf rooms simulate at 100 Hz, so the cap
+ * is Source's own 0.762. A 30 Hz room would need 1.6256 to preserve the
+ * per-second rate, which is exactly why the old value was wrong once surf
+ * moved off 30 Hz.
*
*
* `airAccel` is `sv_airaccelerate 150` unchanged: it is already a per-second
* `airAccel` is `sv_airaccelerate 150` unchanged: it is already a per-second
* rate (the kernel multiplies it by dt), so it does not need a tick-rate
* rate (the kernel multiplies it by dt), so it does not need a tick-rate
- * scale. At 30 Hz its term is 150 × 6.35 × (1/30) = 31.75 m/s per tick,
- * well above the 1.6256 cap — the cap is what limits surf, exactly as in CS.
+ * scale. At 100 Hz its term is 150 × 6.35 × (1/100) = 9.525 m/s per tick,
+ * well above the 0.762 cap — the cap is what limits surf, exactly as in CS.
* `sv_airaccelerate` only becomes visible when a very small wish speed makes
* `sv_airaccelerate` only becomes visible when a very small wish speed makes
* the rate term smaller than the cap.
* the rate term smaller than the cap.
*
*
* `groundNormalY` stays at 0.7 (cos 45.57°), CS's standable plane. Ride ramps
* `groundNormalY` stays at 0.7 (cos 45.57°), CS's standable plane. Ride ramps
* are authored at 50–60°, so cos(50°)=0.643 < 0.7 and the controller never
* are authored at 50–60°, so cos(50°)=0.643 < 0.7 and the controller never
* treats them as ground; the 45° edge stays exactly where CS puts it.
* treats them as ground; the 45° edge stays exactly where CS puts it.
+ *
+ * Eye offsets are above the capsule CENTER: standing (1.6256 − 0.9144) = 0.7112,
+ * ducking (0.7112 − 0.4572) = 0.254. Prone is not a Source stance; keep the
+ * engine's existing low profile.
*/
*/
export const SURF_MOVEMENT: MovementParams = {
export const SURF_MOVEMENT: MovementParams = {
maxGroundSpeed: 6.35,
maxGroundSpeed: 6.35,
@@ -40,16 +53,18 @@ export const SURF_MOVEMENT: MovementParams = {
@@ -40,16 +53,18 @@ export const SURF_MOVEMENT: MovementParams = {
maxSprintSpeed: 6.35,
maxSprintSpeed: 6.35,
groundAccel: 10,
groundAccel: 10,
airAccel: 150,
airAccel: 150,
- airSpeedCap: 1.6256,
+ airSpeedCap: 0.762,
friction: 4,
friction: 4,
- stopSpeed: 2.032,
+ stopSpeed: 1.905,
gravity: 20.32,
gravity: 20.32,
jumpSpeed: 7.6706,
jumpSpeed: 7.6706,
groundNormalY: 0.7,
groundNormalY: 0.7,
- stepHeight: 0.45,
- capsuleHeight: 1.8,
- capsuleRadius: 0.4,
- eyeOffset: 0.65,
+ stepHeight: 0.4572,
+ capsuleHeight: 1.8288,
+ capsuleRadius: 0.4064,
+ eyeOffset: 0.7112,
+ stanceHull: [1.8288, 0.9144, 0.7],
+ stanceEye: [0.7112, 0.254, 0.14],
skin: 0.02,
skin: 0.02,
maxHorizontalSpeed: 88.9,
maxHorizontalSpeed: 88.9,
autoBhop: false,
autoBhop: false,
@@ -68,7 +83,6 @@ export function movementParamsFor(style: MovementStyle): MovementParams {
@@ -68,7 +83,6 @@ export function movementParamsFor(style: MovementStyle): MovementParams {
return params
return params
}
}
-
/**
/**
* The movement style in force for a world: the loaded map's choice overrides
* The movement style in force for a world: the loaded map's choice overrides
* the content world's. Both sides call this with their own (identical) map
* the content world's. Both sides call this with their own (identical) map
@@ -2,15 +2,22 @@ import { describe, expect, it } from 'vitest'
@@ -2,15 +2,22 @@ import { describe, expect, it } from 'vitest'
import { vec3 } from '@openvibe/shared'
import { vec3 } from '@openvibe/shared'
import type { CollisionQueries, SweepHit } from './collision.js'
import type { CollisionQueries, SweepHit } from './collision.js'
import { DEFAULT_MOVEMENT } from './params.js'
import { DEFAULT_MOVEMENT } from './params.js'
-import { createMoveState, stepMovement, type PlayerMoveState } from './simulate.js'
+import {
+ Stance,
+ createMoveState,
+ eyeOffsetFor,
+ hullHeightFor,
+ stepMovement,
+ type PlayerMoveState,
+} from './simulate.js'
import { SURF_MOVEMENT } from './styles.js'
import { SURF_MOVEMENT } from './styles.js'
/**
/**
* The feel proof for Ride. A 55° ramp is steeper than cos⁻¹(0.7) = 45.57°, so
* The feel proof for Ride. A 55° ramp is steeper than cos⁻¹(0.7) = 45.57°, so
* NEITHER style ever grounds on it; the difference is the air controller.
* NEITHER style ever grounds on it; the difference is the air controller.
- * Surf's CS:S cap (scaled to 30 Hz) lets a strafe + turn keep or gain
- * horizontal speed; the sandbox default's 0.85 m/s cap gives the ramp the
- * speed instead. A 40° slope is ground in both styles.
+ * Surf runs at 100 Hz with CS:S's native 0.762 m/s wish cap; the sandbox runs
+ * at 30 Hz with its own 0.85 cap and gives the ramp the speed instead. A 40°
+ * slope is ground in both styles.
*
*
* The collision world is an analytic half-space rather than Rapier: the test
* The collision world is an analytic half-space rather than Rapier: the test
* is about the movement rule, and the half-space has no box edges, so the
* is about the movement rule, and the half-space has no box edges, so the
@@ -25,8 +32,7 @@ function planeQueries(angleDeg: number): CollisionQueries {
@@ -25,8 +32,7 @@ function planeQueries(angleDeg: number): CollisionQueries {
sweepCapsule(from, to, radius): SweepHit | null {
sweepCapsule(from, to, radius): SweepHit | null {
const da = from.x * n.x + from.y * n.y + from.z * n.z - radius
const da = from.x * n.x + from.y * n.y + from.z * n.z - radius
const db = to.x * n.x + to.y * n.y + to.z * n.z - radius
const db = to.x * n.x + to.y * n.y + to.z * n.z - radius
- if (da <= 0)
- return { fraction: 0, normal: { ...n }, point: { ...from } }
+ if (da <= 0) return { fraction: 0, normal: { ...n }, point: { ...from } }
if (db >= 0) return null
if (db >= 0) return null
const f = da / (da - db)
const f = da / (da - db)
return {
return {
@@ -42,67 +48,121 @@ function planeQueries(angleDeg: number): CollisionQueries {
@@ -42,67 +48,121 @@ function planeQueries(angleDeg: number): CollisionQueries {
}
}
}
}
+/** Open air: no ground, no ramp, so only the air rule changes velocity. */
+const FLAT_AIR: CollisionQueries = { sweepCapsule: () => null }
+
function spawnOnPlane(angleDeg: number, above = 1.2): PlayerMoveState {
function spawnOnPlane(angleDeg: number, above = 1.2): PlayerMoveState {
const a = angleDeg * RAD
const a = angleDeg * RAD
const n = { x: 0, y: Math.cos(a), z: Math.sin(a) }
const n = { x: 0, y: Math.cos(a), z: Math.sin(a) }
return createMoveState(vec3(n.x * above, n.y * above, n.z * above))
return createMoveState(vec3(n.x * above, n.y * above, n.z * above))
}
}
-/** 3 s of a strafe whose yaw turns 45°, the surf line. */
-function runLine(params: typeof DEFAULT_MOVEMENT, angle: number): number[] {
+/** 3 s of a strafe whose yaw turns 45°, the surf line, at the room's own rate. */
+function runLine(params: typeof DEFAULT_MOVEMENT, angle: number, tickRate: number): number[] {
const state = spawnOnPlane(angle)
const state = spawnOnPlane(angle)
state.vel.x = 0
state.vel.x = 0
state.vel.y = 0
state.vel.y = 0
state.vel.z = 10
state.vel.z = 10
const speeds: number[] = []
const speeds: number[] = []
- for (let tick = 0; tick < 90; tick++) {
- const yaw = (tick / 30) * 45 * RAD
+ const ticks = 3 * tickRate
+ for (let tick = 0; tick < ticks; tick++) {
+ const yaw = (tick / tickRate) * 45 * RAD
stepMovement(
stepMovement(
state,
state,
{ moveX: 1, moveZ: 0, yaw, pitch: 0, buttons: 0 },
{ moveX: 1, moveZ: 0, yaw, pitch: 0, buttons: 0 },
params,
params,
planeQueries(angle),
planeQueries(angle),
- 1 / 30,
+ 1 / tickRate,
)
)
- if (tick % 30 === 29) speeds.push(Math.hypot(state.vel.x, state.vel.z))
+ if ((tick + 1) % tickRate === 0) speeds.push(Math.hypot(state.vel.x, state.vel.z))
}
}
return speeds
return speeds
}
}
+/** The horizontal speed a single airborne tick adds toward the wish direction. */
+function firstAirTickGain(tickRate: number): number {
+ const state = createMoveState(vec3(0, 100, 0))
+ stepMovement(
+ state,
+ { moveX: 1, moveZ: 0, yaw: 0, pitch: 0, buttons: 0 },
+ SURF_MOVEMENT,
+ FLAT_AIR,
+ 1 / tickRate,
+ )
+ return Math.hypot(state.vel.x, state.vel.z)
+}
+
describe('surf feel on a steep ramp', () => {
describe('surf feel on a steep ramp', () => {
- it('keeps or gains horizontal speed over 3 s in the surf style', () => {
- const speeds = runLine(SURF_MOVEMENT, 55)
+ it('keeps or gains horizontal speed over 3 s in the surf style at 100 Hz', () => {
+ const speeds = runLine(SURF_MOVEMENT, 55, 100)
expect(speeds[0]).toBeGreaterThan(9)
expect(speeds[0]).toBeGreaterThan(9)
expect(speeds[2]).toBeGreaterThanOrEqual(10 * 0.99)
expect(speeds[2]).toBeGreaterThanOrEqual(10 * 0.99)
})
})
- it('loses horizontal speed in the default style on the same ramp and line', () => {
- const speeds = runLine(DEFAULT_MOVEMENT, 55)
+ it('loses horizontal speed in the default style on the same ramp and line at 30 Hz', () => {
+ const speeds = runLine(DEFAULT_MOVEMENT, 55, 30)
expect(speeds[2]).toBeLessThan(9)
expect(speeds[2]).toBeLessThan(9)
- expect(speeds[2]).toBeLessThan(runLine(SURF_MOVEMENT, 55)[2]! * 0.75)
+ expect(speeds[2]).toBeLessThan(runLine(SURF_MOVEMENT, 55, 100)[2]! * 0.75)
})
})
- it('a 40° slope is ground in both styles', () => {
- for (const params of [SURF_MOVEMENT, DEFAULT_MOVEMENT]) {
+ it('a 40° slope is ground in both styles at their native rates', () => {
+ for (const [params, tickRate] of [
+ [SURF_MOVEMENT, 100],
+ [DEFAULT_MOVEMENT, 30],
+ ] as const) {
const state = spawnOnPlane(40, 0.1)
const state = spawnOnPlane(40, 0.1)
stepMovement(
stepMovement(
state,
state,
{ moveX: 0, moveZ: 0, yaw: 0, pitch: 0, buttons: 0 },
{ moveX: 0, moveZ: 0, yaw: 0, pitch: 0, buttons: 0 },
params,
params,
planeQueries(40),
planeQueries(40),
- 1 / 30,
+ 1 / tickRate,
)
)
expect(state.grounded).toBe(true)
expect(state.grounded).toBe(true)
}
}
})
})
+})
+
+describe('source air cap', () => {
+ it('adds the same 0.762 m/s per airborne tick at 64 and 100 Hz', () => {
+ const at64 = firstAirTickGain(64)
+ const at100 = firstAirTickGain(100)
+ expect(at64).toBeCloseTo(30 * 0.0254, 10)
+ expect(at100).toBeCloseTo(at64, 10)
+ })
- it('CS:S constants are the converted Source values, scaled at 30 Hz', () => {
+ it('is 48.768 m/s² at 64 Hz and 76.20 m/s² at 100 Hz — the rate difference is intentional', () => {
+ expect(firstAirTickGain(64) * 64).toBeCloseTo(48.768, 8)
+ expect(firstAirTickGain(100) * 100).toBeCloseTo(76.2, 8)
+ })
+})
+
+describe('CS:S constants', () => {
+ it('uses the converted Source values and the native 100 Hz cap', () => {
expect(SURF_MOVEMENT.groundNormalY).toBe(0.7)
expect(SURF_MOVEMENT.groundNormalY).toBe(0.7)
expect(SURF_MOVEMENT.gravity).toBeCloseTo(800 * 0.0254)
expect(SURF_MOVEMENT.gravity).toBeCloseTo(800 * 0.0254)
expect(SURF_MOVEMENT.maxHorizontalSpeed).toBeCloseTo(3500 * 0.0254)
expect(SURF_MOVEMENT.maxHorizontalSpeed).toBeCloseTo(3500 * 0.0254)
- expect(SURF_MOVEMENT.airSpeedCap).toBeCloseTo(30 * 0.0254 * (64 / 30))
+ expect(SURF_MOVEMENT.airSpeedCap).toBeCloseTo(30 * 0.0254)
expect(SURF_MOVEMENT.airAccel).toBe(150)
expect(SURF_MOVEMENT.airAccel).toBe(150)
+ expect(SURF_MOVEMENT.maxGroundSpeed).toBeCloseTo(250 * 0.0254)
+ expect(SURF_MOVEMENT.jumpSpeed).toBeCloseTo(301.993 * 0.0254)
+ expect(SURF_MOVEMENT.stopSpeed).toBeCloseTo(75 * 0.0254)
expect(SURF_MOVEMENT.autoBhop).toBe(false)
expect(SURF_MOVEMENT.autoBhop).toBe(false)
expect(SURF_MOVEMENT.maxSprintSpeed).toBe(SURF_MOVEMENT.maxGroundSpeed)
expect(SURF_MOVEMENT.maxSprintSpeed).toBe(SURF_MOVEMENT.maxGroundSpeed)
})
})
+
+ it('uses the exact hull, crouch, eye and step heights without changing the sandbox', () => {
+ expect(SURF_MOVEMENT.capsuleHeight).toBeCloseTo(72 * 0.0254)
+ expect(SURF_MOVEMENT.capsuleRadius).toBeCloseTo(16 * 0.0254)
+ expect(SURF_MOVEMENT.eyeOffset).toBeCloseTo(64 * 0.0254 - 36 * 0.0254)
+ expect(SURF_MOVEMENT.stepHeight).toBeCloseTo(18 * 0.0254)
+ expect(hullHeightFor(Stance.Stand, SURF_MOVEMENT)).toBeCloseTo(1.8288)
+ expect(hullHeightFor(Stance.Crouch, SURF_MOVEMENT)).toBeCloseTo(36 * 0.0254)
+ expect(eyeOffsetFor(Stance.Stand, SURF_MOVEMENT)).toBeCloseTo(64 * 0.0254 - 36 * 0.0254)
+ expect(eyeOffsetFor(Stance.Crouch, SURF_MOVEMENT)).toBeCloseTo(28 * 0.0254 - 18 * 0.0254)
+
+ // Source values are opt-in by style: the sandbox keeps its current hull.
+ expect(DEFAULT_MOVEMENT.stanceHull).toEqual([1.8, 1.2, 0.7])
+ expect(DEFAULT_MOVEMENT.stanceEye).toEqual([0.65, 0.38, 0.14])
+ })
})
})
@@ -9,9 +9,10 @@ import type { PlayerMoveState } from '../movement/simulate.js'
@@ -9,9 +9,10 @@ import type { PlayerMoveState } from '../movement/simulate.js'
* simulated input, and it returns what happened. No client time, no client
* simulated input, and it returns what happened. No client time, no client
* position and no wall clock participate in a run.
* position and no wall clock participate in a run.
*
*
- * Timing: the server ticks at 30 Hz, so a tick count alone is 33.3 ms coarse.
- * Every crossing is a slab intersection against the zone AABB, giving the
- * fraction of the segment at which it happened:
+ * Timing: the room ticks at its configured rate (100 Hz for surf, 30 Hz for
+ * the sandbox), and the run carries that rate. Every crossing is a slab
+ * intersection against the zone AABB, giving the fraction of the segment at
+ * which it happened:
*
*
* timeMs = (endTick - startTick + fracEnd - fracStart) * (1000 / tickRate)
* timeMs = (endTick - startTick + fracEnd - fracStart) * (1000 / tickRate)
*
*
@@ -6,6 +6,16 @@
@@ -6,6 +6,16 @@
export const SIM_TICK_RATE = 30
export const SIM_TICK_RATE = 30
export const SIM_DT = 1 / SIM_TICK_RATE
export const SIM_DT = 1 / SIM_TICK_RATE
+/**
+ * Native CS:S surf rate. Surf rooms simulate at this rate with the unscaled
+ * Source air cap (0.762 m/s per tick); other rooms stay at SIM_TICK_RATE.
+ * The process scheduler runs at least this fast, and each room's accumulator
+ * decides how many of its own fixed steps to take.
+ */
+export const SURF_TICK_RATE = 100
+/** Snapshots every N surf ticks → 25 Hz, inside Source's 20–30 Hz range. */
+export const SURF_SNAPSHOT_EVERY = 4
+
export class FixedTimestep {
export class FixedTimestep {
private accumulator = 0
private accumulator = 0
constructor(
constructor(