@jgengine/convex 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -34,6 +34,68 @@ At publish, rename this heading to the new version and mirror the entries into
34
34
 
35
35
  ### Fixed
36
36
 
37
+ ## 0.17.0
38
+
39
+ ### Migrate
40
+
41
+ - **Bump lockstep SDK packages to `^0.17.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.14.0`; `@jgengine/github` is `0.4.0`.
42
+ - **A deleted player profile stays deleted.** When a game persists players (`save.scope` includes `player`), hydrating a member with no `jgPlayerProfiles` row no longer falls back to the copy of their state kept on the server row — that copy was resurrected and then written back as authoritative, so a world reset that cleared profiles but not servers silently restored every player's old balance. A game whose save scope excludes players (`"none"`, `"chunks"`) is unchanged: the server-row copy is still their state. If you seed members onto a server row without going through `joinServer`, write their profile row too.
43
+ - **`slotsPerServer` is capped at 256** (`JG_MAX_MEMBERS_PER_SERVER`). `createGameServerFunctions` now throws at wiring time on a larger value instead of accepting it and failing at the write that overflows the document. One server is one Convex document holding the world row, the roster, and every member's session state; a bigger number was never a bigger world. Shard across servers, or move world state into `jgWorldChunks`.
44
+ - **The tick cron only sweeps games that declare `loop.onTick`.** A game with no tick hook used to pay a full hydrate plus a full persist every second per running server, for a snapshot `runtime.tick` returned unchanged; it now costs nothing while idle. If you relied on the tick cron to autosave a hookless game, the 60s `flushDirtyServers` cron and the `flushSave` mutation still do it.
45
+
46
+ ### Changed
47
+
48
+ - **Walking NPCs now stop at the geometry that stops the player.** `wander`, `patrol`, and `ctx.scene.entity.moveToward` consulted no obstacles at all, so a pedestrian walked through the building the player cannot enter. All three now slide their step against the scene's blocking physical colliders — the same query `stepPlayerMovement` reads. A patrol's path state still advances on its own clock (an authored route is not rewound by contact), but the entity can no longer end a tick inside a solid. Pass `avoidSolids: false` to `moveToward` for a mover meant to ignore the world (a flier, a camera rig).
49
+ - **`@jgengine/assets` verify fails a model source that contributes no index entries.** A model pack that has never been pulled is a pin nothing has ever checked — that is how a Street Pack archive sat in the catalogue titled "Downtown City MegaKit". Record why a pack is not pulled in the new `AssetSource.unpulled` string; materials and sprites pull on demand and are exempt.
50
+ - **`persistServerSnapshot` returns `boolean` and skips a write that would change nothing.** Every subscriber of a query that read a server row re-receives the whole result when that document changes, so an unconditional write on the 1 Hz tick re-pushed the entire world to every client once a second whether or not anything moved. `ServerPersistPlan` gains `changed` (`serverState` / `sessionPlayers` / `revision` / `any`) for hosts that write field-by-field.
51
+ - **`sessionPlayers` is merged over the stored map, not rebuilt from the snapshot**, and entries for non-members are pruned. This is what makes a partially hydrated snapshot safe to persist.
52
+ - **`tickActiveServers` is bounded** to `maxServersPerTick` (default 32) servers per transaction, oldest tick anchor first, instead of pulling every running server of every game into one transaction. `flushDirtyServers` is bounded the same way.
53
+
54
+ ### Added
55
+
56
+ - **`solidObstaclesNear` / `resolveWalkerStep` (`@jgengine/core/movement/solidObstacles`)** — the engine's single answer to "what is solid here", now shared rather than re-derived per consumer. `solidObstaclesNear(ctx, position, reachX, reachZ)` returns the blocking physical colliders near a point through a bounded `inBox` broadphase; `resolveWalkerStep(ctx, position, stepX, stepZ)` slides a horizontal step against them with the player's axis-separated sliding. Reach for these instead of deriving obstacles from a game catalog's own solidity flags.
57
+ - **Bounded hydration and a client chunk read** — `loadServerSnapshot(ctx, server, runtime, { players, chunkKeys })` hydrates only the members and chunks a mutation will touch instead of a document read per member plus one per chunk, and the generated `getChunks` query lets a client read chunk state by key (capped at `MAX_CHUNKS_PER_QUERY`). Chunks were the documented escape hatch for world state too large for one row, but reads were unbounded and clients could not see them at all, so moving geometry there made things worse. `getServerMeta` returns lobby state (status, member count, revision) without the world payload.
58
+ - **`@jgengine/core/runtime/worldChunks`** — `chunkKeyAt` / `chunkCoordAt` / `chunkKeyOf` / `parseChunkKey` / `chunkKeysInRadius` at a declared cell size, plus `setChunk` / `updateChunk` / `deleteChunk`, which mark the key dirty so the host actually persists it. `chunkKey` was an opaque game-chosen string and the engine never wrote `dirty.chunks`, so every game reimplemented the coordinate mapping and a game that forgot to push a key silently never saved that chunk.
59
+ - **Placed objects cull to `replication.aoiRadius`.** `objects` is the one large spatial replicated set and had no projector, so a world with a few thousand placements sent all of them to every viewer. It now joins the same area-of-interest policy as `entities` and `stats`. Unset `aoiRadius` still replicates everything.
60
+ - **`createGameServerFunctions({ matchmaking: "singleton" })`** — one shared world per game: auto-match always targets the existing world and a join past capacity fails loudly, instead of quietly creating a second world the player would be alone in. `joinServer` also reconciles a stored `slotsPerServer`/`save` that has drifted from the host options, which previously made raising the option a no-op on worlds that already existed. `selectJoinTarget` (`@jgengine/core/runtime/hostPolicy`) is the pure decision behind it.
61
+ - **`GameRuntime.hasTick`** reports whether a runtime declares `loop.onTick`, so any host's scheduler can skip a server whose tick is a no-op by construction.
62
+
63
+ ### Removed
64
+
65
+ - **Asset source `quaternius-downtown-city`.** It was titled "Downtown City MegaKit" but pinned at the Street Pack archive, which ships Blends/FBX/OBJ only — a pull found no models. Nothing referenced it.
66
+
67
+ ## 0.16.0
68
+
69
+ ### Migrate
70
+
71
+ - **Bump lockstep SDK packages to `^0.16.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.13.0`; `@jgengine/github` is `0.3.0`.
72
+ - **Placed objects now replicate.** `objects` joined the always-on baseline snapshot modules, so `ctx.hydrate` replaces the client's placed object set with the host's, the way `entities` already did. A game whose peers all run the same authored placement is unaffected; a game that places props on the client only, after hydrate, must move that placement to the host or keep it out of `ctx.scene.object`.
73
+
74
+ ### Changed
75
+
76
+ - **`GeneratedBuilding`'s `kit` prop is now the `BuildingKit`**; the per-part React escape hatch it used to name moved to `partRenderer`, so `kit` means the same thing on `GeneratedBuilding` and `InstancedBuildings`. Rename the prop if you passed a `BuildingKitRenderer`.
77
+
78
+ ### Added
79
+
80
+ - **`modelLoadFallbacks()` from `@jgengine/shell/render/modelLoad`** lists every model the shared loader diagnosed as broken and replaced with a magenta placeholder. A placeholder was only ever a console warning, so a shipped game could render one in its starting area and nobody found out until someone looked at a screenshot; a capture host or smoke test can now require the list to be empty. `clearModelLoadFallbacks()` resets it between runs.
81
+ - **`worldHealthBars.occlude` / `WorldHealthBars`+`WorldNameplates` `occlude` prop.** World bars and nameplates are a screen-space overlay drawn outside the WebGL depth buffer, so nothing in the scene can hide them and an enemy's health bar reads straight through the building it stands behind. Set `occlude: true` to drop a bar when world geometry blocks the line from the player to the entity. Default `false` preserves the always-visible behaviour every shipped game currently relies on.
82
+
83
+ - **`DustField` + `WeatherLayer` mode `"dust"` (`@jgengine/shell/weather`) — wind-borne particulate.** `WeatherLayer` could only do `clear`/`rain`/`snow`/`mixed`, so an arid, volcanic, ashen, or blown-out world had no way to put anything in the air. Dust drifts on horizontal wind with a slow vertical bob rather than falling; `groundBias` packs density toward the surface, motes fade near the top of the volume so recycled ones do not pop against the sky, and faster motes ride higher. Set `dustAlways` to keep it in the air alongside rain or snow instead of replacing them. Defaults `DEFAULT_DUST_COUNT`/`DEFAULT_DUST_DENSITY` sit beside the rain and snow pair.
84
+
85
+ - **Authored `city` volumes render their near-LOD facades with a `BuildingKit`.** `CityRules.buildingKit` names a kit; a game registers the bound art with `registerBuildingKit(kit)` from `@jgengine/shell/structures/buildingKitRegistry` and the scene document carries only the name, so a city stays portable and free of model paths. Unset or unknown names keep the palette blocks. This puts the whole `city` primitive — not just the `building()` feature — on the kit path.
86
+ - **`BuildingKit` from `@jgengine/core/world/buildingKit`** binds a generated building's facade part slots to real models, so procedural buildings render as an instanced asset kit instead of untextured blocks. `defineBuildingKit({ parts: { wall: ["Wall_A.glb", …] } })` maps a part kind — or a `${facade}.${kind}` slot — to ordered variants that `kit.variant` indexes, with `fit`/`offset`/`rotation`/`scale`/`tint`/`autoOrient` per variant. Kinds a kit leaves unbound keep rendering the palette block, so a partial kit is a valid kit, and `omit` drops a kind entirely. Pass one through `building({ kit })` or straight to `InstancedBuildings`; `buildingKitVariantCounts(kit)` feeds `generateBuilding` so its seeded picks span exactly the models the kit binds.
87
+ - **`generateBuilding`, `generateBuildingDistrict`, and `createBuildingConfig` are now documented public API** (`@jgengine/core/world/buildings`) and appear in `capabilities.md` under `building-generator`.
88
+ - **`capture.views` on `defineGame`** — named framings (`GameCaptureView`: `look`, `lookFrom`, `spawn`, `state`, `run`, `settleMs`) that `shoot <game> --view <name>` and `drive --view <name>` replay exactly, so a before/after pair is the same view by construction. `shoot <game> --list-views` prints what a game declares; an unknown name fails listing them, and any explicit capture flag still wins over the view's value.
89
+ - **`modelLoadIdleMs()` from `@jgengine/shell/render/modelLoad`** reports how long the shared GLB loader has been idle (`0` while a model is in flight). Capture hosts wait on it instead of guessing a settle delay, so an establishing shot no longer races streaming models.
90
+ - **Per-instance state on placed objects** — `SceneObject.state` (opaque `Record`), set through `ctx.scene.object.setState`/`patchState`/`place({ state })`. It survives move/rotate/setVisual, replicates and saves with the object, and round-trips as `RuntimeObjectRow.flags` via `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`@jgengine/core/multiplayer`). A chest's locked flag or a machine's fill level no longer needs a game-owned map keyed by `instanceId`.
91
+ - **`slotInventory` is a working feature** — `ctx.scene.object.slots` gives objects whose catalog entry declares it real per-instance container contents: `layout`/`state`/`count`, `put`/`take`, and `transferIn`/`transferOut` between a player inventory and the object, all validated against the declared slot count, `accepts` kind filter, and stack limits, with neither side mutated on a rejection. Contents live on `SceneObject.slots`, so they replicate and save with the placement.
92
+ - **`loadServerSnapshot` and `persistServerSnapshot` are public** (`@jgengine/convex/server`), and `createGameServerFunctions` also returns `helpers` (`loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand`) bound to the same runtimes and auth mode. A host mutation can now pair snapshot work with its own table writes in one transaction; the registered `runCommand` mutation delegates to `helpers.runCommand`, so there is one implementation.
93
+
94
+ ### Fixed
95
+
96
+ - **Open storefronts are no longer holes through the building.** `generateBuilding` emitted an open ground-floor store as a lone `storefront` panel with no wall behind it, while every upper-floor window was correctly backed by one. Storefront and window materials render at 0.56 opacity and a generated building is a hollow shell, so those bays let you see straight through the building to the world beyond — and open stores only land on the street-facing facade, so a row of shops along a road was the worst case. The bay now emits its wall first, exactly as an upper floor does.
97
+ - **`joinServer`, `flushSave`, `flushDirtyServers`, and the `auto` autosave can write player profiles again.** All four cleared the snapshot's dirty set before handing it to the persist plan, which gates profile writes on exactly that set — so the functions named "flush" flushed the server row and chunks only, and an `onNewPlayer` grant (starting money, starting items) never reached `jgPlayerProfiles`. `joinServer` now persists the snapshot it just marked; the flush paths mark every player dirty first, since hydrating from storage resets the dirty set. `planServerPersist` also clears `dirtyAt` once everything outstanding is written, so a successful flush stops autosave from re-firing forever, and a profile patch no longer clobbers a host-maintained `dirtyAt`.
98
+
37
99
  ## 0.15.0
38
100
 
39
101
  ### Migrate
package/README.md CHANGED
@@ -42,6 +42,19 @@ export const { joinServer, leaveServer, runCommand, flushSave, getServer, getPla
42
42
 
43
43
  No game-specific code lives there — any JGengine game can point at the same deployment. Games without a registered `GameRuntime` fall back to a no-save runtime that only understands `engine.ping`; pass `createGameServerFunctions({ runtimes: [createGameRuntime({ gameId, commands, loop, save })] })` to make `runCommand`/tick/save actually do something.
44
44
 
45
+ The same factory also returns `helpers` — `loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand` — bound to the same runtimes and auth mode, for a host mutation that must pair snapshot work with its own table writes in one transaction. `loadServerSnapshot` / `persistServerSnapshot` are exported standalone for the same reason:
46
+
47
+ ```ts
48
+ export const buyWithReceipt = mutation({
49
+ args: { serverId: v.id("jgGameServers"), input: v.any() },
50
+ handler: async (ctx, args) => {
51
+ const outcome = await helpers.runCommand(ctx, { ...args, command: "shop.buy" });
52
+ if (outcome.ok) await ctx.db.insert("receipts", { serverId: args.serverId, at: Date.now() });
53
+ return outcome;
54
+ },
55
+ });
56
+ ```
57
+
45
58
  Every factory defaults to `auth: "anonymous"` — the client's `externalId` is trusted as claimed, fine for local dev but spoofable. Pass `{ auth: "required" }` to every factory for production; the resolved actor becomes `ctx.auth.getUserIdentity()`'s `subject`, and `externalId` is only cross-checked against it, never trusted alone.
46
59
 
47
60
  See `examples/convex-host` for the reference thin consumer (`bunx convex dev` codegens `convex/_generated/` and prints the dev URL). Point any game that declares `multiplayer: convexPresence({ topology: "shared" })` (or `convex({ topology: "shared", authority: "server" })` for a shared sim) at the same deployment. No Convex Cloud account is needed — `examples/convex-host/docker-compose.yml` runs the open-source backend anywhere Docker runs, with `CONVEX_SELF_HOSTED_URL`/`CONVEX_SELF_HOSTED_ADMIN_KEY` pointing the same CLI at it.
package/dist/server.d.ts CHANGED
@@ -1,12 +1,15 @@
1
- import type { Auth, DataModelFromSchemaDefinition, GenericMutationCtx } from "convex/server";
1
+ import type { Auth, DataModelFromSchemaDefinition, DocumentByName, GenericMutationCtx } from "convex/server";
2
2
  import type { GenericId } from "convex/values";
3
3
  import type { ChatMessage } from "@jgengine/core/game/chat";
4
4
  import type { ChatSendOutcome } from "@jgengine/core/multiplayer/chatContract";
5
5
  import { type PoseSyncRules } from "@jgengine/core/multiplayer/presenceModel";
6
6
  import type { GameRuntime } from "@jgengine/core/runtime/gameRuntime";
7
7
  import type { LeaderboardIncrement } from "@jgengine/core/runtime/hostPersistence";
8
+ import { type MatchmakingMode } from "@jgengine/core/runtime/hostPolicy";
9
+ import type { SaveConfig } from "@jgengine/core/runtime/save";
10
+ import type { GameRuntimeSnapshot } from "@jgengine/core/runtime/snapshot";
8
11
  /** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
9
- export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
12
+ export { JG_MAX_MEMBERS_PER_SERVER, canJoinPrivateServer, isListablePublicly, validateSlotsPerServer, type MatchmakingMode, } from "@jgengine/core/runtime/hostPolicy";
10
13
  /** @internal */
11
14
  export declare function jgengineTables(): {
12
15
  jgGameServers: import("convex/server").TableDefinition<import("convex/values").VObject<{
@@ -60,6 +63,7 @@ export declare function jgengineTables(): {
60
63
  }, "required", "createdAt" | "dirtyAt" | "gameId" | "joinCode" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | "visibility" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
61
64
  by_dirty: ["dirtyAt", "_creationTime"];
62
65
  by_game_and_status: ["gameId", "status", "_creationTime"];
66
+ by_game_status_tick: ["gameId", "status", "tickAnchorMs", "_creationTime"];
63
67
  by_status: ["status", "_creationTime"];
64
68
  }, {}, {}>;
65
69
  jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
@@ -252,6 +256,7 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
252
256
  }, "required", "createdAt" | "dirtyAt" | "gameId" | "joinCode" | "lastSavedAt" | "memberUserIds" | "mode" | "modeConfig" | "revision" | "save" | "save.auto" | "save.scope" | "serverState" | "sessionPlayers" | "slotsPerServer" | "status" | "tickAnchorMs" | "updatedAt" | "visibility" | `modeConfig.${string}` | `serverState.${string}` | `sessionPlayers.${string}`>, {
253
257
  by_dirty: ["dirtyAt", "_creationTime"];
254
258
  by_game_and_status: ["gameId", "status", "_creationTime"];
259
+ by_game_status_tick: ["gameId", "status", "tickAnchorMs", "_creationTime"];
255
260
  by_status: ["status", "_creationTime"];
256
261
  }, {}, {}>;
257
262
  jgPlayerProfiles: import("convex/server").TableDefinition<import("convex/values").VObject<{
@@ -360,8 +365,13 @@ declare const schemaForTypes: import("convex/server").SchemaDefinition<{
360
365
  by_server_channel: ["serverId", "channelId", "_creationTime"];
361
366
  }, {}, {}>;
362
367
  }, true>;
363
- type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes>;
364
- type JGMutationCtx = GenericMutationCtx<JGDataModel>;
368
+ /** Data model of the {@link jgengineTables} schema — the shape a host's own Convex ctx must satisfy to
369
+ * call the exported persistence helpers. */
370
+ export type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes>;
371
+ /** Mutation ctx accepted by {@link loadServerSnapshot} / {@link persistServerSnapshot}. */
372
+ export type JGMutationCtx = GenericMutationCtx<JGDataModel>;
373
+ /** A `jgGameServers` row — the server handle the persistence helpers load from and write back to. */
374
+ export type ServerDoc = DocumentByName<JGDataModel, "jgGameServers">;
365
375
  export type JgAuthMode = "anonymous" | "required";
366
376
  export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
367
377
  /** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`.
