openvibers/openvibe.games Commits

Add enter/exit hysteresis to region interest so boundary entities stop churning spawn/despawn (#9)

OP OpenVibers committed 5 Oct 2026 · c1cbc1a1ed · 5 files, +166 −9

- `apps/server/src/config.ts:62` — added `interestHysteresis: number` to `ServerConfig`.
- `apps/server/src/config.ts:116` — added exported `INTEREST_HYSTERESIS = 0.1` (exit = enter × (1+H)).
- `apps/server/src/config.ts:177` — set `interestHysteresis` beside `interestRadius` in `loadConfig`.
- `apps/server/src/game/replication.ts:10,14` — import `INTEREST_HYSTERESIS` from config and re-export it.
- `apps/server/src/game/replication.ts:99-118` — `updateInterest` now candidates out to `exitRadius = radius*(1+hysteresis)`, admits only inside `radiusSq`, and retains already-known entities up to `exitRadiusSq`.
- `apps/server/src/game/systems/replication.ts:116,122` — `broadcastSpawn`'s direct-distance path named `enterRadiusSq` and documented as the enter radius.
- `apps/server/src/game/systems/replication.ts:160-164` — `replicate` passes `ctx.config.interestHysteresis` alongside `interestRadius`.
- `apps/server/src/game/replication.test.ts:1` — new tests: 0.9·r enters, retained at 1.05·r and exactly 1.1·r, leaves past 1.1·r, oscillation in the band yields zero further spawn/despawn.
- `test/apps__server__src__game__replication.test.js:1` — generated pointer (test/run.js's index format) so the runner/CI sees the new test.

Where it differed from the read:
- `updateInterest` was at :89 as stated, but its body was :94-111, not :92-115; `broadcastSpawn`'s direct-distance loop was :109-125 (check at :114-120), not :114-121. `config.ts:166` for `interestRadius` matched.
- `broadcastSpawn` already used the enter radius (`config.interestRadius`), so the "fix" was making that intent explicit rather than a numeric change — the described exit-band bug was not present there.
- `ov test <full .ts paths>` doesn't work here: ds-testsel matches against the generated `test/*.test.js` pointer basenames, and the new test had no pointer. I created the pointer in the exact `--index` format and ran `ov test --no-cache replication platformIntegration`.

Test results: `✓ test/apps__server__src__game__replication.test.js` and `✓ test/apps__server__src__game__platformIntegration.test.js` — 2 passed in 7 s (I also fail-probed the new test to confirm it truly executes, then reverted). No commit or push; only the three modified sources plus the two new untracked files above.

Changes

ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex 527a731..f15812e 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -58,6 +58,8 @@ export interface ServerConfig {
snapshotEvery: number
/** Entities beyond this distance from a player are not replicated to them. */
interestRadius: number
++ /** Fraction the interest exit radius exceeds the enter radius (spawn/despawn hysteresis). */
++ interestHysteresis: number
maxPlayers: number
persistFlushSeconds: number
metricsLogSeconds: number
@@@ -105,6 +107,14 @@ function trimSlash(v: string): string {
return v.replace(/\/+$/, '')
}
++/**
++ * Fraction the interest exit radius exceeds the enter radius. A client keeps
++ * an entity it already knows while it stays inside `interestRadius * (1 + H)`,
++ * so an entity loitering on the interest boundary cannot churn spawn/despawn
++ * every snapshot; only a fresh entity must come inside `interestRadius`.
++ */
++export const INTEREST_HYSTERESIS = 0.1
++
export function loadPlatformConfig(env: NodeJS.ProcessEnv): PlatformConfig {
const secret = env.OV_OAUTH_CLIENT_SECRET ?? null
return {
@@@ -164,6 +174,7 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
tickRate: 30,
snapshotEvery: 2,
interestRadius: intEnv(env, 'INTEREST_RADIUS', 80),
++ interestHysteresis: INTEREST_HYSTERESIS,
maxPlayers: intEnv(env, 'MAX_PLAYERS', 64),
persistFlushSeconds: intEnv(env, 'PERSIST_FLUSH_SECONDS', 10),
metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),
ddiff --git a/apps/server/src/game/replication.test.ts b/apps/server/src/game/replication.test.ts
nnew file mode 100644
iindex 0000000..bcbce6f
---- /dev/null
++++ b/apps/server/src/game/replication.test.ts
@@@ -0,0 +1,120 @@
++/**
++ * Interest hysteresis (replication tick). A body only ENTERS a client's
++ * interest inside `radius`, but an already-known body is retained out to
++ * `radius * (1 + INTEREST_HYSTERESIS)` — so a body loitering on the boundary
++ * cannot churn spawn/despawn every snapshot.
++ *
++ * updateInterest touches only world.spatial, world.entities and the session's
++ * position/known set, so the world and session here are minimal stand-ins.
++ */
++import { EntityStore, SpatialHash, type GameEntity } from '@openvibe/gameplay'
++import { quat, vec3, type EntityId } from '@openvibe/shared'
++import { describe, expect, it } from 'vitest'
++import type { GameWorld } from './gameWorld.js'
++import type { PlayerSession } from './playerSession.js'
++import { INTEREST_HYSTERESIS, updateInterest } from './replication.js'
++
++const RADIUS = 80
++const EXIT = RADIUS * (1 + INTEREST_HYSTERESIS)
++
++function makeWorld(): GameWorld {
++ return {
++ spatial: new SpatialHash<EntityId>(16),
++ entities: new EntityStore(),
++ } as unknown as GameWorld
++}
++
++function makeSession(): PlayerSession {
++ return {
++ entityId: 'p1' as EntityId,
++ move: { pos: vec3(0, 0, 0) },
++ known: new Set<EntityId>(),
++ } as unknown as PlayerSession
++}
++
++let nextId = 0
++/** A static prop at (x, 0); moveTo keeps the spatial hash in step. */
++function addBody(world: GameWorld, x: number): { id: EntityId; moveTo: (x: number) => void } {
++ const id = `e${++nextId}` as EntityId
++ const entity: GameEntity = {
++ id,
++ kind: 'prop',
++ transform: { pos: vec3(x, 0, 0), rot: quat() },
++ persistent: false,
++ dirty: false,
++ }
++ world.entities.add(entity)
++ world.spatial.insert(id, x, 0)
++ return {
++ id,
++ moveTo: (nx: number) => {
++ entity.transform.pos.x = nx
++ world.spatial.move(id, nx, 0)
++ },
++ }
++}
++
++describe('interest hysteresis', () => {
++ it('enters inside the radius and is retained past it up to the exit radius', () => {
++ const world = makeWorld()
++ const session = makeSession()
++ const body = addBody(world, 0.9 * RADIUS)
++
++ const entering = updateInterest(session, world, RADIUS)
++ expect(entering.entered.map((e) => e.id)).toEqual([body.id])
++ expect(entering.left).toEqual([])
++ expect(session.known.has(body.id)).toBe(true)
++
++ // Inside the hysteresis band: no re-spawn and no despawn.
++ body.moveTo(1.05 * RADIUS)
++ const retained = updateInterest(session, world, RADIUS)
++ expect(retained.entered).toEqual([])
++ expect(retained.left).toEqual([])
++ expect(session.known.has(body.id)).toBe(true)
++
++ // Exactly at the exit radius is still retained.
++ body.moveTo(EXIT)
++ const atEdge = updateInterest(session, world, RADIUS)
++ expect(atEdge.entered).toEqual([])
++ expect(atEdge.left).toEqual([])
++ expect(session.known.has(body.id)).toBe(true)
++ })
++
++ it('leaves only past the exit radius', () => {
++ const world = makeWorld()
++ const session = makeSession()
++ const body = addBody(world, 0.9 * RADIUS)
++ updateInterest(session, world, RADIUS)
++
++ body.moveTo(EXIT + 1)
++ const leaving = updateInterest(session, world, RADIUS)
++ expect(leaving.entered).toEqual([])
++ expect(leaving.left).toEqual([body.id])
++ expect(session.known.has(body.id)).toBe(false)
++ })
++
++ it('does not spawn/despawn a body oscillating in the band', () => {
++ const world = makeWorld()
++ const session = makeSession()
++ const body = addBody(world, 0.9 * RADIUS)
++ const first = updateInterest(session, world, RADIUS)
++ expect(first.entered.map((e) => e.id)).toEqual([body.id])
++
++ let spawns = 0
++ let despawns = 0
++ for (let i = 0; i < 12; i++) {
++ // Cross back and forth over the enter radius, never past the exit one.
++ body.moveTo(i % 2 === 0 ? 1.02 * RADIUS : 0.98 * RADIUS)
++ const diff = updateInterest(session, world, RADIUS)
++ spawns += diff.entered.length
++ despawns += diff.left.length
++ }
++ expect(spawns).toBe(0)
++ expect(despawns).toBe(0)
++ expect(session.known.has(body.id)).toBe(true)
++ })
++
++ it('exports the enter radius framing as INTEREST_HYSTERESIS', () => {
++ expect(INTEREST_HYSTERESIS).toBeGreaterThan(0)
++ })
++})
ddiff --git a/apps/server/src/game/replication.ts b/apps/server/src/game/replication.ts
iindex d081c53..9a40793 100644
---- a/apps/server/src/game/replication.ts
++++ b/apps/server/src/game/replication.ts
@@@ -7,9 +7,12 @@ import type {
WirePlayerState,
} from '@openvibe/protocol'
import { v3distSq, type EntityId } from '@openvibe/shared'
++import { INTEREST_HYSTERESIS } from '../config.js'
import type { GameWorld } from './gameWorld.js'
import type { PlayerSession } from './playerSession.js'
++export { INTEREST_HYSTERESIS }
++
/**
* Interest management + snapshot building.
*
@@@ -85,23 +88,36 @@ export interface InterestDiff {
left: EntityId[]
}
--/** Updates session.known in place and returns what changed. */
++/**
++ * Updates session.known in place and returns what changed.
++ *
++ * `radius` is the ENTER radius. A body must come inside it to be replicated,
++ * but an already-known body is retained out to exitRadius = radius * (1 + H).
++ * The gap is hysteresis: without it a body sitting on the boundary flips
++ * in/out every snapshot, churning spawn/despawn on the wire.
++ */
export function updateInterest(
session: PlayerSession,
world: GameWorld,
radius: number,
++ hysteresis: number = INTEREST_HYSTERESIS,
): InterestDiff {
const radiusSq = radius * radius
++ const exitRadius = radius * (1 + hysteresis)
++ const exitRadiusSq = exitRadius * exitRadius
const entered: GameEntity[] = []
const current = new Set<EntityId>()
-- // Spatial hash prunes candidates; the exact 3D distance test decides.
-- world.spatial.forEachInRadius(session.move.pos.x, session.move.pos.z, radius, (id) => {
++ // Candidates out to the exit radius; the exact 3D distance test decides.
++ world.spatial.forEachInRadius(session.move.pos.x, session.move.pos.z, exitRadius, (id) => {
if (id === session.entityId) return
const entity = world.entities.get(id)
if (!entity) return
-- if (v3distSq(entity.transform.pos, session.move.pos) > radiusSq) return
++ const known = session.known.has(entity.id)
++ const d2 = v3distSq(entity.transform.pos, session.move.pos)
++ // Enter inside the enter radius; stay while inside the exit radius.
++ if (d2 > (known ? exitRadiusSq : radiusSq)) return
current.add(entity.id)
-- if (!session.known.has(entity.id)) entered.push(entity)
++ if (!known) entered.push(entity)
})
const left: EntityId[] = []
for (const id of session.known) {
ddiff --git a/apps/server/src/game/systems/replication.ts b/apps/server/src/game/systems/replication.ts
iindex 24d2a4e..39a4243 100644
---- a/apps/server/src/game/systems/replication.ts
++++ b/apps/server/src/game/systems/replication.ts
@@@ -108,16 +108,18 @@ export class ReplicationSystem implements System, Net {
broadcastSpawn(entity: GameEntity): void {
// Deliver immediately to sessions in range; interest diff would send it
-- // next snapshot anyway, but placement feedback should be instant.
++ // next snapshot anyway, but placement feedback should be instant. Use the
++ // ENTER radius (not the wider exit band) so this matches what
++ // updateInterest will admit on the next tick.
const wire = wireEntityFor(this.ctx.world, entity)
const encoded = encodeServerMessage({ t: 'spawn', entities: [wire] })
-- const radiusSq = this.ctx.config.interestRadius ** 2
++ const enterRadiusSq = this.ctx.config.interestRadius ** 2
for (const session of this.ctx.sessions.values()) {
const d2 =
(entity.transform.pos.x - session.move.pos.x) ** 2 +
(entity.transform.pos.y - session.move.pos.y) ** 2 +
(entity.transform.pos.z - session.move.pos.z) ** 2
-- if (d2 <= radiusSq) {
++ if (d2 <= enterRadiusSq) {
session.known.add(entity.id)
this.sendRaw(session, encoded)
}
@@@ -155,7 +157,12 @@ export class ReplicationSystem implements System, Net {
replicate(): void {
const ctx = this.ctx
for (const session of ctx.sessions.values()) {
-- const diff = updateInterest(session, ctx.world, ctx.config.interestRadius)
++ const diff = updateInterest(
++ session,
++ ctx.world,
++ ctx.config.interestRadius,
++ ctx.config.interestHysteresis,
++ )
if (diff.entered.length > 0) {
this.send(session, {
t: 'spawn',
ddiff --git a/test/apps__server__src__game__replication.test.js b/test/apps__server__src__game__replication.test.js
nnew file mode 100644
iindex 0000000..0c6d3de
---- /dev/null
++++ b/test/apps__server__src__game__replication.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/game/replication.test.ts'
++// covers: 'apps/server/src/game'
++// Generated by node test/run.js --index; do not edit.