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)

OP OpenVibers committed 16 days ago · 8a878fd2b7 · 21 files, +333 −114

Changes

ddiff --git a/apps/client/src/game/localPlayer.ts b/apps/client/src/game/localPlayer.ts
iindex e48d14e..d00d8f4 100644
---- a/apps/client/src/game/localPlayer.ts
++++ b/apps/client/src/game/localPlayer.ts
@@@ -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) {
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex df7e8c7..37fc49c 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -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)
ddiff --git a/apps/client/src/render/entityView.ts b/apps/client/src/render/entityView.ts
iindex 8b703a3..9c1c200 100644
---- a/apps/client/src/render/entityView.ts
++++ b/apps/client/src/render/entityView.ts
@@@ -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 = null
private lastUpdateTime = 0
++ /** The room's movement preset, for remote-player mirror hulls and feet. */
++ private readonly movement: MovementParams
constructor(
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.
ddiff --git a/apps/client/src/render/firstPersonBody.ts b/apps/client/src/render/firstPersonBody.ts
iindex b352444..2869285 100644
---- a/apps/client/src/render/firstPersonBody.ts
++++ b/apps/client/src/render/firstPersonBody.ts
@@@ -66,7 +66,7 @@ export class FirstPersonBody {
const speed = Math.hypot(move.vel.x, move.vel.z)
const yaw = this.player.viewYaw
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
// pitches down so looking down shows your chest and legs from above
// instead of the inside of your own collar.
ddiff --git a/apps/client/src/render/topDownMarkers.ts b/apps/client/src/render/topDownMarkers.ts
iindex 894471c..95ca884 100644
---- a/apps/client/src/render/topDownMarkers.ts
++++ b/apps/client/src/render/topDownMarkers.ts
@@@ -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.viewYaw
for (const [id, e] of this.state.entities) {
if (e.kind !== 'player' || id === this.state.myEntityId) continue
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex edd3aa8..253f726 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -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',
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 6e87605..5dcffc7 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -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
ddiff --git a/apps/server/src/game/instanceHub.ts b/apps/server/src/game/instanceHub.ts
iindex 6c9b632..d29e861 100644
---- a/apps/server/src/game/instanceHub.ts
++++ b/apps/server/src/game/instanceHub.ts
@@@ -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)
}
ddiff --git a/apps/server/src/game/placeRouter.ts b/apps/server/src/game/placeRouter.ts
iindex 1a63de2..377c8a1 100644
---- a/apps/server/src/game/placeRouter.ts
++++ b/apps/server/src/game/placeRouter.ts
@@@ -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> {
ddiff --git a/apps/server/src/game/playerSession.ts b/apps/server/src/game/playerSession.ts
iindex 68249f2..47157ec 100644
---- a/apps/server/src/game/playerSession.ts
++++ b/apps/server/src/game/playerSession.ts
@@@ -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.z
return out
}
ddiff --git a/apps/server/src/game/surfMaps.test.ts b/apps/server/src/game/surfMaps.test.ts
iindex cf9fa72..a45a723 100644
---- a/apps/server/src/game/surfMaps.test.ts
++++ b/apps/server/src/game/surfMaps.test.ts
@@@ -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') {
ddiff --git a/apps/server/src/game/surfRules.test.ts b/apps/server/src/game/surfRules.test.ts
iindex 626b318..8ffc515 100644
---- a/apps/server/src/game/surfRules.test.ts
++++ b/apps/server/src/game/surfRules.test.ts
@@@ -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)
++ })
++})
ddiff --git a/apps/server/src/game/systems/movement.ts b/apps/server/src/game/systems/movement.ts
iindex 2d30454..3f861a5 100644
---- a/apps/server/src/game/systems/movement.ts
++++ b/apps/server/src/game/systems/movement.ts
@@@ -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 = 0
let simulated = 0
-- for (const input of takeInputs(session)) {
++ for (const input of takeInputs(session, ctx.config.tickRate)) {
session.lastInput = input
session.starvedTicks = 0
session.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',
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 51f2dca..2bf6bb1 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -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 / 1000
let nextTick = performance.now()
let running = true
const loop = (): void => {
if (!running) return
const 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.
ddiff --git a/apps/server/src/observability/readiness.ts b/apps/server/src/observability/readiness.ts
iindex ac09821..2093fa5 100644
---- a/apps/server/src/observability/readiness.ts
++++ b/apps/server/src/observability/readiness.ts
@@@ -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
ddiff --git a/packages/gameplay/src/movement/params.ts b/packages/gameplay/src/movement/params.ts
iindex 1aad03a..1be0fe2 100644
---- a/packages/gameplay/src/movement/params.ts
++++ b/packages/gameplay/src/movement/params.ts
@@@ -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,
ddiff --git a/packages/gameplay/src/movement/simulate.ts b/packages/gameplay/src/movement/simulate.ts
iindex b47949c..4d5ef54 100644
---- a/packages/gameplay/src/movement/simulate.ts
++++ b/packages/gameplay/src/movement/simulate.ts
@@@ -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.Stand
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) {
// 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
ddiff --git a/packages/gameplay/src/movement/styles.ts b/packages/gameplay/src/movement/styles.ts
iindex 0624d82..2452ee6 100644
---- a/packages/gameplay/src/movement/styles.ts
++++ b/packages/gameplay/src/movement/styles.ts
@@@ -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
ddiff --git a/packages/gameplay/src/movement/surf.test.ts b/packages/gameplay/src/movement/surf.test.ts
iindex 85632b9..9ea708b 100644
---- a/packages/gameplay/src/movement/surf.test.ts
++++ b/packages/gameplay/src/movement/surf.test.ts
@@@ -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 - 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
const 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 * RAD
const 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 = 0
state.vel.y = 0
state.vel.z = 10
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(
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])
++ })
})
ddiff --git a/packages/gameplay/src/surf/run.ts b/packages/gameplay/src/surf/run.ts
iindex c8e9e5c..d175653 100644
---- a/packages/gameplay/src/surf/run.ts
++++ b/packages/gameplay/src/surf/run.ts
@@@ -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)
*
ddiff --git a/packages/shared/src/tick.ts b/packages/shared/src/tick.ts
iindex 71b29dc..c5f47a8 100644
---- a/packages/shared/src/tick.ts
++++ b/packages/shared/src/tick.ts
@@@ -6,6 +6,16 @@
export const SIM_TICK_RATE = 30
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 {
private accumulator = 0
constructor(