@jgengine/convex 0.49.1 → 0.51.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/dist/server.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
2
2
  import { ConvexError, v } from "convex/values";
3
3
  import { validateChatMessage } from "@jgengine/core/chat/chat";
4
+ import { definedOnly } from "@jgengine/core/data/definedOnly";
4
5
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
5
- 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";
6
7
  import { SERVER_ONLY_COMMAND_REASON, commandScopeEscape, scopeWithActor } from "@jgengine/core/commands/commandRunner";
7
8
  import { createGameRuntime, initialPlayerState } from "@jgengine/core/runtime/gameRuntime";
8
9
  import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
@@ -298,10 +299,10 @@ async function ensureServerCapacity(ctx, server) {
298
299
  const existing = await serverCapacity(ctx, server);
299
300
  if (existing)
300
301
  return existing;
301
- const id = await ctx.db.insert("jgServerCapacity", {
302
+ const id = await ctx.db.insert("jgServerCapacity", definedOnly({
302
303
  serverId: server._id, gameId: server.gameId, status: server.status, mode: server.mode,
303
304
  memberCount: server.memberCount ?? server.memberUserIds.length, slotsPerServer: server.slotsPerServer,
304
- });
305
+ }));
305
306
  return (await ctx.db.get("jgServerCapacity", id));
306
307
  }
307
308
  /**
@@ -387,7 +388,7 @@ export async function applyLeaderboardIncrements(ctx, gameId, entries) {
387
388
  await ctx.db.patch(existing._id, { value: existing.value + entry.by, updatedAt: now });
388
389
  }
389
390
  else {
390
- await ctx.db.insert("jgLeaderboardRows", {
391
+ await ctx.db.insert("jgLeaderboardRows", definedOnly({
391
392
  gameId,
392
393
  stat: entry.stat,
393
394
  scope: entry.scope,
@@ -395,7 +396,7 @@ export async function applyLeaderboardIncrements(ctx, gameId, entries) {
395
396
  userId: entry.userId,
396
397
  value: entry.by,
397
398
  updatedAt: now,
398
- });
399
+ }));
399
400
  }
400
401
  }
401
402
  }
@@ -475,7 +476,8 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
475
476
  await ctx.db.insert("jgWorldChunks", {
476
477
  serverId: server._id,
477
478
  chunkKey: chunk.chunkKey,
478
- ...chunkPatch,
479
+ snapshot: chunk.snapshot,
480
+ updatedAt: chunk.updatedAt,
479
481
  });
480
482
  }
481
483
  }
@@ -582,10 +584,10 @@ export function createGameServerFunctions(options) {
582
584
  const serverId = await ctx.db.insert("jgGameServers", {
583
585
  gameId: args.gameId,
584
586
  status: "running",
585
- mode: args.mode,
587
+ ...(args.mode === undefined ? {} : { mode: args.mode }),
586
588
  modeConfig: args.modeConfig,
587
589
  visibility: args.visibility ?? "public",
588
- joinCode: args.joinCode,
590
+ ...(args.joinCode === undefined ? {} : { joinCode: args.joinCode }),
589
591
  topology: shared ? "shared" : "rooms",
590
592
  memberCount: 0,
591
593
  memberUserIds: [],
@@ -772,14 +774,22 @@ export function createGameServerFunctions(options) {
772
774
  }
773
775
  return joinAs(ctx, args.userId, { gameId: args.gameId, serverId }, undefined);
774
776
  },
775
- async ensureServer(ctx, gameId) {
777
+ async findServer(ctx, gameId) {
776
778
  if (!shared || matchmaking !== "singleton")
777
- throw new Error("ensureServer requires a shared singleton host");
779
+ throw new Error("findServer requires a shared singleton host");
778
780
  for (const status of ["running", "open"]) {
779
781
  const existing = await ctx.db.query("jgGameServers").withIndex("by_game_and_status", q => q.eq("gameId", gameId).eq("status", status)).first();
780
782
  if (existing)
781
783
  return existing;
782
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;
783
793
  const runtime = resolveRuntime(registry, gameId);
784
794
  const now = Date.now();
785
795
  const id = await ctx.db.insert("jgGameServers", {
@@ -930,7 +940,7 @@ export function createGameServerFunctions(options) {
930
940
  _id: server._id,
931
941
  gameId: server.gameId,
932
942
  status: server.status,
933
- mode: server.mode,
943
+ ...(server.mode === undefined ? {} : { mode: server.mode }),
934
944
  memberUserIds: server.memberUserIds,
935
945
  slotsPerServer: server.slotsPerServer,
936
946
  save: server.save,
@@ -958,7 +968,7 @@ export function createGameServerFunctions(options) {
958
968
  memberCount: v.number(),
959
969
  slotsPerServer: v.number(),
960
970
  }), v.null()),
961
- handler: (ctx, args) => withMember(ctx, mode, args, null, async (server) => ({
971
+ handler: (ctx, args) => withMember(ctx, mode, args, null, async (server) => definedOnly({
962
972
  _id: server._id,
963
973
  gameId: server.gameId,
964
974
  status: server.status,
@@ -983,10 +993,10 @@ export function createGameServerFunctions(options) {
983
993
  const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", actor)).unique();
984
994
  if (!member)
985
995
  return null;
986
- return { _id: capacity.serverId, gameId: capacity.gameId, status: capacity.status, mode: capacity.mode, memberCount: capacity.memberCount, slotsPerServer: capacity.slotsPerServer };
996
+ return definedOnly({ _id: capacity.serverId, gameId: capacity.gameId, status: capacity.status, mode: capacity.mode, memberCount: capacity.memberCount, slotsPerServer: capacity.slotsPerServer });
987
997
  }
988
998
  const server = await requireServerMember(ctx, args.serverId, actor);
989
- return server ? { _id: server._id, gameId: server.gameId, status: server.status, mode: server.mode, memberCount: server.memberUserIds.length, slotsPerServer: server.slotsPerServer } : null;
999
+ return server ? definedOnly({ _id: server._id, gameId: server.gameId, status: server.status, mode: server.mode, memberCount: server.memberUserIds.length, slotsPerServer: server.slotsPerServer }) : null;
990
1000
  },
991
1001
  });
992
1002
  /**
@@ -1125,7 +1135,7 @@ export function createGameServerFunctions(options) {
1125
1135
  return Promise.all(rows
1126
1136
  .filter((row) => isListablePublicly(row.visibility))
1127
1137
  .slice(0, limit)
1128
- .map(async (row) => ({
1138
+ .map(async (row) => definedOnly({
1129
1139
  _id: row._id,
1130
1140
  status: row.status,
1131
1141
  memberCount: (await serverCapacity(ctx, row))?.memberCount ?? row.memberCount ?? row.memberUserIds.length,
@@ -1285,6 +1295,10 @@ export function createLeaderboardFunctions(options) {
1285
1295
  */