@@ -370,14 +380,112 @@ export declare const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules;
370
380
  export declare function resolveActor(ctx: {
371
381
  auth: Auth;
372
382
  }, claimedExternalId: string | undefined, mode: JgAuthMode): Promise<string | null>;
383
+ /**
384
+ * How much of a server to hydrate. Both fields default to "everything", which is a document read per
385
+ * member plus one per chunk — the cost that makes a large shared world unaffordable per mutation.
386
+ * Narrow them when the caller knows what it will touch: a command that only moves the actor's own
387
+ * state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes
388
+ * (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`).
389
+ *
390
+ * Persisting a narrowed snapshot is safe: the plan merges session state over the stored map and
391
+ * writes only dirty profiles and dirty chunks, so members and chunks left unhydrated are untouched.
392
+ */
393
+ export type LoadSnapshotScope = {
394
+ /** Member ids to hydrate; omit for the whole roster. */
395
+ players?: readonly string[];
396
+ /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */
397
+ chunkKeys?: readonly string[];
398
+ };
399
+ /**
400
+ * Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
401
+ * Reach for it when writing your own mutation that must read runtime state before touching host tables.
402
+ * Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world.
403
+ * @capability convex-load-server-snapshot read a hosted server's runtime snapshot inside a host-written Convex mutation
404
+ */
405
+ export declare function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime, scope?: LoadSnapshotScope): Promise<GameRuntimeSnapshot>;
373
406
  /** @internal */
374
407
  export declare function applyLeaderboardIncrements(ctx: JGMutationCtx, gameId: string, entries: LeaderboardIncrement[]): Promise<void>;
408
+ /**
409
+ * Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard
410
+ * increments, all under `save`. Pair it with your own table writes to keep both in one transaction.
411
+ *
412
+ * A persist that would change nothing writes nothing and returns `false`. That matters beyond write
413
+ * cost: every subscriber of a query that read this row re-receives the whole result when the document
414
+ * changes, so an unconditional write on a 1 Hz tick re-pushes the entire world to every client once a
415
+ * second whether or not anything moved.
416
+ * @capability convex-persist-server-snapshot write runtime state and host tables in one Convex transaction
417
+ */
418
+ export declare function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise<boolean>;
375
419
  export declare const JG_RUNTIME_TICK_MS = 1000;
