@jgengine/convex 0.50.0 → 0.52.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.
@@ -104,6 +104,14 @@ export function createConvexPresenceSync(client, api, config, tuning) {
104
104
  const viewerChunks = new Map();
105
105
  const refreshers = new Map();
106
106
  const lastSentAt = new Map();
107
+ // A pose row outlives its tab unless something ends it; the last unsubscribe and an unload both do.
108
+ const leave = (serverId) => {
109
+ void client.mutation(api.presence.leave, { serverId, ...(config.userId === undefined ? {} : { externalId: config.userId }) }).catch(() => undefined);
110
+ };
111
+ const onPageHide = () => {
112
+ for (const serverId of refreshers.keys())
113
+ leave(serverId);
114
+ };
107
115
  return {
108
116
  subscribe(serverId, onChange) {
109
117
  let unsubscribe;
@@ -115,6 +123,8 @@ export function createConvexPresenceSync(client, api, config, tuning) {
115
123
  let listeners = refreshers.get(serverId);
116
124
  if (listeners === undefined) {
117
125
  listeners = new Set();
126
+ if (refreshers.size === 0)
127
+ globalThis.addEventListener?.("pagehide", onPageHide);
118
128
  refreshers.set(serverId, listeners);
119
129
  }
120
130
  listeners.add(refresh);
@@ -127,6 +137,9 @@ export function createConvexPresenceSync(client, api, config, tuning) {
127
137
  viewerChunks.delete(serverId);
128
138
  gates.delete(serverId);
129
139
  lastSentAt.delete(serverId);
140
+ leave(serverId);
141
+ if (refreshers.size === 0)
142
+ globalThis.removeEventListener?.("pagehide", onPageHide);
130
143
  }
131
144
  };
132
145
  },
@@ -39,7 +39,7 @@ export interface HostedWorldOutcome<T> {
39
39
  */
40
40
  export declare function invokeHostedWorld<T>(invocation: HostedWorldInvocation<T>): Promise<HostedWorldOutcome<T>>;
41
41
  /** Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. */
42
- export declare const JG_HOSTED_TICK_MS = 1000;
42
+ export declare const JG_HOSTED_TICK_MS = 50;
43
43
  /**
44
44
  * The Convex counterpart of ws's `createWorldGameHost`: serves GameContext worlds statelessly by
45
45
  * reconstructing a hosted session from the persisted `jgHostedWorlds` row on every invocation via
@@ -2,6 +2,7 @@ import { defineSchema, internalMutationGeneric, mutationGeneric, queryGeneric, }
2
2
  import { ConvexError, v } from "convex/values";
3
3
  import { createHostedGameRunner, INPUT_COMMAND } from "@jgengine/core/runtime/hostedGameRunner";
4
4
  import { createHostedWorldSessionAsync, } from "@jgengine/core/runtime/hostedWorldSession";
5
+ import { DEFAULT_HOST_TICK_MS, resolveHostTickMs } from "@jgengine/core/runtime/hostPolicy";
5
6
  import { commitIfRevisionMatch } from "./occ.js";
6
7
  import { jgengineHostedTables, resolveActor } from "./server.js";
7
8
  /**
@@ -40,7 +41,7 @@ const query = queryGeneric;
40
41
  const mutation = mutationGeneric;
41
42
  const internalMutation = internalMutationGeneric;
42
43
  /** Default minimum elapsed ms before `tickHostedWorlds` advances a world — override via the factory's `tickMs`. */
43
- export const JG_HOSTED_TICK_MS = 1_000;
44
+ export const JG_HOSTED_TICK_MS = DEFAULT_HOST_TICK_MS;
44
45
  async function getWorldRow(ctx, gameId, serverId) {
45
46
  return await ctx.db
46
47
  .query("jgHostedWorlds")
@@ -100,7 +101,7 @@ async function persistWorldRow(ctx, args) {
100
101
  */
101
102
  export function createHostedGameServerFunctions(options) {
102
103
  const mode = options.auth ?? "anonymous";
103
- const tickMs = options.tickMs ?? JG_HOSTED_TICK_MS;
104
+ const tickMs = resolveHostTickMs(options.tickMs ?? JG_HOSTED_TICK_MS);
104
105
  function resolveGame(gameId) {
105
106
  const game = options.games[gameId];
106
107
  if (game === undefined)
package/dist/server.d.ts CHANGED
@@ -662,6 +662,10 @@ export type LoadedServerSnapshot = {
662
662
  */
663
663
  export type GameServerHelpers = {
664
664
  ensureServer: (ctx: JGMutationCtx, gameId: string) => Promise<ServerDoc>;
665
+ /** The shared singleton world `ensureServer` would return, read without creating it, so a query can scope reads to it. `null` until a player or host mutation creates it. */
666
+ findServer: (ctx: {
667
+ db: Pick<JGQueryCtx["db"], "query">;
668
+ }, gameId: string) => Promise<ServerDoc | null>;
665
669
  loadSnapshot: (ctx: JGMutationCtx, serverId: string, scope?: LoadSnapshotScope) => Promise<LoadedServerSnapshot | null>;
666
670
  applyCommand: (args: {
667
671
  gameId: string;
@@ -904,6 +908,17 @@ export type PresenceSyncResult = {
904
908
  lastSeenAt: number;
905
909
  displaced: boolean;
906
910
  };
911
+ /** A live pose row handed to a `checkpoint` writer, so a host can persist its own last-position state. */
912
+ export type PresenceCheckpoint = {
913
+ userId: string;
914
+ homeGameId?: string;
915
+ position: {
916
+ x: number;
917
+ y: number;
918
+ z: number;
919
+ };
920
+ rotationY: number;
921
+ };
907
922
  /**
908
923
  * The pose lane of a hosted server: the backend behind `PresenceSync`, which replicates where each
909
924
  * member of one `jgGameServers` server is standing. It is not the backend for `PresenceTransport` —
@@ -936,6 +951,14 @@ export declare function createPresenceFunctions(options?: {
936
951
  y: number;
937
952
  z: number;
938
953
  } | null;
954
+ /**
955
+ * Persist host-owned last-position state from a live row: on a sync at most once per `intervalMs`
956
+ * (default 30 s), and always when `leave` ends the row, so a returning player spawns where they stood.
957
+ */
958
+ checkpoint?: {
959
+ intervalMs?: number;
960
+ write: (ctx: JGMutationCtx, row: PresenceCheckpoint, nowMs: number) => Promise<void>;
961
+ };
939
962
  }): {
940
963
  list: import("convex/server").RegisteredQuery<"public", {
941
964
  externalId?: string;
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import { ConvexError, v } from "convex/values";
3
3
  import { validateChatMessage } from "@jgengine/core/chat/chat";
4
4
  import { definedOnly } from "@jgengine/core/data/definedOnly";
5
5
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
6
- import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, isPresenceExpired, pickReusablePresence, resolveActivePresence, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
6
+ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, isPresenceExpired, pickReusablePresence, resolveActivePresence, shouldPersistWorldSnapshot, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
7
7
  import { SERVER_ONLY_COMMAND_REASON, commandScopeEscape, scopeWithActor } from "@jgengine/core/commands/commandRunner";
8
8
  import { createGameRuntime, initialPlayerState } from "@jgengine/core/runtime/gameRuntime";
9
9
  import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
@@ -774,14 +774,22 @@ export function createGameServerFunctions(options) {
774
774
  }
775
775
  return joinAs(ctx, args.userId, { gameId: args.gameId, serverId }, undefined);
776
776
  },
777
- async ensureServer(ctx, gameId) {
777
+ async findServer(ctx, gameId) {
778
778
  if (!shared || matchmaking !== "singleton")
779
- throw new Error("ensureServer requires a shared singleton host");
779
+ throw new Error("findServer requires a shared singleton host");
780
780
  for (const status of ["running", "open"]) {
781
781
  const existing = await ctx.db.query("jgGameServers").withIndex("by_game_and_status", q => q.eq("gameId", gameId).eq("status", status)).first();
782
782
  if (existing)
783
783
  return existing;
784
784
  }
785
+ return null;
786
+ },
787
+ async ensureServer(ctx, gameId) {
788
+ if (!shared || matchmaking !== "singleton")
789
+ throw new Error("ensureServer requires a shared singleton host");
790
+ const existing = await helpers.findServer(ctx, gameId);
791
+ if (existing)
792
+ return existing;
785
793
  const runtime = resolveRuntime(registry, gameId);
786
794
  const now = Date.now();
787
795
  const id = await ctx.db.insert("jgGameServers", {
@@ -1287,6 +1295,10 @@ export function createLeaderboardFunctions(options) {
1287
1295
  */
1288
1296
  export function createPresenceFunctions(options) {
1289
1297
  const mode = options?.auth ?? "required";
1298
+ const checkpointIntervalMs = options?.checkpoint?.intervalMs ?? 30_000;
1299
+ if (!Number.isFinite(checkpointIntervalMs) || checkpointIntervalMs < 0)
1300
+ throw new Error("Invalid presence checkpoint interval");
1301
+ const checkpointOf = (row) => definedOnly({ userId: row.userId, homeGameId: row.homeGameId, position: { x: row.x, y: row.y, z: row.z }, rotationY: row.rotationY });
1290
1302
  const freshWindowMs = options?.freshWindowMs ?? 10_000;
1291
1303
  const idleCutoffMs = options?.idleCutoffMs ?? 60_000;
1292
1304
  const maxReapPerRun = options?.maxReapPerRun ?? 256;
@@ -1412,9 +1424,15 @@ export function createPresenceFunctions(options) {
1412
1424
  ...(args.label === undefined ? {} : { label: args.label }),
1413
1425
  };
1414
1426
  const displaced = existing?.revokedAt !== undefined;
1427
+ const checkpoint = options?.checkpoint !== undefined
1428
+ && shouldPersistWorldSnapshot(existing?.lastWorldSnapshotAt, now, checkpointIntervalMs);
1429
+ if (checkpoint) {
1430
+ await options?.checkpoint?.write(ctx, checkpointOf({ userId: actorUserId, homeGameId: existing?.homeGameId, ...pose }), now);
1431
+ }
1432
+ const stamp = checkpoint ? { lastWorldSnapshotAt: now } : {};
1415
1433
  if (existing) {
1416
- if (decision.changed || decision.refreshKeepAlive || displaced || Object.keys(identity).length > 0) {
1417
- await ctx.db.patch(existing._id, { ...pose, ...identity, chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]), updatedAt: now, revokedAt: undefined });
1434
+ if (decision.changed || decision.refreshKeepAlive || displaced || checkpoint || Object.keys(identity).length > 0) {
1435
+ await ctx.db.patch(existing._id, { ...pose, ...identity, ...stamp, chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]), updatedAt: now, revokedAt: undefined });
1418
1436
  }
1419
1437
  }
1420
1438
  else {
@@ -1424,6 +1442,7 @@ export function createPresenceFunctions(options) {
1424
1442
  ...pose,
1425
1443
  chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]),
1426
1444
  ...identity,
1445
+ ...stamp,
1427
1446
  updatedAt: now,
1428
1447
  });
1429
1448
  }
@@ -1456,8 +1475,10 @@ export function createPresenceFunctions(options) {
1456
1475
  for (const row of rows) {
1457
1476
  if (args.sessionId !== undefined && row.sessionId !== args.sessionId)
1458
1477
  continue;
1459
- if (row.revokedAt === undefined)
1460
- await ctx.db.patch(row._id, { revokedAt: now });
1478
+ if (row.revokedAt !== undefined)
1479
+ continue;
1480
+ await options?.checkpoint?.write(ctx, checkpointOf(row), now);
1481
+ await ctx.db.patch(row._id, { revokedAt: now });
1461
1482
  }
1462
1483
  return null;
1463
1484
  },
@@ -1622,6 +1643,7 @@ export function createChatFunctions(options) {
1622
1643
  id: v.string(),
1623
1644
  channelId: v.string(),
1624
1645
  fromUserId: v.string(),
1646
+ fromLabel: v.optional(v.string()),
1625
1647
  body: v.string(),
1626
1648
  at: v.number(),
1627
1649
  })),
@@ -1634,10 +1656,11 @@ export function createChatFunctions(options) {
1634
1656
  return rows
1635
1657
  .slice()
1636
1658
  .reverse()
1637
- .map((row) => ({
1659
+ .map((row) => definedOnly({
1638
1660
  id: row._id,
1639
1661
  channelId: row.channelId,
1640
1662
  fromUserId: row.userId,
1663
+ fromLabel: row.authorName,
1641
1664
  body: row.body,
1642
1665
  at: row.at,
1643
1666
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/convex",
3
- "version": "0.50.0",
3
+ "version": "0.52.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": "tsgo --noEmit -p tsconfig.json"
31
31
  },
32
32
  "dependencies": {
33
- "@jgengine/core": "^0.50.0"
33
+ "@jgengine/core": "^0.52.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "convex": "^1.31.2",
@@ -3,82 +3,62 @@ name: jgengine
3
3
  description: Route game work to the smallest useful JGengine guidance set.
4
4
  ---
5
5
 
6
- # JGengine intake and router
6
+ # JGengine router
7
7
 
8
- This skill decides what to load; it is not an engine manual. Package map, commands, layering, and license live in the JGengine README (repo-root [`README.md`](../../../README.md) in the monorepo, [jgengine.com](https://jgengine.com) from a created project); open it when you need one of those facts.
8
+ JGengine is a TypeScript SDK of small general systems that combine into any game. Rules live in [AGENTS.md](../../../AGENTS.md); packages, layering and commands in [README.md](../../../README.md) (jgengine.com from a created project). Load only the domain a task needs.
9
9
 
10
- A created project installs only `jgengine`, `create-game-jg`, `jgengine-editor`, `jgengine-verify`, `game-design`, and `jgengine-ui`. Run `npx jgengine skills -p --all` before loading any other domain named below. Those projects do not ship `api.md`; read exact signatures from the `.d.ts` files under `node_modules/@jgengine/<pkg>/dist/`.
10
+ A created project ships `jgengine`, `create-game-jg`, `jgengine-editor`, `jgengine-verify`, `game-design` and `jgengine-ui`; `npx jgengine skills -p --all` adds the rest, with no `api.md` (read `node_modules/@jgengine/<pkg>/dist/*.d.ts`). Upgrading a project, `doctor` and `sync`: `jgengine-editor`.
11
11
 
12
- Created projects pin their scaffolding CLI as a local devDependency, separately from SDK versions. Run `npx jgengine doctor` after SDK upgrades to check CLI compatibility. Skill installation preserves newer or differing unversioned copies; update the project CLI first, and use `skills -p --force` only when replacement is intended. Each packaged skill install records its CLI and SDK versions in `.jgengine-version.json`.
12
+ ## Start
13
13
 
14
- ## Framework-first decisions
14
+ A new game or big feature starts with `create-game-jg`: it owns the create flow, `PLAN.md` and milestones. Never copy `games/*` or reach for a genre kit or preset. Pitch to testable promise: `game-design`; authored spaces and encounters: `level-design`.
15
15
 
16
- For every requested interaction, feature, fix or visual change, follow the boundary in
17
- [AGENTS.md](../../../AGENTS.md): discover the installed
18
- capability, adopt it, or improve its shared owner before adding reusable game-local code.
19
- Games keep unique rules, content, assets and presentation. Custom markup is not a reason
20
- to copy focus handling, input tracking, storage or model lifecycle.
16
+ ## The parts
21
17
 
22
- Check engine `beta` before recreating an upstream fix: games run the engine source from
23
- the same commit, so a fix that exists is adopted by importing it. After adoption, remove
24
- superseded local machinery and verify the actual game. Outside this repository, report
25
- missing SDK contracts upstream rather than treating a local engine implementation as the
26
- finished solution.
18
+ Break every feature into these before looking anything up: "supply drop" is a Schedule, Placement, Ladder, Requirement, Table and Motion track. One home each; `@jgengine/core/parts` re-exports their model layers.
27
19
 
28
- ## 1. Establish the target
20
+ | Part | Home |
21
+ | --- | --- |
22
+ | Meter | `stats/statPool.ts` |
23
+ | Ladder | `threshold/thresholds.ts` |
24
+ | Phases | `time/stateSchedule.ts` |
25
+ | Schedule | `time/pacedSchedule.ts` |
26
+ | Slots | `time/refillSlots.ts` |
27
+ | Placement | `world/spatialSample.ts`, `game/spawnPoints.ts` |
28
+ | Table | `random/pick.ts` |
29
+ | Requirement | `rules/predicate.ts` |
30
+ | Modifiers | `progression/statGraph.ts` |
31
+ | Inheritance | `inheritance/inheritance.ts` |
32
+ | Motion track | `movement/motionTrack.ts` |
29
33
 
30
- A new game or a big new feature starts with the `create-game-jg` skill: agree the direction with the user, map every feature with `jgengine has`, write `PLAN.md` with its design brief, then `jgengine create` and build it one milestone per PR, each the next 10 minutes of play. Load it before anything below.
34
+ The cookbook shows each common feature built from these in a few lines: [recipes/cookbook.md](recipes/cookbook.md).
31
35
 
32
- Treat the pitch as a unique composition of needs, not a genre to fill in — never reach for a genre kit, preset, archetype, or class template ("default sports car", "default RPG", "default FPS") in the SDK or skills (see [AGENTS.md](../../../AGENTS.md)). Use `game-design` to turn the pitch into a testable player promise, verbs, loops, progression, failure, and completion scenario. Use `level-design` when play depends on authored spaces, routes, encounters, exploration, or spatial pacing. Capture only decisions that change architecture:
36
+ ## Domains
33
37
 
34
- - player point of view and controls
35
- - world shape and authored content
36
- - core loop, interaction, combat, and progression
37
- - solo, local, hosted, or persistent multiplayer
38
- - HUD/menu states and **custom** UI art direction (compose shipped building blocks + HudTheme; no unskinned stock face)
39
- - one observable completion scenario
38
+ | Load | When |
39
+ | --- | --- |
40
+ | `jgengine-editor` | anything visible in the world: placement, terrain, paths, assets; project upgrades |
41
+ | `jgengine-world` | runtime movement, input, interaction, AI, navigation, audio |
42
+ | `jgengine-gameplay` | state, systems, inventory, crafting, quests, economy, turns |
43
+ | `jgengine-combat` | targeting, stats, damage, effects, abilities, loot |
44
+ | `jgengine-ui` | HUD, menus, layout, accessibility, touch |
45
+ | `jgengine-assets` | models, sprites, audio files, licensing, catalogs |
46
+ | `jgengine-multiplayer` | authority, replication, sessions, persistence |
47
+ | `game-improvement` | deepening an existing game |
48
+ | `jgengine-verify` | before any claim that a change works |
49
+ | `workflow` | landing a change (monorepo) |
50
+ | `improve` | backlog passes |
40
51
 
41
- Start a game only through `create-game-jg` (it picks the template and runs `jgengine create`); never copy `games/*`. The game's `jgengine.json` says what it is and uses (template, editor, `@jgengine` packages, and in-repo its adoption debt and boot probes). Change what a game uses by editing it, then run `jgengine sync`, which also brings a game up to the running engine: its `@jgengine` versions and its template's shared files (wiring, page, tooling scripts, AGENTS.md). A shared file the game rewrote goes in its `owns` list so sync leaves it alone.
52
+ World content is editor-first: anything that adds, moves or restyles something visible starts in `jgengine-editor`, and `jgengine-world` consumes the document.
42
53
 
43
- For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine@latest sync` in the project (or at its workspace root): it moves every `@jgengine` pin and catalog entry to that release, refreshes the template's files, and prints every Migrate step and Adopt-worthy addition in between. Install, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
54
+ ## Discover before writing
44
55
 
45
- `doctor` resolves Bun `catalog:` and `catalog:<name>` declarations from the nearest workspace's `workspaces.catalog` or `workspaces.catalogs` and follows hoisted SDK installs; missing or malformed catalog entries fail diagnostics. `sync` at a workspace root moves the catalog pins (preserving per-game `catalog:` declarations, CLI/github versions and a held editor override) and changes declarations only: run `bun install` at the root, then `jgengine doctor --workspace --json` once there.
56
+ 1. `npx jgengine has "<feature>" ...` answers many features at once (covered, partial or gap, with imports). `npx jgengine find <intent>` names the home folder, primitives, recipes and assets. Search the moment you would hand-roll a HUD window, inventory grid, hotkey, minimap, stat bar or character motion.
57
+ 2. `npx jgengine recipe <name>` prints a typechecked snippet; `npx jgengine recipe` lists them. [recipes/minimal-game.md](recipes/minimal-game.md) is the whole-game walkthrough; each domain has `recipes/`.
58
+ 3. Open `api.md` or a `.d.ts` only for exact signatures, with grep.
46
59
 
47
- When a linked consumer renders differently despite matching root SDK versions, `doctor` checks direct consumers and SDK dependency/peer paths, including private copies with the same version. Review the reported importer and real paths, align the shared catalog and linked-consumer declarations, then reinstall at the root and rerun `doctor --workspace`; a root install may leave private linked-package installs intact. Restart running games after reinstall. Workspace mode checks installed identities only, reports coverage, and refuses unsupported patterns, escaped symlinks or exhausted traversal budgets. The default project audit stays scoped; neither mode covers custom bundler aliases or indirect non-SDK chains.
60
+ If nothing fits, name the shared owner first (AGENTS.md, "Reusable capability goes upstream first"); report SDK gaps upstream outside this repo.
48
61
 
49
- ## 2. Select domains
62
+ ## Finish
50
63
 
51
- Load only rows the target needs. Do not preload every domain.
52
-
53
- | Need | Skill | Owner |
54
- | --- | --- | --- |
55
- | player promise, pillars, verbs, loops, economy, progression, balance | `game-design` | experience and rule intent |
56
- | substantial existing-game improvement across depth, world and presentation | `game-improvement` | ordered improvement plan and per-game completion |
57
- | metrics, topology, encounters, navigation, spatial pacing | `level-design` | playable space intent |
58
- | scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
59
- | runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
60
- | game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
61
- | targeting, stats, damage, effects, abilities, projectiles, loot | `jgengine-combat` | combat resolution |
62
- | HUD, menus, layout, accessibility, touch presentation | `jgengine-ui` | player-facing interface |
63
- | models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
64
- | authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
65
-
66
- World content routes editor-first: any request that adds, moves, restyles, or removes something visible in the world — however phrased ("design this world", "place the enemies", "make it look better") — starts in `jgengine-editor`; the other domains consume the authored document. Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
67
-
68
- ## 3. Discover before designing
69
-
70
- Within each selected domain:
71
-
72
- 0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) (`combat-loop`, `loot`, `world-drops`, `click-target`, `ability-bar`, `hitscan-weapon`, `rolled-gear`, `enter-vehicle` and more); `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
73
- 1. Search capabilities by intent; do not read `capabilities.md` top to bottom. Several features at once: `npx jgengine has torpor riding "snap building"` answers each (covered, partial or gap, with folders and imports) as one JSON document. `npx jgengine find <intent>` names the concept's home folder in each layer (read from the engine source or the installed packages), searches every domain's index and every recipe at once, and prints the primitive and its import (or the recipe to open) and matching assets; agents can use `--json --limit 5` for ranked imports and recipes with total, partial-match, and truncation fields. In the monorepo, `rg -i "<intent>" .claude/skills/*/capabilities.md` does the same search. Search the moment you are tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. A `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob almost certainly has a shipped drop-in (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
74
- 2. Open `api.md` (monorepo) or the package `.d.ts` only when exact signatures are needed; grep it, never read it whole.
75
- 3. Open the linked reference only for a deeper recipe or trap.
76
- 4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
77
-
78
- If no capability fits, identify the upstream package seam before writing game-local code. In this repository, implement a reusable primitive with its first adopter and a reviewed consumer cleanup; coordinate a differently composed second consumer when applicable. Recipes wire SDK APIs, not copied implementations. If editor-owned content cannot be expressed, file the editor gap first; track any fallback and its removal path.
79
-
80
- ## 4. Build and finish
81
-
82
- Keep scene coordinates in the editor document, state serializable, randomness injected, and hot-path work bounded. Use `jgengine-verify` for evidence appropriate to the change. Inside this monorepo, `workflow` owns issue/PR completion. Visual polish work also uses the `jgengine-ui` quality reference.
83
-
84
- **Required asset pass — completion is gated on real models.** A game is not done while its entities and props are still primitive markers or box placeholders when real models are available for them. Before claiming completion, run an asset pass: every placed entity has its `entityModels` set and every prop its `objectModels` set to real, resolved model refs — not primitive-shape markers. Scaffolds ship with `jgengine-assets` and `npx jgengine assets pull` installed but never auto-triggered, so this step is on you to run explicitly. `jgengine-assets` owns the how-to (sourcing, licensing, `assets pull`, catalog indexing, and resolving refs); load it for the workflow. If a game genuinely wants primitive shapes as its art direction, say so — otherwise unresolved markers/box props are unfinished, not a style.
64
+ Keep scene coordinates in the editor document, state serializable, randomness injected. A game is not done while its entities and props are box markers: run the asset pass in `jgengine-assets`. Prove it with `jgengine-verify`.
@@ -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.50.0" — Installed `@jgengine/core` semver - compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.52.0" — Installed `@jgengine/core` semver - compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -32,7 +32,8 @@
32
32
  - `SceneOwnershipManifest` (interface): interface SceneOwnershipManifest — Serializable record of a world's ownership declarations — persisted next to the scene document or shipped by a game.
33
33
  - `SceneProvenance` (type): type SceneProvenance = | AuthoredProvenance | GeneratedProvenance | RuntimeProvenance | TransientProvenance — The provenance of one editor-visible object — authored, generated, runtime, or transient.
34
34
  - `SceneProvenanceKind` (type): type SceneProvenanceKind = "authored" | "generated" | "runtime" | "transient" — Scene ownership boundary — gives every editor-visible object an explicit provenance and a single verdict (expose / bake / reject) so the editor never presents unauthored content as broken authored content.
35
- - `SpawnPointDistanceBias` (type): type SpawnPointDistanceBias = "near" | "far" | "none" — Preference for picking a spawn point relative to `avoid` positions: closer, farther, or unweighted.
35
+ - `SpawnPointBiasStrength` (type): type SpawnPointBiasStrength = "subtle" | "moderate" | "strong" — How strongly the distance preference weighs candidates — a named intent, not a weighting exponent.
36
+ - `SpawnPointDistanceBias` (type): type SpawnPointDistanceBias = "near" | "far" | "none" — Which way a spawn's chance leans, relative to `avoid`: closer, farther, or unweighted.
36
37
  - `SpawnPointSelectionOptions` (interface): interface SpawnPointSelectionOptions — Semantic options for selecting a spawn point without exposing weighting internals.
37
38
  - `TransientProvenance` (interface): interface TransientProvenance — An ephemeral simulation object — a spawned agent, projectile, or particle that never persists.
38
39
  - `WorldFeature` (type): type WorldFeature = | PlaceWorldFeature | ({ kind: "biomes" } & BiomesWorldConfig) | ({ kind: "voxel" } & VoxelWorldConfig) | ({ kind: "plots" } & PlotsWorldConfig) | ({ kind: "tilemap" } & TilemapWorldConfig) | EnvironmentWorldFeature | { kind: "flat" } — A declared world shape passed to `defineGame`. The preferred model is the place feature from `world()` (`@jgengine/core/placement/place`): substrate + laws, with all dressing authored in the editor. The remaining members — biomes, voxel grid, plots, tilemap, environment, flat — are the legacy code-declared shapes kept for existing games.
@@ -54,6 +55,7 @@
54
55
  - `road` (function): function road(config: RoadEnvironmentConfig): RoadEnvironmentDescriptor — Declare a road ribbon for an `environment()` world; the shell drapes and renders it over the terrain.
55
56
  - `seededRng` (function): function seededRng(seed: string | number): SeededRng — Deterministic pseudo-random generator seeded from a string or number — same seed, same sequence.
56
57
  - `seededStreams` (function): function seededStreams(seed: string | number): (stream: string) => () => number — Derives independent, deterministic {@link seededRng} streams from one base seed, keyed by stream name.
58
+ - `selectSpawnPoint` (function): function selectSpawnPoint(options: SpawnPointSelectionOptions): NavPoint | null — Select a spawn point by game intent, keeping the weighting in the placement part's candidate table.
57
59
  - `snow` (function): function snow(config: SnowEnvironmentConfig = {}): SnowEnvironmentDescriptor — Declares a snowfall weather effect for `environment()` — area, density, drift, wind, and flake opacity.
58
60
  - `terrain` (function): function terrain(config: TerrainEnvironmentConfig = {}): TerrainEnvironmentDescriptor — Declares a heightfield terrain patch for `environment()` — bounds, noise, materials, and flatten masks.
59
61
  - `tilemap` (function): function tilemap(config: TilemapWorldConfig): WorldFeature — Declares a 2D tilemap world from a map string.
@@ -273,6 +275,7 @@
273
275
 
274
276
  - `FrameModule` (interface): interface FrameModule — Frame subsystem exposing the frame-timing, profiling, and render-sample facades.
275
277
  - `createFrameModule` (const): const createFrameModule: (deps: { readProbes: () => Record<string, unknown>; }) => FrameModule — Create the frame subsystem that records frame/sim/phase timings, computes stats, and captures long-frame events.
278
+ - `percentile` (function): function percentile(sorted: readonly number[], fraction: number): number — Value at `fraction` (0-1) of an ascending-sorted list; 0 when empty.
276
279
 
277
280
  ## @jgengine/core/devtools/frameBreakdown
278
281
 
@@ -331,6 +334,12 @@
331
334
 
332
335
  - `diagnose` (function): function diagnose(frame: NonNullable<ReturnType<typeof devtools.frame.stats>>, longs: readonly LongFrameEvent[]): string | null — ⚠ undocumented
333
336
 
337
+ ## @jgengine/core/devtools/perfOverlay.draw
338
+
339
+ - `PERF_OVERLAY_FRAMES` (const): const PERF_OVERLAY_FRAMES: 120 — Frames the FPS graph keeps.
340
+ - `PerfOverlay` (function): function PerfOverlay({ sample }: { sample: PerfOverlaySample }): ReactElement — The F3 performance overlay every game gets: an FPS graph of the last frames, draws, triangles, scene nodes, linked programs and the costliest subsystems from the devtools frame breakdown. Pair it with {@link PerfOverlayProbe} inside the Canvas, sharing one sample.
341
+ - `PerfOverlaySample` (interface): interface PerfOverlaySample — What the overlay draws, filled in place by {@link PerfOverlayProbe} and read by {@link PerfOverlay}.
342
+
334
343
  ## @jgengine/core/devtools/report
335
344
 
336
345
  - `FrameWindow` (interface): interface FrameWindow — Frame timings over the measured window, as the player sees them.
@@ -436,7 +445,7 @@
436
445
 
437
446
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
438
447
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
439
- - `VERSION` (const): const VERSION: "0.50.0" — Installed `@jgengine/core` semver - compare against {@link CHANGELOG} keys when migrating.
448
+ - `VERSION` (const): const VERSION: "0.52.0" — Installed `@jgengine/core` semver - compare against {@link CHANGELOG} keys when migrating.
440
449
 
441
450
  ## @jgengine/core/runtime/adapter
442
451
 
@@ -476,7 +485,7 @@
476
485
  - `CameraDirector` (interface): interface CameraDirector — Runtime camera policy at `ctx.camera`: follow target, rig overlay, cinematic, chase tuning, FOV kicks and RTS pan jumps.
477
486
  - `CameraDirectorSnapshot` (type): type CameraDirectorSnapshot = CameraSerialized<CameraDirectorState> — JSON-safe camera policy; documented instant chase responses use an explicit Infinity marker.
478
487
  - `CameraDirectorState` (interface): interface CameraDirectorState — Camera policy state before the JSON-safe snapshot number encoding.
479
- - `CameraRigConfig` (type): type CameraRigConfig = Omit<GameCameraConfig, "rig" | "projection" | "pixelPerfect" | "frustum" | "playerFov" | "weapon" | "onCameraFollow"> — Data overlay for an existing rig; callbacks and Canvas projection/preferences remain configured by the game.
488
+ - `CameraRigConfig` (type): type CameraRigConfig = Omit<GameCameraConfig, "rig" | "lens" | "follow"> & { follow?: Omit<GameCameraFollowConfig, "weapon" | "onCameraFollow"> } — Data overlay for an existing rig; callbacks and Canvas projection/preferences remain configured by the game.
480
489
  - `CameraRigOverride` (interface): interface CameraRigOverride — Runtime rig selection and detached data overlay over the configured camera.
481
490
  - `ChaseCameraTuning` (type): type ChaseCameraTuning = Partial< Pick< ChaseCameraConfig, | "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank" | "shakePerSpeed" | "velocityYaw" | "yawResponse" | "headingSource" | "distanceBySpeed" | "pitchFollow" | "fovKick" | "lookBackAction" | "collision" | "view" … — Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11), a whole driving-feel overlay applied only while a vehicle is piloted (#1299), or a `view` switch between chase and seat cameras.
482
491
  - `nextChaseView` (function): function nextChaseView(current: ChaseView, views: readonly ChaseView[] = CHASE_VIEWS): ChaseView — The view after `current` in `views`, wrapping at the end; a view missing from the list starts the cycle over. Pair with `setChaseTuning` to bind a "change camera" key: `ctx.camera.setChaseTuning({ ...ctx.camera.chaseTuning(), view: nextChaseView(view) })`.
@@ -497,7 +506,7 @@
497
506
  - `GameContext` (interface): interface GameContext — The live engine handle a game's loop, systems, commands, and UI read and mutate — entities, objects, the reactive store, opted-in `game.*` subsystems, world queries, and the sim clock. One context = one running world.
498
507
  - `GameContextCards` (interface): interface GameContextCards { pile(id: string, config?: CardPileConfig): CardPile } — ⚠ undocumented
499
508
  - `GameContextCommands` (interface): interface GameContextCommands { define<TInput>(name: string, definition: CommandDefinition<GameContext, TInput>): void; has(name: string): boolean; names(): string[]; run(name: string, input: unknown): CommandResult<GameContext>; runAs(actorUserId: string, name: string,… — ⚠ undocumented
500
- - `GameContextContent` (interface): interface GameContextContent { itemById?(itemId: string): GameContextItemEntry | null | undefined; entityById?(catalogId: string): GameContextEntityEntry | null | undefined; objectById?(catalogId: string): GameContextObjectEntry | null | undefined } — ⚠ undocumented
509
+ - `GameContextContent` (interface): interface GameContextContent { defaultRarity?: string; itemById?(itemId: string): GameContextItemEntry | null | undefined; entityById?(catalogId: string): GameContextEntityEntry | null | undefined; objectById?(catalogId: string): GameContextObjectEntry | null | undefin… — ⚠ undocumented
501
510
  - `GameContextEconomy` (interface): interface GameContextEconomy { balance(userId: string, currencyId: string | CurrencyDefinition): number; grant(userId: string, currencyId: string | CurrencyDefinition, amount: number): void; charge(userId: string, currencyId: string | CurrencyDefinition, amount: number… — ⚠ undocumented
502
511
  - `GameContextEntityEntry` (interface): interface GameContextEntityEntry { stats?: StatCatalog; receive?: ReceiveMap; onDeath?: OnDeathSpec; movement?: PoseAllowedStates & {walkSpeed?: number}; role?: CatalogEntityRole; verbs?: readonly ContextVerb[]; colliders?: EntityColliderSet; scale?: number } — ⚠ undocumented · used by `entityEntryFromCatalog` (@jgengine/core/editor): Turns an authored entity row into the runtime {@link GameContextEntityEntry} a spawn consumes: looks up `entityId` in the document's {@link …
503
512
  - `GameContextFeed` (interface): interface GameContextFeed extends Omit<GameFeed, "bind"> { bind(action: keyof GameEventMap): () => void } — ⚠ undocumented
@@ -528,7 +537,7 @@
528
537
  - `GameContext` (interface): interface GameContext — The live engine handle a game's loop, systems, commands, and UI read and mutate — entities, objects, the reactive store, opted-in `game.*` subsystems, world queries, and the sim clock. One context = one running world.
529
538
  - `GameContextCards` (interface): interface GameContextCards { pile(id: string, config?: CardPileConfig): CardPile } — ⚠ undocumented
530
539
  - `GameContextCommands` (interface): interface GameContextCommands { define<TInput>(name: string, definition: CommandDefinition<GameContext, TInput>): void; has(name: string): boolean; names(): string[]; run(name: string, input: unknown): CommandResult<GameContext>; runAs(actorUserId: string, name: string,… — ⚠ undocumented
531
- - `GameContextContent` (interface): interface GameContextContent { itemById?(itemId: string): GameContextItemEntry | null | undefined; entityById?(catalogId: string): GameContextEntityEntry | null | undefined; objectById?(catalogId: string): GameContextObjectEntry | null | undefined } — ⚠ undocumented
540
+ - `GameContextContent` (interface): interface GameContextContent { defaultRarity?: string; itemById?(itemId: string): GameContextItemEntry | null | undefined; entityById?(catalogId: string): GameContextEntityEntry | null | undefined; objectById?(catalogId: string): GameContextObjectEntry | null | undefin… — ⚠ undocumented
532
541
  - `GameContextEconomy` (interface): interface GameContextEconomy { balance(userId: string, currencyId: string | CurrencyDefinition): number; grant(userId: string, currencyId: string | CurrencyDefinition, amount: number): void; charge(userId: string, currencyId: string | CurrencyDefinition, amount: number… — ⚠ undocumented
533
542
  - `GameContextEntityEntry` (interface): interface GameContextEntityEntry { stats?: StatCatalog; receive?: ReceiveMap; onDeath?: OnDeathSpec; movement?: PoseAllowedStates & {walkSpeed?: number}; role?: CatalogEntityRole; verbs?: readonly ContextVerb[]; colliders?: EntityColliderSet; scale?: number } — ⚠ undocumented · used by `entityEntryFromCatalog` (@jgengine/core/editor): Turns an authored entity row into the runtime {@link GameContextEntityEntry} a spawn consumes: looks up `entityId` in the document's {@link …
534
543
  - `GameContextFeed` (interface): interface GameContextFeed extends Omit<GameFeed, "bind"> { bind(action: keyof GameEventMap): () => void } — ⚠ undocumented
@@ -594,9 +603,12 @@
594
603
 
595
604
  ## @jgengine/core/runtime/hostPolicy
596
605
 
606
+ - `DEFAULT_HOST_SAVE_INTERVAL_MS` (const): const DEFAULT_HOST_SAVE_INTERVAL_MS: 1000 — Milliseconds between auto-saves of a hosted world that changed; 0 saves after every changed tick.
607
+ - `DEFAULT_HOST_TICK_MS` (const): const DEFAULT_HOST_TICK_MS: 50 — Authoritative host tick interval every host uses unless the game sets one: 20 Hz.
597
608
  - `JG_MAX_MEMBERS_PER_SERVER` (const): const JG_MAX_MEMBERS_PER_SERVER: 256 — Room-topology ceiling on `slotsPerServer`; shared membership uses indexed rows instead.
598
609
  - `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.
599
610
  - `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.
611
+ - `MAX_HOST_TICK_MS` (const): const MAX_HOST_TICK_MS: 1000 — Longest tick interval a host accepts; 1 Hz is the floor for a world that still feels live.
600
612
  - `MAX_SESSION_ID_LENGTH` (const): const MAX_SESSION_ID_LENGTH: 128 — Longest accepted client join/leave `sessionId`, in UTF-16 code units.
601
613
  - `MatchmakingMode` (type): type MatchmakingMode = "auto" | "singleton" — How auto-match behaves when no `serverId` is supplied.
602
614
  - `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.
@@ -605,6 +617,7 @@
605
617
  - `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.
606
618
  - `isServerFull` (function): function isServerFull(memberCount: number, slotsPerServer: number, isMember: boolean): 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.
607
619
  - `isServerMember` (function): function isServerMember(memberUserIds: readonly string[], userId: string): boolean — Whether `userId` is already on the server's member roster.
620
+ - `resolveHostTickMs` (function): function resolveHostTickMs(tickMs: number | undefined): number — Validate a host tick interval in milliseconds (greater than zero, at most {@link MAX_HOST_TICK_MS}); `undefined` yields {@link DEFAULT_HOST_TICK_MS}.
608
621
  - `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.
609
622
  - `statusAfterLeave` (function): function statusAfterLeave(remainingMemberCount: number, currentStatus: GameServerStatus): GameServerStatus — Status after a leave: empty rooms reopen; non-empty rooms keep their current status.
610
623
  - `validateSlotsPerServer` (function): function validateSlotsPerServer(slotsPerServer: number, topology: "rooms" | "shared" = "rooms"): { ok: true } | { ok: false; reason: string } — Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold.
@@ -707,11 +720,12 @@
707
720
 
708
721
  ## @jgengine/core/runtime/simLoop
709
722
 
723
+ - `DEFAULT_SIM_HZ` (const): const DEFAULT_SIM_HZ: 60 — Steps per second a loop runs at when `hz` is not set: the physics backends' own substep rate, so one sim step is one solver step.
710
724
  - `SimAdvanceResult` (interface): interface SimAdvanceResult — What one {@link SimLoop.advance} did.
711
725
  - `SimLoop` (interface): interface SimLoop — The stepper handle: advance with real time, read the tick and interpolation alpha, retune the rate, snapshot and restore.
712
726
  - `SimLoopState` (interface): interface SimLoopState — Serializable loop position: the tick count and the real seconds accumulated toward the next fixed step.
713
727
  - `SimulationConfig` (interface): interface SimulationConfig — How the simulation is stepped: a fixed rate with a catch-up cap, or once per frame with the frame's dt.
714
- - `createSimLoop` (function): function createSimLoop(initial?: SimulationConfig): SimLoop — The simulation stepper every driver (shell frame, headless runner, authoritative host) advances through, so the sim sees the same dt sequence regardless of who drives it. Fixed mode accumulates real time and steps in equal slices with a catch-up cap; variable mode steps once per advance.
728
+ - `createSimLoop` (function): function createSimLoop(initial?: SimulationConfig): SimLoop — The simulation stepper every driver (shell frame, headless runner, authoritative host) advances through, so the sim sees the same dt sequence regardless of who drives it. Fixed mode accumulates real time and steps in equal slices with a catch-up cap (60 Hz unless the game picks another rate); variable mode steps once per advance.
715
729
 
716
730
  ## @jgengine/core/runtime/simSnapshot
717
731
 
@@ -791,9 +805,12 @@
791
805
 
792
806
  ## @jgengine/core/runtime/worldReplication
793
807
 
808
+ - `ReplicatorView` (interface): interface ReplicatorView — Narrows {@link WorldReplicator.diff} to one viewer's interest.
794
809
  - `WorldDiff` (interface): interface WorldDiff — A revision-stamped delta over a {@link WorldSnapshot}. The host sends one per tick to each client, carrying only what changed since that client's last acknowledged revision — entity/stat/store deltas plus whole snapshots of any other opted-in module (feed, leaderboard, chat, …) that changed. Fold it onto a prior baseline with {@link applyWorldDiff}.
795
810
  - `WorldReplicator` (type): type WorldReplicator = ReturnType<typeof createWorldReplicator> — The stateful diff tracker returned by {@link createWorldReplicator}: `commit()`, `diff(sinceRevision)`, `revision()`.
796
811
  - `WorldReplicatorOptions` (interface): interface WorldReplicatorOptions — Optional acceleration for {@link createWorldReplicator}: a monotone world-dirty counter (aggregated from each {@link SnapshotModule.version}). When it hasn't advanced since the last commit nothing mutated, so the replicator skips re-reading and re-serializing the whole world — the change-detection short-circuit item #28 asks for. Omit it (or pass a snapshot-only source) to keep the original full-re-serialize-per-commit behavior.
812
+ - `WorldViewState` (interface): interface WorldViewState — What one viewer has been sent, held by whoever serves that viewer (one per connection and world). `sent` is the entity ids the viewer's mirror holds; `null` means no baseline has been served yet. `all` marks a viewer that holds every entity (no interest radius applied to it), so narrowing later knows what to drop.
813
+ - `createWorldViewState` (function): function createWorldViewState(): WorldViewState — A fresh {@link WorldViewState}: the next pull is a baseline.
797
814
 
798
815
  ## @jgengine/core/runtime/worldSnapshot
799
816
 
@@ -831,8 +848,8 @@
831
848
  - `HudCanvas` (function): function HudCanvas({ layout, editChord, showDuring, className, style, children, }: { layout: HudLayoutStore; editChord?: HudEditChord | false; /** Opt-in play-phase gate: render the HUD only during these phases (e.g. `["playing"]`). Omit for always-visible (default). */ showDuring?: readonly GamePha… — Full-viewport HUD surface. Each `HudPanel` names an anchor and a priority; the canvas measures every panel and places them all at once so none lands on another, at any screen size. Panels sharing an anchor stack outward from that edge; a panel that would cover a neighbour slides along its edge to the nearest free spot; when the screen is too small, the lowest-priority panels fold into a toggle, and only then does the whole HUD shrink. Device safe areas and every touch control (any region registered as kind `control` or `system`) are kept clear. Games declare panels, never place them per size.
832
849
  - `HudFade` (function): function HudFade({ watch, idleMs = 3500, dimTo = 0.18, hold = false, children, className, style }: HudFadeProps): ReactNode — A HUD group that steps back when nothing is happening: it fades after a quiet spell and comes back the moment a watched value changes, so the world fills the screen and the HUD speaks only when it has news. Hovering it also brings it back.
833
850
  - `HudPanel` (function): function HudPanel({ id, anchor = "top-left", order, chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }: { id: string; anchor?: HudAnchor; /** Stack position within the anchor, asc… — A HUD block anchored to a corner, edge or the centre. `HudCanvas` places every panel together: panels sharing an anchor stack outward from the screen edge in ascending `order`, and a panel that would cover another slides to the nearest free spot. When the screen is too small, lower-`priority` panels fold into a toggle first. On fine pointers panels stay draggable through the edit chord; a dragged panel leaves the flow and keeps its custom placement.
834
- - `HudSlots` (function): function HudSlots({ storageKey, slots, overlay, showDuring = DEFAULT_SLOT_PHASES }: HudSlotsProps): ReactNode — The play HUD in one line: a few small panels pinned to the screen's edges, scaled from a 1600x900 design to any window and movable by the player (the layout is remembered per `storageKey`). Keep it to a handful of slots; the world is the picture.
835
- - `PlayableGame` (type): type PlayableGame<TProbe extends ProbeReadings = ProbeReadings> = PlayableGameContract< ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps>, TProbe > & { /** HUD theme the shell applies at the `GameUI` root. Unset uses `defaultHud… — The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle. `TProbe` is what the game's `capture.probe` returns, so a proof flow over `typeof game` names only real probes.
851
+ - `HudSlots` (function): function HudSlots({ storageKey, slots = {}, panels = {}, overlay, showDuring = DEFAULT_SLOT_PHASES, className }: HudSlotsProps): ReactNode — The play HUD in one line: a few small panels pinned to the screen's edges, scaled from a 1600x900 design to any window and movable by the player (the layout is remembered per `storageKey`). Keep it to a handful of slots; the world is the picture.
852
+ - `PlayableGame` (type): type PlayableGame<TProbe extends ProbeReadings = ProbeReadings> = PlayableGameContract< ComponentType, ComponentType, RenderEntity, RenderObject, ComponentType<ViewmodelProps>, ComponentType<WorldOverlayProps>, TProbe, ComponentType<RemotePlayersProps> > & { /** HUD theme the shell applies at the `G… — The concrete React-bound playable a shell `defineGame` returns and `GameHost` mounts — the one PlayableGame games handle. `TProbe` is what the game's `capture.probe` returns, so a proof flow over `typeof game` names only real probes.
836
853
  - `StoreHandle` (interface): interface StoreHandle<T> — A typed, cast-free handle onto one slot of the reactive game store (`ctx.game.store`). The single type parameter is fixed at definition; `read`/`write`/`update` never widen to `unknown`, so no call site repeats `store.get(key) as T`. Writes flow through the engine store, so they bump `ctx.version()`, serialize into a {@link WorldSnapshot}, and replay under host authority — the same guarantees a hand-rolled `store.get`/`store.set` pair gives up the moment it forks run state into a module-level singleton.
837
854
  - `SystemDefinition` (interface): interface SystemDefinition — A reusable game capability — lifecycle, timing, events, and optional save / replication / reset / disposal. Pass instances via `defineGame({ systems })`. Prefer one system per meaningful capability (`combat`, `quests`), not per micro-tick.
838
855
  - `authoredSpawnPosition` (function): function authoredSpawnPosition(document: AuthoredSpawnDocumentLike, options: AuthoredSpawnOptions = {}): [number, number, number] | null — Position of the authored spawn marker as a spawn-ready `[x, y, z]` tuple, or null when the document has none. Reads the first `player_spawn` marker by default, so dragging the marker in the editor moves where players spawn — no coordinates copied into game code.
@@ -168,6 +168,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
168
168
 
169
169
  - `SceneObjectSlots` (interface) · `import { SceneObjectSlots } from "@jgengine/core/runtime/context/objectSlots"`
170
170
 
171
+ ## perf-overlay — F3 in-game performance overlay — FPS graph, draws, nodes, programs and the costliest subsystems
172
+
173
+ - `PerfOverlay` (function) · `import { PerfOverlay } from "@jgengine/core/devtools/perfOverlay.draw"`
174
+
171
175
  ## placed-object-rehydrate — rebuild live placed objects, with their state and slot contents, from persisted snapshot rows
172
176
 
173
177
  - `fromRuntimeObjectRow` (function) · `import { fromRuntimeObjectRow } from "@jgengine/core/runtime/objectRows"`
@@ -214,6 +218,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
214
218
 
215
219
  - `createSimSnapshotRegistry` (function) · `import { createSimSnapshotRegistry } from "@jgengine/core/runtime/simSnapshot"`
216
220
 
221
+ ## spawn-point-select — select a spawn location by its distance preference from avoided positions
222
+
223
+ - `selectSpawnPoint` (function) · `import { selectSpawnPoint } from "@jgengine/core/authoring"`
224
+
217
225
  ## tune-control-labels — find a Tune panel field by its control name (drive --fill, screen readers)
218
226
 
219
227
  - `tuneControlLabels` (function) · `import { tuneControlLabels } from "@jgengine/core/devtools/TunePanel.view"`