1286
1296
  export function createPresenceFunctions(options) {
1287
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 });
1288
1302
  const freshWindowMs = options?.freshWindowMs ?? 10_000;
1289
1303
  const idleCutoffMs = options?.idleCutoffMs ?? 60_000;
1290
1304
  const maxReapPerRun = options?.maxReapPerRun ?? 256;
@@ -1327,7 +1341,7 @@ export function createPresenceFunctions(options) {
1327
1341
  }
1328
1342
  return rows
1329
1343
  .filter((row) => row.revokedAt === undefined && row.updatedAt >= threshold)
1330
- .map((row) => ({
1344
+ .map((row) => definedOnly({
1331
1345
  userId: row.userId,
1332
1346
  sessionId: row.sessionId,
1333
1347
  kind: row.kind,
@@ -1410,9 +1424,15 @@ export function createPresenceFunctions(options) {
1410
1424
  ...(args.label === undefined ? {} : { label: args.label }),
1411
1425
  };
1412
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 } : {};
1413
1433
  if (existing) {
1414
- if (decision.changed || decision.refreshKeepAlive || displaced || Object.keys(identity).length > 0) {
1415
- 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 });
1416
1436
  }
1417
1437
  }
1418
1438
  else {
@@ -1422,6 +1442,7 @@ export function createPresenceFunctions(options) {
1422
1442
  ...pose,
1423
1443
  chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]),
1424
1444
  ...identity,
1445
+ ...stamp,
1425
1446
  updatedAt: now,
1426
1447
  });
1427
1448
  }
@@ -1454,8 +1475,10 @@ export function createPresenceFunctions(options) {
1454
1475
  for (const row of rows) {
1455
1476
  if (args.sessionId !== undefined && row.sessionId !== args.sessionId)
1456
1477
  continue;
1457
- if (row.revokedAt === undefined)
1458
- 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 });
1459
1482
  }
1460
1483
  return null;
1461
1484
  },