376
- /** @internal */
420
+ /** Outcome of a runtime command: applied, or refused with a reason (unknown server, non-member, validation, revision conflict). */
421
+ export type RunCommandOutcome = {
422
+ ok: true;
423
+ } | {
424
+ ok: false;
425
+ reason: string;
426
+ };
427
+ /** Arguments of the `runCommand` mutation, and of the equivalent {@link GameServerHelpers.runCommand} helper. */
428
+ export type RunCommandArgs = {
429
+ serverId: string;
430
+ command: string;
431
+ input: unknown;
432
+ externalId?: string;
433
+ };
434
+ /** A server resolved for runtime work: its row, its registered runtime, and its hydrated snapshot. */
435
+ export type LoadedServerSnapshot = {
436
+ server: ServerDoc;
437
+ runtime: GameRuntime;
438
+ snapshot: GameRuntimeSnapshot;
439
+ };
440
+ /**
441
+ * The plain-function half of {@link createGameServerFunctions}, bound to the same runtime registry and
442
+ * auth mode as its mutations. Reach for it when a host mutation must pair snapshot work with its own
443
+ * table writes in one transaction, which a pre-registered mutation cannot do.
444
+ */
445
+ export type GameServerHelpers = {
446
+ loadSnapshot: (ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope) => Promise<LoadedServerSnapshot | null>;
447
+ applyCommand: (args: {
448
+ gameId: string;
449
+ loadedRevision: number;
450
+ currentRevision: number;
451
+ snapshot: GameRuntimeSnapshot;
452
+ actorUserId: string;
453
+ command: string;
454
+ input: unknown;
455
+ }) => {
456
+ ok: true;
457
+ snapshot: GameRuntimeSnapshot;
458
+ } | {
459
+ ok: false;
460
+ reason: string;
461
+ };
462
+ persistSnapshot: (ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save?: SaveConfig) => Promise<boolean>;
463
+ runCommand: (ctx: JGMutationCtx, args: RunCommandArgs) => Promise<RunCommandOutcome>;
464
+ };
465
+ /** How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist. */
466
+ export declare const DEFAULT_MAX_SERVERS_PER_TICK = 32;
467
+ /** How many chunk rows one `getChunks` call may return. */
468
+ export declare const MAX_CHUNKS_PER_QUERY = 64;
469
+ /**
470
+ * Build the hosted-server Convex functions for a set of game runtimes.
471
+ *
472
+ * **Supported world size.** One server is one Convex document holding the world row, the member
473
+ * roster, and every member's session state, and the host rewrites that document whenever the world
474
+ * changes. `slotsPerServer` is therefore capped at {@link JG_MAX_MEMBERS_PER_SERVER} (256) and a
475
+ * larger value throws here, at wiring time, rather than at the write that overflows the document.
476
+ * World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key
477
+ * ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`.
478
+ *
479
+ * `matchmaking: "singleton"` makes auto-match target one shared world per game: a join past capacity
480
+ * fails loudly instead of quietly starting a second world the player would be alone in.
481
+ * @internal
482
+ */
377
483
  export declare function createGameServerFunctions(options?: {
378
484
  runtimes?: GameRuntime[];
379
485
  auth?: JgAuthMode;
380
486
  slotsPerServer?: number;
487
+ matchmaking?: MatchmakingMode;
488
+ maxServersPerTick?: number;
381
489
  allowedFeedActions?: readonly string[];
382
490
  }): {
383
491
  joinServer: import("convex/server").RegisteredMutation<"public", {
@@ -401,12 +509,7 @@ export declare function createGameServerFunctions(options?: {
401
509
  externalId?: string | undefined;
402
510
  input: any;
403
511
  serverId: GenericId<"jgGameServers">;
404
- }, Promise<{
405
- ok: false;
406
- reason: string;
407
- } | {
408
- ok: true;
409
- }>>;
512
+ }, Promise<RunCommandOutcome>>;
410
513
  flushSave: import("convex/server").RegisteredMutation<"public", {
411
514
  externalId?: string | undefined;
412
515
  serverId: GenericId<"jgGameServers">;
@@ -432,6 +535,28 @@ export declare function createGameServerFunctions(options?: {
432
535
  };
433
536
  updatedAt: number;
434
537
  } | null>>;
538
+ getServerMeta: import("convex/server").RegisteredQuery<"public", {
539
+ externalId?: string | undefined;
540
+ serverId: GenericId<"jgGameServers">;
541
+ }, Promise<{
542
+ _id: GenericId<"jgGameServers">;
543
+ gameId: string;
544
+ status: "closed" | "open" | "running";
545
+ mode: string | undefined;
546
+ memberCount: number;
547
+ slotsPerServer: number;
548
+ revision: number;
549
+ updatedAt: number;
550
+ } | null>>;
551
+ getChunks: import("convex/server").RegisteredQuery<"public", {
552
+ chunkKeys: string[];
553
+ externalId?: string | undefined;
554
+ serverId: GenericId<"jgGameServers">;
555
+ }, Promise<{
556
+ chunkKey: string;
557
+ snapshot: unknown;
558
+ updatedAt: number;
559
+ }[]>>;
435
560
  getPlayerProfile: import("convex/server").RegisteredQuery<"public", {
436
561
  externalId?: string | undefined;
437
562
  gameId: string;
@@ -472,6 +597,7 @@ export declare function createGameServerFunctions(options?: {
472
597
  flushDirtyServers: import("convex/server").RegisteredMutation<"internal", {}, Promise<{
473
598
  saved: number;
474
599
  }>>;
600
+ helpers: GameServerHelpers;
475
601
  };
476
602
  /** @internal */
477
603
  export declare function createLeaderboardFunctions(options?: {
package/dist/server.js CHANGED
@@ -3,12 +3,12 @@ import { ConvexError, v } from "convex/values";
3
3
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
4
4
  import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
5
5
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
6
- import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
7
- import { isAutoJoinCandidate, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
8
- import { clearDirtyFlags, createEmptyServerRow } from "@jgengine/core/runtime/snapshot";
6
+ import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
7
+ import { JG_MAX_MEMBERS_PER_SERVER, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, selectJoinTarget, statusAfterLeave, validateSlotsPerServer, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
8
+ import { createEmptyServerRow, markAllPlayersDirty } from "@jgengine/core/runtime/snapshot";
9
9
  import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
10
10
  /** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
11
- export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
11
+ export { JG_MAX_MEMBERS_PER_SERVER, canJoinPrivateServer, isListablePublicly, validateSlotsPerServer, } from "@jgengine/core/runtime/hostPolicy";
12
12
  const saveConfigValidator = v.union(v.literal("none"), v.object({
13
13
  auto: v.string(),
14
14
  scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
@@ -37,6 +37,7 @@ export function jgengineTables() {
37
37
  })
38
38
  .index("by_game_and_status", ["gameId", "status"])
39
39
  .index("by_status", ["status"])
40
+ .index("by_game_status_tick", ["gameId", "status", "tickAnchorMs"])
40
41
  .index("by_dirty", ["dirtyAt"]),
41
42
  jgPlayerProfiles: defineTable({
42
43
  userId: v.string(),
@@ -218,9 +219,19 @@ function serverDocToRecord(server) {
218
219
  updatedAt: server.updatedAt,
219
220
  };
220
221
  }
221
- async function loadServerSnapshot(ctx, server, runtime) {
222
+ /**
223
+ * Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
224
+ * Reach for it when writing your own mutation that must read runtime state before touching host tables.
225
+ * Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world.
226
+ * @capability convex-load-server-snapshot read a hosted server's runtime snapshot inside a host-written Convex mutation
227
+ */
228
+ export async function loadServerSnapshot(ctx, server, runtime, scope) {
222
229
  const profiles = {};
223
- for (const userId of server.memberUserIds) {
230
+ const memberIds = new Set(server.memberUserIds);
231
+ const hydrateUserIds = scope?.players === undefined
232
+ ? server.memberUserIds
233
+ : scope.players.filter((userId) => memberIds.has(userId));
234
+ for (const userId of hydrateUserIds) {
224
235
  const profile = await ctx.db
225
236
  .query("jgPlayerProfiles")
226
237
  .withIndex("by_user_and_game", (q) => q.eq("userId", userId).eq("gameId", server.gameId))
@@ -235,14 +246,25 @@ async function loadServerSnapshot(ctx, server, runtime) {
235
246
  }
236
247
  : null;
237
248
  }
238
- const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles);
239
- const chunks = await ctx.db
240
- .query("jgWorldChunks")
241
- .withIndex("by_server", (q) => q.eq("serverId", server._id))
242
- .collect();
249
+ const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles, hydrateUserIds);
243
250
  const chunksByKey = {};
244
- for (const chunk of chunks) {
245
- chunksByKey[chunk.chunkKey] = chunk.snapshot;
251
+ if (scope?.chunkKeys === undefined) {
252
+ const chunks = await ctx.db
253
+ .query("jgWorldChunks")
254
+ .withIndex("by_server", (q) => q.eq("serverId", server._id))
255
+ .collect();
256
+ for (const chunk of chunks)
257
+ chunksByKey[chunk.chunkKey] = chunk.snapshot;
258
+ }
259
+ else {
260
+ for (const chunkKey of new Set(scope.chunkKeys)) {
261
+ const chunk = await ctx.db
262
+ .query("jgWorldChunks")
263
+ .withIndex("by_server_and_chunk", (q) => q.eq("serverId", server._id).eq("chunkKey", chunkKey))
264
+ .unique();
265
+ if (chunk)
266
+ chunksByKey[chunk.chunkKey] = chunk.snapshot;
267
+ }
246
268
  }
247
269
  return runtime.hydrate({
248
270
  gameId: server.gameId,
@@ -279,9 +301,21 @@ export async function applyLeaderboardIncrements(ctx, gameId, entries) {
279
301
  }
280
302
  }
281
303
  }
282
- async function persistServerSnapshot(ctx, server, snapshot, save) {
304
+ /**
305
+ * Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard
306
+ * increments, all under `save`. Pair it with your own table writes to keep both in one transaction.
307
+ *
308
+ * A persist that would change nothing writes nothing and returns `false`. That matters beyond write
309
+ * cost: every subscriber of a query that read this row re-receives the whole result when the document
310
+ * changes, so an unconditional write on a 1 Hz tick re-pushes the entire world to every client once a
311
+ * second whether or not anything moved.
312
+ * @capability convex-persist-server-snapshot write runtime state and host tables in one Convex transaction
313
+ */
314
+ export async function persistServerSnapshot(ctx, server, snapshot, save) {
283
315
  const now = Date.now();
284
316
  const plan = planServerPersist(serverDocToRecord(server), snapshot, save, now);
317
+ if (!plan.changed.any)
318
+ return false;
285
319
  if (plan.leaderboard.length > 0) {
286
320
  await applyLeaderboardIncrements(ctx, server.gameId, plan.leaderboard);
287
321
  }
@@ -293,7 +327,6 @@ async function persistServerSnapshot(ctx, server, snapshot, save) {
293
327
  const profilePatch = {
294
328
  playerState: profile.playerState,
295
329
  revision: profile.revision,
296
- dirtyAt: undefined,
297
330
  updatedAt: profile.updatedAt,
298
331
  };
299
332
  if (existing) {
@@ -338,29 +371,56 @@ async function persistServerSnapshot(ctx, server, snapshot, save) {
338
371
  await ctx.db.delete(existing._id);
339
372
  }
340
373
  await ctx.db.patch(server._id, {
341
- serverState: plan.server.serverState,
342
- sessionPlayers: plan.server.sessionPlayers,
374
+ ...(plan.changed.serverState ? { serverState: plan.server.serverState } : {}),
375
+ ...(plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}),
343
376
  revision: plan.server.revision,
344
377
  dirtyAt: plan.server.dirtyAt,
345
378
  updatedAt: plan.server.updatedAt,
346
379
  lastSavedAt: plan.server.lastSavedAt,
347
380
  });
381
+ return true;
348
382
  }
349
- async function flushServerIfDue(ctx, server, now, runtime) {
383
+ /** Autosave a server whose `save.auto` interval has elapsed, reusing an already-hydrated snapshot. */
384
+ async function flushServerIfDue(ctx, server, now, runtime, loaded) {
350
385
  if (!shouldAutoSave(server.save, server.dirtyAt, server.lastSavedAt, now)) {
351
386
  return false;
352
387
  }
353
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
354
- await persistServerSnapshot(ctx, server, clearDirtyFlags(snapshot), server.save);
388
+ const snapshot = loaded ?? (await loadServerSnapshot(ctx, server, runtime));
389
+ await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
355
390
  return true;
356
391
  }
357
392
  export const JG_RUNTIME_TICK_MS = 1_000;
358
- /** @internal */
393
+ /** How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist. */
394
+ export const DEFAULT_MAX_SERVERS_PER_TICK = 32;
395
+ /** How many chunk rows one `getChunks` call may return. */
396
+ export const MAX_CHUNKS_PER_QUERY = 64;
397
+ /**
398
+ * Build the hosted-server Convex functions for a set of game runtimes.
399
+ *
400
+ * **Supported world size.** One server is one Convex document holding the world row, the member
401
+ * roster, and every member's session state, and the host rewrites that document whenever the world
402
+ * changes. `slotsPerServer` is therefore capped at {@link JG_MAX_MEMBERS_PER_SERVER} (256) and a
403
+ * larger value throws here, at wiring time, rather than at the write that overflows the document.
404
+ * World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key
405
+ * ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`.
406
+ *
407
+ * `matchmaking: "singleton"` makes auto-match target one shared world per game: a join past capacity
408
+ * fails loudly instead of quietly starting a second world the player would be alone in.
409
+ * @internal
410
+ */
359
411
  export function createGameServerFunctions(options) {
360
412
  const mode = options?.auth ?? "required";
361
413
  const hostSlotsPerServer = options?.slotsPerServer ?? 16;
414
+ const slotsCheck = validateSlotsPerServer(hostSlotsPerServer);
415
+ if (!slotsCheck.ok)
416
+ throw new Error(slotsCheck.reason);
417
+ const matchmaking = options?.matchmaking ?? "auto";
418
+ const maxServersPerTick = Math.max(1, options?.maxServersPerTick ?? DEFAULT_MAX_SERVERS_PER_TICK);
362
419
  const feedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []);
363
420
  const registry = buildRuntimeRegistry(options?.runtimes);
421
+ const tickableGameIds = (options?.runtimes ?? [])
422
+ .filter((runtime) => runtime.hasTick)
423
+ .map((runtime) => runtime.gameId);
364
424
  const joinServer = mutation({
365
425
  args: {
366
426
  gameId: v.string(),
@@ -388,6 +448,7 @@ export function createGameServerFunctions(options) {
388
448
  if (server && server.gameId !== args.gameId) {
389
449
  throw new ConvexError("Server belongs to a different game");
390
450
  }
451
+ let mayCreate = true;
391
452
  if (!server) {
392
453
  const joinable = [];
393
454
  for (const status of ["running", "open"]) {
@@ -397,15 +458,21 @@ export function createGameServerFunctions(options) {
397
458
  .collect();
398
459
  joinable.push(...rows);
399
460
  }
400
- server =
401
- joinable.find((row) => isAutoJoinCandidate({
402
- memberUserIds: row.memberUserIds,
403
- slotsPerServer: row.slotsPerServer,
404
- visibility: row.visibility,
405
- userId: actorUserId,
406
- })) ?? null;
461
+ // Two concurrent first-joins can both see an empty list here and both insert. Convex's OCC
462
+ // resolves it — the loser's read set is invalidated and its mutation retries against the
463
+ // winner's row — so the engine adds no lock. A `HostPersistence` backend without
464
+ // serializable-snapshot retry must supply that guarantee itself.
465
+ const target = selectJoinTarget(joinable, {
466
+ userId: actorUserId,
467
+ mode: matchmaking,
468
+ slotsPerServer,
469
+ });
470
+ if (target.kind === "refuse")
471
+ throw new ConvexError(target.reason);
472
+ server = target.kind === "join" ? target.row : null;
473
+ mayCreate = target.kind === "create";
407
474
  }
408
- if (!server) {
475
+ if (!server && mayCreate) {
409
476
  const serverId = await ctx.db.insert("jgGameServers", {
410
477
  gameId: args.gameId,
411
478
  status: "running",
@@ -427,6 +494,17 @@ export function createGameServerFunctions(options) {
427
494
  if (!server)
428
495
  throw new ConvexError("Failed to create server");
429
496
  }
497
+ if (!server)
498
+ throw new ConvexError("No joinable server");
499
+ // Capacity and save policy live in host options, not in the row. Without this a long-lived
500
+ // world keeps whatever it was created with and raising the host option silently does nothing.
501
+ if (server.slotsPerServer !== slotsPerServer || JSON.stringify(server.save) !== JSON.stringify(save)) {
502
+ await ctx.db.patch(server._id, { slotsPerServer, save, updatedAt: now });
503
+ const reconciled = await ctx.db.get("jgGameServers", server._id);
504
+ if (!reconciled)
505
+ throw new ConvexError("Server missing after reconcile");
506
+ server = reconciled;
507
+ }
430
508
  if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
431
509
  throw new ConvexError("Server is full");
432
510
  }
@@ -456,7 +534,7 @@ export function createGameServerFunctions(options) {
456
534
  throw new ConvexError("Server missing after join");
457
535
  let snapshot = await loadServerSnapshot(ctx, refreshed, runtime);
458
536
  snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew);
459
- await persistServerSnapshot(ctx, refreshed, clearDirtyFlags(snapshot), save);
537
+ await persistServerSnapshot(ctx, refreshed, snapshot, save);
460
538
  return { serverId: refreshed._id, isNew };
461
539
  },
462
540
  });
@@ -494,17 +572,24 @@ export function createGameServerFunctions(options) {
494
572
  return null;
495
573
  },
496
574
  });
497
- const runCommand = mutation({
498
- args: {
499
- serverId: v.id("jgGameServers"),
500
- command: v.string(),
501
- input: v.any(),
502
- externalId: v.optional(v.string()),
575
+ const helpers = {
576
+ async loadSnapshot(ctx, serverId, scope) {
577
+ const server = await ctx.db.get("jgGameServers", serverId);
578
+ if (!server)
579
+ return null;
580
+ const runtime = resolveRuntime(registry, server.gameId);
581
+ return { server, runtime, snapshot: await loadServerSnapshot(ctx, server, runtime, scope) };
503
582
  },
504
- returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
505
- handler: async (ctx, args) => {
583
+ applyCommand(args) {
584
+ return applyCommandWithOcc({ ...args, runtime: resolveRuntime(registry, args.gameId) });
585
+ },
586
+ persistSnapshot(ctx, server, snapshot, save) {
587
+ return persistServerSnapshot(ctx, server, snapshot, save ?? server.save);
588
+ },
589
+ async runCommand(ctx, args) {
506
590
  const actorUserId = await requireActor(ctx, args.externalId, mode);
507
- const server = await ctx.db.get("jgGameServers", args.serverId);
591
+ const serverId = args.serverId;
592
+ const server = await ctx.db.get("jgGameServers", serverId);
508
593
  if (!server) {
509
594
  return { ok: false, reason: "Server not found" };
510
595
  }
@@ -526,7 +611,7 @@ export function createGameServerFunctions(options) {
526
611
  if (!result.ok) {
527
612
  return result;
528
613
  }
529
- const latest = await ctx.db.get("jgGameServers", args.serverId);
614
+ const latest = await ctx.db.get("jgGameServers", serverId);
530
615
  if (!latest) {
531
616
  return { ok: false, reason: "Server not found" };
532
617
  }
@@ -537,6 +622,16 @@ export function createGameServerFunctions(options) {
537
622
  await persistServerSnapshot(ctx, latest, result.snapshot, latest.save);
538
623
  return { ok: true };
539
624
  },
625
+ };
626
+ const runCommand = mutation({
627
+ args: {
628
+ serverId: v.id("jgGameServers"),
629
+ command: v.string(),
630
+ input: v.any(),
631
+ externalId: v.optional(v.string()),
632
+ },
633
+ returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
634
+ handler: (ctx, args) => helpers.runCommand(ctx, args),
540
635
  });
541
636
  const flushSave = mutation({
542
637
  args: {
@@ -551,7 +646,7 @@ export function createGameServerFunctions(options) {
551
646
  return false;
552
647
  const runtime = resolveRuntime(registry, server.gameId);
553
648
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
554
- await persistServerSnapshot(ctx, server, clearDirtyFlags(snapshot), server.save);
649
+ await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
555
650
  return true;
556
651
  },
557
652
  });
@@ -591,6 +686,63 @@ export function createGameServerFunctions(options) {
591
686
  };
592
687
  }),
593
688
  });
689
+ /**
690
+ * Lobby-scale view of a server: status, capacity, revision — no world state. Subscribe a menu,
691
+ * roster, or "is it up" indicator to this instead of `getServer`, whose payload is the whole world.
692
+ */
693
+ const getServerMeta = query({
694
+ args: {
695
+ serverId: v.id("jgGameServers"),
696
+ externalId: v.optional(v.string()),
697
+ },
698
+ returns: v.union(v.object({
699
+ _id: v.id("jgGameServers"),
700
+ gameId: v.string(),
701
+ status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
702
+ mode: v.optional(v.string()),
703
+ memberCount: v.number(),
704
+ slotsPerServer: v.number(),
705
+ revision: v.number(),
706
+ updatedAt: v.number(),
707
+ }), v.null()),
708
+ handler: (ctx, args) => withMember(ctx, mode, args, null, (server) => ({
709
+ _id: server._id,
710
+ gameId: server.gameId,
711
+ status: server.status,
712
+ mode: server.mode,
713
+ memberCount: server.memberUserIds.length,
714
+ slotsPerServer: server.slotsPerServer,
715
+ revision: server.revision,
716
+ updatedAt: server.updatedAt,
717
+ })),
718
+ });
719
+ /**
720
+ * Read world chunks by key — the client half of the chunk escape hatch. Without it a game that moved
721
+ * geometry out of `serverState` had no way to show it, because `getServer` returns only the row.
722
+ * Bounded to {@link MAX_CHUNKS_PER_QUERY} keys; ask for the cells around the viewer
723
+ * (`chunkKeysInRadius`), not the world.
724
+ */
725
+ const getChunks = query({
726
+ args: {
727
+ serverId: v.id("jgGameServers"),
728
+ chunkKeys: v.array(v.string()),
729
+ externalId: v.optional(v.string()),
730
+ },
731
+ returns: v.array(v.object({ chunkKey: v.string(), snapshot: v.any(), updatedAt: v.number() })),
732
+ handler: (ctx, args) => withMember(ctx, mode, args, [], async (server) => {
733
+ const keys = [...new Set(args.chunkKeys)].slice(0, MAX_CHUNKS_PER_QUERY);
734
+ const out = [];
735
+ for (const chunkKey of keys) {
736
+ const row = await ctx.db
737
+ .query("jgWorldChunks")
738
+ .withIndex("by_server_and_chunk", (q) => q.eq("serverId", server._id).eq("chunkKey", chunkKey))
739
+ .unique();
740
+ if (row)
741
+ out.push({ chunkKey: row.chunkKey, snapshot: row.snapshot, updatedAt: row.updatedAt });
742
+ }
743
+ return out;
744
+ }),
745
+ });
594
746
  const getPlayerProfile = query({
595
747
  args: {
596
748
  gameId: v.string(),
@@ -710,31 +862,41 @@ export function createGameServerFunctions(options) {
710
862
  }));
711
863
  },
712
864
  });
865
+ // Only games that declare `loop.onTick` are swept, and only `maxServersPerTick` of their servers per
866
+ // transaction, oldest anchor first. A game with no tick hook used to pay a full hydrate + persist per
867
+ // running server per second to hand `runtime.tick` a snapshot it returned unchanged; now it pays
868
+ // nothing, and no single transaction can fan out across every world of every game.
713
869
  const tickActiveServers = internalMutation({
714
870
  args: {},
715
871
  handler: async (ctx) => {
716
872
  const now = Date.now();
717
- const servers = await ctx.db
718
- .query("jgGameServers")
719
- .withIndex("by_status", (q) => q.eq("status", "running"))
720
- .collect();
721
873
  let ticked = 0;
722
874
  let saved = 0;
723
- for (const server of servers) {
724
- if (server.memberUserIds.length === 0)
725
- continue;
726
- const elapsedMs = now - server.tickAnchorMs;
727
- if (elapsedMs < JG_RUNTIME_TICK_MS)
728
- continue;
729
- const runtime = resolveRuntime(registry, server.gameId);
730
- let snapshot = await loadServerSnapshot(ctx, server, runtime);
731
- snapshot = runtime.tick(snapshot, elapsedMs / 1_000);
732
- await persistServerSnapshot(ctx, server, snapshot, server.save);
733
- await ctx.db.patch(server._id, { tickAnchorMs: now, updatedAt: now });
734
- ticked += 1;
735
- const refreshed = await ctx.db.get("jgGameServers", server._id);
736
- if (refreshed && (await flushServerIfDue(ctx, refreshed, now, runtime))) {
737
- saved += 1;
875
+ let budget = maxServersPerTick;
876
+ for (const gameId of tickableGameIds) {
877
+ if (budget <= 0)
878
+ break;
879
+ const servers = await ctx.db
880
+ .query("jgGameServers")
881
+ .withIndex("by_game_status_tick", (q) => q.eq("gameId", gameId).eq("status", "running"))
882
+ .take(budget);
883
+ for (const server of servers) {
884
+ if (server.memberUserIds.length === 0)
885
+ continue;
886
+ const elapsedMs = now - server.tickAnchorMs;
887
+ if (elapsedMs < JG_RUNTIME_TICK_MS)
888
+ continue;
889
+ budget -= 1;
890
+ const runtime = resolveRuntime(registry, server.gameId);
891
+ const loaded = await loadServerSnapshot(ctx, server, runtime);
892
+ const snapshot = runtime.tick(loaded, elapsedMs / 1_000);
893
+ await persistServerSnapshot(ctx, server, snapshot, server.save);
894
+ await ctx.db.patch(server._id, { tickAnchorMs: now });
895
+ ticked += 1;
896
+ const refreshed = await ctx.db.get("jgGameServers", server._id);
897
+ if (refreshed && (await flushServerIfDue(ctx, refreshed, now, runtime, snapshot))) {
898
+ saved += 1;
899
+ }
738
900
  }
739
901
  }
740
902
  return { ticked, saved };
@@ -743,18 +905,16 @@ export function createGameServerFunctions(options) {
743
905
  const flushDirtyServers = internalMutation({
744
906
  args: {},
745
907
  handler: async (ctx) => {
746
- const now = Date.now();
747
908
  const servers = await ctx.db
748
909
  .query("jgGameServers")
749
910
  .withIndex("by_dirty", (q) => q.gt("dirtyAt", 0))
750
- .collect();
911
+ .take(maxServersPerTick);
751
912
  let saved = 0;
752
913
  for (const server of servers) {
753
914
  const runtime = resolveRuntime(registry, server.gameId);
754
915
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
755
- await persistServerSnapshot(ctx, server, clearDirtyFlags(snapshot), server.save);
916
+ await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
756
917
  saved += 1;
757
- void now;
758
918
  }
759
919
  return { saved };
760
920
  },
@@ -765,12 +925,15 @@ export function createGameServerFunctions(options) {
765
925
  runCommand,
766
926
  flushSave,
767
927
  getServer,
928
+ getServerMeta,
929
+ getChunks,
768
930
  getPlayerProfile,
769
931
  getFeed,
770
932
  pushFeedEntry,
771
933
  listOpenServers,
772
934
  tickActiveServers,
773
935
  flushDirtyServers,
936
+ helpers,
774
937
  };
775
938
  }
776
939
  const leaderboardScopeValidator = v.union(v.literal("global"), v.literal("server"), v.literal("profile"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/convex",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Convex adapters for JGengine: game transport, presence transport, and backend wiring over @jgengine/core.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  "check-types": "bun ../../scripts/check-types-preflight.ts && tsgo --noEmit -p tsconfig.json"
31
31
  },
32
32
  "dependencies": {
33
- "@jgengine/core": "^0.15.0"
33
+ "@jgengine/core": "^0.17.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "convex": "^1.31.2",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.15.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.17.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -228,7 +228,7 @@
228
228
 
229
229
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
230
230
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
231
- - `VERSION` (const): const VERSION: "0.15.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
231
+ - `VERSION` (const): const VERSION: "0.17.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
232
232
 
233
233
  ## @jgengine/core/runtime/adapter
234
234
 
@@ -264,6 +264,13 @@
264
264
  - `CommandValidationError` (type): type CommandValidationError = { reason: string } — ⚠ undocumented
265
265
  - `RunCommandResult` (type): type RunCommandResult = | { ok: true; snapshot: GameRuntimeSnapshot } | { ok: false; reason: string } — ⚠ undocumented
266
266
 
267
+ ## @jgengine/core/runtime/context/objectSlots
268
+
269
+ - `ObjectSlotRejection` (type): type ObjectSlotRejection = | "unknown-object" | "no-slot-inventory" | "unknown-inventory" | "no-space" | "wrong-kind" | "slot-occupied" | "invalid-slot" | "insufficient" — Why an object slot mutation was refused — the instance, its catalog entry, the player inventory, or the declared `accepts`/capacity.
270
+ - `ObjectSlotResult` (type): type ObjectSlotResult = { status: "ok" } | { status: "rejected"; reason: ObjectSlotRejection } — Outcome of an object slot mutation: applied, or refused with a reason and no state change on either side.
271
+ - `ObjectSlotTransfer` (interface): interface ObjectSlotTransfer — Which player inventory a transfer moves between, and what it moves.
272
+ - `SceneObjectSlots` (interface): interface SceneObjectSlots — Per-instance container contents for placed objects whose catalog entry declares `slotInventory` — the engine side of "install a GPU into a rack", "put an item in a chest". Contents live on `SceneObject.slots`, so they replicate and save with the object rather than needing a game-owned table keyed by `instanceId`, and every mutation is validated against the declared `accepts` and stack limits.
273
+
267
274
  ## @jgengine/core/runtime/gameContext
268
275
 
269
276
  - `CatalogEntityRole` (type): type CatalogEntityRole = "player" | "enemy" | "hostile" | "npc" | "vehicle" — ⚠ undocumented
@@ -327,7 +334,7 @@
327
334
 
328
335
  ## @jgengine/core/runtime/gameRuntime
329
336
 
330
- - `GameRuntime` (type): type GameRuntime = { gameId: string; save: SaveConfig; hydrate: (input: HydrateInput) => GameRuntimeSnapshot; runCommand: ( snapshot: GameRuntimeSnapshot, actorUserId: string, commandName: string, input: unknown, ) => ReturnType<typeof runCommand>; tick: (snapshot: GameRuntimeSnapshot, dtSeconds: nu… — ⚠ undocumented
337
+ - `GameRuntime` (type): type GameRuntime = { gameId: string; save: SaveConfig; /** * Whether this runtime declares `loop.onTick`. A host's tick cron reads it to skip hydrating and * persisting a server whose `tick` is a no-op by construction — the difference between a world that * costs a full read/write every second while… — ⚠ undocumented
331
338
  - `GameRuntimeDefinition` (type): type GameRuntimeDefinition = { gameId: string; save: SaveConfig; commands: Record<string, CommandDef>; loop?: ServerLoopHooks; } — ⚠ undocumented
332
339
  - `HydrateInput` (type): type HydrateInput = { gameId: string; serverId: string; serverRow: RuntimeServerRow; playersByUserId: Record<string, RuntimePlayerRow>; chunksByKey: Record<string, RuntimeChunkRow>; revision?: number; } — ⚠ undocumented
333
340
  - `RuntimeInitContext` (type): type RuntimeInitContext = { snapshot: GameRuntimeSnapshot; setSnapshot: (snapshot: GameRuntimeSnapshot) => void; } — ⚠ undocumented
@@ -358,21 +365,28 @@
358
365
  - `OPEN_SERVER_LISTING_MAX` (const): const OPEN_SERVER_LISTING_MAX: 100 — ⚠ undocumented
359
366
  - `PlayerProfileRecord` (type): type PlayerProfileRecord = { userId: string; gameId: string; playerState: RuntimePlayerRow; revision: number; updatedAt: number; } — ⚠ undocumented
360
367
  - `ServerListing` (type): type ServerListing = { serverId: string; status: GameServerStatus; memberCount: number; slotsPerServer: number; mode?: string; label?: string; visibility?: SessionVisibility; joinCode?: string; tags?: string[]; updatedAt: number; } — ⚠ undocumented
361
- - `ServerPersistPlan` (type): type ServerPersistPlan = { server: GameServerRecord; profiles: PlayerProfileRecord[]; chunks: WorldChunkRecord[]; deletedChunks: string[]; leaderboard: LeaderboardIncrement[]; } — ⚠ undocumented
368
+ - `ServerPersistPlan` (type): type ServerPersistPlan = { server: GameServerRecord; profiles: PlayerProfileRecord[]; chunks: WorldChunkRecord[]; deletedChunks: string[]; leaderboard: LeaderboardIncrement[]; /** * Which fields of the server row the plan actually moved. A store that pays per written field — * or whose subscribers r… — ⚠ undocumented
362
369
  - `SessionAttributes` (type): type SessionAttributes = { label?: string; mode?: string; visibility?: SessionVisibility; joinCode?: string; tags?: string[]; } — ⚠ undocumented
363
370
  - `SessionVisibility` (type): type SessionVisibility = "public" | "private" — ⚠ undocumented
364
371
  - `ToServerListingOptions` (type): type ToServerListingOptions = { includeJoinCode?: boolean; } — ⚠ undocumented
365
372
  - `WorldChunkRecord` (type): type WorldChunkRecord = { serverId: string; chunkKey: string; snapshot: RuntimeChunkRow; updatedAt: number; } — ⚠ undocumented
373
+ - `isSnapshotClean` (function): function isSnapshotClean(snapshot: GameRuntimeSnapshot): boolean — True when a snapshot carries no unwritten mutation — nothing for a persist to do.
366
374
 
367
375
  ## @jgengine/core/runtime/hostPolicy
368
376
 
377
+ - `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row.
378
+ - `JoinCandidate` (type): type JoinCandidate = { memberUserIds: readonly string[]; slotsPerServer: number; visibility?: SessionVisibility | undefined; createdAt: number; } — A row `selectJoinTarget` can choose between — the matchmaking-relevant fields of a server record.
379
+ - `JoinTarget` (type): type JoinTarget<T extends JoinCandidate> = | { kind: "join"; row: T } | { kind: "create" } | { kind: "refuse"; reason: string } — What a join should do with the candidate rows it found.
380
+ - `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied.
369
381
  - `canJoinPrivateServer` (function): function canJoinPrivateServer(args: { isMember: boolean; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Private-server join-code gate. Existing members always pass; non-members must present a matching `joinCode` (loose-normalized via {@link normalizeJoinCode}). Callers still decide whether the server is private — this only answers the code/membership half.
370
382
  - `isAutoJoinCandidate` (function): function isAutoJoinCandidate(args: { memberUserIds: readonly string[]; slotsPerServer: number; visibility: SessionVisibility | undefined; userId: string; }): boolean — Auto-match candidate when no `serverId` is supplied: already a member, or a public room with free capacity. Private rooms are never auto-picked (join-by-code / direct id only).
371
383
  - `isListablePublicly` (function): function isListablePublicly(visibility: SessionVisibility | undefined): boolean — True when a server's `visibility` should surface in public listings / browse results.
372
384
  - `isPrivateJoinBlocked` (function): function isPrivateJoinBlocked(args: { visibility: SessionVisibility | undefined; memberUserIds: readonly string[]; userId: string; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Whether a private-visibility server blocks this join (non-member without a matching code). Public / undefined visibility never blocks.
373
385
  - `isServerFull` (function): function isServerFull(memberUserIds: readonly string[], slotsPerServer: number, userId: string): boolean — True when the server has no free slots for a non-member. Existing members never count as "full" so rejoin/leave cycles keep working.
374
386
  - `isServerMember` (function): function isServerMember(memberUserIds: readonly string[], userId: string): boolean — Whether `userId` is already on the server's member roster.
387
+ - `selectJoinTarget` (function): function selectJoinTarget<T extends JoinCandidate>(rows: readonly T[], args: { userId: string; mode?: MatchmakingMode; /** Capacity to judge rows against — the host's current option, when it supersedes the stored value. */ slotsPerServer?: number; }): JoinTarget<T> — Resolve an auto-match join against the game's joinable rows under a {@link MatchmakingMode}. Reach for it instead of hand-rolling the "find a room with space, else make one" scan, which is where a singleton world silently shards into per-player copies.
375
388
  - `statusAfterLeave` (function): function statusAfterLeave(remainingMemberCount: number, currentStatus: GameServerStatus): GameServerStatus — Status after a leave: empty rooms reopen; non-empty rooms keep their current status.
389
+ - `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number): { ok: true } | { ok: false; reason: string } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold.
376
390
  - `withJoinedMember` (function): function withJoinedMember(memberUserIds: readonly string[], userId: string): string[] — Roster after a successful join: unchanged when already a member, else appended.
377
391
  - `withoutMember` (function): function withoutMember(memberUserIds: readonly string[], userId: string): string[] — Roster after a leave: `userId` removed; order of remaining members preserved.
378
392
 
@@ -402,6 +416,11 @@
402
416
  - `MotionIntentBatch` (interface): interface MotionIntentBatch — ⚠ undocumented
403
417
  - `MotionIntents` (interface): interface MotionIntents — Seam for game code to reach the motion the shell's FrameDriver otherwise owns privately (#162.4). Game code calls `impulse`, `pushHorizontal`, `setVerticalVelocity`, and/or `setY` from `onTick` or commands; the shell calls `takePending()` once per frame, before integrating gravity, to drain what accumulated. `setY` wins over physics for that frame; impulses add to the velocity the driver is about to integrate; a later `setVerticalVelocity` replaces that velocity outright. Horizontal pushes compose with the walk controller (#282.4): they add to its horizontal velocity and decay naturally as it re-blends toward input — knockback, dashes, explosion shoves without raw `setPose` offsets.
404
418
 
419
+ ## @jgengine/core/runtime/objectRows
420
+
421
+ - `fromRuntimeObjectRow` (function): function fromRuntimeObjectRow(row: RuntimeObjectRow): SceneObject — Inverse of {@link toRuntimeObjectRow}: rebuild the live placed object a host persisted.
422
+ - `toRuntimeObjectRow` (function): function toRuntimeObjectRow(object: SceneObject): RuntimeObjectRow — Convert a live placed object into its persisted row — the bridge between `ctx.scene.object` and `GameRuntimeSnapshot.server.objects`/`chunks[].objects`. Per-instance `state` is carried as `flags`. Presentation overrides (`visual`, `animation`) are catalog- and authoring-resolved and deliberately stay off the persisted row — put anything a command must own in `state`.
423
+
405
424
  ## @jgengine/core/runtime/perContext
406
425
 
407
426
  - `perContext` (function): function perContext<T>(init: (ctx: GameContext) => T): (ctx: GameContext) => T — Per-`GameContext` lazy state, keyed by context identity through a `WeakMap` so it is reclaimed when the context is — the seam a game uses instead of a module-global `Map`, which is process-global and bleeds across worlds when one host serves several `serverId`s (#632). `init` runs once per distinct context on first access; the same context always resolves to the same value. This mirrors the engine's built-in per-ctx pattern (`ctx.player.motion`, `stepPlayerMovement`).
@@ -441,7 +460,7 @@
441
460
  - `RuntimeChunkRow` (type): type RuntimeChunkRow = { chunkKey: string; objects: RuntimeObjectRow[]; entities: RuntimeEntityRow[]; flags?: Record<string, unknown>; } — ⚠ undocumented
442
461
  - `RuntimeEntityRow` (type): type RuntimeEntityRow = { instanceId: string; catalogId: string; position?: [number, number, number]; rotationY?: number; parentSpace?: string; group?: string; stats?: Record<string, { current: number; max: number; min?: number }>; targetInstanceId?: string | null; userId?: string; } — ⚠ undocumented
443
462
  - `RuntimeInventorySlot` (type): type RuntimeInventorySlot = { item: string; count: number; slot?: number; } — ⚠ undocumented
444
- - `RuntimeObjectRow` (type): type RuntimeObjectRow = { instanceId: string; catalogId: string; position: [number, number, number]; rotationY?: number; parentSpace?: string; flags?: Record<string, unknown>; } — ⚠ undocumented
463
+ - `RuntimeObjectRow` (type): type RuntimeObjectRow = { instanceId: string; catalogId: string; position: [number, number, number]; rotationY?: number; parentSpace?: string; /** Opaque per-instance state; the persisted name for `SceneObject.state`. */ flags?: Record<string, unknown>; /** Container contents for a catalog `slotInve… — The persisted form of a placed `SceneObject`: identity, placement, per-instance state, and slot contents. Convert with `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`runtime/objectRows`).
445
464
  - `RuntimePlayerRow` (type): type RuntimePlayerRow = { userId: string; inventories: Record<string, RuntimeInventorySlot[]>; economy: Record<string, number>; unlocks: string[]; quests?: unknown; social?: unknown; leaderboard?: Record<string, number>; session?: Record<string, unknown>; } — ⚠ undocumented
446
465
  - `RuntimeProfileRow` (type): type RuntimeProfileRow = { userId: string; gameId: string; player: RuntimePlayerRow; updatedAt: number; } — ⚠ undocumented
447
466
  - `RuntimeServerRow` (type): type RuntimeServerRow = { entities: RuntimeEntityRow[]; objects: RuntimeObjectRow[]; session: Record<string, unknown>; feeds?: Record<string, unknown[]>; } — ⚠ undocumented
@@ -483,6 +502,20 @@
483
502
  - `WorldHostConnection` (interface): interface WorldHostConnection — One client's link on the host side: routes its upstream frames into the shared session, pushes it sync frames.
484
503
  - `WorldServerFrame` (type): type WorldServerFrame = | { t: "baseline"; revision: number; snapshot: WorldSnapshot } | { t: "diff"; diff: WorldDiff } — Host→client frame: the full baseline a joiner needs, then per-tick diffs. What a transport marshals downstream.
485
504
 
505
+ ## @jgengine/core/runtime/worldChunks
506
+
507
+ - `ChunkCoord` (type): type ChunkCoord = { cx: number; cz: number } — Integer cell coordinates of a chunk on the ground plane.
508
+ - `DEFAULT_CHUNK_SIZE` (const): const DEFAULT_CHUNK_SIZE: 64 — Default edge length, in world units, of one persisted world chunk.
509
+ - `chunkCoordAt` (function): function chunkCoordAt(position: readonly [number, number, number], size: number = DEFAULT_CHUNK_SIZE): ChunkCoord — Chunk coordinates containing a world position at `size` units per cell.
510
+ - `chunkKeyAt` (function): function chunkKeyAt(position: readonly [number, number, number], size: number = DEFAULT_CHUNK_SIZE): string — Chunk key containing a world position at `size` units per cell.
511
+ - `chunkKeyOf` (function): function chunkKeyOf(coord: ChunkCoord): string — Chunk key for a cell — the canonical `"cx,cz"` string a `jgWorldChunks` row is stored under. Games used to invent this mapping each time, which made two systems in one game disagree about which row a position belongs to.
512
+ - `chunkKeysInRadius` (function): function chunkKeysInRadius(center: readonly [number, number, number], radius: number, size: number = DEFAULT_CHUNK_SIZE): string[] — Every chunk key whose cell intersects a radius around a world position — the key list a viewer's bounded hydration or client chunk query asks for, instead of loading a whole world.
513
+ - `createEmptyChunkRow` (function): function createEmptyChunkRow(chunkKey: string): RuntimeChunkRow — An empty chunk row for `chunkKey` — the starting value for a cell nothing has been placed in yet.
514
+ - `deleteChunk` (function): function deleteChunk(snapshot: GameRuntimeSnapshot, chunkKey: string): GameRuntimeSnapshot — Drop a chunk from a snapshot and mark the key dirty so the host deletes its stored row.
515
+ - `parseChunkKey` (function): function parseChunkKey(key: string): ChunkCoord | null — Parse a `chunkKeyOf` string back to coordinates; `null` for anything not in that form.
516
+ - `setChunk` (function): function setChunk(snapshot: GameRuntimeSnapshot, chunkKey: string, chunk: RuntimeChunkRow): GameRuntimeSnapshot — Write a chunk into a snapshot and mark it dirty, so the next persist actually stores it. The engine never writes `dirty.chunks` for a raw `snapshot.chunks[key] = …` assignment, so a command that mutated the map by hand silently never saved that chunk.
517
+ - `updateChunk` (function): function updateChunk(snapshot: GameRuntimeSnapshot, chunkKey: string, update: (chunk: RuntimeChunkRow) => RuntimeChunkRow): GameRuntimeSnapshot — Read-modify-write a chunk (creating it empty when absent), marking it dirty.
518
+
486
519
  ## @jgengine/core/runtime/worldMirror
487
520
 
488
521
  - `WorldMirror` (interface): interface WorldMirror — The client end of host-authoritative replication: folds a host's baseline + {@link WorldDiff} stream onto a local {@link GameContext}. It keeps the last full {@link WorldSnapshot}, advances it with each diff, and pushes the result through `ctx.hydrate` — so the client mirrors exactly the subsystems its own game opted into and silently ignores host modules it lacks. This is the inverse of a {@link HostedWorldSession}; the transport in between (loopback, ws, Convex) is irrelevant.
@@ -48,6 +48,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
48
48
 
49
49
  - `canJoinPrivateServer` (function) · `import { canJoinPrivateServer } from "@jgengine/core/runtime/hostPolicy"`
50
50
 
51
+ ## host-join-target — choose or refuse an auto-matched server under a matchmaking mode
52
+
53
+ - `selectJoinTarget` (function) · `import { selectJoinTarget } from "@jgengine/core/runtime/hostPolicy"`
54
+
51
55
  ## host-listing-filter — exclude private sessions from public browse results
52
56
 
53
57
  - `isListablePublicly` (function) · `import { isListablePublicly } from "@jgengine/core/runtime/hostPolicy"`
@@ -100,6 +104,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
100
104
 
101
105
  - `ws` (function) · `import { ws } from "@jgengine/core/runtime/adapter"`
102
106
 
107
+ ## object-slot-inventory — placed objects hold validated per-instance container contents that replicate and save with the placement
108
+
109
+ - `SceneObjectSlots` (interface) · `import { SceneObjectSlots } from "@jgengine/core/runtime/context/objectSlots"`
110
+
111
+ ## placed-object-rehydrate — rebuild live placed objects, with their state and slot contents, from persisted snapshot rows
112
+
113
+ - `fromRuntimeObjectRow` (function) · `import { fromRuntimeObjectRow } from "@jgengine/core/runtime/objectRows"`
114
+
115
+ ## placed-object-rows — a placed object's per-instance state and container contents convert to and from the persisted snapshot row a host stores
116
+
117
+ - `toRuntimeObjectRow` (function) · `import { toRuntimeObjectRow } from "@jgengine/core/runtime/objectRows"`
118
+
103
119
  ## render-fallback-diagnostics — Enable/disable the dev-only placeholder-fallback probe.
104
120
 
105
121
  - `armFallbackSeams` (function) · `import { armFallbackSeams } from "@jgengine/core/devtools/fallbackSeams"`
@@ -134,6 +150,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
134
150
 
135
151
  - `defineStore` (function) · `import { defineStore } from "@jgengine/shell/gameKit"`
136
152
 
153
+ ## world-chunk-key — map world positions to persisted chunk keys at a declared cell size
154
+
155
+ - `chunkKeyOf` (function) · `import { chunkKeyOf } from "@jgengine/core/runtime/worldChunks"`
156
+
157
+ ## world-chunk-write — mutate a snapshot's chunk and have the host persist it
158
+
159
+ - `setChunk` (function) · `import { setChunk } from "@jgengine/core/runtime/worldChunks"`
160
+
137
161
  ## world-convergence — derive environment coordinate content from the scene document
138
162
 
139
163
  - `environmentContentFromDocument` (function) · `import { environmentContentFromDocument } from "@jgengine/shell/gameKit"`
@@ -14,14 +14,26 @@
14
14
  - `ConvexSaveBackendOptions` (interface): interface ConvexSaveBackendOptions — Config for {@link createConvexSaveBackend} — the Convex client, optional function refs (defaults to the `saves.*` convention), and an optional key `namespace`.
15
15
  - `ConvexSaveFunctions` (interface): interface ConvexSaveFunctions — The three Convex functions a save backend calls — a `query` that returns the stored string (or `null`) and two `mutation`s. Point them at your app's own module, or lean on the default `saves.*` convention.
16
16
  - `DEFAULT_CONVEX_POSE_RULES` (const): const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules — ⚠ undocumented
17
+ - `DEFAULT_MAX_SERVERS_PER_TICK` (const): const DEFAULT_MAX_SERVERS_PER_TICK: 32 — How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist.
18
+ - `GameServerHelpers` (type): type GameServerHelpers = { loadSnapshot: ( ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope, ) => Promise<LoadedServerSnapshot | null>; applyCommand: (args: { gameId: string; loadedRevision: number; currentRevision: number; snapshot: GameRuntimeSnapshot; actorUserId: string; command: … — The plain-function half of {@link createGameServerFunctions}, bound to the same runtime registry and auth mode as its mutations. Reach for it when a host mutation must pair snapshot work with its own table writes in one transaction, which a pre-registered mutation cannot do.
17
19
  - `HostedGameConfig` (interface): interface HostedGameConfig — One game the hosted path can serve, bound per `gameId`: its definition plus content lookup.
18
20
  - `HostedWorldInvocation` (interface): interface HostedWorldInvocation<T> — Everything one stateless invocation reconstructs a hosted world from: the persisted record via `store`, plus the member roster and held inputs the snapshot doesn't carry, and the op to apply to the fresh session.
19
21
  - `HostedWorldOutcome` (interface): interface HostedWorldOutcome<T> — What one stateless invocation produced: the op's value, the resulting revision, whether the world changed (and was saved back), and the post-op roster.
22
+ - `JGDataModel` (type): type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes> — Data model of the {@link jgengineTables} schema — the shape a host's own Convex ctx must satisfy to call the exported persistence helpers.
23
+ - `JGMutationCtx` (type): type JGMutationCtx = GenericMutationCtx<JGDataModel> — Mutation ctx accepted by {@link loadServerSnapshot} / {@link persistServerSnapshot}.
20
24
  - `JG_HOSTED_TICK_MS` (const): const JG_HOSTED_TICK_MS: 1000 — Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`.
25
+ - `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row.
21
26
  - `JG_RUNTIME_TICK_MS` (const): const JG_RUNTIME_TICK_MS: 1000 — ⚠ undocumented
22
27
  - `JgAuthMode` (type): type JgAuthMode = "anonymous" | "required" — ⚠ undocumented
23
28
  - `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers"; } — ⚠ undocumented
29
+ - `LoadSnapshotScope` (type): type LoadSnapshotScope = { /** Member ids to hydrate; omit for the whole roster. */ players?: readonly string[]; /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */ chunkKeys?: readonly string[]; } — How much of a server to hydrate. Both fields default to "everything", which is a document read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch: a command that only moves the actor's own state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`).
30
+ - `LoadedServerSnapshot` (type): type LoadedServerSnapshot = { server: ServerDoc; runtime: GameRuntime; snapshot: GameRuntimeSnapshot; } — A server resolved for runtime work: its row, its registered runtime, and its hydrated snapshot.
31
+ - `MAX_CHUNKS_PER_QUERY` (const): const MAX_CHUNKS_PER_QUERY: 64 — How many chunk rows one `getChunks` call may return.
32
+ - `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied.
24
33
  - `REVISION_CONFLICT_REASON` (const): const REVISION_CONFLICT_REASON: "Revision conflict" — ⚠ undocumented
34
+ - `RunCommandArgs` (type): type RunCommandArgs = { serverId: string; command: string; input: unknown; externalId?: string; } — Arguments of the `runCommand` mutation, and of the equivalent {@link GameServerHelpers.runCommand} helper.
35
+ - `RunCommandOutcome` (type): type RunCommandOutcome = { ok: true } | { ok: false; reason: string } — Outcome of a runtime command: applied, or refused with a reason (unknown server, non-member, validation, revision conflict).
36
+ - `ServerDoc` (type): type ServerDoc = DocumentByName<JGDataModel, "jgGameServers"> — A `jgGameServers` row — the server handle the persistence helpers load from and write back to.
25
37
  - `canJoinPrivateServer` (function): function canJoinPrivateServer(args: { isMember: boolean; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Private-server join-code gate. Existing members always pass; non-members must present a matching `joinCode` (loose-normalized via {@link normalizeJoinCode}). Callers still decide whether the server is private — this only answers the code/membership half.
26
38
  - `createConvexBackend` (function): function createConvexBackend<TRawPresenceRow extends { actorExternalId: string } = { actorExternalId: string }, TPresenceRow = unknown, TPresenceLocation = unknown, TGameId extends string = string>(options: ConvexBackendOptions<TRawPresenceRow, TPresenceRow>): ConvexBackend<TPresenceRow, TPresenceLo… — ⚠ undocumented
27
39
  - `createConvexChatSync` (function): function createConvexChatSync(client: ConvexReactClient, api: ConvexGameApi, config: ConvexGameTransportConfig, serverId: string): ChatSync — ⚠ undocumented
@@ -36,8 +48,11 @@
36
48
  - `defaultConvexGameApi` (function): function defaultConvexGameApi(): ConvexGameApi — ⚠ undocumented
37
49
  - `defaultConvexSaveFunctions` (function): function defaultConvexSaveFunctions(): ConvexSaveFunctions — Default `saves.read` / `saves.write` / `saves.remove` refs for apps that follow the convention — pass your own `functions` to override.
38
50
  - `isListablePublicly` (function): function isListablePublicly(visibility: SessionVisibility | undefined): boolean — True when a server's `visibility` should surface in public listings / browse results.
51
+ - `loadServerSnapshot` (function): function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime, scope?: LoadSnapshotScope): Promise<GameRuntimeSnapshot> — Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks. Reach for it when writing your own mutation that must read runtime state before touching host tables. Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world.
52
+ - `persistServerSnapshot` (function): function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise<boolean> — Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard increments, all under `save`. Pair it with your own table writes to keep both in one transaction.
39
53
  - `randomConvexPlayerId` (function): function randomConvexPlayerId(): string — ⚠ undocumented
40
54
  - `resolveConvexMultiplayer` (function): function resolveConvexMultiplayer(args: { game: GameDefinition; gameId: string; url?: string; client?: ConvexReactClient; api?: ConvexGameApi; userId?: string; force?: boolean; feedActions?: string[]; poseTuning?: PoseSyncTuning; }): MultiplayerSession | null — ⚠ undocumented
55
+ - `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number): { ok: true; } | { ok: false; reason: string; } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold.
41
56
  - `watchConvexQuery` (function): function watchConvexQuery<TArgs extends DefaultFunctionArgs, TResult, TView>(client: ConvexReactClient, query: FunctionReference<"query", "public", TArgs, TResult>, args: TArgs, toView: (result: TResult) => TView, onChange: (view: TView) => void): () => void — ⚠ undocumented
42
57
 
43
58
  ## @jgengine/convex/convexChatTransport
@@ -96,11 +111,26 @@
96
111
  ## @jgengine/convex/server
97
112
 
98
113
  - `DEFAULT_CONVEX_POSE_RULES` (const): const DEFAULT_CONVEX_POSE_RULES: PoseSyncRules — ⚠ undocumented
114
+ - `DEFAULT_MAX_SERVERS_PER_TICK` (const): const DEFAULT_MAX_SERVERS_PER_TICK: 32 — How many running servers one `tickActiveServers` transaction may hydrate, tick, and persist.
115
+ - `GameServerHelpers` (type): type GameServerHelpers = { loadSnapshot: ( ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope, ) => Promise<LoadedServerSnapshot | null>; applyCommand: (args: { gameId: string; loadedRevision: number; currentRevision: number; snapshot: GameRuntimeSnapshot; actorUserId: string; command: … — The plain-function half of {@link createGameServerFunctions}, bound to the same runtime registry and auth mode as its mutations. Reach for it when a host mutation must pair snapshot work with its own table writes in one transaction, which a pre-registered mutation cannot do.
116
+ - `JGDataModel` (type): type JGDataModel = DataModelFromSchemaDefinition<typeof schemaForTypes> — Data model of the {@link jgengineTables} schema — the shape a host's own Convex ctx must satisfy to call the exported persistence helpers.
117
+ - `JGMutationCtx` (type): type JGMutationCtx = GenericMutationCtx<JGDataModel> — Mutation ctx accepted by {@link loadServerSnapshot} / {@link persistServerSnapshot}.
118
+ - `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Hard ceiling on `slotsPerServer` for a single hosted server row.
99
119
  - `JG_RUNTIME_TICK_MS` (const): const JG_RUNTIME_TICK_MS: 1000 — ⚠ undocumented
100
120
  - `JgAuthMode` (type): type JgAuthMode = "anonymous" | "required" — ⚠ undocumented
101
121
  - `JgCronSpec` (type): type JgCronSpec = { name: string; intervalSeconds: number; functionKey: "tickActiveServers" | "flushDirtyServers"; } — ⚠ undocumented
122
+ - `LoadSnapshotScope` (type): type LoadSnapshotScope = { /** Member ids to hydrate; omit for the whole roster. */ players?: readonly string[]; /** Chunk keys to hydrate; omit for every chunk of the server, `[]` for none. */ chunkKeys?: readonly string[]; } — How much of a server to hydrate. Both fields default to "everything", which is a document read per member plus one per chunk — the cost that makes a large shared world unaffordable per mutation. Narrow them when the caller knows what it will touch: a command that only moves the actor's own state needs `players: [actorUserId]`, and a spatial edit needs only the chunk keys it writes (`chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`).
123
+ - `LoadedServerSnapshot` (type): type LoadedServerSnapshot = { server: ServerDoc; runtime: GameRuntime; snapshot: GameRuntimeSnapshot; } — A server resolved for runtime work: its row, its registered runtime, and its hydrated snapshot.
124
+ - `MAX_CHUNKS_PER_QUERY` (const): const MAX_CHUNKS_PER_QUERY: 64 — How many chunk rows one `getChunks` call may return.
125
+ - `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied.
126
+ - `RunCommandArgs` (type): type RunCommandArgs = { serverId: string; command: string; input: unknown; externalId?: string; } — Arguments of the `runCommand` mutation, and of the equivalent {@link GameServerHelpers.runCommand} helper.
127
+ - `RunCommandOutcome` (type): type RunCommandOutcome = { ok: true } | { ok: false; reason: string } — Outcome of a runtime command: applied, or refused with a reason (unknown server, non-member, validation, revision conflict).
128
+ - `ServerDoc` (type): type ServerDoc = DocumentByName<JGDataModel, "jgGameServers"> — A `jgGameServers` row — the server handle the persistence helpers load from and write back to.
102
129
  - `canJoinPrivateServer` (function): function canJoinPrivateServer(args: { isMember: boolean; joinCode: string | undefined; suppliedCode: string | undefined; }): boolean — Private-server join-code gate. Existing members always pass; non-members must present a matching `joinCode` (loose-normalized via {@link normalizeJoinCode}). Callers still decide whether the server is private — this only answers the code/membership half.
103
130
  - `isListablePublicly` (function): function isListablePublicly(visibility: SessionVisibility | undefined): boolean — True when a server's `visibility` should surface in public listings / browse results.
131
+ - `loadServerSnapshot` (function): function loadServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, runtime: GameRuntime, scope?: LoadSnapshotScope): Promise<GameRuntimeSnapshot> — Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks. Reach for it when writing your own mutation that must read runtime state before touching host tables. Pass a {@link LoadSnapshotScope} to bound the read instead of loading the whole world.
132
+ - `persistServerSnapshot` (function): function persistServerSnapshot(ctx: JGMutationCtx, server: ServerDoc, snapshot: GameRuntimeSnapshot, save: SaveConfig): Promise<boolean> — Write a snapshot back: server row, dirty player profiles, dirty chunks, and drained leaderboard increments, all under `save`. Pair it with your own table writes to keep both in one transaction.
133
+ - `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number): { ok: true; } | { ok: false; reason: string; } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold.
104
134
 
105
135
  ## @jgengine/core/multiplayer
106
136
 
@@ -137,10 +167,12 @@
137
167
  - `createPoseSyncGate` (function): function createPoseSyncGate(tuning: PoseSyncTuning): PoseSyncGate — ⚠ undocumented
138
168
  - `createPushToTalk` (function): function createPushToTalk(config?: { mode?: PushToTalkMode; onChange?: (transmitting: boolean) => void; }): PushToTalk — ⚠ undocumented
139
169
  - `findByJoinCode` (function): function findByJoinCode(listings: readonly SessionListing[], code: string): SessionListing | null — ⚠ undocumented
170
+ - `fromRuntimeObjectRow` (function): function fromRuntimeObjectRow(row: RuntimeObjectRow): SceneObject — Inverse of {@link toRuntimeObjectRow}: rebuild the live placed object a host persisted.
140
171
  - `normalizeJoinCode` (function): function normalizeJoinCode(code: string): string — ⚠ undocumented
141
172
  - `quickMatch` (function): function quickMatch(listings: readonly SessionListing[], filter: MatchFilter = {}): SessionListing | null — ⚠ undocumented
142
173
  - `resolveGuestSession` (function): function resolveGuestSession(seed?: string): AuthSession — ⚠ undocumented
143
174
  - `sessionPlayer` (function): function sessionPlayer(session: AuthSession): PlayerIdentity — ⚠ undocumented
175
+ - `toRuntimeObjectRow` (function): function toRuntimeObjectRow(object: SceneObject): RuntimeObjectRow — Convert a live placed object into its persisted row — the bridge between `ctx.scene.object` and `GameRuntimeSnapshot.server.objects`/`chunks[].objects`. Per-instance `state` is carried as `flags`. Presentation overrides (`visual`, `animation`) are catalog- and authoring-resolved and deliberately stay off the persisted row — put anything a command must own in `state`.
144
176
  - `validateFeedWrite` (function): function validateFeedWrite(gate: FeedWriteGate | undefined, action: string): { ok: true } | { ok: false; reason: string } — ⚠ undocumented
145
177
 
146
178
  ## @jgengine/core/multiplayer/chatContract
@@ -4,6 +4,14 @@
4
4
 
5
5
  Reach for these before hand-rolling. Each row is *the thing you need* → *the primitive that already does it*.
6
6
 
7
+ ## convex-load-server-snapshot — read a hosted server's runtime snapshot inside a host-written Convex mutation
8
+
9
+ - `loadServerSnapshot` (function) · `import { loadServerSnapshot } from "@jgengine/convex"`
10
+
11
+ ## convex-persist-server-snapshot — write runtime state and host tables in one Convex transaction
12
+
13
+ - `persistServerSnapshot` (function) · `import { persistServerSnapshot } from "@jgengine/convex"`
14
+
7
15
  ## convex-save — save a game to Convex (cloud) through the SaveBackend seam
8
16
 
9
17
  - `createConvexSaveBackend` (function) · `import { createConvexSaveBackend } from "@jgengine/convex"`
@@ -15,3 +23,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
15
23
  ## host-listing-filter — exclude private sessions from public browse results
16
24
 
17
25
  - `isListablePublicly` (function) · `import { isListablePublicly } from "@jgengine/convex"`
26
+
27
+ ## placed-object-rehydrate — rebuild live placed objects, with their state and slot contents, from persisted snapshot rows
28
+
29
+ - `fromRuntimeObjectRow` (function) · `import { fromRuntimeObjectRow } from "@jgengine/core/multiplayer"`
30
+
31
+ ## placed-object-rows — a placed object's per-instance state and container contents convert to and from the persisted snapshot row a host stores
32
+
33
+ - `toRuntimeObjectRow` (function) · `import { toRuntimeObjectRow } from "@jgengine/core/multiplayer"`
@@ -33,7 +33,13 @@ Once a session resolves, the shell (`GameHost`'s internal player) wires it up wi
33
33
 
34
34
  A synced pose (`@jgengine/ws/protocol`'s `WsPose`/`WsPresenceRow`) carries an optional `appearance: Record<string, string | number | boolean>` alongside position/rotation — primitive-valued client-set tags (skin id, mount id, active emote, team color) riding the same pose-sync frames as position, so a remote player's cosmetic state updates on the existing presence channel instead of a second round-trip.
35
35
 
36
- **Server side** — `@jgengine/convex/server` ships an entire authoritative Convex backend as factories, not a template to copy: `jgengineTables()` (schema spread), `createGameServerFunctions({ runtimes?, auth? })`, `createLeaderboardFunctions({ auth? })`, `createPresenceFunctions({ auth?, freshWindowMs? })`, `createChatFunctions({ auth?, historyLimit?, maxBodyLength?, minIntervalMs? })`, and `jgengineCronSpecs()` (tick/flush interval metadata for a `crons.ts`). A consumer's `convex/` directory is ~25 lines: `schema.ts` (`defineSchema({ ...jgengineTables() })`), four one-line re-export files (`runtime.ts`, `leaderboard.ts`, `presence.ts`, `chat.ts`, each just calling its factory), and `crons.ts` registering the tick (1s) + flush (60s) internal mutations — see `examples/convex-host` for the reference shape. No game-specific code lives there; any JGengine game can point at the same deployment. Games without a registered `GameRuntime` fall back to a no-save runtime that only understands `engine.ping`; pass `createGameServerFunctions({ runtimes: [createGameRuntime({ gameId, commands, loop, save })] })` to make `runCommand` / tick / save actually do something.
36
+ **Server side** — `@jgengine/convex/server` ships an entire authoritative Convex backend as factories, not a template to copy: `jgengineTables()` (schema spread), `createGameServerFunctions({ runtimes?, auth? })`, `createLeaderboardFunctions({ auth? })`, `createPresenceFunctions({ auth?, freshWindowMs? })`, `createChatFunctions({ auth?, historyLimit?, maxBodyLength?, minIntervalMs? })`, and `jgengineCronSpecs()` (tick/flush interval metadata for a `crons.ts`). A consumer's `convex/` directory is ~25 lines: `schema.ts` (`defineSchema({ ...jgengineTables() })`), four one-line re-export files (`runtime.ts`, `leaderboard.ts`, `presence.ts`, `chat.ts`, each just calling its factory), and `crons.ts` registering the tick (1s) + flush (60s) internal mutations — see `examples/convex-host` for the reference shape. No game-specific code lives there; any JGengine game can point at the same deployment. Games without a registered `GameRuntime` fall back to a no-save runtime that only understands `engine.ping`; pass `createGameServerFunctions({ runtimes: [createGameRuntime({ gameId, commands, loop, save })] })` to make `runCommand` / tick / save actually do something. When a host mutation must pair snapshot work with its own table writes in one transaction, use the factory's `helpers` (`loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand` — same registry and auth mode) or the standalone `loadServerSnapshot` / `persistServerSnapshot` exports.
37
+
38
+ **How big a hosted world gets.** One server is one Convex document holding the world row, the roster, and every member's session state, so member count is a per-transaction cost. `slotsPerServer` is capped at `JG_MAX_MEMBERS_PER_SERVER` (256) and `createGameServerFunctions` throws at wiring time on anything larger — a bigger number was never a bigger world, just a document that overflows later. World geometry that outgrows one row goes in `jgWorldChunks`: hydrate by key (`loadServerSnapshot(ctx, server, runtime, { players, chunkKeys })`), read from clients with the generated `getChunks`, and key cells with `chunkKeyAt` / `chunkKeysInRadius` from `@jgengine/core/runtime/worldChunks`. Mutate chunks through `setChunk` / `updateChunk` / `deleteChunk` — a raw `snapshot.chunks[key] = …` is never marked dirty and never persists.
39
+
40
+ **What the host does per second.** The tick cron sweeps only games whose runtime declares `loop.onTick`, at most `maxServersPerTick` (default 32) servers per transaction, oldest anchor first; a game with no tick hook costs nothing while idle. A persist that would change nothing writes nothing, so an idle world stops re-pushing itself to every subscriber once a second. Subscribe lobby UI to `getServerMeta` (status, member count, revision) rather than `getServer`, whose payload is the whole world, and set `replication: { aoiRadius }` so `entities`, `stats`, and `objects` all cull to each viewer.
41
+
42
+ **One shared world.** `createGameServerFunctions({ matchmaking: "singleton" })` makes auto-match always target the existing world and refuse a join past capacity, instead of quietly creating a second world the player would be alone in. `joinServer` also reconciles a stored `slotsPerServer`/`save` that has drifted from the host options, so raising the option takes effect on worlds that already exist. Auto-match relies on Convex OCC to resolve two concurrent first-joins (the loser's read set is invalidated and its mutation retries); a non-Convex `HostPersistence` must supply that guarantee itself.
37
43
 
38
44
  Auth defaults to `"anonymous"` (`JgAuthMode`) on every factory — the client's `externalId` is trusted as claimed, fine for local dev but spoofable. Pass `{ auth: "required" }` to every factory for production; the resolved actor becomes `ctx.auth.getUserIdentity()`'s `subject` and `externalId` is only cross-checked against it, never trusted alone.
39
45