openvibers/openvibe.games Commits
Games M4: instances per place, fenced leases, room transfer, region interest (one process) (#45)
Changes
@@@ -11,6 +11,12 @@ DATABASE_DIR=dataGAMES_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=@@@ -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 trafficdisconnects. JSON encoding today behind an encode/decode boundary sized fora 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 isbroadcast and it drops out of snapshots; on wake it re-enters. Settledprops 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 mostseeds from the world definition; afterwards the database is the world's source of truth. Thesimulation 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 boundaryAdapters to the OpenVibe platform live in `apps/server/src/platform` and@@@ -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 TCPpeer is the client and `X-Forwarded-For`/`X-Real-IP`/`CF-Connecting-IP` are ignored (they areclient-supplied and would otherwise let a caller mint fresh rate-limit buckets); `true` means the@@@ -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@@@ -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",@@@ -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-sharedshipped.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. -->@@@ -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,@@@ -343,7 +343,18 @@ async function start(): Promise<void> {})let rejected = falseconnection.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 {nnew file mode 100644@@@ -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')++})@@@ -61,6 +61,8 @@ export class ClientState {/** OpenVibe rank from welcome (owner/admin/moderator, null = player). */myRank: 'owner' | 'admin' | 'moderator' | null = nullmyPlayerId = ''++ 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 ?? 1this.myEntityId = msg.entityIdthis.myPlayerId = msg.playerIdthis.myRank = msg.rank@@@ -109,6 +120,8 @@ export class ClientState {this.serverTick = msg.tickthis.events.emit('welcome', { entityId: msg.entityId, tickRate: msg.tickRate })break++ case 'transfer_result':++ breakcase 'reject':breakcase 'spawn':@@@ -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.'nnew file mode 100644@@@ -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,++ ),++)@@@ -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('')@@@ -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?: numbermaxPlayers: numberpersistFlushSeconds: numbermetricsLogSeconds: 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++}@@@ -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)@@@ -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)nnew file mode 100644@@@ -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)++})nnew file mode 100644@@@ -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}`++ }++}@@@ -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(),@@@ -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: booleansend(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,@@@ -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 = 80const 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()@@@ -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 * exitRadiusconst 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) returnconst 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. */@@@ -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.ctxconst 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)@@@ -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 undefinedconst { 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') returnfor (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) returnconst 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 = falseclearInterval(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 httpDoneawait 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,@@@ -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@@@ -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.@@@ -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",@@@ -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"}@@@ -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": [@@@ -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": [@@@ -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": [@@@ -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"}nnew file mode 100644@@@ -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.@@@ -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 objectrequires `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 Contractsinstall manifest uses `games.players.read`, `games.world.emit`,`games.player.message`, and `games.storage.kv` for these script requests.For example:@@@ -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[] = []nnew file mode 100644@@@ -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)++);@@@ -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'nnew file mode 100644@@@ -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)++ })++})nnew file mode 100644@@@ -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}`)++ }++}@@@ -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 = {}): PgPersistenceStorot_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 = {}): PgPersistenceStoconst 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 = {}): PgPersistenceStoconst 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 SETparams = 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 = {}): PgPersistenceStoawait 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 = {}): PgPersistenceStomods: 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()@@@ -13,6 +13,8 @@ export { MIGRATIONS_DIR }const TABLES = ['places',++ 'game_instances',++ 'character_instance_positions','world_meta','world_entities','world_constraints',@@@ -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 smugglingexpect(decodeClientMessage(@@@ -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>@@@ -25,6 +25,16 @@ export interface ServerWelcome {tick: numbertickRate: numbersnapshotRate: 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@@@ -1,2 +1,2 @@/** Wire protocol version — bump on ANY breaking message change. */--export const PROTOCOL_VERSION = 21++export const PROTOCOL_VERSION = 22nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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.
@@ -11,6 +11,12 @@ DATABASE_DIR=data
@@ -11,6 +11,12 @@ DATABASE_DIR=data
GAMES_MIGRATIONS_DIR=
GAMES_MIGRATIONS_DIR=
# The place this instance serves; world rows and meta are keyed by it.
# The place this instance serves; world rows and meta are keyed by it.
GAMES_PLACE_ID=scraplandia
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 (optional): shared per-actor rate-limit counters across instances ──
VALKEY_URL=
VALKEY_URL=
@@ -69,10 +69,10 @@ scene or engine, so there is no Node-only path to keep separate.
@@ -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
zod-validated (range-clamped axes, size caps) and malformed traffic
disconnects. JSON encoding today behind an encode/decode boundary sized for
disconnects. JSON encoding today behind an encode/decode boundary sized for
a binary codec swap.
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
- **Sleep networking:** when a prop settles, one final `entity` pin is
broadcast and it drops out of snapshots; on wake it re-enters. Settled
broadcast and it drops out of snapshots; on wake it re-enters. Settled
props also mark themselves dirty exactly once for the persistence flush.
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
@@ -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
seeds from the world definition; afterwards the database is the world's source of truth. The
simulation never reads the platform tables.
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
## Platform boundary
Adapters to the OpenVibe platform live in `apps/server/src/platform` and
Adapters to the OpenVibe platform live in `apps/server/src/platform` and
@@ -115,6 +115,10 @@ an embedded PGlite database under `DATABASE_DIR` (default `data/`) — developme
@@ -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`
`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
(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).
(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
`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
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
client-supplied and would otherwise let a caller mint fresh rate-limit buckets); `true` means the
@@ -135,6 +135,10 @@ with live world reconciliation.
@@ -135,6 +135,10 @@ with live world reconciliation.
## Phase 11 — Scale 🔜 next (Stage 12)
## 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
- Region persistence/streaming when the authored world justifies it
- Network measurement first (bytes/client, snapshot sizes, entity counts);
- Network measurement first (bytes/client, snapshot sizes, entity counts);
binary snapshot codec only when measurements demand it
binary snapshot codec only when measurements demand it
@@ -6,7 +6,7 @@
@@ -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",
"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,
"port": 8000,
"domain": "openvibe.games, play.openvibe.games",
"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": [
"capabilities": [
"games.mod.manage",
"games.mod.manage",
"games.mod.read",
"games.mod.read",
@@ -25,6 +25,7 @@
@@ -25,6 +25,7 @@
"authoritative multiplayer physics sandbox (deterministic Rapier on the server)",
"authoritative multiplayer physics sandbox (deterministic Rapier on the server)",
"Source-style movement, physgun building, gathering, crafting, skills",
"Source-style movement, physgun building, gathering, crafting, skills",
"persistent world, characters and props in PostgreSQL (write-behind flusher; the tick never awaits I/O)",
"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",
"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",
"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",
"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",
@@ -347,13 +347,30 @@
@@ -347,13 +347,30 @@
gap: 6px 16px;
gap: 6px 16px;
padding: 12px 20px;
padding: 12px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
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>
</style>
</head>
</head>
<body>
<body>
@@ -375,9 +392,9 @@
@@ -375,9 +392,9 @@
<header>
<header>
<h1 class="hero-title">Open<b>Vibe.Games</b></h1>
<h1 class="hero-title">Open<b>Vibe.Games</b></h1>
<p class="hero-sub">
<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>
</p>
</header>
</header>
@@ -404,7 +421,6 @@
@@ -404,7 +421,6 @@
>
>
</div>
</div>
</div>
</div>
-
</section>
</section>
<section class="account">
<section class="account">
@@ -423,10 +439,30 @@
@@ -423,10 +439,30 @@
<!-- What shipped on OpenVibe.Games: the network's shared "shipped" views (openvibe-shared
<!-- 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. -->
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>
</section>
<!-- Shared cross-site footer (/shared/footer.js, this site's pinned copy): network links, legal, signed-in row. -->
<!-- Shared cross-site footer (/shared/footer.js, this site's pinned copy): network links, legal, signed-in row. -->
@@ -52,6 +52,14 @@ export class LocalPlayer {
@@ -52,6 +52,14 @@ export class LocalPlayer {
* EYES glide (Source's smoothed stair climb). */
* EYES glide (Source's smoothed stair climb). */
private stepOffset = 0
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(
constructor(
scene: Scene,
scene: Scene,
private readonly physics: PhysicsWorld,
private readonly physics: PhysicsWorld,
@@ -343,7 +343,18 @@ async function start(): Promise<void> {
@@ -343,7 +343,18 @@ async function start(): Promise<void> {
})
})
let rejected = false
let rejected = false
connection.onMessage = (msg) => {
connection.onMessage = (msg) => {
+ if (msg.t === 'welcome' && state.myEntityId && msg.instanceId !== state.instanceId) {
+ player.onTransfer()
+ }
state.apply(msg)
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') {
if (msg.t === 'map_reload') {
void (async () => {
void (async () => {
try {
try {
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -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')
+})
@@ -61,6 +61,8 @@ export class ClientState {
@@ -61,6 +61,8 @@ export class ClientState {
/** OpenVibe rank from welcome (owner/admin/moderator, null = player). */
/** OpenVibe rank from welcome (owner/admin/moderator, null = player). */
myRank: 'owner' | 'admin' | 'moderator' | null = null
myRank: 'owner' | 'admin' | 'moderator' | null = null
myPlayerId = ''
myPlayerId = ''
+ instanceId: string | null = null
+ instanceCount = 1
/** Player ids I trust with my props. */
/** Player ids I trust with my props. */
friends: { id: string; name: string }[] = []
friends: { id: string; name: string }[] = []
tickRate = 30
tickRate = 30
@@ -101,6 +103,15 @@ export class ClientState {
@@ -101,6 +103,15 @@ export class ClientState {
apply(msg: ServerMessage): void {
apply(msg: ServerMessage): void {
switch (msg.t) {
switch (msg.t) {
case 'welcome':
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.myEntityId = msg.entityId
this.myPlayerId = msg.playerId
this.myPlayerId = msg.playerId
this.myRank = msg.rank
this.myRank = msg.rank
@@ -109,6 +120,8 @@ export class ClientState {
@@ -109,6 +120,8 @@ export class ClientState {
this.serverTick = msg.tick
this.serverTick = msg.tick
this.events.emit('welcome', { entityId: msg.entityId, tickRate: msg.tickRate })
this.events.emit('welcome', { entityId: msg.entityId, tickRate: msg.tickRate })
break
break
+ case 'transfer_result':
+ break
case 'reject':
case 'reject':
break
break
case 'spawn':
case 'spawn':
@@ -63,6 +63,9 @@ export class Hud {
@@ -63,6 +63,9 @@ export class Hud {
state.events.on('craftJobs', () => this.renderMenu())
state.events.on('craftJobs', () => this.renderMenu())
state.events.on('skills', () => this.renderMenu())
state.events.on('skills', () => this.renderMenu())
state.events.on('friendsChanged', () => this.renderMenu())
state.events.on('friendsChanged', () => this.renderMenu())
+ state.events.on('welcome', () => {
+ if (this.activeTab === 'players') this.renderMenu()
+ })
state.events.on('entityAdded', () => {
state.events.on('entityAdded', () => {
if (this.activeTab === 'players') this.renderMenu()
if (this.activeTab === 'players') this.renderMenu()
})
})
@@ -941,6 +944,24 @@ export class Hud {
@@ -941,6 +944,24 @@ export class Hud {
private renderPlayers(): void {
private renderPlayers(): void {
const wrap = document.createElement('div')
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')
const hint = document.createElement('div')
hint.className = 'hint-line'
hint.className = 'hint-line'
hint.textContent = 'Trusted players can move and unfreeze your props.'
hint.textContent = 'Trusted players can move and unfreeze your props.'
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -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,
+ ),
+)
@@ -69,6 +69,27 @@ describe('loadConfig scriptMods', () => {
@@ -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', () => {
describe('loadConfig indexnowKey', () => {
it('is empty (IndexNow off) unless INDEXNOW_KEY is set, and never carries padding', () => {
it('is empty (IndexNow off) unless INDEXNOW_KEY is set, and never carries padding', () => {
expect(loadConfig({}).indexnowKey).toBe('')
expect(loadConfig({}).indexnowKey).toBe('')
@@ -60,6 +60,12 @@ export interface ServerConfig {
@@ -60,6 +60,12 @@ export interface ServerConfig {
interestRadius: number
interestRadius: number
/** Fraction the interest exit radius exceeds the enter radius (spawn/despawn hysteresis). */
/** Fraction the interest exit radius exceeds the enter radius (spawn/despawn hysteresis). */
interestHysteresis: number
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
maxPlayers: number
persistFlushSeconds: number
persistFlushSeconds: number
metricsLogSeconds: number
metricsLogSeconds: number
@@ -173,8 +179,14 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
@@ -173,8 +179,14 @@ export function loadConfig(env: NodeJS.ProcessEnv): ServerConfig {
indexnowKey: String(env.INDEXNOW_KEY ?? '').trim(),
indexnowKey: String(env.INDEXNOW_KEY ?? '').trim(),
tickRate: 30,
tickRate: 30,
snapshotEvery: 2,
snapshotEvery: 2,
- interestRadius: intEnv(env, 'INTEREST_RADIUS', 80),
+ interestRadius: positiveIntEnv(
+ env,
+ 'GAMES_INTEREST_RADIUS',
+ positiveIntEnv(env, 'INTEREST_RADIUS', 80),
+ ),
interestHysteresis: INTEREST_HYSTERESIS,
interestHysteresis: INTEREST_HYSTERESIS,
+ interestCell: positiveIntEnv(env, 'GAMES_INTEREST_CELL', 16),
+ instancesPerPlace: positiveIntEnv(env, 'GAMES_INSTANCES_PER_PLACE', 1),
maxPlayers: intEnv(env, 'MAX_PLAYERS', 64),
maxPlayers: intEnv(env, 'MAX_PLAYERS', 64),
persistFlushSeconds: intEnv(env, 'PERSIST_FLUSH_SECONDS', 10),
persistFlushSeconds: intEnv(env, 'PERSIST_FLUSH_SECONDS', 10),
metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),
metricsLogSeconds: intEnv(env, 'METRICS_LOG_SECONDS', 30),
@@ -190,3 +202,9 @@ function intEnv(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
@@ -190,3 +202,9 @@ function intEnv(env: NodeJS.ProcessEnv, key: string, fallback: number): number {
if (Number.isNaN(value)) throw new Error(`invalid ${key}: ${raw}`)
if (Number.isNaN(value)) throw new Error(`invalid ${key}: ${raw}`)
return value
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
+}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
import { createEnvironment } from '@openvibe/gameplay'
import { createEnvironment } from '@openvibe/gameplay'
import type { PersistenceStore } from '@openvibe/persistence'
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 { Logger } from '@openvibe/shared'
import type { ServerConfig } from '../config.js'
import type { ServerConfig } from '../config.js'
import { nearbyWorkstationKinds } from './interactions.js'
import { nearbyWorkstationKinds } from './interactions.js'
@@ -157,11 +157,20 @@ export class GameServer {
@@ -157,11 +157,20 @@ export class GameServer {
return this.ctx.clock.tick
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). */
/** Players connected now (the readiness `sessions` check; a restart would disconnect them). */
onlineCount(): number {
onlineCount(): number {
return this.systems.sessions.onlineCount()
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
* The client message types this server dispatches, one handler each. Diagnostics, and the
* handler-table test that pins it against the protocol.
* handler-table test that pins it against the protocol.
@@ -227,6 +236,11 @@ export class GameServer {
@@ -227,6 +236,11 @@ export class GameServer {
return this.systems.persistence.drain(timeoutMs)
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). */
/** Exposes crafting context for the client-facing recipe availability (welcome-time). */
workstationsNear(session: PlayerSession): ReadonlySet<string> {
workstationsNear(session: PlayerSession): ReadonlySet<string> {
return nearbyWorkstationKinds(session, this.ctx.world)
return nearbyWorkstationKinds(session, this.ctx.world)
@@ -91,13 +91,17 @@ export class GameWorld {
@@ -91,13 +91,17 @@ export class GameWorld {
* power coupling, and NPC perception all consume this one structure.
* power coupling, and NPC perception all consume this one structure.
* GameWorld maintains props/resources; the server maintains players.
* GameWorld maintains props/resources; the server maintains players.
*/
*/
- readonly spatial = new SpatialHash<EntityId>(16)
+ readonly spatial: SpatialHash<EntityId>
constructor(
constructor(
readonly content: ContentRegistry,
readonly content: ContentRegistry,
readonly physics: PhysicsWorld,
readonly physics: PhysicsWorld,
private readonly log: Logger,
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.zones = new ZoneIndex(content.world.zones)
this.mapStatics = new MapStaticLayer(physics)
this.mapStatics = new MapStaticLayer(physics)
this.mapTerrain = new MapTerrainLayer(physics)
this.mapTerrain = new MapTerrainLayer(physics)
new file mode 100644
new file mode 100644
@@ -0,0 +1,191 @@
@@ -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)
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,303 @@
@@ -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}`
+ }
+}
@@ -257,7 +257,7 @@ describe('Games on platform identity, events and mods (real server, restart)', (
@@ -257,7 +257,7 @@ describe('Games on platform identity, events and mods (real server, restart)', (
oldBundle.conn.identity = { guestKeyHash: hashGuestKey('old-bundle') }
oldBundle.conn.identity = { guestKeyHash: hashGuestKey('old-bundle') }
a.game.onMessage(oldBundle.conn, {
a.game.onMessage(oldBundle.conn, {
t: 'hello',
t: 'hello',
- v: PROTOCOL_VERSION - 1,
+ v: PROTOCOL_VERSION - 2,
slot: 0,
slot: 0,
name: 'Old',
name: 'Old',
appearance: defaultAppearance(),
appearance: defaultAppearance(),
@@ -107,6 +107,8 @@ export interface PlayerSession {
@@ -107,6 +107,8 @@ export interface PlayerSession {
lastProcessedSeq: number
lastProcessedSeq: number
/** Entity ids this client currently knows about (interest management). */
/** Entity ids this client currently knows about (interest management). */
known: Set<EntityId>
known: Set<EntityId>
+ /** Ground-plane cells this client subscribed to on the last snapshot. */
+ interestCells: Set<number>
/** Player state changed since last persistence flush. */
/** Player state changed since last persistence flush. */
dirty: boolean
dirty: boolean
send(text: string): void
send(text: string): void
@@ -178,6 +180,7 @@ export function createSession(init: SessionInit): PlayerSession {
@@ -178,6 +180,7 @@ export function createSession(init: SessionInit): PlayerSession {
starvedTicks: 0,
starvedTicks: 0,
lastProcessedSeq: 0,
lastProcessedSeq: 0,
known: new Set(),
known: new Set(),
+ interestCells: new Set(),
dirty: true,
dirty: true,
send: init.send,
send: init.send,
closeConnection: init.closeConnection,
closeConnection: init.closeConnection,
@@ -9,10 +9,11 @@
@@ -9,10 +9,11 @@
*/
*/
import { EntityStore, SpatialHash, type GameEntity } from '@openvibe/gameplay'
import { EntityStore, SpatialHash, type GameEntity } from '@openvibe/gameplay'
import { quat, vec3, type EntityId } from '@openvibe/shared'
import { quat, vec3, type EntityId } from '@openvibe/shared'
+import { encodeServerMessage } from '@openvibe/protocol'
import { describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { GameWorld } from './gameWorld.js'
import type { GameWorld } from './gameWorld.js'
import type { PlayerSession } from './playerSession.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 RADIUS = 80
const EXIT = RADIUS * (1 + INTEREST_HYSTERESIS)
const EXIT = RADIUS * (1 + INTEREST_HYSTERESIS)
@@ -55,6 +56,41 @@ function addBody(world: GameWorld, x: number): { id: EntityId; moveTo: (x: numbe
@@ -55,6 +56,41 @@ function addBody(world: GameWorld, x: number): { id: EntityId; moveTo: (x: numbe
}
}
describe('interest hysteresis', () => {
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', () => {
it('enters inside the radius and is retained past it up to the exit radius', () => {
const world = makeWorld()
const world = makeWorld()
const session = makeSession()
const session = makeSession()
@@ -86,6 +86,8 @@ export function wirePlayerFor(session: PlayerSession): WirePlayerState {
@@ -86,6 +86,8 @@ export function wirePlayerFor(session: PlayerSession): WirePlayerState {
export interface InterestDiff {
export interface InterestDiff {
entered: GameEntity[]
entered: GameEntity[]
left: EntityId[]
left: EntityId[]
+ enteredCells: number[]
+ leftCells: number[]
}
}
/**
/**
@@ -107,8 +109,13 @@ export function updateInterest(
@@ -107,8 +109,13 @@ export function updateInterest(
const exitRadiusSq = exitRadius * exitRadius
const exitRadiusSq = exitRadius * exitRadius
const entered: GameEntity[] = []
const entered: GameEntity[] = []
const current = new Set<EntityId>()
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
if (id === session.entityId) return
const entity = world.entities.get(id)
const entity = world.entities.get(id)
if (!entity) return
if (!entity) return
@@ -124,7 +131,7 @@ export function updateInterest(
@@ -124,7 +131,7 @@ export function updateInterest(
if (!current.has(id)) left.push(id)
if (!current.has(id)) left.push(id)
}
}
session.known = current
session.known = current
- return { entered, left }
+ return { entered, left, enteredCells, leftCells }
}
}
/** Snapshot for one session: relevant players + awake relevant prop bodies. */
/** Snapshot for one session: relevant players + awake relevant prop bodies. */
@@ -24,6 +24,15 @@ export class SessionsSystem implements System {
@@ -24,6 +24,15 @@ export class SessionsSystem implements System {
constructor(private readonly ctx: ServerContext) {
constructor(private readonly ctx: ServerContext) {
this.handlers = {
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) => {
hello: (session, msg, conn) => {
// A session already exists: a duplicate hello is silently ignored.
// A session already exists: a duplicate hello is silently ignored.
if (session) return
if (session) return
@@ -39,7 +48,7 @@ export class SessionsSystem implements System {
@@ -39,7 +48,7 @@ export class SessionsSystem implements System {
conn.close(4012, 'no_identity')
conn.close(4012, 'no_identity')
return
return
}
}
- void this.handleHello(conn, msg as ClientHello)
+ void this.join(conn, msg as ClientHello)
},
},
}
}
}
}
@@ -49,6 +58,13 @@ export class SessionsSystem implements System {
@@ -49,6 +58,13 @@ export class SessionsSystem implements System {
return this.ctx.sessions.size
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 {
onDisconnect(conn: GameConnection): void {
const session = this.ctx.sessions.getByConn(conn)
const session = this.ctx.sessions.getByConn(conn)
if (!session) return
if (!session) return
@@ -88,7 +104,7 @@ export class SessionsSystem implements System {
@@ -88,7 +104,7 @@ export class SessionsSystem implements System {
const ctx = this.ctx
const ctx = this.ctx
const serverDigest = ctx.world.content.digest
const serverDigest = ctx.world.content.digest
// Protocol first: a stale bundle predates the digest and only reloads itself on protocol_mismatch.
// 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.send(encodeServerMessage({ t: 'reject', reason: 'protocol_mismatch' }))
conn.close(4002, 'protocol_mismatch')
conn.close(4002, 'protocol_mismatch')
return
return
@@ -251,7 +267,7 @@ export class SessionsSystem implements System {
@@ -251,7 +267,7 @@ export class SessionsSystem implements System {
ctx.net.send(session, {
ctx.net.send(session, {
t: 'welcome',
t: 'welcome',
- v: PROTOCOL_VERSION,
+ v: msg.v,
contentDigest: serverDigest,
contentDigest: serverDigest,
rank,
rank,
playerId: playerId as string,
playerId: playerId as string,
@@ -259,6 +275,12 @@ export class SessionsSystem implements System {
@@ -259,6 +275,12 @@ export class SessionsSystem implements System {
tick: ctx.clock.tick,
tick: ctx.clock.tick,
tickRate: ctx.config.tickRate,
tickRate: ctx.config.tickRate,
snapshotRate: ctx.config.tickRate / ctx.config.snapshotEvery,
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.sendInventory(session)
ctx.net.sendSkills(session)
ctx.net.sendSkills(session)
@@ -10,13 +10,14 @@ import {
@@ -10,13 +10,14 @@ import {
SCRAPLANDIA_PACK,
SCRAPLANDIA_PACK,
setMapOverride,
setMapOverride,
} from '@openvibe/content'
} from '@openvibe/content'
-import { openPgStore } from '@openvibe/persistence'
+import { InstanceRegistry, openPgStore, type InstanceLease } from '@openvibe/persistence'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createRapierWorld, loadRapier } from '@openvibe/physics/rapier'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { loadConfig } from './config.js'
import { loadConfig } from './config.js'
import { openDb } from './db.js'
import { openDb } from './db.js'
import { GameServer } from './game/gameServer.js'
import { GameServer } from './game/gameServer.js'
import { GameWorld } from './game/gameWorld.js'
import { GameWorld } from './game/gameWorld.js'
+import { InstanceHub } from './game/instanceHub.js'
import { attachEditorWs } from './net/editorWs.js'
import { attachEditorWs } from './net/editorWs.js'
import {
import {
closeSockets,
closeSockets,
@@ -79,8 +80,23 @@ async function main(): Promise<void> {
@@ -79,8 +80,23 @@ async function main(): Promise<void> {
// PostgreSQL (or embedded PGlite in development), migrated before anything writes. It opens
// PostgreSQL (or embedded PGlite in development), migrated before anything writes. It opens
// before content because enabled mod packs add definitions to the content registry.
// before content because enabled mod packs add definitions to the content registry.
const db = await openDb(config.db, log)
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()
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
// 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.
// installs granted games.def.define; one that no longer builds is skipped and logged.
@@ -123,10 +139,11 @@ async function main(): Promise<void> {
@@ -123,10 +139,11 @@ async function main(): Promise<void> {
packs: packs.length,
packs: packs.length,
})
})
- const physics = createRapierWorld(await loadRapier())
+ const rapier = await loadRapier()
+ const physics = createRapierWorld(rapier)
const metrics = new ServerMetrics()
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)
await world.seedOrRestore(store)
// ── Platform integration (roadmap Wave 12) ─────────────────────────
// ── Platform integration (roadmap Wave 12) ─────────────────────────
@@ -217,8 +234,9 @@ async function main(): Promise<void> {
@@ -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
// 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.
// 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' })
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')
const { createQuickJsSandboxFactory } = await import('./mods/script/quickjs.js')
// Built-in pack code is reviewed with the place; installed packs use only the approved subset.
// Built-in pack code is reviewed with the place; installed packs use only the approved subset.
const grants: Record<string, string[]> = Object.fromEntries(
const grants: Record<string, string[]> = Object.fromEntries(
@@ -236,36 +254,117 @@ async function main(): Promise<void> {
@@ -236,36 +254,117 @@ async function main(): Promise<void> {
.map((cap) => cap.slice(6))
.map((cap) => cap.slice(6))
}
}
}
}
- scripts = new ScriptModHost(
+ const host = new ScriptModHost(
content.allMods(),
content.allMods(),
createQuickJsSandboxFactory(),
createQuickJsSandboxFactory(),
- scriptLog,
+ scriptLog.child({ slot }),
undefined,
undefined,
{
{
- placeSeed: config.placeId,
+ placeSeed: slot === 0 ? config.placeId : `${config.placeId}:${slot}`,
grants: grants as Record<string, ScriptCapability[]>,
grants: grants as Record<string, ScriptCapability[]>,
storage: new PgScriptStorage(store.mods, config.placeId, scriptLog),
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 })
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.
// Async boot: env, markets and the NPC population load before the tick loop starts.
await game.load()
await game.load()
await scripts?.start()
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> => {
const syncScriptView = async (view: ModView): Promise<void> => {
if (view.mod.runtime !== 'games-quickjs@1') return
if (view.mod.runtime !== 'games-quickjs@1') return
for (const raw of view.pack.mods ?? []) {
for (const raw of view.pack.mods ?? []) {
const script = ScriptModSchema.parse(raw)
const script = ScriptModSchema.parse(raw)
const active = view.mod.status === 'enabled' && view.granted.has('games.script.run')
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) {
if (!active) {
content.removeScriptMod(script.id)
content.removeScriptMod(script.id)
continue
continue
@@ -276,8 +375,8 @@ async function main(): Promise<void> {
@@ -276,8 +375,8 @@ async function main(): Promise<void> {
const pinned = { ...script, granted }
const pinned = { ...script, granted }
if (content.allMods().some((mod) => mod.id === script.id)) content.replaceScriptMod(pinned)
if (content.allMods().some((mod) => mod.id === script.id)) content.replaceScriptMod(pinned)
else content.addScriptMod(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)
if (!result.ok)
log.warn('script failed to start after grant change', {
log.warn('script failed to start after grant change', {
mod: script.id,
mod: script.id,
@@ -285,9 +384,10 @@ async function main(): Promise<void> {
@@ -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
const accountData = platform
@@ -297,11 +397,13 @@ async function main(): Promise<void> {
@@ -297,11 +397,13 @@ async function main(): Promise<void> {
send: networkSender({ networkUrl: config.platform.networkUrl, tokens: platform.tokens }),
send: networkSender({ networkUrl: config.platform.networkUrl, tokens: platform.tokens }),
closeSessions: async (subjects) => {
closeSessions: async (subjects) => {
let closed = 0
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.
// Let the closing sockets' character saves land before the rows are erased.
if (closed) {
if (closed) {
await new Promise((r) => setTimeout(r, 2000))
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' }),
log: platformLog.child({ system: 'account-data' }),
@@ -313,7 +415,11 @@ async function main(): Promise<void> {
@@ -313,7 +415,11 @@ async function main(): Promise<void> {
const revocations = createRevocationEvents({
const revocations = createRevocationEvents({
db,
db,
secrets: config.platform.eventsSecrets,
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.
// 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()),
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
// 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> {
@@ -361,7 +467,7 @@ async function main(): Promise<void> {
const readiness = createReadiness({
const readiness = createReadiness({
pingDb: () => db.ready(),
pingDb: () => db.ready(),
metrics,
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
// 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> {
@@ -389,12 +495,14 @@ async function main(): Promise<void> {
(next, revision) => {
(next, revision) => {
try {
try {
setMapOverride(compileMapFileV2(next))
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)
editors.broadcastSaved(revision)
log.info('map applied live', { terrains: next.terrains.length, revision })
log.info('map applied live', { terrains: next.terrains.length, revision })
} catch (err) {
} catch (err) {
@@ -523,18 +631,26 @@ async function main(): Promise<void> {
@@ -523,18 +631,26 @@ async function main(): Promise<void> {
script.capabilities ?? (['players.read', 'world.emit'] as const)
script.capabilities ?? (['players.read', 'world.emit'] as const)
).filter((cap) => prior.granted.has(scriptGrant(cap)))
).filter((cap) => prior.granted.has(scriptGrant(cap)))
const pinned = { ...script, granted: grants }
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 {
try {
const view = await mods.replace(id, request, actor)
const view = await mods.replace(id, request, actor)
content.replaceScriptMod(pinned)
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
return view
} catch (error) {
} catch (error) {
- prepared.cancel()
+ for (const candidate of prepared) candidate.cancel()
throw error
throw error
}
}
},
},
@@ -557,7 +673,7 @@ async function main(): Promise<void> {
@@ -557,7 +673,7 @@ async function main(): Promise<void> {
const drainer = httpDrainer(http)
const drainer = httpDrainer(http)
world.reconcileMapNodes()
world.reconcileMapNodes()
world.reconcileMapProps()
world.reconcileMapProps()
- const gameWss = attachWebSocket(http, game, {
+ const gameWss = attachWebSocket(http, hub, {
tickets: ticketStore,
tickets: ticketStore,
upgradeLimits: actorLimits,
upgradeLimits: actorLimits,
clientAddress,
clientAddress,
@@ -612,7 +728,7 @@ async function main(): Promise<void> {
@@ -612,7 +728,7 @@ async function main(): Promise<void> {
if (!running) return
if (!running) return
const now = performance.now()
const now = performance.now()
while (now >= nextTick) {
while (now >= nextTick) {
- game.step()
+ hub.step()
nextTick += tickMs
nextTick += tickMs
// If we fell far behind (debugger pause, EL stall), resync instead of
// If we fell far behind (debugger pause, EL stall), resync instead of
// spiraling through hundreds of catch-up ticks.
// spiraling through hundreds of catch-up ticks.
@@ -647,27 +763,28 @@ async function main(): Promise<void> {
@@ -647,27 +763,28 @@ async function main(): Promise<void> {
const httpDone = drainer.close(DRAIN_MS)
const httpDone = drainer.close(DRAIN_MS)
running = false
running = false
clearInterval(metricsTimer)
clearInterval(metricsTimer)
+ clearInterval(heartbeatTimer)
clearInterval(pruneTimer)
clearInterval(pruneTimer)
editors.stop()
editors.stop()
const mirrorDone = mirror?.stop()
const mirrorDone = mirror?.stop()
- game.announceRestart()
+ for (const room of hub.activeRooms) room.game.announceRestart()
const [players, editorPeers] = await Promise.all([
const [players, editorPeers] = await Promise.all([
closeSockets(gameWss, SOCKETS_MS),
closeSockets(gameWss, SOCKETS_MS),
closeSockets(editors.wss, SOCKETS_MS),
closeSockets(editors.wss, SOCKETS_MS),
])
])
// Mark every session dirty and queue the final checkpoint, then let the whole write-behind queue
// 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.
// (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
const cut = await httpDone
await within(2000, mirrorDone)
await within(2000, mirrorDone)
await within(1000, progressSummary?.settle())
await within(1000, progressSummary?.settle())
await within(2000, outbox?.stop())
await within(2000, outbox?.stop())
await mods.flushWrites()
await mods.flushWrites()
await store.close()
await store.close()
- physics.dispose()
await valkey?.close()
await valkey?.close()
log.info('stopped', {
log.info('stopped', {
ms: Date.now() - t0,
ms: Date.now() - t0,
@@ -128,7 +128,7 @@ export async function acceptUpgrade(
@@ -128,7 +128,7 @@ export async function acceptUpgrade(
export function attachWebSocket(
export function attachWebSocket(
http: Server,
http: Server,
- game: GameServer,
+ game: Pick<GameServer, 'onMessage' | 'onDisconnect'>,
opts: WsTransportOptions,
opts: WsTransportOptions,
): WebSocketServer {
): WebSocketServer {
const { log } = opts
const { log } = opts
@@ -123,7 +123,7 @@ The Live legacy import (`platform/liveLegacyImport.ts`, `scripts/importLiveLegac
@@ -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: 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.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.
- **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.
- **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.
- **M6: client floor.** Quality tiers, KTX2 and atlases, baked icons, bundle budget, editor E2E in CI.
@@ -6,12 +6,8 @@
@@ -6,12 +6,8 @@
"visibility": "first-party",
"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.",
"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",
"inputSchema": "games.mod-manage-request@1",
- "permissions": [
- "games:mods:admin"
- ],
- "resourceConstraints": [
- "none"
- ],
+ "permissions": ["games:mods:admin"],
+ "resourceConstraints": ["none"],
"quotaClass": "games-admin",
"quotaClass": "games-admin",
"events": [
"events": [
"games.mod.installed",
"games.mod.installed",
@@ -5,17 +5,11 @@
@@ -5,17 +5,11 @@
"status": "active",
"status": "active",
"visibility": "public",
"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.",
"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",
"quotaClass": "games-mod-runtime",
"events": [],
"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",
"inputSchema": "games.script-message@1",
"outputSchema": "common.no-body@1"
"outputSchema": "common.no-body@1"
}
}
@@ -5,12 +5,8 @@
@@ -5,12 +5,8 @@
"status": "active",
"status": "active",
"visibility": "public",
"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.",
"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",
"quotaClass": "games-mod-runtime",
"events": [],
"events": [],
"implementedBy": [
"implementedBy": [
@@ -5,12 +5,8 @@
@@ -5,12 +5,8 @@
"status": "active",
"status": "active",
"visibility": "public",
"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.",
"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",
"quotaClass": "games-mod-runtime",
"events": [],
"events": [],
"implementedBy": [
"implementedBy": [
@@ -5,12 +5,8 @@
@@ -5,12 +5,8 @@
"status": "active",
"status": "active",
"visibility": "public",
"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.",
"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",
"quotaClass": "games-mod-runtime",
"events": [],
"events": [],
"implementedBy": [
"implementedBy": [
@@ -5,17 +5,11 @@
@@ -5,17 +5,11 @@
"status": "active",
"status": "active",
"visibility": "public",
"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.",
"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",
"quotaClass": "games-mod-runtime",
"events": [],
"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",
"inputSchema": "games.script-intent@1",
"outputSchema": "common.no-body@1"
"outputSchema": "common.no-body@1"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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.
@@ -87,23 +87,24 @@ The only thing the host adds to a fresh QuickJS context is the frozen global
@@ -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`,
`game`. Everything else is the language's own intrinsics: no `require`,
`process`, `fs`, network, timers, `console` or QuickJS `std`/`os`.
`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
`game.players` and `game.player` require `players.read`; `game.emit` requires
`world.emit`; `game.message` requires `player.message`; the storage object
`world.emit`; `game.message` requires `player.message`; the storage object
requires `storage.kv`. A binding without its grant is absent from the frozen
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`,
install manifest uses `games.players.read`, `games.world.emit`,
`games.player.message`, and `games.storage.kv` for these script requests.
`games.player.message`, and `games.storage.kv` for these script requests.
For example:
For example:
@@ -84,6 +84,27 @@ export class SpatialHash<T> {
@@ -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). */
/** Radius candidates as an array (convenience for call sites that filter). */
queryRadius(x: number, z: number, radius: number): T[] {
queryRadius(x: number, z: number, radius: number): T[] {
const out: T[] = []
const out: T[] = []
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -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)
+);
@@ -2,4 +2,5 @@ export * from './dto.js'
@@ -2,4 +2,5 @@ export * from './dto.js'
export * from './repositories.js'
export * from './repositories.js'
export { accountKey } from './accountKey.js'
export { accountKey } from './accountKey.js'
export { openPgStore, type PgPersistenceStore, type PgStoreOptions } from './pg/pgStore.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'
export { MIGRATIONS_DIR } from './migrations.js'
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -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)
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -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}`)
+ }
+}
@@ -19,6 +19,10 @@ import { BATCH_CHUNK, chunks, idList } from './sqlUtils.js'
@@ -19,6 +19,10 @@ import { BATCH_CHUNK, chunks, idList } from './sqlUtils.js'
export interface PgStoreOptions {
export interface PgStoreOptions {
/** GAMES_PLACE_ID (default 'scraplandia'): the place every world row belongs to on this instance. */
/** GAMES_PLACE_ID (default 'scraplandia'): the place every world row belongs to on this instance. */
placeId?: string
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 {
export interface PgPersistenceStore extends PersistenceStore {
@@ -77,12 +81,23 @@ interface ConstraintRow {
@@ -77,12 +81,23 @@ interface ConstraintRow {
export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceStore {
export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceStore {
const placeId = opts.placeId ?? process.env.GAMES_PLACE_ID ?? 'scraplandia'
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 = {
const worldEntities: WorldEntityRepository = {
async loadAll(): Promise<WorldEntityDto[]> {
async loadAll(): Promise<WorldEntityDto[]> {
const rows = await db.many<WorldEntityRow>(
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
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) => ({
return rows.map((row) => ({
id: row.id,
id: row.id,
@@ -97,25 +112,26 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
@@ -97,25 +112,26 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
}))
}))
},
},
async upsertMany(entities: readonly WorldEntityDto[]): Promise<void> {
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
INSERT INTO world_entities
${sql.insert(rows, [
${sql.insert(rows, [
'place_id',
'place_id',
@@ -139,18 +155,25 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
@@ -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,
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
motion=excluded.motion, state=excluded.state, owner_id=excluded.owner_id, updated_at=excluded.updated_at
`)
`)
- }
+ }
+ })
},
},
async deleteMany(ids: readonly string[]): Promise<void> {
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> {
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
@@ -243,28 +266,67 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
const row = await db.maybe<CharacterRow>(
const row = await db.maybe<CharacterRow>(
sql`${characters} WHERE account_key = ${accountKey(token)} AND slot = ${slot}`,
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> {
async findById(id: string): Promise<PlayerDto | null> {
const row = await db.maybe<CharacterRow>(sql`${characters} WHERE id = ${id}`)
const row = await db.maybe<CharacterRow>(sql`${characters} WHERE id = ${id}`)
return row ? rowToPlayer(row) : null
return row ? rowToPlayer(row) : null
},
},
async upsert(player: PlayerDto): Promise<void> {
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> {
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> {
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 {
function upsertCharacter(list: readonly PlayerDto[]): Sql {
return sql`
return sql`
INSERT INTO characters
INSERT INTO characters
@@ -283,7 +345,7 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
@@ -283,7 +345,7 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
const constraints: ConstraintRepository = {
const constraints: ConstraintRepository = {
async loadAll(): Promise<ConstraintDto[]> {
async loadAll(): Promise<ConstraintDto[]> {
const rows = await db.many<ConstraintRow>(
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) => ({
return rows.map((row) => ({
id: row.id,
id: row.id,
@@ -295,51 +357,57 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
@@ -295,51 +357,57 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
}))
}))
},
},
async upsertMany(list: readonly ConstraintDto[]): Promise<void> {
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
INSERT INTO world_constraints
${sql.insert(rows, ['place_id', 'id', 'type', 'entity_a', 'entity_b', 'params', 'updated_at'])}
${sql.insert(rows, ['place_id', 'id', 'type', 'entity_a', 'entity_b', 'params', 'updated_at'])}
ON CONFLICT (place_id, id) DO UPDATE SET
ON CONFLICT (place_id, id) DO UPDATE SET
params = excluded.params, updated_at = excluded.updated_at
params = excluded.params, updated_at = excluded.updated_at
`)
`)
- }
+ }
+ })
},
},
async deleteMany(ids: readonly string[]): Promise<void> {
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 = {
const meta: MetaRepository = {
async get(key: string): Promise<unknown | null> {
async get(key: string): Promise<unknown | null> {
const value = await db.value<unknown>(
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
return value ?? null
},
},
async set(key: string, value: unknown): Promise<void> {
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
ON CONFLICT (place_id, key) DO UPDATE SET value = excluded.value
- `)
+ `),
+ )
},
},
async list(prefix: string): Promise<{ key: string; value: unknown }[]> {
async list(prefix: string): Promise<{ key: string; value: unknown }[]> {
const rows = await db.many<{ 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 }))
return rows.map((r) => ({ key: r.key, value: r.value }))
},
},
@@ -352,6 +420,10 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
@@ -352,6 +420,10 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
await db.exec(
await db.exec(
sql`INSERT INTO places (id, name) VALUES (${placeId}, ${name}) ON CONFLICT (id) DO NOTHING`,
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,
worldEntities,
players,
players,
@@ -361,7 +433,10 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
@@ -361,7 +433,10 @@ export function openPgStore(db: Db, opts: PgStoreOptions = {}): PgPersistenceSto
mods: createModRepository(db),
mods: createModRepository(db),
mediaMirrors: createMediaMirrorRepository(db),
mediaMirrors: createMediaMirrorRepository(db),
transaction<T>(fn: (t: Tx) => Promise<T>): Promise<T> {
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> {
close(): Promise<void> {
return db.close()
return db.close()
@@ -13,6 +13,8 @@ export { MIGRATIONS_DIR }
@@ -13,6 +13,8 @@ export { MIGRATIONS_DIR }
const TABLES = [
const TABLES = [
'places',
'places',
+ 'game_instances',
+ 'character_instance_positions',
'world_meta',
'world_meta',
'world_entities',
'world_entities',
'world_constraints',
'world_constraints',
@@ -19,6 +19,7 @@ describe('protocol codec', () => {
@@ -19,6 +19,7 @@ describe('protocol codec', () => {
},
},
{ t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yawQ: 12345, pitchQ: -4096, intents: 5 },
{ t: 'input', seq: 42, moveX: 1, moveZ: -0.5, yawQ: 12345, pitchQ: -4096, intents: 5 },
{ t: 'use', target: 'abc123' },
{ t: 'use', target: 'abc123' },
+ { t: 'transfer', instanceId: 'scraplandia:1' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
{ t: 'craft', recipe: 'craft_wooden_crate' },
{ t: 'drop', slot: 3, count: 5 },
{ t: 'drop', slot: 3, count: 5 },
{ t: 'inv_move', from: 0, to: 5, count: 3 },
{ t: 'inv_move', from: 0, to: 5, count: 3 },
@@ -36,6 +37,7 @@ describe('protocol codec', () => {
@@ -36,6 +37,7 @@ describe('protocol codec', () => {
expect(decodeClientMessage('not json')).toBeNull()
expect(decodeClientMessage('not json')).toBeNull()
expect(decodeClientMessage('{"t":"nope"}')).toBeNull()
expect(decodeClientMessage('{"t":"nope"}')).toBeNull()
expect(decodeClientMessage('{"t":"input","seq":-1}')).toBeNull()
expect(decodeClientMessage('{"t":"input","seq":-1}')).toBeNull()
+ expect(decodeClientMessage('{"t":"transfer","instanceId":""}')).toBeNull()
// NaN/Infinity smuggling
// NaN/Infinity smuggling
expect(
expect(
decodeClientMessage(
decodeClientMessage(
@@ -22,6 +22,12 @@ export const ClientHelloSchema = z.object({
@@ -22,6 +22,12 @@ export const ClientHelloSchema = z.object({
appearance: AppearanceSchema,
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;
* One input command per simulation tick. The client sends these at tick rate;
* `seq` is echoed in snapshots for prediction reconciliation. The server
* `seq` is echoed in snapshots for prediction reconciliation. The server
@@ -285,6 +291,7 @@ export const ClientContainerSortSchema = z.object({
@@ -285,6 +291,7 @@ export const ClientContainerSortSchema = z.object({
})
})
export const ClientMessageSchema = z.union([
export const ClientMessageSchema = z.union([
+ ClientTransferSchema,
ClientEditModeSchema,
ClientEditModeSchema,
ClientHelloSchema,
ClientHelloSchema,
ClientInputSchema,
ClientInputSchema,
@@ -315,6 +322,7 @@ export const ClientMessageSchema = z.union([
@@ -315,6 +322,7 @@ export const ClientMessageSchema = z.union([
])
])
export type ClientHello = z.infer<typeof ClientHelloSchema>
export type ClientHello = z.infer<typeof ClientHelloSchema>
+export type ClientTransfer = z.infer<typeof ClientTransferSchema>
export type ClientEditMode = z.infer<typeof ClientEditModeSchema>
export type ClientEditMode = z.infer<typeof ClientEditModeSchema>
export type ClientInput = z.infer<typeof ClientInputSchema>
export type ClientInput = z.infer<typeof ClientInputSchema>
export type ClientUse = z.infer<typeof ClientUseSchema>
export type ClientUse = z.infer<typeof ClientUseSchema>
@@ -25,6 +25,16 @@ export interface ServerWelcome {
@@ -25,6 +25,16 @@ export interface ServerWelcome {
tick: number
tick: number
tickRate: number
tickRate: number
snapshotRate: 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 {
export interface ServerReject {
@@ -262,6 +272,7 @@ export interface ServerContainer {
@@ -262,6 +272,7 @@ export interface ServerContainer {
export type ServerMessage =
export type ServerMessage =
| ServerWelcome
| ServerWelcome
+ | ServerTransferResult
| ServerReject
| ServerReject
| ServerSpawn
| ServerSpawn
| ServerDespawn
| ServerDespawn
@@ -1,2 +1,2 @@
@@ -1,2 +1,2 @@
/** Wire protocol version — bump on ANY breaking message change. */
/** Wire protocol version — bump on ANY breaking message change. */
-export const PROTOCOL_VERSION = 21
+export const PROTOCOL_VERSION = 22
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -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.