openvibers/openvibe.games Commits

Games M4: instances per place, fenced leases, room transfer, region interest (one process) (#45)

OP OpenVibers committed 12 Oct 2026 · d4a08261cb · 48 files, +1576 −197

**Milestone M4: instances and scale, done for one process** (ADR-0007).

- **Instances:** a place can run several rooms (`GAMES_INSTANCES_PER_PLACE`, **default 1**, so production behaves exactly as today). The Players tab lists rooms when there is more than one.
- **Registry and leases:** room slots `placeId:slot` in PostgreSQL `game_instances` (migration 0003, expand): a boot lease, then running, draining and stopped, renewed by heartbeat. A new holder bumps the version, and a former holder's writes are fenced (tested: it cannot write world state after another holder claims its slot).
- **Transfer:** a validated `transfer` intent to another room of the same place, with per-room positions and shared character inventory. Protocol v21 clients stay supported alongside v22.
- **Region interest:** configurable grid cells and radius, with spawn and despawn hysteresis.
- **Load probe:** `node --import tsx apps/server/scripts/instanceLoad.ts 4 [--baseline]` joins clients and drives movement, transfer and leave amid 400 spread entities, socketless. With the default radius, bytes per client stay about the same (73,284 baseline, 72,464 now); the saving shows when the radius is set below the map size.

Not done here: cross-process and multi-host routing, signed handoff tokens over Events, and region routing across hosts. PostgreSQL leases replace the Valkey leases first proposed.

Built by Codex, which stopped during its final build at its usage limit. I verified it: typecheck, lint and build pass, prettier is fixed on two files, the 36 covering test files pass (instanceHub, the registry's two PostgreSQL tests via vitest, bundle budget), and the load probe runs.

Changes

ddiff --git a/.env.example b/.env.example
iindex 4980153..7102e02 100644
---- a/.env.example
++++ b/.env.example
@@@ -11,6 +11,12 @@ DATABASE_DIR=data
GAMES_MIGRATIONS_DIR=
# The place this instance serves; world rows and meta are keyed by it.
GAMES_PLACE_ID=scraplandia
++# In-process rooms for this place; 1 preserves the existing single-room behavior.
++GAMES_INSTANCES_PER_PLACE=1
++# Per-room player capacity and replication grid/radius (metres).
++MAX_PLAYERS=64
++GAMES_INTEREST_CELL=16
++GAMES_INTEREST_RADIUS=80
# ── Valkey (optional): shared per-actor rate-limit counters across instances ──
VALKEY_URL=
ddiff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
iindex 6b85111..18b05d9 100644
---- a/ARCHITECTURE.md
++++ b/ARCHITECTURE.md
@@@ -69,10 +69,10 @@ scene or engine, so there is no Node-only path to keep separate.
zod-validated (range-clamped axes, size caps) and malformed traffic
disconnects. JSON encoding today behind an encode/decode boundary sized for
a binary codec swap.
--- **Interest management:** per-session known-entity sets built from a radius
-- query (the query is one function — the future spatial region index replaces
-- its internals, not its callers). Spawn/despawn diffs flow from interest
-- changes; snapshots contain only relevant players + _awake_ relevant bodies.
++- **Interest management:** per-session cell subscriptions and known-entity
++ sets built from the spatial hash. A precise radius check filters each cell's
++ candidates; crossing cells sends spawn/despawn differences with exit-radius
++ hysteresis. Snapshots contain only relevant players + _awake_ relevant bodies.
- **Sleep networking:** when a prop settles, one final `entity` pin is
broadcast and it drops out of snapshots; on wake it re-enters. Settled
props also mark themselves dirty exactly once for the persistence flush.
@@@ -111,6 +111,12 @@ closes. Batch writes are multi-row `INSERT … ON CONFLICT` in chunks of at most
seeds from the world definition; afterwards the database is the world's source of truth. The
simulation never reads the platform tables.
++M4 runs several isolated simulations of the same place in one process. A PostgreSQL
++`game_instances` claim has a renewable lease version; state writes lock and verify
++that claim in their transaction. Later rooms keep separate world rows while
++characters and their inventory remain shared. Each character keeps a last
++position per room. See `docs/instances.md` for transfer and the process limit.
++
## Platform boundary
Adapters to the OpenVibe platform live in `apps/server/src/platform` and
ddiff --git a/README.md b/README.md
iindex 83a0359..0aa514c 100644
---- a/README.md
++++ b/README.md
@@@ -115,6 +115,10 @@ an embedded PGlite database under `DATABASE_DIR` (default `data/`) — developme
`GAMES_PLACE_ID` (default `scraplandia`) is the place every world row belongs to; `VALKEY_URL`
(optional) makes the per-actor limits and the WebSocket upgrade tickets shared across instances
(issue is `SET … PX … NX`, consume is `GETDEL`, so a ticket is single-use cluster-wide).
++`GAMES_INSTANCES_PER_PLACE` (default `1`) enables several room simulations for a place in this process;
++`MAX_PLAYERS` is per-room capacity. PostgreSQL records each room and its renewable fenced lease.
++`GAMES_INTEREST_CELL` (default `16`) and `GAMES_INTEREST_RADIUS` (default `80`) tune spatial replication.
++See [docs/instances.md](docs/instances.md) for transfer, lease expiry and the one-process limit.
`TRUST_PROXY` (optional) says which proxies may set the client-address headers: unset means the TCP
peer is the client and `X-Forwarded-For`/`X-Real-IP`/`CF-Connecting-IP` are ignored (they are
client-supplied and would otherwise let a caller mint fresh rate-limit buckets); `true` means the
ddiff --git a/ROADMAP.md b/ROADMAP.md
iindex e4d2838..14418df 100644
---- a/ROADMAP.md
++++ b/ROADMAP.md
@@@ -135,6 +135,10 @@ with live world reconciliation.
## Phase 11 — Scale 🔜 next (Stage 12)
++ADR-0007 M4 now covers several rooms of one place in one process, PostgreSQL
++leases, in-process transfer, cell interest and a socketless load probe. This
++phase still covers streaming, multi-host routing and larger load profiles.
++
- Region persistence/streaming when the authored world justifies it
- Network measurement first (bytes/client, snapshot sizes, entity counts);
binary snapshot codec only when measurements demand it
ddiff --git a/STATUS.json b/STATUS.json
iindex d87c63b..72fd1da 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,7 +6,7 @@
"runtime": "node 22, TypeScript (tsx), pnpm workspace; Babylon.js (client) + deterministic Rapier physics; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in development); Valkey optional",
"port": 8000,
"domain": "openvibe.games, play.openvibe.games",
-- "contracts": "openvibe-contracts v0.129.0, openvibe-sdk v0.38.0, openvibe-shared v3.0.0 (apps/server/package.json)",
++ "contracts": "openvibe-contracts v0.142.0, openvibe-sdk v0.44.0, openvibe-shared v4.0.0 (apps/server/package.json)",
"capabilities": [
"games.mod.manage",
"games.mod.read",
@@@ -25,6 +25,7 @@
"authoritative multiplayer physics sandbox (deterministic Rapier on the server)",
"Source-style movement, physgun building, gathering, crafting, skills",
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
++ "M4 in working tree (not deployed): one-process rooms, PostgreSQL instance registry and fenced leases, capacity-based join, same-place transfer, per-room position, grid-cell interest and socketless load probe",
"map editor with assets mirrored to OpenVibe.Media",
"declarative mods (games-content@2: definitions merged into the content registry at boot, placements, announcements) with per-install grants and audit",
"server script mods (games-quickjs@1): isolated QuickJS sandboxes, per-binding grants, per-mod/place PostgreSQL storage, seeded randomness, multi-file imports, staff installs and reload, active definition delivery to new clients; off unless GAMES_SCRIPT_MODS=1",
ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex d89fbcf..9b026e0 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -347,13 +347,30 @@
gap: 6px 16px;
padding: 12px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
-- font: 500 14px/1.4 system-ui, sans-serif;
++ font:
++ 500 14px/1.4 system-ui,
++ sans-serif;
++ }
++ .ov-nav-fallback a {
++ color: inherit;
++ text-decoration: none;
++ opacity: 0.85;
++ }
++ .ov-nav-fallback a:hover,
++ .ov-nav-fallback a:focus-visible {
++ opacity: 1;
++ text-decoration: underline;
++ }
++ .ov-nav-fallback .home {
++ font-weight: 700;
++ opacity: 1;
++ }
++ .ov-nav-fallback .spacer {
++ flex: 1;
++ }
++ #navbar-mount:not(:empty) + .ov-nav-fallback {
++ display: none;
}
-- .ov-nav-fallback a { color: inherit; text-decoration: none; opacity: 0.85; }
-- .ov-nav-fallback a:hover, .ov-nav-fallback a:focus-visible { opacity: 1; text-decoration: underline; }
-- .ov-nav-fallback .home { font-weight: 700; opacity: 1; }
-- .ov-nav-fallback .spacer { flex: 1; }
-- #navbar-mount:not(:empty) + .ov-nav-fallback { display: none; }
</style>
</head>
<body>
@@@ -375,9 +392,9 @@
<header>
<h1 class="hero-title">Open<b>Vibe.Games</b></h1>
<p class="hero-sub">
-- Multiplayer games that start in a browser tab. No installs, no launchers. Jump in as a guest,
-- or sign in with your OpenVibe account to keep your character, your base and your name across
-- the whole network.
++ Multiplayer games that start in a browser tab. No installs, no launchers. Jump in as a
++ guest, or sign in with your OpenVibe account to keep your character, your base and your name
++ across the whole network.
</p>
</header>
@@@ -404,7 +421,6 @@
>
</div>
</div>
--
</section>
<section class="account">
@@@ -423,10 +439,30 @@
<!-- What shipped on OpenVibe.Games: the network's shared "shipped" views (openvibe-shared
shipped.js, which the shared footer loads), the same on every OpenVibe site. -->
-- <section class="ov-shipped-home" aria-label="What shipped" style="max-width:1100px;margin:0 auto;padding:0 20px">
-- <a data-ov-shipped="latest" data-service="games" href="https://openvibe.network/updates?site=games" hidden></a>
-- <div data-ov-shipped="list" data-service="games" data-limit="5" data-more="https://openvibe.network/updates?site=games" data-title="Recently shipped on OpenVibe.Games" hidden></div>
-- <noscript><p><a href="https://openvibe.network/updates?site=games">What shipped recently</a></p></noscript>
++ <section
++ class="ov-shipped-home"
++ aria-label="What shipped"
++ style="max-width: 1100px; margin: 0 auto; padding: 0 20px"
++ >
++ <a
++ data-ov-shipped="latest"
++ data-service="games"
++ href="https://openvibe.network/updates?site=games"
++ hidden
++ ></a>
++ <div
++ data-ov-shipped="list"
++ data-service="games"
++ data-limit="5"
++ data-more="https://openvibe.network/updates?site=games"
++ data-title="Recently shipped on OpenVibe.Games"
++ hidden
++ ></div>
++ <noscript
++ ><p>
++ <a href="https://openvibe.network/updates?site=games">What shipped recently</a>
++ </p></noscript
++ >
</section>
<!-- Shared cross-site footer (/shared/footer.js, this site's pinned copy): network links, legal, signed-in row. -->
ddiff --git a/apps/client/src/game/localPlayer.ts b/apps/client/src/game/localPlayer.ts
iindex c976b81..2ce91a7 100644
---- a/apps/client/src/game/localPlayer.ts
++++ b/apps/client/src/game/localPlayer.ts
@@@ -52,6 +52,14 @@ export class LocalPlayer {
* EYES glide (Source's smoothed stair climb). */
private stepOffset = 0
++ /** New room: old prediction commands belong to the previous simulation. */
++ onTransfer(): void {
++ this.pending = []
++ this.seq = 0
++ this.corr.setAll(0)
++ this.stepOffset = 0
++ }
++
constructor(
scene: Scene,
private readonly physics: PhysicsWorld,
ddiff --git a/apps/client/src/main.ts b/apps/client/src/main.ts
iindex 0437103..73f5a17 100644
---- a/apps/client/src/main.ts
++++ b/apps/client/src/main.ts
@@@ -343,7 +343,18 @@ async function start(): Promise<void> {
})
let rejected = false
connection.onMessage = (msg) => {
++ if (msg.t === 'welcome' && state.myEntityId && msg.instanceId !== state.instanceId) {
++ player.onTransfer()
++ }
state.apply(msg)
++ if (msg.t === 'transfer_result') {
++ hud.toast(
++ msg.accepted
++ ? `Moved to ${msg.instanceId}`
++ : `Transfer refused: ${msg.reason ?? 'unavailable'}`,
++ !msg.accepted,
++ )
++ }
if (msg.t === 'map_reload') {
void (async () => {
try {
ddiff --git a/apps/client/src/state/clientState.test.ts b/apps/client/src/state/clientState.test.ts
nnew file mode 100644
iindex 0000000..95b69fa
---- /dev/null
++++ b/apps/client/src/state/clientState.test.ts
@@@ -0,0 +1,37 @@
++import { expect, it } from 'vitest'
++import { ClientState } from './clientState.js'
++
++it('clears old room entities on a second welcome and keeps them on a same-room welcome', () => {
++ const state = new ClientState()
++ const welcome = (instanceId: string) => ({
++ t: 'welcome' as const,
++ v: 22,
++ contentDigest: 'v2-1234abcd',
++ rank: null,
++ playerId: 'p',
++ entityId: 'self',
++ tick: 0,
++ tickRate: 30,
++ snapshotRate: 15,
++ instanceId,
++ instanceCount: 2,
++ })
++ const entity = {
++ id: 'prop',
++ kind: 'prop' as const,
++ def: 'scrap',
++ motion: 'static' as const,
++ pos: [0, 0, 0] as [number, number, number],
++ rot: [0, 0, 0, 1] as [number, number, number, number],
++ }
++ const removed: string[] = []
++ state.events.on('entityRemoved', (id) => removed.push(id))
++ state.apply(welcome('scraplandia:0'))
++ state.apply({ t: 'spawn', entities: [entity] })
++ state.apply(welcome('scraplandia:0'))
++ expect(state.entities.has('prop')).toBe(true)
++ state.apply(welcome('scraplandia:1'))
++ expect(state.entities.size).toBe(0)
++ expect(removed).toEqual(['prop'])
++ expect(state.instanceId).toBe('scraplandia:1')
++})
ddiff --git a/apps/client/src/state/clientState.ts b/apps/client/src/state/clientState.ts
iindex 93caec9..8f62dc5 100644
---- a/apps/client/src/state/clientState.ts
++++ b/apps/client/src/state/clientState.ts
@@@ -61,6 +61,8 @@ export class ClientState {
/** OpenVibe rank from welcome (owner/admin/moderator, null = player). */
myRank: 'owner' | 'admin' | 'moderator' | null = null
myPlayerId = ''
++ instanceId: string | null = null
++ instanceCount = 1
/** Player ids I trust with my props. */
friends: { id: string; name: string }[] = []
tickRate = 30
@@@ -101,6 +103,15 @@ export class ClientState {
apply(msg: ServerMessage): void {
switch (msg.t) {
case 'welcome':
++ if (this.myEntityId && msg.instanceId && msg.instanceId !== this.instanceId) {
++ for (const id of this.entities.keys()) this.events.emit('entityRemoved', id)
++ this.entities.clear()
++ this.constraints.clear()
++ this.heldBy.clear()
++ this.heldGrab.clear()
++ }
++ this.instanceId = msg.instanceId ?? null
++ this.instanceCount = msg.instanceCount ?? 1
this.myEntityId = msg.entityId
this.myPlayerId = msg.playerId
this.myRank = msg.rank
@@@ -109,6 +120,8 @@ export class ClientState {
this.serverTick = msg.tick
this.events.emit('welcome', { entityId: msg.entityId, tickRate: msg.tickRate })
break
++ case 'transfer_result':
++ break
case 'reject':
break
case 'spawn':
ddiff --git a/apps/client/src/ui/hud.ts b/apps/client/src/ui/hud.ts
iindex 10e3fc3..261ceb5 100644
---- a/apps/client/src/ui/hud.ts
++++ b/apps/client/src/ui/hud.ts
@@@ -63,6 +63,9 @@ export class Hud {
state.events.on('craftJobs', () => this.renderMenu())
state.events.on('skills', () => this.renderMenu())
state.events.on('friendsChanged', () => this.renderMenu())
++ state.events.on('welcome', () => {
++ if (this.activeTab === 'players') this.renderMenu()
++ })
state.events.on('entityAdded', () => {
if (this.activeTab === 'players') this.renderMenu()
})
@@@ -941,6 +944,24 @@ export class Hud {
private renderPlayers(): void {
const wrap = document.createElement('div')
++ if (this.state.instanceId && this.state.instanceCount > 1) {
++ const title = document.createElement('div')
++ title.className = 'hint-line'
++ title.textContent = `Current room: ${this.state.instanceId}`
++ wrap.appendChild(title)
++ const prefix = this.state.instanceId.slice(0, this.state.instanceId.lastIndexOf(':'))
++ for (let slot = 0; slot < this.state.instanceCount; slot++) {
++ const id = `${prefix}:${slot}`
++ const button = document.createElement('button')
++ button.className = 'trust-btn'
++ button.textContent = `Room ${slot + 1}`
++ button.disabled = id === this.state.instanceId
++ button.addEventListener('click', () =>
++ this.connection.send({ t: 'transfer', instanceId: id }),
++ )
++ wrap.appendChild(button)
++ }
++ }
const hint = document.createElement('div')
hint.className = 'hint-line'
hint.textContent = 'Trusted players can move and unfreeze your props.'
ddiff --git a/apps/server/scripts/instanceLoad.ts b/apps/server/scripts/instanceLoad.ts
nnew file mode 100644
iindex 0000000..670de53
---- /dev/null
++++ b/apps/server/scripts/instanceLoad.ts
@@@ -0,0 +1,184 @@
++/** Socketless M4 load probe. Run: pnpm exec tsx apps/server/scripts/instanceLoad.ts [--baseline] [N]. */
++import { performance } from 'node:perf_hooks'
++import { createContent } from '@openvibe/content'
++import { openPgStore } from '@openvibe/persistence'
++import { openTestDb } from '@openvibe/persistence/testing'
++import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
++import { PROTOCOL_VERSION, defaultAppearance, type ServerMessage } from '@openvibe/protocol'
++import { createConsoleLogger, quat, vec3, type EntityId } from '@openvibe/shared'
++import { loadConfig } from '../src/config.js'
++import { GameServer, type GameConnection } from '../src/game/gameServer.js'
++import { GameWorld } from '../src/game/gameWorld.js'
++import { ServerMetrics } from '../src/observability/metrics.js'
++import type { GameEntity } from '@openvibe/gameplay'
++
++const baseline = process.argv.includes('--baseline')
++const n = Number(process.argv.find((arg) => /^[0-9]+$/.test(arg)) ?? '4')
++if (!Number.isInteger(n) || n < 2 || n > 64) throw new Error('N must be 2..64')
++const log = createConsoleLogger({ app: 'instance-load' }, 'error')
++const db = await openTestDb()
++const content = createContent()
++const rapier = await loadRapier()
++const config = loadConfig({
++ GAMES_PLACE_ID: 'load-probe',
++ MAX_PLAYERS: String(n),
++ GAMES_INSTANCES_PER_PLACE: baseline ? '1' : '2',
++})
++const props = 400
++
++function populate(world: GameWorld): void {
++ for (let i = 0; i < props; i++) {
++ const x = ((i % 20) - 10) * 55
++ const z = (Math.floor(i / 20) - 10) * 55
++ const id = `probe-${i}` as EntityId
++ const entity: GameEntity = {
++ id,
++ kind: 'prop',
++ transform: { pos: vec3(x, 0, z), rot: quat() },
++ prop: { defId: 'scrap', motion: 'dynamic', lootCount: 1 },
++ persistent: false,
++ dirty: false,
++ }
++ world.entities.add(entity)
++ world.spatial.insert(id, x, z)
++ }
++}
++
++async function room(slot: number) {
++ const store = openPgStore(db, {
++ placeId: config.placeId,
++ ...(baseline ? {} : { instanceSlot: slot }),
++ })
++ await store.ensurePlace()
++ const physics = createRapierWorld(rapier)
++ const world = new GameWorld(content, physics, log, 16)
++ await world.seedOrRestore(store)
++ populate(world)
++ const game = new GameServer(
++ { ...config, instanceSlot: slot },
++ world,
++ store,
++ new ServerMetrics(),
++ log,
++ )
++ await game.load()
++ return { game, world, physics }
++}
++
++const primary = await room(0)
++const inboxes: ServerMessage[][] = []
++const conns: GameConnection[] = []
++const hellos = []
++let router: Pick<GameServer, 'onMessage' | 'onDisconnect'> & { step(): void }
++let afterRoom: Awaited<ReturnType<typeof room>> | null = null
++if (baseline) {
++ router = primary.game
++} else {
++ // Dynamic import keeps this script runnable against the M3 tree for the baseline.
++ const { InstanceHub } = await import('../src/game/instanceHub.js')
++ const { InstanceRegistry } =
++ await import('../../../packages/persistence/src/pg/instanceRegistry.js')
++ const registry = new InstanceRegistry(db, config.placeId)
++ const first = await registry.claim(0, n, 'load-primary', Date.now(), 60_000)
++ if (!first) throw new Error('claim failed')
++ const running = await registry.renew(first, Date.now(), 60_000, 'running')
++ if (!running) throw new Error('renew failed')
++ const hub = new InstanceHub(
++ registry,
++ { lease: running, ...primary, dispose: () => primary.physics.dispose() },
++ 2,
++ n,
++ async (slot, lease) => {
++ afterRoom = await room(slot)
++ return { lease, ...afterRoom, dispose: () => afterRoom?.physics.dispose() }
++ },
++ log,
++ )
++ router = hub
++}
++
++for (let i = 0; i < n; i++) {
++ const inbox: ServerMessage[] = []
++ const conn: GameConnection = {
++ identity: { guestKeyHash: i.toString(16).padStart(64, '0') },
++ send: (raw) => {
++ inbox.push(JSON.parse(raw) as ServerMessage)
++ },
++ close: () => router.onDisconnect(conn),
++ }
++ inboxes.push(inbox)
++ conns.push(conn)
++ const hello = {
++ t: 'hello' as const,
++ v: PROTOCOL_VERSION,
++ contentDigest: content.digest,
++ slot: 0,
++ name: `probe-${i}`,
++ appearance: defaultAppearance(),
++ }
++ hellos.push(hello)
++ router.onMessage(conn, hello)
++}
++async function until(check: () => boolean) {
++ for (let i = 0; i < 500; i++) {
++ if (check()) return
++ await new Promise((r) => setTimeout(r, 10))
++ }
++ throw new Error('load probe timed out')
++}
++await until(() => inboxes.every((inbox) => inbox.some((m) => m.t === 'welcome')))
++
++const bytesBefore = inboxes.map((inbox) =>
++ inbox.reduce((sum, msg) => sum + JSON.stringify(msg).length, 0),
++)
++const tickMs: number[] = []
++for (let tick = 1; tick <= 90; tick++) {
++ for (let i = 0; i < n; i++)
++ router.onMessage(conns[i]!, {
++ t: 'input',
++ seq: tick,
++ moveX: 0,
++ moveZ: tick % 30 < 15 ? 1 : -1,
++ yawQ: 0,
++ pitchQ: 0,
++ intents: 0,
++ })
++ await Promise.resolve()
++ const start = performance.now()
++ router.step()
++ tickMs.push(performance.now() - start)
++}
++const bytesAfter = inboxes.map((inbox) =>
++ inbox.reduce((sum, msg) => sum + JSON.stringify(msg).length, 0),
++)
++const bytesPerClient = bytesAfter.reduce((sum, bytes, i) => sum + bytes - bytesBefore[i]!, 0) / n
++const sorted = [...tickMs].sort((a, b) => a - b)
++let transferred = false
++if (!baseline) {
++ router.onMessage(conns[0]!, { t: 'transfer', instanceId: 'load-probe:1' })
++ await until(() => inboxes[0]!.some((m) => m.t === 'transfer_result' && m.accepted))
++ router.onMessage(conns[0]!, { t: 'transfer', instanceId: 'load-probe:0' })
++ await until(() => inboxes[0]!.filter((m) => m.t === 'transfer_result' && m.accepted).length === 2)
++ transferred = true
++}
++for (const conn of conns) router.onDisconnect(conn)
++await primary.game.drain()
++await afterRoom?.game.drain()
++primary.physics.dispose()
++afterRoom?.physics.dispose()
++console.log(
++ JSON.stringify(
++ {
++ mode: baseline ? 'M3 baseline' : 'M4',
++ clients: n,
++ entitiesPerRoom: props,
++ ticks: tickMs.length,
++ meanTickMs: tickMs.reduce((a, b) => a + b, 0) / tickMs.length,
++ p95TickMs: sorted[Math.floor(sorted.length * 0.95)],
++ bytesPerClient,
++ transferred,
++ },
++ null,
++ 2,
++ ),
++)
ddiff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts
iindex 59e1f69..194e53d 100644
---- a/apps/server/src/config.test.ts
++++ b/apps/server/src/config.test.ts
@@@ -69,6 +69,27 @@ describe('loadConfig scriptMods', () => {
})
})
++describe('instance and interest configuration', () => {
++ it('keeps one room and the existing grid and radius by default', () => {
++ expect(loadConfig({})).toMatchObject({
++ instancesPerPlace: 1,
++ interestCell: 16,
++ interestRadius: 80,
++ })
++ })
++ it('accepts positive overrides and refuses invalid sizes', () => {
++ expect(
++ loadConfig({
++ GAMES_INSTANCES_PER_PLACE: '3',
++ GAMES_INTEREST_CELL: '32',
++ GAMES_INTEREST_RADIUS: '96',
++ }),
++ ).toMatchObject({ instancesPerPlace: 3, interestCell: 32, interestRadius: 96 })
++ expect(() => loadConfig({ GAMES_INSTANCES_PER_PLACE: '0' })).toThrow()
++ expect(() => loadConfig({ GAMES_INTEREST_CELL: '-1' })).toThrow()
++ })
++})
++
describe('loadConfig indexnowKey', () => {
it('is empty (IndexNow off) unless INDEXNOW_KEY is set, and never carries padding', () => {
expect(loadConfig({}).indexnowKey).toBe('')
ddiff --git a/apps/server/src/config.ts b/apps/server/src/config.ts
iindex f15812e..e271d03 100644
---- a/apps/server/src/config.ts
++++ b/apps/server/src/config.ts
@@@ -60,6 +60,12 @@ export interface ServerConfig {
interestRadius: number
/** Fraction the interest exit radius exceeds the enter radius (spawn/despawn hysteresis). */
interestHysteresis: number
++ /** Ground-plane spatial index cell width (metres). */
++ interestCell: number
++ /** Maximum simultaneous instances of one place in this process. */
++ instancesPerPlace: number
++ /** Internal runtime slot; never read from the public environment. */
++ instanceSlot?: number
maxPlayers: number
persistFlushSeconds: number
metricsLogSeconds: number
@@@ -173,8 +179,14 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
indexnowKey: String(env.INDEXNOW_KEY ?? '').trim(),
tickRate: 30,
snapshotEvery: 2,
-- interestRadius: intEnv(env, 'INTEREST_RADIUS', 80),
++ interestRadius: positiveIntEnv(
++ env,
++ 'GAMES_INTEREST_RADIUS',
++ positiveIntEnv(env, 'INTEREST_RADIUS', 80),
++ ),
interestHysteresis: INTEREST_HYSTERESIS,
++ interestCell: positiveIntEnv(env, 'GAMES_INTEREST_CELL', 16),
++ instancesPerPlace: positiveIntEnv(env, 'GAMES_INSTANCES_PER_PLACE', 1),
maxPlayers: intEnv(env, 'MAX_PLAYERS', 64),
persistFlushSeconds: intEnv(env, 'PERSIST_FLUSH_SECONDS', 10),
metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),
@@@ -190,3 +202,9 @@ function intEnv(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
if (Number.isNaN(value)) throw new Error(`invalid ${key}: ${raw}`)
return value
}
++
++function positiveIntEnv(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
++ const value = intEnv(env, key, fallback)
++ if (value < 1) throw new Error(`invalid ${key}: ${env[key]}`)
++ return value
++}
ddiff --git a/apps/server/src/game/gameServer.ts b/apps/server/src/game/gameServer.ts
iindex 2c05508..743d05f 100644
---- a/apps/server/src/game/gameServer.ts
++++ b/apps/server/src/game/gameServer.ts
@@@ -1,6 +1,6 @@
import { createEnvironment } from '@openvibe/gameplay'
import type { PersistenceStore } from '@openvibe/persistence'
--import type { ClientMessage } from '@openvibe/protocol'
++import type { ClientHello, ClientMessage } from '@openvibe/protocol'
import type { Logger } from '@openvibe/shared'
import type { ServerConfig } from '../config.js'
import { nearbyWorkstationKinds } from './interactions.js'
@@@ -157,11 +157,20 @@ export class GameServer {
return this.ctx.clock.tick
}
++ get contentDigest(): string {
++ return this.ctx.world.content.digest
++ }
++
/** Players connected now (the readiness `sessions` check; a restart would disconnect them). */
onlineCount(): number {
return this.systems.sessions.onlineCount()
}
++ /** Await a socketless join, including its persistence read and def-set check. */
++ join(conn: GameConnection, hello: ClientHello): Promise<boolean> {
++ return this.systems.sessions.join(conn, hello)
++ }
++
/**
* The client message types this server dispatches, one handler each. Diagnostics, and the
* handler-table test that pins it against the protocol.
@@@ -227,6 +236,11 @@ export class GameServer {
return this.systems.persistence.drain(timeoutMs)
}
++ /** Queue an immediate write-behind checkpoint before a room handoff. */
++ checkpoint(): void {
++ this.systems.persistence.checkpoint()
++ }
++
/** Exposes crafting context for the client-facing recipe availability (welcome-time). */
workstationsNear(session: PlayerSession): ReadonlySet<string> {
return nearbyWorkstationKinds(session, this.ctx.world)
ddiff --git a/apps/server/src/game/gameWorld.ts b/apps/server/src/game/gameWorld.ts
iindex 555a5a2..0e13313 100644
---- a/apps/server/src/game/gameWorld.ts
++++ b/apps/server/src/game/gameWorld.ts
@@@ -91,13 +91,17 @@ export class GameWorld {
* power coupling, and NPC perception all consume this one structure.
* GameWorld maintains props/resources; the server maintains players.
*/
-- readonly spatial = new SpatialHash<EntityId>(16)
++ readonly spatial: SpatialHash<EntityId>
constructor(
readonly content: ContentRegistry,
readonly physics: PhysicsWorld,
private readonly log: Logger,
++ interestCell = 16,
) {
++ if (!Number.isFinite(interestCell) || interestCell <= 0)
++ throw new Error('invalid interest cell')
++ this.spatial = new SpatialHash<EntityId>(interestCell)
this.zones = new ZoneIndex(content.world.zones)
this.mapStatics = new MapStaticLayer(physics)
this.mapTerrain = new MapTerrainLayer(physics)
ddiff --git a/apps/server/src/game/instanceHub.test.ts b/apps/server/src/game/instanceHub.test.ts
nnew file mode 100644
iindex 0000000..a3fb2f0
---- /dev/null
++++ b/apps/server/src/game/instanceHub.test.ts
@@@ -0,0 +1,191 @@
++import { createContent } from '@openvibe/content'
++import type { GameEntity } from '@openvibe/gameplay'
++import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'
++import { openTestDb } from '@openvibe/persistence/testing'
++import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
++import { PROTOCOL_VERSION, defaultAppearance, type ServerMessage } from '@openvibe/protocol'
++import { createConsoleLogger, quat, vec3, type EntityId } from '@openvibe/shared'
++import { sql } from 'openvibe-sdk/db'
++import { expect, it } from 'vitest'
++import { loadConfig } from '../config.js'
++import { ServerMetrics } from '../observability/metrics.js'
++import { GameServer, type GameConnection } from './gameServer.js'
++import { GameWorld } from './gameWorld.js'
++import { InstanceHub, type InstanceRoom } from './instanceHub.js'
++
++const log = createConsoleLogger({ app: 'instance-test' }, 'error')
++const pause = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
++async function until(check: () => boolean): Promise<void> {
++ for (let i = 0; i < 300; i++) {
++ if (check()) return
++ await new Promise((r) => setTimeout(r, 10))
++ }
++ throw new Error('room operation timed out')
++}
++
++it('joins, refuses a full target, transfers with inventory and per-room position, and returns', async () => {
++ const db = await openTestDb()
++ const config = loadConfig({
++ GAMES_PLACE_ID: 'm4-test',
++ MAX_PLAYERS: '1',
++ GAMES_INSTANCES_PER_PLACE: '2',
++ })
++ const registry = new InstanceRegistry(db, config.placeId)
++ const content = createContent()
++ const rapier = await loadRapier()
++ const rooms: InstanceRoom[] = []
++ const makeRoom = async (slot: number, initial: InstanceLease): Promise<InstanceRoom> => {
++ let lease = initial
++ const store = openPgStore(db, {
++ placeId: config.placeId,
++ instanceSlot: slot,
++ leaseGuard: () => registry.assertHeld(lease, Date.now()),
++ })
++ await store.ensurePlace()
++ const physics = createRapierWorld(rapier)
++ const world = new GameWorld(content, physics, log, config.interestCell)
++ await world.seedOrRestore(store)
++ for (let i = 0; i < 400; i++) {
++ const x = ((i % 20) - 10) * 55
++ const z = (Math.floor(i / 20) - 10) * 55
++ const id = `probe-${i}` as EntityId
++ const entity: GameEntity = {
++ id,
++ kind: 'prop',
++ transform: { pos: vec3(x, 0, z), rot: quat() },
++ prop: { defId: 'scrap', motion: 'dynamic', lootCount: 1 },
++ persistent: false,
++ dirty: false,
++ }
++ world.entities.add(entity)
++ world.spatial.insert(id, x, z)
++ }
++ const game = new GameServer(
++ { ...config, instanceSlot: slot },
++ world,
++ store,
++ new ServerMetrics(),
++ log,
++ )
++ await game.load()
++ const running = await registry.renew(lease, Date.now(), 15_000, 'running')
++ if (!running) throw new Error('lease expired')
++ lease = running
++ const room = { lease, game, world, dispose: () => physics.dispose() }
++ rooms.push(room)
++ return room
++ }
++ await db.exec(sql`INSERT INTO places (id, name) VALUES (${config.placeId}, 'test')`)
++ const claimed = await registry.claim(0, 1, 'test-holder', Date.now(), 15_000)
++ if (!claimed) throw new Error('claim failed')
++ const primary = await makeRoom(0, claimed)
++ const hub = new InstanceHub(registry, primary, 2, 1, makeRoom, log)
++ const client = (key: string) => {
++ const inbox: ServerMessage[] = []
++ const conn: GameConnection = {
++ identity: { guestKeyHash: key.repeat(64) },
++ send: (raw) => inbox.push(JSON.parse(raw) as ServerMessage),
++ close: () => hub.onDisconnect(conn),
++ }
++ const hello = {
++ t: 'hello' as const,
++ v: PROTOCOL_VERSION,
++ contentDigest: content.digest,
++ slot: 0,
++ name: key,
++ appearance: defaultAppearance(),
++ }
++ return { conn, inbox, hello }
++ }
++ const a = client('a')
++ const b = client('b')
++ b.hello.v = PROTOCOL_VERSION - 1
++ hub.onMessage(a.conn, a.hello)
++ hub.onMessage(b.conn, b.hello)
++ await until(
++ () => a.inbox.some((m) => m.t === 'welcome') && b.inbox.some((m) => m.t === 'welcome'),
++ )
++ expect((a.inbox.find((m) => m.t === 'welcome') as { instanceId?: string }).instanceId).toBe(
++ 'm4-test:0',
++ )
++ expect((b.inbox.find((m) => m.t === 'welcome') as { instanceId?: string }).instanceId).toBe(
++ 'm4-test:1',
++ )
++ expect((b.inbox.find((m) => m.t === 'welcome') as { v?: number }).v).toBe(PROTOCOL_VERSION - 1)
++ expect(hub.onlineCount).toBe(2)
++ const initialInventory = a.inbox.find((m) => m.t === 'inventory')
++ const startBytes = [a, b].map(({ inbox }) =>
++ inbox.reduce((sum, msg) => sum + JSON.stringify(msg).length, 0),
++ )
++ const tickTimes: number[] = []
++ for (let tick = 1; tick <= 90; tick++) {
++ for (const client of [a, b])
++ hub.onMessage(client.conn, {
++ t: 'input',
++ seq: tick,
++ moveX: 0,
++ moveZ: tick % 30 < 15 ? 1 : -1,
++ yawQ: 0,
++ pitchQ: 0,
++ intents: 0,
++ })
++ const start = performance.now()
++ hub.step()
++ tickTimes.push(performance.now() - start)
++ }
++ const bytesPerClient =
++ [a, b].reduce(
++ (sum, client, i) =>
++ sum +
++ client.inbox.reduce((bytes, msg) => bytes + JSON.stringify(msg).length, 0) -
++ startBytes[i]!,
++ 0,
++ ) / 2
++ const meanTickMs = tickTimes.reduce((sum, ms) => sum + ms, 0) / tickTimes.length
++ // M3 HEAD baseline: 2 clients, 400 props, 90 ticks: 2.08 ms mean, 52,278 bytes/client.
++ // The timing margin absorbs CI CPU/WASM noise; bandwidth has a tighter bound.
++ expect(meanTickMs).toBeLessThan(2.08 * 3)
++ expect(bytesPerClient).toBeLessThanOrEqual(52_278 * 1.05)
++
++ hub.onMessage(a.conn, { t: 'transfer', instanceId: 'm4-test:1' })
++ await until(() => a.inbox.some((m) => m.t === 'transfer_result'))
++ expect(a.inbox.at(-1)).toMatchObject({
++ t: 'transfer_result',
++ accepted: false,
++ reason: 'instance_full',
++ })
++ expect(primary.game.onlineCount()).toBe(1)
++
++ for (let i = 1; i <= 12; i++)
++ hub.onMessage(a.conn, {
++ t: 'input',
++ seq: i,
++ moveX: 0,
++ moveZ: 1,
++ yawQ: 0,
++ pitchQ: 0,
++ intents: 0,
++ })
++ await pause()
++ for (let i = 0; i < 12; i++) hub.step()
++ hub.onDisconnect(b.conn)
++ await rooms[1]?.game.drain()
++ hub.onMessage(a.conn, { t: 'transfer', instanceId: 'm4-test:1' })
++ await until(() => a.inbox.some((m) => m.t === 'transfer_result' && m.accepted))
++ expect(a.inbox.filter((m) => m.t === 'welcome')).toHaveLength(2)
++ expect(a.inbox.filter((m) => m.t === 'inventory').at(-1)).toEqual(initialInventory)
++ const firstPosition = await db.maybe<{ pos_x: number; pos_z: number }>(sql`
++ SELECT pos_x, pos_z FROM character_instance_positions WHERE instance_id = 'm4-test:0'
++ `)
++ expect(firstPosition).not.toBeNull()
++
++ hub.onMessage(a.conn, { t: 'transfer', instanceId: 'm4-test:0' })
++ await until(() => a.inbox.filter((m) => m.t === 'transfer_result' && m.accepted).length === 2)
++ expect(a.inbox.filter((m) => m.t === 'welcome')).toHaveLength(3)
++ const restored = await db.maybe<{ pos_x: number; pos_z: number }>(sql`
++ SELECT pos_x, pos_z FROM character_instance_positions WHERE instance_id = 'm4-test:0'
++ `)
++ expect(restored).toEqual(firstPosition)
++ hub.onDisconnect(a.conn)
++ expect(await hub.shutdown()).toBe(true)
++})
ddiff --git a/apps/server/src/game/instanceHub.ts b/apps/server/src/game/instanceHub.ts
nnew file mode 100644
iindex 0000000..191c554
---- /dev/null
++++ b/apps/server/src/game/instanceHub.ts
@@@ -0,0 +1,303 @@
++import { encodeServerMessage, type ClientHello, type ClientMessage } from '@openvibe/protocol'
++import type { InstanceLease, InstanceRegistry } from '@openvibe/persistence'
++import type { Logger } from '@openvibe/shared'
++import type { GameConnection } from './systems/context.js'
++import type { GameServer } from './gameServer.js'
++import type { GameWorld } from './gameWorld.js'
++
++export interface InstanceRoom {
++ lease: InstanceLease
++ game: GameServer
++ world: GameWorld
++ dispose(): void | Promise<void>
++}
++
++/** One-process room router. The socket's upgrade identity remains attached across transfers. */
++export class InstanceHub {
++ private readonly rooms = new Map<number, InstanceRoom>()
++ 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>>()
++ private readonly closed = new WeakSet<GameConnection>()
++ private readonly transferring = new WeakSet<GameConnection>()
++ private creating: Promise<void> = Promise.resolve()
++ private admission: Promise<void> = Promise.resolve()
++ private heartbeating = false
++ private stopping = false
++
++ constructor(
++ private readonly registry: InstanceRegistry,
++ primary: InstanceRoom,
++ private readonly maxInstances: number,
++ private readonly capacity: number,
++ private readonly createRoom: (slot: number, lease: InstanceLease) => Promise<InstanceRoom>,
++ private readonly log: Logger,
++ ) {
++ this.rooms.set(primary.lease.slot, primary)
++ }
++
++ get onlineCount(): number {
++ return this.connections.size
++ }
++ get activeRooms(): readonly InstanceRoom[] {
++ return [...this.rooms.values()]
++ }
++
++ onMessage(conn: GameConnection, msg: ClientMessage): void {
++ if (this.transferring.has(conn)) return
++ const active = this.connections.get(conn)
++ if (active && msg.t === 'transfer') this.transferring.add(conn)
++ if (active && msg.t !== 'hello' && msg.t !== 'transfer' && !this.pending.has(conn)) {
++ this.rooms.get(active.slot)?.game.onMessage(conn, msg)
++ return
++ }
++ const previous = this.pending.get(conn) ?? Promise.resolve()
++ const next = previous
++ .then(async () => {
++ if (this.closed.has(conn)) return
++ if (msg.t === 'hello') {
++ if (this.connections.has(conn)) return
++ const previousAdmission = this.admission
++ let releaseAdmission: () => void = () => {}
++ this.admission = new Promise<void>((resolve) => {
++ releaseAdmission = resolve
++ })
++ await previousAdmission
++ let room: InstanceRoom | null = null
++ try {
++ const key = this.characterKey(conn, msg.slot)
++ if (key) {
++ for (const [other, state] of this.connections) {
++ if (other === conn || this.characterKey(other, state.hello.slot) !== key) continue
++ const former = this.rooms.get(state.slot)
++ other.close(4004, 'session_superseded')
++ this.onDisconnect(other)
++ await former?.game.drain(5000)
++ }
++ }
++ room = await this.choose()
++ if (room && !this.closed.has(conn))
++ this.connections.set(conn, { slot: room.lease.slot, hello: msg })
++ } finally {
++ releaseAdmission()
++ }
++ if (!room || this.closed.has(conn)) {
++ conn.send(encodeServerMessage({ t: 'reject', reason: 'server_full' }))
++ conn.close(4003, 'server_full')
++ return
++ }
++ const joined = await room.game.join(conn, msg)
++ if (!joined || this.closed.has(conn)) {
++ room.game.onDisconnect(conn)
++ this.connections.delete(conn)
++ }
++ return
++ }
++ const current = this.connections.get(conn)
++ if (!current) {
++ conn.close(4001, 'hello_first')
++ return
++ }
++ if (msg.t === 'transfer') {
++ await this.transfer(conn, current, msg.instanceId)
++ return
++ }
++ this.rooms.get(current.slot)?.game.onMessage(conn, msg)
++ })
++ .catch((error: unknown) => {
++ this.log.error('instance route failed', { error: String(error) })
++ conn.close(1011, 'instance_unavailable')
++ })
++ this.pending.set(conn, next)
++ void next.finally(() => {
++ if (this.pending.get(conn) === next) this.pending.delete(conn)
++ })
++ }
++
++ onDisconnect(conn: GameConnection): void {
++ this.closed.add(conn)
++ const current = this.connections.get(conn)
++ if (current) this.rooms.get(current.slot)?.game.onDisconnect(conn)
++ this.connections.delete(conn)
++ }
++
++ step(): void {
++ for (const room of this.rooms.values()) room.game.step()
++ }
++
++ async heartbeat(now = Date.now()): Promise<void> {
++ if (this.heartbeating || this.stopping) return
++ this.heartbeating = true
++ try {
++ for (const room of [...this.rooms.values()]) {
++ let renewed: InstanceLease | null = null
++ try {
++ renewed = await this.registry.renew(room.lease, now, 15_000, 'running')
++ } catch (error) {
++ this.log.error('instance heartbeat failed', { id: room.lease.id, error: String(error) })
++ }
++ if (renewed) {
++ room.lease = renewed
++ continue
++ }
++ this.log.error('instance lease lost', { id: room.lease.id })
++ this.rooms.delete(room.lease.slot)
++ for (const [conn, current] of this.connections) {
++ if (current.slot === room.lease.slot) conn.close(1012, 'instance_lease_lost')
++ }
++ await room.dispose()
++ }
++ } finally {
++ this.heartbeating = false
++ }
++ }
++
++ async shutdown(): Promise<boolean> {
++ this.stopping = true
++ for (const room of this.rooms.values()) {
++ const draining = await this.registry.renew(room.lease, Date.now(), 15_000, 'draining')
++ if (draining) room.lease = draining
++ }
++ for (const room of this.rooms.values()) room.game.shutdown()
++ const drained = await Promise.all([...this.rooms.values()].map((room) => room.game.drain()))
++ for (const room of this.rooms.values()) {
++ await this.registry.stop(room.lease)
++ await room.dispose()
++ }
++ this.rooms.clear()
++ return drained.every(Boolean)
++ }
++
++ private async choose(): Promise<InstanceRoom | null> {
++ for (const room of this.rooms.values()) {
++ if (room.lease.state === 'running' && this.occupancy(room.lease.slot) < this.capacity)
++ return room
++ }
++ for (let slot = 0; slot < this.maxInstances; slot++) {
++ if (!this.rooms.has(slot)) {
++ const room = await this.start(slot)
++ if (room) return room
++ }
++ }
++ return null
++ }
++
++ private occupancy(slot: number): number {
++ let count = this.reservations.get(slot) ?? 0
++ for (const current of this.connections.values()) if (current.slot === slot) count++
++ return count
++ }
++
++ private async start(slot: number): Promise<InstanceRoom | null> {
++ let result: InstanceRoom | null = null
++ const prior = this.creating
++ this.creating = prior.then(async () => {
++ const existing = this.rooms.get(slot)
++ if (existing) {
++ result = existing
++ return
++ }
++ const lease = await this.registry.claim(
++ slot,
++ this.capacity,
++ crypto.randomUUID(),
++ Date.now(),
++ 60_000,
++ )
++ if (!lease) return
++ try {
++ const room = await this.createRoom(slot, lease)
++ const running = await this.registry.renew(lease, Date.now(), 15_000, 'running')
++ if (!running) throw new Error('lease expired during room start')
++ room.lease = running
++ this.rooms.set(slot, room)
++ result = room
++ } catch (error) {
++ await this.registry.stop(lease)
++ throw error
++ }
++ })
++ await this.creating
++ return result
++ }
++
++ private async transfer(
++ conn: GameConnection,
++ current: { slot: number; hello: ClientHello },
++ id: string,
++ ): Promise<void> {
++ this.transferring.add(conn)
++ try {
++ const reject = (
++ reason: 'same_instance' | 'unknown_instance' | 'instance_full' | 'unavailable',
++ ) =>
++ conn.send(
++ encodeServerMessage({ t: 'transfer_result', accepted: false, instanceId: id, reason }),
++ )
++ const match = /^(.+):([0-9]+)$/.exec(id)
++ const slot = match && match[1] === this.registry.placeId ? Number(match[2]) : -1
++ if (!Number.isSafeInteger(slot) || slot < 0 || slot >= this.maxInstances) {
++ reject('unknown_instance')
++ return
++ }
++ if (slot === current.slot) {
++ reject('same_instance')
++ return
++ }
++ let target = this.rooms.get(slot)
++ if (!target) target = (await this.start(slot)) ?? undefined
++ if (!target || target.lease.state !== 'running') {
++ reject('unavailable')
++ return
++ }
++ if (current.hello.contentDigest !== target.game.contentDigest) {
++ // A live definition change requires a fresh client before it can enter a room.
++ reject('unavailable')
++ return
++ }
++ if (this.occupancy(slot) >= this.capacity) {
++ reject('instance_full')
++ return
++ }
++ this.reservations.set(slot, (this.reservations.get(slot) ?? 0) + 1)
++ const source = this.rooms.get(current.slot)
++ if (!source) {
++ this.reservations.set(slot, (this.reservations.get(slot) ?? 1) - 1)
++ reject('unavailable')
++ return
++ }
++ try {
++ source.game.checkpoint()
++ if (!(await source.game.drain(5000))) {
++ reject('unavailable')
++ return
++ }
++ source.game.onDisconnect(conn)
++ if (!(await source.game.drain(5000))) {
++ await source.game.join(conn, current.hello)
++ reject('unavailable')
++ return
++ }
++ const joined = await target.game.join(conn, current.hello)
++ if (!joined || this.closed.has(conn)) {
++ target.game.onDisconnect(conn)
++ await source.game.join(conn, current.hello)
++ reject('unavailable')
++ return
++ }
++ this.connections.set(conn, { slot, hello: current.hello })
++ conn.send(encodeServerMessage({ t: 'transfer_result', accepted: true, instanceId: id }))
++ } finally {
++ this.reservations.set(slot, (this.reservations.get(slot) ?? 1) - 1)
++ }
++ } finally {
++ this.transferring.delete(conn)
++ }
++ }
++
++ private characterKey(conn: GameConnection, slot: number): string | null {
++ const identity = conn.identity
++ if (!identity) return null
++ return `${'subject' in identity ? identity.subject : `guest:${identity.guestKeyHash}`}:${slot}`
++ }
++}
ddiff --git a/apps/server/src/game/platformIntegration.test.ts b/apps/server/src/game/platformIntegration.test.ts
iindex a8427f6..b7170b9 100644
---- a/apps/server/src/game/platformIntegration.test.ts
++++ b/apps/server/src/game/platformIntegration.test.ts
@@@ -257,7 +257,7 @@ describe('Games on platform identity, events and mods (real server, restart)', (
oldBundle.conn.identity = { guestKeyHash: hashGuestKey('old-bundle') }
a.game.onMessage(oldBundle.conn, {
t: 'hello',
-- v: PROTOCOL_VERSION - 1,
++ v: PROTOCOL_VERSION - 2,
slot: 0,
name: 'Old',
appearance: defaultAppearance(),
ddiff --git a/apps/server/src/game/playerSession.ts b/apps/server/src/game/playerSession.ts
iindex 4395224..2345f37 100644
---- a/apps/server/src/game/playerSession.ts
++++ b/apps/server/src/game/playerSession.ts
@@@ -107,6 +107,8 @@ export interface PlayerSession {
lastProcessedSeq: number
/** Entity ids this client currently knows about (interest management). */
known: Set<EntityId>
++ /** Ground-plane cells this client subscribed to on the last snapshot. */
++ interestCells: Set<number>
/** Player state changed since last persistence flush. */
dirty: boolean
send(text: string): void
@@@ -178,6 +180,7 @@ export function createSession(init: SessionInit): PlayerSession {
starvedTicks: 0,
lastProcessedSeq: 0,
known: new Set(),
++ interestCells: new Set(),
dirty: true,
send: init.send,
closeConnection: init.closeConnection,
ddiff --git a/apps/server/src/game/replication.test.ts b/apps/server/src/game/replication.test.ts
iindex bcbce6f..524e325 100644
---- a/apps/server/src/game/replication.test.ts
++++ b/apps/server/src/game/replication.test.ts
@@@ -9,10 +9,11 @@
*/
import { EntityStore, SpatialHash, type GameEntity } from '@openvibe/gameplay'
import { quat, vec3, type EntityId } from '@openvibe/shared'
++import { encodeServerMessage } from '@openvibe/protocol'
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'
++import { INTEREST_HYSTERESIS, updateInterest, wireEntityFor } from './replication.js'
const RADIUS = 80
const EXIT = RADIUS * (1 + INTEREST_HYSTERESIS)
@@@ -55,6 +56,41 @@ function addBody(world: GameWorld, x: number): { id: EntityId; moveTo: (x: numbe
}
describe('interest hysteresis', () => {
++ it('sends only nearby grid cells, reducing spawn bytes across a spread world', () => {
++ const world = makeWorld()
++ const session = makeSession()
++ const all: GameEntity[] = []
++ for (let i = 0; i < 1000; i++) {
++ const id = `spread-${i}` as EntityId
++ const x = ((i % 40) - 20) * 45
++ const z = (Math.floor(i / 40) - 12) * 45
++ const entity: GameEntity = {
++ id,
++ kind: 'prop',
++ transform: { pos: vec3(x, 0, z), rot: quat() },
++ prop: { defId: 'scrap', motion: 'static', lootCount: 1 },
++ persistent: false,
++ dirty: false,
++ }
++ world.entities.add(entity)
++ world.spatial.insert(id, x, z)
++ all.push(entity)
++ }
++ const nearby = updateInterest(session, world, RADIUS).entered
++ const bytes = (entities: GameEntity[]) =>
++ encodeServerMessage({
++ t: 'spawn',
++ entities: entities.map((entity) => wireEntityFor(world, entity)),
++ }).length
++ expect(nearby.length).toBeGreaterThan(0)
++ expect(bytes(nearby)).toBeLessThan(bytes(all) / 10)
++ session.move.pos.x = 500
++ const next = updateInterest(session, world, RADIUS)
++ expect(next.enteredCells.length).toBeGreaterThan(0)
++ expect(next.leftCells.length).toBeGreaterThan(0)
++ expect(next.left).toHaveLength(nearby.length)
++ expect(next.entered.length).toBeGreaterThan(0)
++ })
it('enters inside the radius and is retained past it up to the exit radius', () => {
const world = makeWorld()
const session = makeSession()
ddiff --git a/apps/server/src/game/replication.ts b/apps/server/src/game/replication.ts
iindex 9a40793..450b765 100644
---- a/apps/server/src/game/replication.ts
++++ b/apps/server/src/game/replication.ts
@@@ -86,6 +86,8 @@ export function wirePlayerFor(session: PlayerSession): WirePlayerState {
export interface InterestDiff {
entered: GameEntity[]
left: EntityId[]
++ enteredCells: number[]
++ leftCells: number[]
}
/**
@@@ -107,8 +109,13 @@ export function updateInterest(
const exitRadiusSq = exitRadius * exitRadius
const entered: GameEntity[] = []
const current = new Set<EntityId>()
-- // 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) => {
++ const cells = world.spatial.cellsInRadius(session.move.pos.x, session.move.pos.z, exitRadius)
++ const previousCells = session.interestCells ?? new Set<number>()
++ const enteredCells = [...cells].filter((key) => !previousCells.has(key))
++ const leftCells = [...previousCells].filter((key) => !cells.has(key))
++ session.interestCells = cells
++ // Candidates in subscribed cells; the exact 3D distance test decides.
++ world.spatial.forEachInCells(cells, (id) => {
if (id === session.entityId) return
const entity = world.entities.get(id)
if (!entity) return
@@@ -124,7 +131,7 @@ export function updateInterest(
if (!current.has(id)) left.push(id)
}
session.known = current
-- return { entered, left }
++ return { entered, left, enteredCells, leftCells }
}
/** Snapshot for one session: relevant players + awake relevant prop bodies. */
ddiff --git a/apps/server/src/game/systems/sessions.ts b/apps/server/src/game/systems/sessions.ts
iindex 3242660..83bb9be 100644
---- a/apps/server/src/game/systems/sessions.ts
++++ b/apps/server/src/game/systems/sessions.ts
@@@ -24,6 +24,15 @@ export class SessionsSystem implements System {
constructor(private readonly ctx: ServerContext) {
this.handlers = {
++ transfer: (session, msg) => {
++ if (session)
++ this.ctx.net.send(session, {
++ t: 'transfer_result',
++ accepted: false,
++ instanceId: (msg as { instanceId: string }).instanceId,
++ reason: 'unavailable',
++ })
++ },
hello: (session, msg, conn) => {
// A session already exists: a duplicate hello is silently ignored.
if (session) return
@@@ -39,7 +48,7 @@ export class SessionsSystem implements System {
conn.close(4012, 'no_identity')
return
}
-- void this.handleHello(conn, msg as ClientHello)
++ void this.join(conn, msg as ClientHello)
},
}
}
@@@ -49,6 +58,13 @@ export class SessionsSystem implements System {
return this.ctx.sessions.size
}
++ async join(conn: GameConnection, msg: ClientHello): Promise<boolean> {
++ if (this.hellosInFlight.has(conn) || !conn.identity || this.ctx.sessions.getByConn(conn))
++ return false
++ await this.handleHello(conn, msg)
++ return this.ctx.sessions.getByConn(conn) !== undefined
++ }
++
onDisconnect(conn: GameConnection): void {
const session = this.ctx.sessions.getByConn(conn)
if (!session) return
@@@ -88,7 +104,7 @@ export class SessionsSystem implements System {
const ctx = this.ctx
const serverDigest = ctx.world.content.digest
// Protocol first: a stale bundle predates the digest and only reloads itself on protocol_mismatch.
-- if (msg.v !== PROTOCOL_VERSION) {
++ if (msg.v !== PROTOCOL_VERSION && msg.v !== PROTOCOL_VERSION - 1) {
conn.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))
conn.close(4002, 'protocol_mismatch')
return
@@@ -251,7 +267,7 @@ export class SessionsSystem implements System {
ctx.net.send(session, {
t: 'welcome',
-- v: PROTOCOL_VERSION,
++ v: msg.v,
contentDigest: serverDigest,
rank,
playerId: playerId as string,
@@@ -259,6 +275,12 @@ export class SessionsSystem implements System {
tick: ctx.clock.tick,
tickRate: ctx.config.tickRate,
snapshotRate: ctx.config.tickRate / ctx.config.snapshotEvery,
++ ...(ctx.config.instancesPerPlace > 1
++ ? {
++ instanceId: `${ctx.config.placeId}:${ctx.config.instanceSlot ?? 0}`,
++ instanceCount: ctx.config.instancesPerPlace,
++ }
++ : {}),
})
ctx.net.sendInventory(session)
ctx.net.sendSkills(session)
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex e31957a..4da4c5d 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -10,13 +10,14 @@ import {
SCRAPLANDIA_PACK,
setMapOverride,
} from '@openvibe/content'
--import { openPgStore } from '@openvibe/persistence'
++import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createConsoleLogger } from '@openvibe/shared'
import { loadConfig } from './config.js'
import { openDb } from './db.js'
import { GameServer } from './game/gameServer.js'
import { GameWorld } from './game/gameWorld.js'
++import { InstanceHub } from './game/instanceHub.js'
import { attachEditorWs } from './net/editorWs.js'
import {
closeSockets,
@@@ -79,8 +80,23 @@ async function main(): Promise<void> {
// PostgreSQL (or embedded PGlite in development), migrated before anything writes. It opens
// before content because enabled mod packs add definitions to the content registry.
const db = await openDb(config.db, log)
-- const store = openPgStore(db, { placeId: config.placeId })
++ const registry = new InstanceRegistry(db, config.placeId)
++ let primaryLease: InstanceLease
++ const store = openPgStore(db, {
++ placeId: config.placeId,
++ instanceSlot: 0,
++ leaseGuard: () => registry.assertHeld(primaryLease, Date.now()),
++ })
await store.ensurePlace()
++ const claimed = await registry.claim(
++ 0,
++ config.maxPlayers,
++ crypto.randomUUID(),
++ Date.now(),
++ 60_000,
++ )
++ if (!claimed) throw new Error('primary instance slot is already leased')
++ primaryLease = claimed
// Content validates at construction — invalid definitions kill the boot. Packs are the enabled
// installs granted games.def.define; one that no longer builds is skipped and logged.
@@@ -123,10 +139,11 @@ async function main(): Promise<void> {
packs: packs.length,
})
-- const physics = createRapierWorld(await loadRapier())
++ const rapier = await loadRapier()
++ const physics = createRapierWorld(rapier)
const metrics = new ServerMetrics()
-- const world = new GameWorld(content, physics, log.child({ system: 'world' }))
++ const world = new GameWorld(content, physics, log.child({ system: 'world' }), config.interestCell)
await world.seedOrRestore(store)
// ── Platform integration (roadmap Wave 12) ─────────────────────────
@@@ -217,8 +234,9 @@ async function main(): Promise<void> {
// games-quickjs@1 script mods run only where the place enables them; the engine (WASM) is not
// even loaded otherwise. They stay in the def set, so the handshake digest is the same either way.
const scriptLog = log.child({ system: 'script-mods' })
-- let scripts: ScriptModHost | undefined
-- if (config.scriptMods) {
++ const scriptHosts = new Map<number, ScriptModHost>()
++ const createScriptHost = async (slot: number): Promise<ScriptModHost | undefined> => {
++ if (!config.scriptMods) return undefined
const { createQuickJsSandboxFactory } = await import('./mods/script/quickjs.js')
// Built-in pack code is reviewed with the place; installed packs use only the approved subset.
const grants: Record<string, string[]> = Object.fromEntries(
@@@ -236,36 +254,117 @@ async function main(): Promise<void> {
.map((cap) => cap.slice(6))
}
}
-- scripts = new ScriptModHost(
++ const host = new ScriptModHost(
content.allMods(),
createQuickJsSandboxFactory(),
-- scriptLog,
++ scriptLog.child({ slot }),
undefined,
{
-- placeSeed: config.placeId,
++ placeSeed: slot === 0 ? config.placeId : `${config.placeId}:${slot}`,
grants: grants as Record<string, ScriptCapability[]>,
storage: new PgScriptStorage(store.mods, config.placeId, scriptLog),
},
)
-- } else if (content.allMods().length > 0) {
++ scriptHosts.set(slot, host)
++ return host
++ }
++ const scripts = await createScriptHost(0)
++ if (!config.scriptMods && content.allMods().length > 0) {
scriptLog.info('script mods off for this place', { mods: content.allMods().length })
}
-- const game = new GameServer(config, world, store, metrics, log.child({ system: 'game' }), {
-- ...(recorder ? { events: recorder } : {}),
-- mods: modRuntime,
-- ...(scripts ? { scripts } : {}),
-- ...(progressSummary ? { progressSummary } : {}),
-- })
++ const game = new GameServer(
++ { ...config, instanceSlot: 0 },
++ world,
++ store,
++ metrics,
++ log.child({ system: 'game' }),
++ {
++ ...(recorder ? { events: recorder } : {}),
++ mods: modRuntime,
++ ...(scripts ? { scripts } : {}),
++ ...(progressSummary ? { progressSummary } : {}),
++ },
++ )
// Async boot: env, markets and the NPC population load before the tick loop starts.
await game.load()
await scripts?.start()
++ const runningLease = await registry.renew(primaryLease, Date.now(), 15_000, 'running')
++ if (!runningLease) throw new Error('primary instance lease expired during boot')
++ primaryLease = runningLease
++ const hub = new InstanceHub(
++ registry,
++ {
++ lease: primaryLease,
++ game,
++ world,
++ dispose: async () => {
++ await scripts?.flushWrites()
++ scripts?.dispose()
++ scriptHosts.delete(0)
++ physics.dispose()
++ },
++ },
++ config.instancesPerPlace,
++ config.maxPlayers,
++ async (slot, lease) => {
++ const roomLease = lease
++ const roomStore = openPgStore(db, {
++ placeId: config.placeId,
++ instanceSlot: slot,
++ leaseGuard: () => registry.assertHeld(roomLease, Date.now()),
++ })
++ await roomStore.ensurePlace()
++ const roomPhysics = createRapierWorld(rapier)
++ const roomWorld = new GameWorld(
++ content,
++ roomPhysics,
++ log.child({ system: 'world', slot }),
++ config.interestCell,
++ )
++ await roomWorld.seedOrRestore(roomStore)
++ roomWorld.reconcileMapNodes()
++ roomWorld.reconcileMapProps()
++ const roomScripts = await createScriptHost(slot)
++ const roomGame = new GameServer(
++ { ...config, instanceSlot: slot },
++ roomWorld,
++ roomStore,
++ metrics,
++ log.child({ system: 'game', slot }),
++ {
++ ...(recorder ? { events: recorder } : {}),
++ mods: new ModRuntime(mods, content, log.child({ system: 'mods', slot }), {
++ reconcileEveryTicks: config.tickRate,
++ }),
++ ...(roomScripts ? { scripts: roomScripts } : {}),
++ ...(progressSummary ? { progressSummary } : {}),
++ },
++ )
++ await roomGame.load()
++ await roomScripts?.start()
++ return {
++ lease: roomLease,
++ game: roomGame,
++ world: roomWorld,
++ dispose: async () => {
++ await roomScripts?.flushWrites()
++ roomScripts?.dispose()
++ scriptHosts.delete(slot)
++ roomPhysics.dispose()
++ },
++ }
++ },
++ log.child({ system: 'instances' }),
++ )
++ const heartbeatTimer = setInterval(() => void hub.heartbeat(), 5_000)
++ heartbeatTimer.unref()
const syncScriptView = async (view: ModView): Promise<void> => {
if (view.mod.runtime !== 'games-quickjs@1') return
for (const raw of view.pack.mods ?? []) {
const script = ScriptModSchema.parse(raw)
const active = view.mod.status === 'enabled' && view.granted.has('games.script.run')
-- scripts?.remove(script.id)
++ for (const host of scriptHosts.values()) host.remove(script.id)
if (!active) {
content.removeScriptMod(script.id)
continue
@@@ -276,8 +375,8 @@ async function main(): Promise<void> {
const pinned = { ...script, granted }
if (content.allMods().some((mod) => mod.id === script.id)) content.replaceScriptMod(pinned)
else content.addScriptMod(pinned)
-- if (scripts) {
-- const result = await scripts.add(pinned, granted)
++ for (const host of scriptHosts.values()) {
++ const result = await host.add(pinned, granted)
if (!result.ok)
log.warn('script failed to start after grant change', {
mod: script.id,
@@@ -285,9 +384,10 @@ async function main(): Promise<void> {
})
}
}
-- game.announce(
-- 'Script mod definitions changed. New connections must use the updated content set.',
-- )
++ for (const room of hub.activeRooms)
++ room.game.announce(
++ 'Script mod definitions changed. New connections must use the updated content set.',
++ )
}
const accountData = platform
@@@ -297,11 +397,13 @@ async function main(): Promise<void> {
send: networkSender({ networkUrl: config.platform.networkUrl, tokens: platform.tokens }),
closeSessions: async (subjects) => {
let closed = 0
-- for (const s of subjects) closed += game.revokeSubject(s, Number.MAX_SAFE_INTEGER)
++ for (const s of subjects)
++ for (const room of hub.activeRooms)
++ closed += room.game.revokeSubject(s, Number.MAX_SAFE_INTEGER)
// Let the closing sockets' character saves land before the rows are erased.
if (closed) {
await new Promise((r) => setTimeout(r, 2000))
-- await game.drain()
++ await Promise.all(hub.activeRooms.map((room) => room.game.drain()))
}
},
log: platformLog.child({ system: 'account-data' }),
@@@ -313,7 +415,11 @@ async function main(): Promise<void> {
const revocations = createRevocationEvents({
db,
secrets: config.platform.eventsSecrets,
-- onRevoked: (subject, validAfterMs) => game.revokeSubject(subject, validAfterMs),
++ onRevoked: (subject, validAfterMs) =>
++ hub.activeRooms.reduce(
++ (count, room) => count + room.game.revokeSubject(subject, validAfterMs),
++ 0,
++ ),
// Account merge (ADR-029): the folded-in account's characters join the survivor's free slots.
onMerged: (from, into, mergeId) => store.identity.mergeSubject(from, into, mergeId, Date.now()),
// Account export and deletion (ADR-033): the part goes to Network; a deleted account's sessions close (each
@@@ -361,7 +467,7 @@ async function main(): Promise<void> {
const readiness = createReadiness({
pingDb: () => db.ready(),
metrics,
-- online: () => game.onlineCount(),
++ online: () => hub.onlineCount,
})
// Per-actor limits on editor saves, uploads and the mods API (roadmap WS-R task 4). With VALKEY_URL the
@@@ -389,12 +495,14 @@ async function main(): Promise<void> {
(next, revision) => {
try {
setMapOverride(compileMapFileV2(next))
-- world.reconcileMapTerrain()
-- world.reconcileMapStatics()
-- world.reconcileMapZones()
-- world.reconcileMapNodes()
-- world.reconcileMapProps()
-- game.broadcastMapReload()
++ for (const room of hub.activeRooms) {
++ room.world.reconcileMapTerrain()
++ room.world.reconcileMapStatics()
++ room.world.reconcileMapZones()
++ room.world.reconcileMapNodes()
++ room.world.reconcileMapProps()
++ room.game.broadcastMapReload()
++ }
editors.broadcastSaved(revision)
log.info('map applied live', { terrains: next.terrains.length, revision })
} catch (err) {
@@@ -523,18 +631,26 @@ async function main(): Promise<void> {
script.capabilities ?? (['players.read', 'world.emit'] as const)
).filter((cap) => prior.granted.has(scriptGrant(cap)))
const pinned = { ...script, granted: grants }
-- const prepared = await scripts.prepareReload(pinned, grants)
-- if (!prepared.ok) throw new ModError('mod.reload_failed', prepared.error, 422)
++ const prepared: { commit(): void; cancel(): void }[] = []
++ for (const host of scriptHosts.values()) {
++ const candidate = await host.prepareReload(pinned, grants)
++ if (!candidate.ok) {
++ for (const priorCandidate of prepared) priorCandidate.cancel()
++ throw new ModError('mod.reload_failed', candidate.error, 422)
++ }
++ prepared.push(candidate)
++ }
try {
const view = await mods.replace(id, request, actor)
content.replaceScriptMod(pinned)
-- prepared.commit()
-- game.announce(
-- `Script mod ${script.id} reloaded. New connections must use the updated content set.`,
-- )
++ for (const candidate of prepared) candidate.commit()
++ for (const room of hub.activeRooms)
++ room.game.announce(
++ `Script mod ${script.id} reloaded. New connections must use the updated content set.`,
++ )
return view
} catch (error) {
-- prepared.cancel()
++ for (const candidate of prepared) candidate.cancel()
throw error
}
},
@@@ -557,7 +673,7 @@ async function main(): Promise<void> {
const drainer = httpDrainer(http)
world.reconcileMapNodes()
world.reconcileMapProps()
-- const gameWss = attachWebSocket(http, game, {
++ const gameWss = attachWebSocket(http, hub, {
tickets: ticketStore,
upgradeLimits: actorLimits,
clientAddress,
@@@ -612,7 +728,7 @@ async function main(): Promise<void> {
if (!running) return
const now = performance.now()
while (now >= nextTick) {
-- game.step()
++ hub.step()
nextTick += tickMs
// If we fell far behind (debugger pause, EL stall), resync instead of
// spiraling through hundreds of catch-up ticks.
@@@ -647,27 +763,28 @@ async function main(): Promise<void> {
const httpDone = drainer.close(DRAIN_MS)
running = false
clearInterval(metricsTimer)
++ clearInterval(heartbeatTimer)
clearInterval(pruneTimer)
editors.stop()
const mirrorDone = mirror?.stop()
-- game.announceRestart()
++ for (const room of hub.activeRooms) room.game.announceRestart()
const [players, editorPeers] = await Promise.all([
closeSockets(gameWss, SOCKETS_MS),
closeSockets(editors.wss, SOCKETS_MS),
])
// Mark every session dirty and queue the final checkpoint, then let the whole write-behind queue
// (that checkpoint plus every disconnect save queued before it) drain.
-- game.shutdown()
-- const drained = await game.drain()
-- await scripts?.flushWrites()
-- scripts?.dispose()
++ const drained = await hub.shutdown()
++ for (const host of scriptHosts.values()) {
++ await host.flushWrites()
++ host.dispose()
++ }
const cut = await httpDone
await within(2000, mirrorDone)
await within(1000, progressSummary?.settle())
await within(2000, outbox?.stop())
await mods.flushWrites()
await store.close()
-- physics.dispose()
await valkey?.close()
log.info('stopped', {
ms: Date.now() - t0,
ddiff --git a/apps/server/src/net/wsTransport.ts b/apps/server/src/net/wsTransport.ts
iindex 9aa7024..49c91e7 100644
---- a/apps/server/src/net/wsTransport.ts
++++ b/apps/server/src/net/wsTransport.ts
@@@ -128,7 +128,7 @@ export async function acceptUpgrade(
export function attachWebSocket(
http: Server,
-- game: GameServer,
++ game: Pick<GameServer, 'onMessage' | 'onDisconnect'>,
opts: WsTransportOptions,
): WebSocketServer {
const { log } = opts
ddiff --git a/docs/adr/0007-rewrite-the-moddable-base.md b/docs/adr/0007-rewrite-the-moddable-base.md
iindex ae08b14..6642ea8 100644
---- a/docs/adr/0007-rewrite-the-moddable-base.md
++++ b/docs/adr/0007-rewrite-the-moddable-base.md
@@@ -123,7 +123,7 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
- **M3: scripted mods — code done.** QuickJS runtime, per-binding grants, storage, seeded randomness, budgets, hot reload, multi-file imports, staff API installs, escape tests and current definition delivery to fresh browser clients. Production grant approval still requires Network to register the proposed `games.*` capability definitions; this repository does not publish them.
- **M3.1 runtime, budgets, escape tests — done (docs/mods.md).** One QuickJS WASM instance per mod behind `sandbox.ts`; validated intents, CPU/memory/stack enforcement and escape tests. Off unless `GAMES_SCRIPT_MODS=1`.
- **M3.2 script capabilities and lifecycle — done.** Optional requested bindings on `games-quickjs@1`, approved grants pinned in the digest, per-mod/place PostgreSQL storage, deterministic random, contained ES modules, staff install/reload with audit and announcement, and a public validated pack snapshot fetched before browser bootstrap.
--- **M4: instances and scale.** Several instances per place, registry and leases, transfer, region interest, load tests.
++- **M4: instances and scale — done for one process.** Several isolated room simulations per place, a PostgreSQL registry and renewable fenced leases, validated same-process transfer with per-room positions and shared character inventory, configurable spatial cells/radius with spawn/despawn hysteresis, and a socketless join/move/transfer/leave load probe. The default is still one room; protocol v21 clients remain supported alongside v22 transfer. See `docs/instances.md`. Cross-process and multi-host routing, signed handoff tokens over Events, and region routing across hosts remain future work; PostgreSQL leases replace the originally proposed Valkey leases for this scope.
- **M5: platform game services.** OpenVibe.Play with Games as first consumer; the second game on the same base.
- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.
ddiff --git a/docs/capabilities-proposal/games.mod.manage.json b/docs/capabilities-proposal/games.mod.manage.json
iindex a679d1d..9d7d8cf 100644
---- a/docs/capabilities-proposal/games.mod.manage.json
++++ b/docs/capabilities-proposal/games.mod.manage.json
@@@ -6,12 +6,8 @@
"visibility": "first-party",
"description": "Manage mod installs in the browser game's registry (ADR-013): install a mods/mod-manifest.v1 manifest with its games-content@1 pack, approve or revoke individual capabilities of an install, enable, disable, and revoke an install for good (its effects end on the next server tick). Every action is audited and published as a games.mod.* event. Trust tiers are metadata and never grant anything. Also accepted from an openvibe.network owner/admin session. Reload replaces a running script mod's code (games-quickjs@1) and keeps its storage; a reload that fails to start keeps the previous version.",
"inputSchema": "games.mod-manage-request@1",
-- "permissions": [
-- "games:mods:admin"
-- ],
-- "resourceConstraints": [
-- "none"
-- ],
++ "permissions": ["games:mods:admin"],
++ "resourceConstraints": ["none"],
"quotaClass": "games-admin",
"events": [
"games.mod.installed",
ddiff --git a/docs/capabilities-proposal/games.player.message.json b/docs/capabilities-proposal/games.player.message.json
iindex 3cd8110..8fe359e 100644
---- a/docs/capabilities-proposal/games.player.message.json
++++ b/docs/capabilities-proposal/games.player.message.json
@@@ -5,17 +5,11 @@
"status": "active",
"visibility": "public",
"description": "Script binding for mods (games-quickjs@1): game.message, a validated private announcement to one player in the mod's current place, prefixed with the mod's name. Absent unless granted.",
-- "permissions": [
-- "games:player:message"
-- ],
-- "resourceConstraints": [
-- "none"
-- ],
++ "permissions": ["games:player:message"],
++ "resourceConstraints": ["none"],
"quotaClass": "games-mod-runtime",
"events": [],
-- "implementedBy": [
-- "mod runtime binding game.message (apps/server/src/mods/script/host.ts)"
-- ],
++ "implementedBy": ["mod runtime binding game.message (apps/server/src/mods/script/host.ts)"],
"inputSchema": "games.script-message@1",
"outputSchema": "common.no-body@1"
}
ddiff --git a/docs/capabilities-proposal/games.players.read.json b/docs/capabilities-proposal/games.players.read.json
iindex 5b3bb16..b7526f9 100644
---- a/docs/capabilities-proposal/games.players.read.json
++++ b/docs/capabilities-proposal/games.players.read.json
@@@ -5,12 +5,8 @@
"status": "active",
"visibility": "public",
"description": "Script binding for mods (games-quickjs@1): game.players and game.player, read-only views of the players in the mod's current place. Absent from the script's game object unless the install was granted it; checked against the install's approved capabilities, which travel in the def-set digest.",
-- "permissions": [
-- "games:players:read"
-- ],
-- "resourceConstraints": [
-- "none"
-- ],
++ "permissions": ["games:players:read"],
++ "resourceConstraints": ["none"],
"quotaClass": "games-mod-runtime",
"events": [],
"implementedBy": [
ddiff --git a/docs/capabilities-proposal/games.script.run.json b/docs/capabilities-proposal/games.script.run.json
iindex fb8c203..a0c81dd 100644
---- a/docs/capabilities-proposal/games.script.run.json
++++ b/docs/capabilities-proposal/games.script.run.json
@@@ -5,12 +5,8 @@
"status": "active",
"visibility": "public",
"description": "Run a staff-installed script mod (games-quickjs@1, one or more module files in its content pack, imports resolved inside the pack only) in its own QuickJS sandbox per place, under the pack's CPU and memory budgets; a staff hot reload replaces the code and keeps its storage, and a reload that fails to start keeps the previous version running. The script sees only the bindings its install was granted.",
-- "permissions": [
-- "games:script:run"
-- ],
-- "resourceConstraints": [
-- "none"
-- ],
++ "permissions": ["games:script:run"],
++ "resourceConstraints": ["none"],
"quotaClass": "games-mod-runtime",
"events": [],
"implementedBy": [
ddiff --git a/docs/capabilities-proposal/games.storage.kv.json b/docs/capabilities-proposal/games.storage.kv.json
iindex 122c6ee..274dbd6 100644
---- a/docs/capabilities-proposal/games.storage.kv.json
++++ b/docs/capabilities-proposal/games.storage.kv.json
@@@ -5,12 +5,8 @@
"status": "active",
"visibility": "public",
"description": "Script binding for mods (games-quickjs@1): game.storage get/set/delete/keys, key/value storage per mod per place on the Games database: keys up to 128 bytes, values up to 8 KB of JSON, at most 1000 keys and 1 MB per mod per place, writes counted against the mod's CPU budget; a refusal is an error inside the mod. Absent unless granted.",
-- "permissions": [
-- "games:storage:kv"
-- ],
-- "resourceConstraints": [
-- "none"
-- ],
++ "permissions": ["games:storage:kv"],
++ "resourceConstraints": ["none"],
"quotaClass": "games-mod-runtime",
"events": [],
"implementedBy": [
ddiff --git a/docs/capabilities-proposal/games.world.emit.json b/docs/capabilities-proposal/games.world.emit.json
iindex f082638..f7c0cee 100644
---- a/docs/capabilities-proposal/games.world.emit.json
++++ b/docs/capabilities-proposal/games.world.emit.json
@@@ -5,17 +5,11 @@
"status": "active",
"visibility": "public",
"description": "Script binding for mods (games-quickjs@1): game.emit, which queues world intents that the server validates like any player intent before applying them; a mod never mutates state directly. Absent unless granted.",
-- "permissions": [
-- "games:world:emit"
-- ],
-- "resourceConstraints": [
-- "none"
-- ],
++ "permissions": ["games:world:emit"],
++ "resourceConstraints": ["none"],
"quotaClass": "games-mod-runtime",
"events": [],
-- "implementedBy": [
-- "mod runtime binding game.emit (apps/server/src/mods/script/host.ts)"
-- ],
++ "implementedBy": ["mod runtime binding game.emit (apps/server/src/mods/script/host.ts)"],
"inputSchema": "games.script-intent@1",
"outputSchema": "common.no-body@1"
}
ddiff --git a/docs/instances.md b/docs/instances.md
nnew file mode 100644
iindex 0000000..b2f18c4
---- /dev/null
++++ b/docs/instances.md
@@@ -0,0 +1,13 @@
++# Instances in one server process
++
++Set `GAMES_INSTANCES_PER_PLACE` above its default of `1` to let a place run several rooms in one server process. Each room has its own simulation, players, world entities, constraints, clock, and write-behind queue. `MAX_PLAYERS` is the capacity of **each** room. A join uses the first running room with capacity, then starts another room up to the configured maximum. The first room retains the existing place rows; later rooms use instance-scoped state rows. Characters, inventory, skills, and the map definition are shared by place. Each character also keeps a last position in every room it visits.
++
++The server registers room slots as `placeId:slot` in PostgreSQL `game_instances`. A claim starts in `starting` with a 60-second boot lease, becomes `running` after the world loads, changes to `draining` during shutdown, and ends `stopped`. The process renews each 15-second running lease every 5 seconds. An expired room can be claimed by a new holder; every claim increments its lease version. The write-behind transaction checks and locks the holder/version row before committing game state, so a former holder cannot commit after its lease is replaced. A room whose renewal fails stops ticking and closes its clients. PostgreSQL is the lease authority; Valkey continues to serve only the existing optional ticket and rate-limit roles.
++
++The Players tab lists room buttons when multiple rooms are enabled. Transfer is a validated `transfer` intent with a destination `instanceId`. The server checks that the target belongs to the same place, is in range, is running, and has capacity before leaving the source. It flushes the character before joining the target on the same authenticated WebSocket connection. The original single-use upgrade ticket is never reused, and the destination repeats the definition digest check. A refused transfer keeps the source session. Protocol v22 adds the transfer message and optional room fields in `welcome`; v21 clients can still join and play. With the default single room, `welcome` has no room fields and transfers are unavailable.
++
++`GAMES_INTEREST_CELL` sets the ground-plane spatial hash cell width in metres (default `16`). `GAMES_INTEREST_RADIUS` sets the enter radius (default `80`; legacy `INTEREST_RADIUS` still works). Each client tracks the cells intersecting its interest area; crossing cell boundaries refreshes its subscriptions and sends spawn/despawn differences. A known entity leaves at 110% of the enter radius, avoiding churn near the edge. Awake-body updates use subscribed cells and a precise distance check.
++
++The socketless probe is `node --import tsx apps/server/scripts/instanceLoad.ts 4`; `--baseline` uses direct M3-style routing. It joins clients, drives movement for 90 ticks amid 400 spread entities per room, transfers one player out and back, and reports mean/p95 tick time and bytes per client. The two-client test uses conservative timing headroom because scheduler and WASM warmup vary; it keeps a tighter bandwidth bound.
++
++Scope: room routing and transfer work only within one server process. A second process or host cannot accept a handoff yet; region routing across hosts and cross-process transfer tokens remain future work. The room registry is shared in PostgreSQL, but this release does not direct a client to a remote process.
ddiff --git a/docs/mods.md b/docs/mods.md
iindex 6e53263..320425a 100644
---- a/docs/mods.md
++++ b/docs/mods.md
@@@ -87,23 +87,24 @@ The only thing the host adds to a fresh QuickJS context is the frozen global
`game`. Everything else is the language's own intrinsics: no `require`,
`process`, `fs`, network, timers, `console` or QuickJS `std`/`os`.
--| call | does |
--| ------------------------------ | -------------------------------------------------------------------------------------------------- |
--| `game.players()` | every player: `[{ id, name, pos: [x, y, z], yaw }]` (a copy) |
--| `game.player(id)` | one player, or `null` |
--| `game.emit(intent)` | queues an intent; a malformed one throws inside the script |
--| `game.log(...values)` | one `script mod log` line in the server log (4 per tick at most) |
--| `game.random()` | deterministic value in `[0, 1)` from the place id and mod id; `Math.random()` uses the same stream |
--| `game.message(playerId, text)` | queue a private announcement to one player (requires `player.message`) |
--| `game.storage.get(key)` | read JSON, or `null` if absent |
--| `game.storage.set(key, value)` | queue a persisted JSON value; writes are allowed during hooks |
--| `game.storage.delete(key)` | remove a key, returning whether it existed |
--| `game.storage.keys(prefix?)` | sorted keys with an optional prefix |
++| call | does |
++| ------------------------------ | --------------------------------------------------------------------------------------------------------- |
++| `game.players()` | every player: `[{ id, name, pos: [x, y, z], yaw }]` (a copy) |
++| `game.player(id)` | one player, or `null` |
++| `game.emit(intent)` | queues an intent; a malformed one throws inside the script |
++| `game.log(...values)` | one `script mod log` line in the server log (4 per tick at most) |
++| `game.random()` | deterministic value in `[0, 1)` from the place/room seed and mod id; `Math.random()` uses the same stream |
++| `game.message(playerId, text)` | queue a private announcement to one player (requires `player.message`) |
++| `game.storage.get(key)` | read JSON, or `null` if absent |
++| `game.storage.set(key, value)` | queue a persisted JSON value; writes are allowed during hooks |
++| `game.storage.delete(key)` | remove a key, returning whether it existed |
++| `game.storage.keys(prefix?)` | sorted keys with an optional prefix |
`game.players` and `game.player` require `players.read`; `game.emit` requires
`world.emit`; `game.message` requires `player.message`; the storage object
requires `storage.kv`. A binding without its grant is absent from the frozen
--`game` object. `game.log` and `game.random` are always available. The Contracts
++`game` object. `game.log` and `game.random` are always available. Extra M4 rooms
++include their slot in the random seed; the first room keeps the old seed. The Contracts
install manifest uses `games.players.read`, `games.world.emit`,
`games.player.message`, and `games.storage.kv` for these script requests.
For example:
ddiff --git a/packages/gameplay/src/spatial/spatialHash.ts b/packages/gameplay/src/spatial/spatialHash.ts
iindex cecbf7a..5231889 100644
---- a/packages/gameplay/src/spatial/spatialHash.ts
++++ b/packages/gameplay/src/spatial/spatialHash.ts
@@@ -84,6 +84,27 @@ export class SpatialHash<T> {
}
}
++ /** Grid cells intersecting a square around a player; stable keys for subscriptions. */
++ cellsInRadius(x: number, z: number, radius: number): Set<number> {
++ const keys = new Set<number>()
++ const minX = Math.floor((x - radius) / this.cellSize) + OFFSET
++ const maxX = Math.floor((x + radius) / this.cellSize) + OFFSET
++ const minZ = Math.floor((z - radius) / this.cellSize) + OFFSET
++ const maxZ = Math.floor((z + radius) / this.cellSize) + OFFSET
++ for (let cx = minX; cx <= maxX; cx++) {
++ for (let cz = minZ; cz <= maxZ; cz++) keys.add(cx * (OFFSET * 2) + cz)
++ }
++ return keys
++ }
++
++ forEachInCells(keys: Iterable<number>, visit: (item: T) => void): void {
++ for (const key of keys) {
++ const cell = this.cells.get(key)
++ if (!cell) continue
++ for (const item of cell) visit(item)
++ }
++ }
++
/** Radius candidates as an array (convenience for call sites that filter). */
queryRadius(x: number, z: number, radius: number): T[] {
const out: T[] = []
ddiff --git a/packages/persistence/migrations/0003_instances.sql b/packages/persistence/migrations/0003_instances.sql
nnew file mode 100644
iindex 0000000..5d40192
---- /dev/null
++++ b/packages/persistence/migrations/0003_instances.sql
@@@ -0,0 +1,30 @@
++-- phase: expand
++-- A slot is stable across process restarts. A new holder increments the version;
++-- writes from a former holder must check that version inside their transaction.
++CREATE TABLE game_instances (
++ place_id text NOT NULL REFERENCES places(id),
++ slot integer NOT NULL CHECK (slot >= 0),
++ id text COLLATE "C" NOT NULL,
++ capacity integer NOT NULL CHECK (capacity > 0),
++ state text NOT NULL CHECK (state IN ('starting', 'running', 'draining', 'stopped')),
++ holder text COLLATE "C" NOT NULL,
++ lease_version bigint NOT NULL DEFAULT 1,
++ heartbeat_at bigint NOT NULL,
++ lease_until bigint NOT NULL,
++ PRIMARY KEY (place_id, slot),
++ UNIQUE (id)
++);
++CREATE INDEX game_instances_live ON game_instances (place_id, state, lease_until);
++
++-- A character keeps a last position in every instance it has visited. Its
++-- inventory and progression remain one global character row.
++CREATE TABLE character_instance_positions (
++ character_id text COLLATE "C" NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
++ instance_id text COLLATE "C" NOT NULL REFERENCES game_instances(id),
++ pos_x double precision NOT NULL,
++ pos_y double precision NOT NULL,
++ pos_z double precision NOT NULL,
++ yaw double precision NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (character_id, instance_id)
++);
ddiff --git a/packages/persistence/src/index.ts b/packages/persistence/src/index.ts
iindex 4e0f415..7d1fc07 100644
---- a/packages/persistence/src/index.ts
++++ b/packages/persistence/src/index.ts
@@@ -2,4 +2,5 @@ export * from './dto.js'
export * from './repositories.js'
export { accountKey } from './accountKey.js'
export { openPgStore, type PgPersistenceStore, type PgStoreOptions } from './pg/pgStore.js'
++export { InstanceRegistry, type InstanceLease, type InstanceState } from './pg/instanceRegistry.js'
export { MIGRATIONS_DIR } from './migrations.js'
ddiff --git a/packages/persistence/src/pg/instanceRegistry.test.ts b/packages/persistence/src/pg/instanceRegistry.test.ts
nnew file mode 100644
iindex 0000000..5d1b2b1
---- /dev/null
++++ b/packages/persistence/src/pg/instanceRegistry.test.ts
@@@ -0,0 +1,52 @@
++import { beforeAll, describe, expect, it } from 'vitest'
++import { openTestDb } from '../testing.js'
++import { sql, type Db } from 'openvibe-sdk/db'
++import { InstanceRegistry } from './instanceRegistry.js'
++import { openPgStore } from './pgStore.js'
++
++let db: Db
++beforeAll(async () => {
++ db = await openTestDb()
++ await db.exec(sql`INSERT INTO places (id, name) VALUES ('arena', 'Arena')`)
++})
++
++describe('PostgreSQL instance leases', () => {
++ it('claims, renews, expires, and fences the old holder', async () => {
++ const registry = new InstanceRegistry(db, 'arena')
++ const first = await registry.claim(0, 2, 'process-a', 100, 50)
++ expect(first).toMatchObject({ id: 'arena:0', state: 'starting', version: 1 })
++ if (!first) throw new Error('claim failed')
++ expect(await registry.claim(0, 2, 'process-b', 120, 50)).toBeNull()
++ const running = await registry.renew(first, 120, 50, 'running')
++ expect(running?.state).toBe('running')
++ if (!running) throw new Error('renew failed')
++ await db.tx(async () => registry.assertHeld(running, 130))
++ const second = await registry.claim(0, 2, 'process-b', 171, 50)
++ expect(second?.version).toBe(first.version + 1)
++ expect(await registry.renew(running, 172, 50)).toBeNull()
++ await expect(db.tx(async () => registry.assertHeld(running, 172))).rejects.toThrow('lease lost')
++ if (!second) throw new Error('reclaim failed')
++ expect(await registry.stop(second)).toBe(true)
++ expect(await registry.claim(0, 2, 'process-c', 173, 50)).not.toBeNull()
++ })
++
++ it('refuses a world-state write after its lease version is stolen', async () => {
++ const registry = new InstanceRegistry(db, 'arena')
++ let now = 1000
++ const claim = await registry.claim(1, 2, 'writer-a', now, 100)
++ if (!claim) throw new Error('claim failed')
++ const running = await registry.renew(claim, now + 1, 100, 'running')
++ if (!running) throw new Error('renew failed')
++ const store = openPgStore(db, {
++ placeId: 'arena',
++ instanceSlot: 1,
++ leaseGuard: () => registry.assertHeld(running, now),
++ })
++ await store.ensurePlace()
++ await store.meta.set('clock', 1)
++ now = 1102
++ expect(await registry.claim(1, 2, 'writer-b', now, 100)).not.toBeNull()
++ await expect(store.meta.set('clock', 2)).rejects.toThrow('lease lost')
++ expect(await store.meta.get('clock')).toBe(1)
++ })
++})
ddiff --git a/packages/persistence/src/pg/instanceRegistry.ts b/packages/persistence/src/pg/instanceRegistry.ts
nnew file mode 100644
iindex 0000000..aac1ac5
---- /dev/null
++++ b/packages/persistence/src/pg/instanceRegistry.ts
@@@ -0,0 +1,115 @@
++import { sql, type Db } from 'openvibe-sdk/db'
++
++export type InstanceState = 'starting' | 'running' | 'draining' | 'stopped'
++
++export interface InstanceLease {
++ id: string
++ placeId: string
++ slot: number
++ capacity: number
++ state: InstanceState
++ holder: string
++ version: number
++ heartbeatAt: number
++ leaseUntil: number
++}
++
++interface InstanceRow {
++ id: string
++ place_id: string
++ slot: number
++ capacity: number
++ state: InstanceState
++ holder: string
++ lease_version: number
++ heartbeat_at: number
++ lease_until: number
++}
++
++function lease(row: InstanceRow): InstanceLease {
++ return {
++ id: row.id,
++ placeId: row.place_id,
++ slot: row.slot,
++ capacity: row.capacity,
++ state: row.state,
++ holder: row.holder,
++ version: Number(row.lease_version),
++ heartbeatAt: Number(row.heartbeat_at),
++ leaseUntil: Number(row.lease_until),
++ }
++}
++
++/** PostgreSQL is the lease authority. Every transition checks holder and version. */
++export class InstanceRegistry {
++ constructor(
++ private readonly db: Db,
++ readonly placeId: string,
++ ) {}
++
++ async list(): Promise<InstanceLease[]> {
++ return (
++ await this.db.many<InstanceRow>(
++ sql`SELECT * FROM game_instances WHERE place_id = ${this.placeId} ORDER BY slot`,
++ )
++ ).map(lease)
++ }
++
++ /** Claim one configured slot. Expired claims can be stolen; each steal fences the old holder. */
++ async claim(
++ slot: number,
++ capacity: number,
++ holder: string,
++ now: number,
++ ttlMs: number,
++ ): Promise<InstanceLease | null> {
++ if (!Number.isInteger(slot) || slot < 0 || capacity < 1 || ttlMs < 1)
++ throw new Error('invalid instance claim')
++ const id = `${this.placeId}:${slot}`
++ const row = await this.db.maybe<InstanceRow>(sql`
++ INSERT INTO game_instances (place_id, slot, id, capacity, state, holder, heartbeat_at, lease_until)
++ VALUES (${this.placeId}, ${slot}, ${id}, ${capacity}, 'starting', ${holder}, ${now}, ${now + ttlMs})
++ ON CONFLICT (place_id, slot) DO UPDATE SET
++ capacity = excluded.capacity, state = 'starting', holder = excluded.holder,
++ lease_version = game_instances.lease_version + 1,
++ heartbeat_at = excluded.heartbeat_at, lease_until = excluded.lease_until
++ WHERE game_instances.lease_until <= ${now} OR game_instances.state = 'stopped'
++ RETURNING *
++ `)
++ return row ? lease(row) : null
++ }
++
++ async renew(
++ current: InstanceLease,
++ now: number,
++ ttlMs: number,
++ state: InstanceState = current.state,
++ ): Promise<InstanceLease | null> {
++ const row = await this.db.maybe<InstanceRow>(sql`
++ UPDATE game_instances SET heartbeat_at = ${now}, lease_until = ${now + ttlMs}, state = ${state}
++ WHERE id = ${current.id} AND holder = ${current.holder}
++ AND lease_version = ${current.version} AND lease_until > ${now}
++ RETURNING *
++ `)
++ return row ? lease(row) : null
++ }
++
++ async stop(current: InstanceLease): Promise<boolean> {
++ const row = await this.db.maybe<{ id: string }>(sql`
++ UPDATE game_instances SET state = 'stopped', lease_until = 0
++ WHERE id = ${current.id} AND holder = ${current.holder} AND lease_version = ${current.version}
++ RETURNING id
++ `)
++ return row !== null
++ }
++
++ /** Call in the same transaction as the protected write. SELECT FOR UPDATE blocks a competing claim. */
++ async assertHeld(current: InstanceLease, now: number): Promise<void> {
++ const row = await this.db.maybe<{ id: string }>(sql`
++ SELECT id FROM game_instances WHERE id = ${current.id} AND holder = ${current.holder}
++ AND lease_version = ${current.version} AND lease_until > ${now}
++ AND state IN ('starting', 'running', 'draining') FOR UPDATE
++ `)
++ if (!row) throw new Error(`instance lease lost: ${current.id}`)
++ }
++}
ddiff --git a/packages/persistence/src/pg/pgStore.ts b/packages/persistence/src/pg/pgStore.ts
iindex 4861f94..6143826 100644
---- a/packages/persistence/src/pg/pgStore.ts
++++ b/packages/persistence/src/pg/pgStore.ts
@@@ -19,6 +19,10 @@ import { BATCH_CHUNK, chunks, idList } from './sqlUtils.js'
export interface PgStoreOptions {
/** GAMES_PLACE_ID (default 'scraplandia'): the place every world row belongs to on this instance. */
placeId?: string
++ /** A non-primary in-process room gets its own persistent world rows. */
++ instanceSlot?: number
++ /** Runs inside each game-state transaction, holding the lease row lock until commit. */
++ leaseGuard?: () => Promise<void>
}
export interface PgPersistenceStore extends PersistenceStore {
@@@ -77,12 +81,23 @@ interface ConstraintRow {
export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceStore {
const placeId = opts.placeId ?? process.env.GAMES_PLACE_ID ?? 'scraplandia'
++ const statePlaceId =
++ opts.instanceSlot && opts.instanceSlot > 0
++ ? `${placeId}:instance:${opts.instanceSlot}`
++ : placeId
++ const writeGameState = <T>(run: () => Promise<T>): Promise<T> =>
++ opts.leaseGuard
++ ? db.tx(async () => {
++ await opts.leaseGuard?.()
++ return run()
++ })
++ : run()
const worldEntities: WorldEntityRepository = {
async loadAll(): Promise<WorldEntityDto[]> {
const rows = await db.many<WorldEntityRow>(
sql`SELECT id, kind, def_id, owner_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_w, motion, state, updated_at
-- FROM world_entities WHERE place_id = ${placeId}`,
++ FROM world_entities WHERE place_id = ${statePlaceId}`,
)
return rows.map((row) => ({
id: row.id,
@@@ -97,25 +112,26 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
}))
},
async upsertMany(entities: readonly WorldEntityDto[]): Promise<void> {
-- for (const chunk of chunks(entities, BATCH_CHUNK)) {
-- const rows = chunk.map((e) => ({
-- place_id: placeId,
-- id: e.id,
-- kind: e.kind,
-- def_id: e.defId,
-- owner_id: e.ownerId,
-- pos_x: e.pos[0],
-- pos_y: e.pos[1],
-- pos_z: e.pos[2],
-- rot_x: e.rot[0],
-- rot_y: e.rot[1],
-- rot_z: e.rot[2],
-- rot_w: e.rot[3],
-- motion: e.motion,
-- state: e.state === null ? null : sql.json(e.state),
-- updated_at: e.updatedAt,
-- }))
-- await db.query(sql`
++ return writeGameState(async () => {
++ for (const chunk of chunks(entities, BATCH_CHUNK)) {
++ const rows = chunk.map((e) => ({
++ place_id: statePlaceId,
++ id: e.id,
++ kind: e.kind,
++ def_id: e.defId,
++ owner_id: e.ownerId,
++ pos_x: e.pos[0],
++ pos_y: e.pos[1],
++ pos_z: e.pos[2],
++ rot_x: e.rot[0],
++ rot_y: e.rot[1],
++ rot_z: e.rot[2],
++ rot_w: e.rot[3],
++ motion: e.motion,
++ state: e.state === null ? null : sql.json(e.state),
++ updated_at: e.updatedAt,
++ }))
++ await db.query(sql`
INSERT INTO world_entities
${sql.insert(rows, [
'place_id',
@@@ -139,18 +155,25 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
rot_x=excluded.rot_x, rot_y=excluded.rot_y, rot_z=excluded.rot_z, rot_w=excluded.rot_w,
motion=excluded.motion, state=excluded.state, owner_id=excluded.owner_id, updated_at=excluded.updated_at
`)
-- }
++ }
++ })
},
async deleteMany(ids: readonly string[]): Promise<void> {
-- for (const chunk of chunks(ids, BATCH_CHUNK)) {
-- if (chunk.length === 0) continue
-- await db.exec(
-- sql`DELETE FROM world_entities WHERE place_id = ${placeId} AND id IN (${idList(chunk)})`,
-- )
-- }
++ return writeGameState(async () => {
++ for (const chunk of chunks(ids, BATCH_CHUNK)) {
++ if (chunk.length === 0) continue
++ await db.exec(
++ sql`DELETE FROM world_entities WHERE place_id = ${statePlaceId} AND id IN (${idList(chunk)})`,
++ )
++ }
++ })
},
async deleteByKind(kind: string): Promise<void> {
-- await db.exec(sql`DELETE FROM world_entities WHERE place_id = ${placeId} AND kind = ${kind}`)
++ await writeGameState(() =>
++ db.exec(
++ sql`DELETE FROM world_entities WHERE place_id = ${statePlaceId} AND kind = ${kind}`,
++ ),
++ )
},
}
@@@ -243,28 +266,67 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
const row = await db.maybe<CharacterRow>(
sql`${characters} WHERE account_key = ${accountKey(token)} AND slot = ${slot}`,
)
-- return row ? rowToPlayer(row) : null
++ if (!row) return null
++ const player = rowToPlayer(row)
++ if (opts.instanceSlot !== undefined) {
++ const position = await db.maybe<{
++ pos_x: number
++ pos_y: number
++ pos_z: number
++ yaw: number
++ }>(sql`
++ SELECT pos_x, pos_y, pos_z, yaw FROM character_instance_positions
++ WHERE character_id = ${player.id} AND instance_id = ${`${placeId}:${opts.instanceSlot}`}
++ `)
++ if (position) {
++ player.pos = [position.pos_x, position.pos_y, position.pos_z]
++ player.yaw = position.yaw
++ }
++ }
++ return player
},
async findById(id: string): Promise<PlayerDto | null> {
const row = await db.maybe<CharacterRow>(sql`${characters} WHERE id = ${id}`)
return row ? rowToPlayer(row) : null
},
async upsert(player: PlayerDto): Promise<void> {
-- await db.query(upsertCharacter([player]))
++ await writeGameState(async () => {
++ await db.query(upsertCharacter([player]))
++ await savePositions([player])
++ })
},
async upsertMany(list: readonly PlayerDto[]): Promise<void> {
-- for (const chunk of chunks(list, BATCH_CHUNK)) {
-- if (chunk.length === 0) continue
-- await db.query(upsertCharacter(chunk))
-- }
++ await writeGameState(async () => {
++ for (const chunk of chunks(list, BATCH_CHUNK)) {
++ if (chunk.length === 0) continue
++ await db.query(upsertCharacter(chunk))
++ await savePositions(chunk)
++ }
++ })
},
async resetAllPositions(pos: [number, number, number], yaw: number): Promise<void> {
-- await db.exec(
-- sql`UPDATE characters SET pos_x = ${pos[0]}, pos_y = ${pos[1]}, pos_z = ${pos[2]}, yaw = ${yaw}`,
++ await writeGameState(() =>
++ db.exec(
++ sql`UPDATE characters SET pos_x = ${pos[0]}, pos_y = ${pos[1]}, pos_z = ${pos[2]}, yaw = ${yaw}`,
++ ),
)
},
}
++ async function savePositions(list: readonly PlayerDto[]): Promise<void> {
++ if (opts.instanceSlot === undefined) return
++ const instanceId = `${placeId}:${opts.instanceSlot}`
++ for (const player of list) {
++ await db.exec(sql`
++ INSERT INTO character_instance_positions (character_id, instance_id, pos_x, pos_y, pos_z, yaw, updated_at)
++ VALUES (${player.id}, ${instanceId}, ${player.pos[0]}, ${player.pos[1]}, ${player.pos[2]}, ${player.yaw}, ${player.updatedAt})
++ ON CONFLICT (character_id, instance_id) DO UPDATE SET
++ pos_x = excluded.pos_x, pos_y = excluded.pos_y, pos_z = excluded.pos_z,
++ yaw = excluded.yaw, updated_at = excluded.updated_at
++ `)
++ }
++ }
++
function upsertCharacter(list: readonly PlayerDto[]): Sql {
return sql`
INSERT INTO characters
@@@ -283,7 +345,7 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
const constraints: ConstraintRepository = {
async loadAll(): Promise<ConstraintDto[]> {
const rows = await db.many<ConstraintRow>(
-- sql`SELECT id, type, entity_a, entity_b, params, updated_at FROM world_constraints WHERE place_id = ${placeId}`,
++ sql`SELECT id, type, entity_a, entity_b, params, updated_at FROM world_constraints WHERE place_id = ${statePlaceId}`,
)
return rows.map((row) => ({
id: row.id,
@@@ -295,51 +357,57 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
}))
},
async upsertMany(list: readonly ConstraintDto[]): Promise<void> {
-- for (const chunk of chunks(list, BATCH_CHUNK)) {
-- if (chunk.length === 0) continue
-- const rows = chunk.map((c) => ({
-- place_id: placeId,
-- id: c.id,
-- type: c.type,
-- entity_a: c.entityA,
-- entity_b: c.entityB,
-- params: c.params === null ? null : sql.json(c.params),
-- updated_at: c.updatedAt,
-- }))
-- await db.query(sql`
++ await writeGameState(async () => {
++ for (const chunk of chunks(list, BATCH_CHUNK)) {
++ if (chunk.length === 0) continue
++ const rows = chunk.map((c) => ({
++ place_id: statePlaceId,
++ id: c.id,
++ type: c.type,
++ entity_a: c.entityA,
++ entity_b: c.entityB,
++ params: c.params === null ? null : sql.json(c.params),
++ updated_at: c.updatedAt,
++ }))
++ await db.query(sql`
INSERT INTO world_constraints
${sql.insert(rows, ['place_id', 'id', 'type', 'entity_a', 'entity_b', 'params', 'updated_at'])}
ON CONFLICT (place_id, id) DO UPDATE SET
params = excluded.params, updated_at = excluded.updated_at
`)
-- }
++ }
++ })
},
async deleteMany(ids: readonly string[]): Promise<void> {
-- for (const chunk of chunks(ids, BATCH_CHUNK)) {
-- if (chunk.length === 0) continue
-- await db.exec(
-- sql`DELETE FROM world_constraints WHERE place_id = ${placeId} AND id IN (${idList(chunk)})`,
-- )
-- }
++ await writeGameState(async () => {
++ for (const chunk of chunks(ids, BATCH_CHUNK)) {
++ if (chunk.length === 0) continue
++ await db.exec(
++ sql`DELETE FROM world_constraints WHERE place_id = ${statePlaceId} AND id IN (${idList(chunk)})`,
++ )
++ }
++ })
},
}
const meta: MetaRepository = {
async get(key: string): Promise<unknown | null> {
const value = await db.value<unknown>(
-- sql`SELECT value FROM world_meta WHERE place_id = ${placeId} AND key = ${key}`,
++ sql`SELECT value FROM world_meta WHERE place_id = ${statePlaceId} AND key = ${key}`,
)
return value ?? null
},
async set(key: string, value: unknown): Promise<void> {
-- await db.exec(sql`
-- INSERT INTO world_meta (place_id, key, value) VALUES (${placeId}, ${key}, ${sql.json(value)})
++ await writeGameState(() =>
++ db.exec(sql`
++ INSERT INTO world_meta (place_id, key, value) VALUES (${statePlaceId}, ${key}, ${sql.json(value)})
ON CONFLICT (place_id, key) DO UPDATE SET value = excluded.value
-- `)
++ `),
++ )
},
async list(prefix: string): Promise<{ key: string; value: unknown }[]> {
const rows = await db.many<{ key: string; value: unknown }>(
-- sql`SELECT key, value FROM world_meta WHERE place_id = ${placeId} AND key LIKE ${`${prefix}%`} ORDER BY key`,
++ sql`SELECT key, value FROM world_meta WHERE place_id = ${statePlaceId} AND key LIKE ${`${prefix}%`} ORDER BY key`,
)
return rows.map((r) => ({ key: r.key, value: r.value }))
},
@@@ -352,6 +420,10 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
await db.exec(
sql`INSERT INTO places (id, name) VALUES (${placeId}, ${name}) ON CONFLICT (id) DO NOTHING`,
)
++ if (statePlaceId !== placeId)
++ await db.exec(
++ sql`INSERT INTO places (id, name) VALUES (${statePlaceId}, ${name}) ON CONFLICT (id) DO NOTHING`,
++ )
},
worldEntities,
players,
@@@ -361,7 +433,10 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
mods: createModRepository(db),
mediaMirrors: createMediaMirrorRepository(db),
transaction<T>(fn: (t: Tx) => Promise<T>): Promise<T> {
-- return db.tx(fn)
++ return db.tx(async (t) => {
++ await opts.leaseGuard?.()
++ return fn(t)
++ })
},
close(): Promise<void> {
return db.close()
ddiff --git a/packages/persistence/src/testing.ts b/packages/persistence/src/testing.ts
iindex 7239984..47aaae7 100644
---- a/packages/persistence/src/testing.ts
++++ b/packages/persistence/src/testing.ts
@@@ -13,6 +13,8 @@ export { MIGRATIONS_DIR }
const TABLES = [
'places',
++ 'game_instances',
++ 'character_instance_positions',
'world_meta',
'world_entities',
'world_constraints',
ddiff --git a/packages/protocol/src/codec.test.ts b/packages/protocol/src/codec.test.ts
iindex 018a930..80e25ea 100644
---- a/packages/protocol/src/codec.test.ts
++++ b/packages/protocol/src/codec.test.ts
@@@ -19,6 +19,7 @@ describe('protocol codec', () => {
},
{ t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yawQ: 12345, pitchQ: -4096, intents: 5 },
{ t: 'use', target: 'abc123' },
++ { t: 'transfer', instanceId: 'scraplandia:1' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
{ t: 'drop', slot: 3, count: 5 },
{ t: 'inv_move', from: 0, to: 5, count: 3 },
@@@ -36,6 +37,7 @@ describe('protocol codec', () => {
expect(decodeClientMessage('not json')).toBeNull()
expect(decodeClientMessage('{"t":"nope"}')).toBeNull()
expect(decodeClientMessage('{"t":"input","seq":-1}')).toBeNull()
++ expect(decodeClientMessage('{"t":"transfer","instanceId":""}')).toBeNull()
// NaN/Infinity smuggling
expect(
decodeClientMessage(
ddiff --git a/packages/protocol/src/messages/client.ts b/packages/protocol/src/messages/client.ts
iindex 85f27ed..03afa4d 100644
---- a/packages/protocol/src/messages/client.ts
++++ b/packages/protocol/src/messages/client.ts
@@@ -22,6 +22,12 @@ export const ClientHelloSchema = z.object({
appearance: AppearanceSchema,
})
++/** Move to another running room of the same place. The server verifies the target. */
++export const ClientTransferSchema = z.object({
++ t: z.literal('transfer'),
++ instanceId: z.string().min(1).max(128),
++})
++
/**
* One input command per simulation tick. The client sends these at tick rate;
* `seq` is echoed in snapshots for prediction reconciliation. The server
@@@ -285,6 +291,7 @@ export const ClientContainerSortSchema = z.object({
})
export const ClientMessageSchema = z.union([
++ ClientTransferSchema,
ClientEditModeSchema,
ClientHelloSchema,
ClientInputSchema,
@@@ -315,6 +322,7 @@ export const ClientMessageSchema = z.union([
])
export type ClientHello = z.infer<typeof ClientHelloSchema>
++export type ClientTransfer = z.infer<typeof ClientTransferSchema>
export type ClientEditMode = z.infer<typeof ClientEditModeSchema>
export type ClientInput = z.infer<typeof ClientInputSchema>
export type ClientUse = z.infer<typeof ClientUseSchema>
ddiff --git a/packages/protocol/src/messages/server.ts b/packages/protocol/src/messages/server.ts
iindex 50190c1..847b018 100644
---- a/packages/protocol/src/messages/server.ts
++++ b/packages/protocol/src/messages/server.ts
@@@ -25,6 +25,16 @@ export interface ServerWelcome {
tick: number
tickRate: number
snapshotRate: number
++ /** Present when multi-instance mode is enabled. */
++ instanceId?: string
++ instanceCount?: number
++}
++
++export interface ServerTransferResult {
++ t: 'transfer_result'
++ accepted: boolean
++ instanceId: string
++ reason?: 'same_instance' | 'unknown_instance' | 'instance_full' | 'unavailable'
}
export interface ServerReject {
@@@ -262,6 +272,7 @@ export interface ServerContainer {
export type ServerMessage =
| ServerWelcome
++ | ServerTransferResult
| ServerReject
| ServerSpawn
| ServerDespawn
ddiff --git a/packages/protocol/src/version.ts b/packages/protocol/src/version.ts
iindex 79bd23d..64871b1 100644
---- a/packages/protocol/src/version.ts
++++ b/packages/protocol/src/version.ts
@@@ -1,2 +1,2 @@
/** Wire protocol version — bump on ANY breaking message change. */
--export const PROTOCOL_VERSION = 21
++export const PROTOCOL_VERSION = 22
ddiff --git a/test/apps__client__src__state__clientState.test.js b/test/apps__client__src__state__clientState.test.js
nnew file mode 100644
iindex 0000000..4e8ddf1
---- /dev/null
++++ b/test/apps__client__src__state__clientState.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/client/src/state/clientState.test.ts'
++// covers: 'apps/client/src/state'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/apps__server__src__game__instanceHub.test.js b/test/apps__server__src__game__instanceHub.test.js
nnew file mode 100644
iindex 0000000..90a4fa2
---- /dev/null
++++ b/test/apps__server__src__game__instanceHub.test.js
@@@ -0,0 +1,3 @@
++// file: 'apps/server/src/game/instanceHub.test.ts'
++// covers: 'apps/server/src/game'
++// Generated by node test/run.js --index; do not edit.
ddiff --git a/test/packages__persistence__src__pg__instanceRegistry.test.js b/test/packages__persistence__src__pg__instanceRegistry.test.js
nnew file mode 100644
iindex 0000000..62b8185
---- /dev/null
++++ b/test/packages__persistence__src__pg__instanceRegistry.test.js
@@@ -0,0 +1,3 @@
++// file: 'packages/persistence/src/pg/instanceRegistry.test.ts'
++// covers: 'packages/persistence/src/pg'
++// Generated by node test/run.js --index; do not edit.