@@ -1550,7 +1573,7 @@ export async function sendChatMessage(ctx, args) {
1550
1573
  const gate = await rateLimit(ctx, { key: JSON.stringify(["chat", args.serverId, args.userId]), windowMs: args.minIntervalMs ?? 2000, max: 1 });
1551
1574
  if (!gate.ok)
1552
1575
  return { ok: false, reason: "rate_limited" };
1553
- await ctx.db.insert("jgChatMessages", { serverId: args.serverId, channelId: args.channelId ?? "global", userId: args.userId, body: validation.text, authorName: args.authorName, at: Date.now() });
1576
+ await ctx.db.insert("jgChatMessages", definedOnly({ serverId: args.serverId, channelId: args.channelId ?? "global", userId: args.userId, body: validation.text, authorName: args.authorName, at: Date.now() }));
1554
1577
  return { ok: true };
1555
1578
  }
1556
1579
  /**
@@ -1620,6 +1643,7 @@ export function createChatFunctions(options) {
1620
1643
  id: v.string(),
1621
1644
  channelId: v.string(),
1622
1645
  fromUserId: v.string(),
1646
+ fromLabel: v.optional(v.string()),
1623
1647
  body: v.string(),
1624
1648
  at: v.number(),
1625
1649
  })),
@@ -1632,10 +1656,11 @@ export function createChatFunctions(options) {
1632
1656
  return rows
1633
1657
  .slice()
1634
1658
  .reverse()
1635
- .map((row) => ({
1659
+ .map((row) => definedOnly({
1636
1660
  id: row._id,
1637
1661
  channelId: row.channelId,
1638
1662
  fromUserId: row.userId,
1663
+ fromLabel: row.authorName,
1639
1664
  body: row.body,
1640
1665
  at: row.at,
1641
1666
  }));
@@ -1664,8 +1689,8 @@ export function createChatFunctions(options) {
1664
1689
  .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
1665
1690
  .order("desc")
1666
1691
  .take(historyLimit * 2 + 16);
1667
- for (let i = historyLimit * 2; i < recent.length; i += 1) {
1668
- await ctx.db.delete(recent[i]._id);
1692
+ for (const stale of recent.slice(historyLimit * 2)) {
1693
+ await ctx.db.delete(stale._id);
1669
1694
  }
1670
1695
  return { ok: true };
1671
1696
  },
@@ -18,7 +18,7 @@ export interface WorldPresenceRecord extends PresenceResidentRow {
18
18
  rotationY: number;
19
19
  rotationPitch: number;
20
20
  lastSeenAt: number;
21
- lastWorldSnapshotAt?: number;
21
+ lastWorldSnapshotAt?: number | undefined;
22
22
  }
23
23
  /** World chunk size and host-owned pose, checkpoint and retention policies. */
24
24
  export interface WorldPresenceOptions {
@@ -1,3 +1,4 @@
1
+ import { definedOnly } from "@jgengine/core/data/definedOnly";
1
2
  import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, shouldPersistWorldSnapshot } from "@jgengine/core/multiplayer/presenceModel";
2
3
  import { chunkKeyAt, chunkKeysAround, parseChunkKey, DEFAULT_CHUNK_SIZE } from "@jgengine/core/runtime/worldChunks";
3
4
  function record(row) {
@@ -47,7 +48,7 @@ export function createWorldPresenceStore(options = {}) {
47
48
  label: args.label ?? existing?.label, x: args.position.x, y: args.position.y, z: args.position.z,
48
49
  rotationY: 0, rotationPitch: 0, chunkKey: chunkKeyAt([args.position.x, args.position.y, args.position.z], size),
49
50
  updatedAt: nowMs, revokedAt: undefined };
50
- const id = existing ? existing._id : await ctx.db.insert("jgPoses", patch);
51
+ const id = existing ? existing._id : await ctx.db.insert("jgPoses", definedOnly(patch));
51
52
  if (existing)
52
53
  await ctx.db.patch(id, patch);
53
54
  return record((await ctx.db.get("jgPoses", id)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/convex",
3
- "version": "0.49.1",
3
+ "version": "0.51.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.49.1"
33
+ "@jgengine/core": "^0.51.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`, `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
- Before building a game or a big feature, plan it: `npx jgengine plan <game> --brief "<brief>"` writes `PLAN.md`; list every feature the brief implies under `## Features` and run `npx jgengine plan <game>` again to fill each feature's engine owner and the gaps; then order `## Milestones`, one PR each, and tick each as it lands. For greenfield builds, also write `src/art-direction.md` before any code.
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
- Pick the dimension first: `create` defaults to a 3D world (terrain, sky, player model, editor scene); a grid, card, tile, or other board game with no 3D scene takes `create --template 2d` (a `board` world drawn by GameUI over `@jgengine/core/puzzle/cellGrid`). `create --template 3d|2d|builder` is the one way to start a game, in this monorepo too (`bun run jgengine create "<Title>"` lands in `games/`); 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`.