@jgengine/node 0.10.0 → 0.12.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.
@@ -0,0 +1,176 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { MODEL_EXT, assetIdFromRel, safeAssetFilename } from "./assetNaming.js";
4
+ /**
5
+ * True when `dir` is an already-promoted game project: one whose assets resolve through a typed
6
+ * `src/game/assets.ts` catalog rather than a scanned model folder.
7
+ * @internal
8
+ */
9
+ export function isPromotedProject(dir) {
10
+ return existsSync(join(dir, "src", "game", "assets.ts"));
11
+ }
12
+ /** Scans forward from `open` (index of `{` or `[`) to its matching close, respecting string literals. */
13
+ function matchBracketSpan(source, open) {
14
+ const openCh = source[open];
15
+ const closeCh = openCh === "{" ? "}" : "]";
16
+ let depth = 0;
17
+ let quote = null;
18
+ for (let i = open; i < source.length; i++) {
19
+ const ch = source[i];
20
+ if (quote !== null) {
21
+ if (ch === "\\") {
22
+ i++;
23
+ continue;
24
+ }
25
+ if (ch === quote)
26
+ quote = null;
27
+ continue;
28
+ }
29
+ if (ch === '"' || ch === "'" || ch === "`") {
30
+ quote = ch;
31
+ continue;
32
+ }
33
+ if (ch === openCh)
34
+ depth++;
35
+ else if (ch === closeCh) {
36
+ depth--;
37
+ if (depth === 0)
38
+ return i;
39
+ }
40
+ }
41
+ throw new Error("promotedAssetCatalog: unbalanced brackets in assets.ts");
42
+ }
43
+ /** Serializes one extras entry as a `{ id, url, label }` object literal with stable key order. */
44
+ function serializeEntry(extra) {
45
+ return `{ id: ${JSON.stringify(extra.id)}, url: ${JSON.stringify(extra.url)}, label: ${JSON.stringify(extra.label)} }`;
46
+ }
47
+ /**
48
+ * Adds or replaces one `extras` entry in a promoted game's `src/game/assets.ts` and returns the
49
+ * rewritten text. PURE and deterministic — it does not touch the filesystem.
50
+ *
51
+ * A bounded, non-AST transform: it locates the single `buildCatalog({ ... })` call by brace-depth
52
+ * scan, then upserts an id-keyed entry into that call's `extras: [ ... ]` array (creating the array
53
+ * when absent, collapsing it back to no `extras` when it would become empty). Re-import of the same
54
+ * id replaces its entry in place, so the transform is idempotent and never duplicates an id. If the
55
+ * source has zero or more than one `buildCatalog(` call it THROWS, so the caller falls back to the
56
+ * legacy folder-scan import rather than risk corrupting the file.
57
+ * @internal
58
+ */
59
+ export function upsertCatalogExtra(source, extra) {
60
+ const callRe = /\bbuildCatalog\(\s*\{/g;
61
+ const matches = [...source.matchAll(callRe)];
62
+ if (matches.length !== 1) {
63
+ throw new Error(`promotedAssetCatalog: expected exactly one buildCatalog({...}) call, found ${matches.length}`);
64
+ }
65
+ const match = matches[0];
66
+ // Brace of the options object literal is the last char of the matched `buildCatalog(\s*{`.
67
+ const objOpen = match.index + match[0].length - 1;
68
+ const objClose = matchBracketSpan(source, objOpen);
69
+ const objBody = source.slice(objOpen, objClose + 1);
70
+ // Detect indentation of the object's first inner line to keep emitted code consistent.
71
+ const indentMatch = /\{\s*\n(\s+)\S/.exec(objBody);
72
+ const indent = indentMatch ? indentMatch[1] : " ";
73
+ const extrasRe = /\bextras\s*:\s*\[/;
74
+ const extrasMatch = extrasRe.exec(objBody);
75
+ const entries = new Map();
76
+ if (extrasMatch) {
77
+ const arrOpenInObj = objBody.indexOf("[", extrasMatch.index);
78
+ const arrCloseInObj = matchBracketSpan(objBody, arrOpenInObj);
79
+ const arrInner = objBody.slice(arrOpenInObj + 1, arrCloseInObj);
80
+ // Parse each `{ ... }` object literal in the array via brace-depth scan + JSON.parse.
81
+ let cursor = 0;
82
+ while (cursor < arrInner.length) {
83
+ const braceOpen = arrInner.indexOf("{", cursor);
84
+ if (braceOpen === -1)
85
+ break;
86
+ const braceClose = matchBracketSpan(arrInner, braceOpen);
87
+ const literal = arrInner.slice(braceOpen, braceClose + 1);
88
+ const parsed = parseEntryLiteral(literal);
89
+ entries.set(parsed.id, parsed);
90
+ cursor = braceClose + 1;
91
+ }
92
+ entries.set(extra.id, extra);
93
+ const arrOpenAbs = objOpen + arrOpenInObj;
94
+ const arrCloseAbs = objOpen + arrCloseInObj;
95
+ // Collapse an emptied array back to no `extras` for clean reversibility. Unreachable via a plain
96
+ // import (we just added an entry), but keeps the transform's remove-symmetry intact.
97
+ if (entries.size === 0) {
98
+ return spliceOutExtras(source, objOpen + extrasMatch.index, arrCloseAbs);
99
+ }
100
+ const serialized = serializeArray([...entries.values()], indent);
101
+ return source.slice(0, arrOpenAbs) + serialized + source.slice(arrCloseAbs + 1);
102
+ }
103
+ // No extras yet: splice a fresh `extras: [ <entry> ],` right after the opening `{`.
104
+ entries.set(extra.id, extra);
105
+ const serialized = serializeArray([...entries.values()], indent);
106
+ const insertAt = objOpen + 1;
107
+ const insertion = `\n${indent}extras: ${serialized},`;
108
+ return source.slice(0, insertAt) + insertion + source.slice(insertAt);
109
+ }
110
+ /** Removes an `extras: [...]` declaration (and a trailing comma) so the object reverts cleanly. */
111
+ function spliceOutExtras(source, declStartAbs, arrCloseAbs) {
112
+ let declEndAbs = arrCloseAbs + 1;
113
+ // Swallow a trailing comma after `]`.
114
+ if (source[declEndAbs] === ",")
115
+ declEndAbs++;
116
+ // Swallow leading whitespace/newline before the declaration.
117
+ let start = declStartAbs;
118
+ while (start > 0 && /[ \t]/.test(source[start - 1]))
119
+ start--;
120
+ if (source[start - 1] === "\n")
121
+ start--;
122
+ return source.slice(0, start) + source.slice(declEndAbs);
123
+ }
124
+ /** Serializes an extras array literal with one entry per indented line. */
125
+ function serializeArray(entries, indent) {
126
+ if (entries.length === 0)
127
+ return "[]";
128
+ const inner = entries.map((e) => `${indent} ${serializeEntry(e)},`).join("\n");
129
+ return `[\n${inner}\n${indent}]`;
130
+ }
131
+ /** Parses a `{ id, url, label }` object literal (unquoted keys) into a PromotedExtra via JSON. */
132
+ function parseEntryLiteral(literal) {
133
+ // Quote bare identifier keys so JSON.parse accepts the source-literal form.
134
+ const jsonish = literal.replace(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1"$2"$3');
135
+ const obj = JSON.parse(jsonish);
136
+ if (typeof obj.id !== "string" || typeof obj.url !== "string") {
137
+ throw new Error("promotedAssetCatalog: malformed extras entry");
138
+ }
139
+ return {
140
+ id: obj.id,
141
+ url: obj.url,
142
+ label: typeof obj.label === "string" ? obj.label : obj.id,
143
+ };
144
+ }
145
+ /** Reads the `basePath` string literal from a promoted `assets.ts`, defaulting to `/models`. */
146
+ function readBasePath(source) {
147
+ const m = /\bbasePath\s*:\s*["'`]([^"'`]+)["'`]/.exec(source);
148
+ return m ? m[1] : "/models";
149
+ }
150
+ /**
151
+ * Persists an imported model into a promoted game's `public/` and registers a durable `extras`
152
+ * entry in its `src/game/assets.ts`, so the shipped game resolves the asset through its typed
153
+ * catalog. Copies the bytes under `public/<basePath>/imported/<safe>` and rewrites the catalog
154
+ * source via {@link upsertCatalogExtra}. Returns the same `{ id, url, label }` shape the folder-scan
155
+ * `importEditorAsset` returns — but the url is the shipped `public/` path, not the dev asset route.
156
+ * @internal
157
+ */
158
+ export function importPromotedAsset(dir, filename, bytes) {
159
+ if (!MODEL_EXT.test(filename)) {
160
+ throw new Error(`unsupported asset file (expected .glb/.gltf): ${filename}`);
161
+ }
162
+ const assetsTsPath = join(dir, "src", "game", "assets.ts");
163
+ const source = readFileSync(assetsTsPath, "utf8");
164
+ const basePath = readBasePath(source);
165
+ const safe = safeAssetFilename(filename);
166
+ const publicSub = basePath.replace(/^\/+/, "");
167
+ const targetDir = join(dir, "public", publicSub, "imported");
168
+ mkdirSync(targetDir, { recursive: true });
169
+ writeFileSync(join(targetDir, safe), bytes);
170
+ const url = `${basePath.replace(/\/+$/, "")}/imported/${safe}`;
171
+ const id = assetIdFromRel(safe);
172
+ const label = safe;
173
+ const rewritten = upsertCatalogExtra(source, { id, url, label });
174
+ writeFileSync(assetsTsPath, rewritten);
175
+ return { id, url, label };
176
+ }
@@ -0,0 +1,23 @@
1
+ /** A live signal listener installed by {@link installShutdownHook}; call `remove()` to uninstall it (tests, embedders opting out). */
2
+ export interface ShutdownHook {
3
+ remove(): void;
4
+ }
5
+ /** Config for {@link installShutdownHook}. */
6
+ export interface InstallShutdownHookOptions {
7
+ /** Signals to listen for; default `["SIGINT", "SIGTERM"]`. */
8
+ signals?: readonly NodeJS.Signals[];
9
+ /** Bound on how long `shutdown()` may run before the hook gives up and exits anyway; default 5000ms. */
10
+ timeoutMs?: number;
11
+ /** Called with `0` on a clean shutdown, `1` on error or timeout; default `process.exit`. Override in tests to avoid killing the test process. */
12
+ exit?: (code: number) => void;
13
+ /** Called when `shutdown()` rejects or times out; default logs to `console.error`. */
14
+ onError?: (error: unknown) => void;
15
+ }
16
+ /**
17
+ * Wires `SIGINT`/`SIGTERM` (or a custom signal list) to a clean-shutdown callback — e.g.
18
+ * `() => worldServer.close()` or `() => Promise.all([wsServer.close(), host.stop()])`. Bounded by
19
+ * `timeoutMs` so a stuck flush can't hang the process forever; idempotent — a second signal delivered
20
+ * mid-shutdown reuses the same in-flight run instead of flushing twice. Returns a {@link ShutdownHook}
21
+ * whose `remove()` uninstalls the listeners, for tests and embedders that want their own handling.
22
+ */
23
+ export declare function installShutdownHook(shutdown: () => Promise<void> | void, options?: InstallShutdownHookOptions): ShutdownHook;
@@ -0,0 +1,50 @@
1
+ const DEFAULT_SIGNALS = ["SIGINT", "SIGTERM"];
2
+ const DEFAULT_TIMEOUT_MS = 5_000;
3
+ /**
4
+ * Wires `SIGINT`/`SIGTERM` (or a custom signal list) to a clean-shutdown callback — e.g.
5
+ * `() => worldServer.close()` or `() => Promise.all([wsServer.close(), host.stop()])`. Bounded by
6
+ * `timeoutMs` so a stuck flush can't hang the process forever; idempotent — a second signal delivered
7
+ * mid-shutdown reuses the same in-flight run instead of flushing twice. Returns a {@link ShutdownHook}
8
+ * whose `remove()` uninstalls the listeners, for tests and embedders that want their own handling.
9
+ */
10
+ export function installShutdownHook(shutdown, options = {}) {
11
+ const signals = options.signals ?? DEFAULT_SIGNALS;
12
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
13
+ const exit = options.exit ?? ((code) => process.exit(code));
14
+ const onError = options.onError ?? ((error) => console.error("shutdown failed:", error));
15
+ let inFlight = null;
16
+ function run() {
17
+ inFlight ??= (async () => {
18
+ let timer;
19
+ try {
20
+ await Promise.race([
21
+ Promise.resolve(shutdown()),
22
+ new Promise((_resolve, reject) => {
23
+ timer = setTimeout(() => reject(new Error(`shutdown exceeded ${timeoutMs}ms`)), timeoutMs);
24
+ }),
25
+ ]);
26
+ exit(0);
27
+ }
28
+ catch (error) {
29
+ onError(error);
30
+ exit(1);
31
+ }
32
+ finally {
33
+ if (timer !== undefined)
34
+ clearTimeout(timer);
35
+ }
36
+ })();
37
+ return inFlight;
38
+ }
39
+ function handler() {
40
+ void run();
41
+ }
42
+ for (const signal of signals)
43
+ process.on(signal, handler);
44
+ return {
45
+ remove() {
46
+ for (const signal of signals)
47
+ process.off(signal, handler);
48
+ },
49
+ };
50
+ }
@@ -1,12 +1,15 @@
1
1
  import type { GameDefinition } from "@jgengine/core/game/defineGame";
2
- import type { GameContextContent } from "@jgengine/core/runtime/gameContext";
2
+ import type { GameContextContent, GameContextModels } from "@jgengine/core/runtime/gameContext";
3
3
  import type { ModelAssetRef } from "@jgengine/core/scene/assetCatalog";
4
4
  import { type WorldGameHost } from "@jgengine/ws/worldHost";
5
+ import { type WorldPersistence } from "./persistence.js";
5
6
  import { type GameWsServer, type GameWsServerOptions } from "./wsServer.js";
6
7
  /** A game the world server can host — its authoritative {@link GameDefinition} and the content lookup a `GameContext` reads. */
7
8
  export interface HostedGameDefinition {
8
9
  game: GameDefinition<ModelAssetRef, unknown>;
9
10
  content: GameContextContent;
11
+ /** Render-model lookup for collider auto-fit — pass the same lookup the shell derives (`contextModels(playable)`) so the authoritative host resolves the hitboxes clients see. */
12
+ models?: GameContextModels;
10
13
  }
11
14
  /** Config for {@link createWorldGameServer}: how to resolve a game by id, the tick cadence, and the underlying ws-server/router options (minus `host`, which the server builds). */
12
15
  export interface WorldGameServerOptions extends Omit<GameWsServerOptions, "host"> {
@@ -14,6 +17,8 @@ export interface WorldGameServerOptions extends Omit<GameWsServerOptions, "host"
14
17
  resolveGame(gameId: string): HostedGameDefinition | null;
15
18
  /** Ticks per second for the real-clock loop {@link WorldGameServer.start} drives; default 30. Tests call {@link WorldGameServer.tick} manually instead. */
16
19
  tickHz?: number;
20
+ /** Where each hosted world's authoritative state persists; defaults to an isolated in-memory store per world (lost on exit). Inject a file/SQL/Convex-backed {@link WorldPersistence} to survive a redeploy or crash. */
21
+ persistence?: WorldPersistence;
17
22
  }
18
23
  /** A runnable ws host for GameContext worlds: {@link createWorldGameHost} + {@link createGameWsServer} + a tick loop, with a manual `tick(dt)` seam so a fake clock can drive it in tests. */
19
24
  export interface WorldGameServer {
@@ -25,7 +30,9 @@ export interface WorldGameServer {
25
30
  start(): void;
26
31
  /** Stop the tick interval (idempotent). */
27
32
  stop(): void;
28
- /** Stop the tick loop and tear down the ws server. */
33
+ /** Force-persist every live world's current state via the injected {@link WorldPersistence} (idempotent — safe to call repeatedly). */
34
+ flush(): void;
35
+ /** Stop the tick loop, flush persistence, and tear down the ws server — the clean-shutdown path for a SIGINT/SIGTERM handler. */
29
36
  close(): Promise<void>;
30
37
  /** The bound ws port. */
31
38
  port(): number;
@@ -1,18 +1,28 @@
1
1
  import { createHostedWorldSession } from "@jgengine/core/runtime/hostedWorldSession";
2
2
  import { createWorldGameHost } from "@jgengine/ws/worldHost";
3
+ import { memoryWorldPersistence } from "./persistence.js";
3
4
  import { createGameWsServer } from "./wsServer.js";
4
5
  const DEFAULT_TICK_HZ = 30;
5
6
  /** Build a {@link WorldGameServer} — one process hosting authoritative GameContext worlds over ws, ready for two-client play once {@link WorldGameServer.start} runs. */
6
7
  export function createWorldGameServer(options) {
7
- const { resolveGame, tickHz = DEFAULT_TICK_HZ, ...serverOptions } = options;
8
+ const { resolveGame, tickHz = DEFAULT_TICK_HZ, persistence = memoryWorldPersistence(), ...serverOptions } = options;
8
9
  const clock = options.now ?? (() => Date.now());
10
+ const liveSessions = new Map();
9
11
  const host = createWorldGameHost({
10
12
  now: clock,
11
- session: ({ gameId }) => {
13
+ session: ({ gameId, serverId }) => {
12
14
  const resolved = resolveGame(gameId);
13
15
  if (resolved === null)
14
16
  return null;
15
- return createHostedWorldSession({ definition: resolved.game, content: resolved.content, now: clock });
17
+ const session = createHostedWorldSession({
18
+ definition: resolved.game,
19
+ content: resolved.content,
20
+ now: clock,
21
+ store: persistence.store({ gameId, serverId }),
22
+ ...(resolved.models === undefined ? {} : { models: resolved.models }),
23
+ });
24
+ liveSessions.set(serverId, session);
25
+ return session;
16
26
  },
17
27
  });
18
28
  const ws = createGameWsServer({ ...serverOptions, host });
@@ -27,11 +37,16 @@ export function createWorldGameServer(options) {
27
37
  interval = null;
28
38
  }
29
39
  }
40
+ function flush() {
41
+ for (const session of liveSessions.values())
42
+ session.save();
43
+ }
30
44
  return {
31
45
  host,
32
46
  ws,
33
47
  tick,
34
48
  stop,
49
+ flush,
35
50
  port: ws.port,
36
51
  start() {
37
52
  if (interval !== null)
@@ -45,6 +60,7 @@ export function createWorldGameServer(options) {
45
60
  },
46
61
  async close() {
47
62
  stop();
63
+ flush();
48
64
  await ws.close();
49
65
  },
50
66
  };
@@ -2,10 +2,22 @@ import type { Server as HttpServer } from "node:http";
2
2
  import { WebSocketServer } from "ws";
3
3
  import { type HostRouterOptions, type RewoundPosition } from "@jgengine/ws/hostRouter";
4
4
  export type { RewoundPosition };
5
+ /** Default per-message payload cap (bytes) — `ws` closes the socket with 1009 past this. */
6
+ export declare const DEFAULT_MAX_PAYLOAD_BYTES = 1048576;
7
+ /** Default max concurrent sockets this server accepts before rejecting new ones. */
8
+ export declare const DEFAULT_MAX_CONNECTIONS = 10000;
9
+ /** Default ping/pong interval; a socket that misses one round-trip is terminated. */
10
+ export declare const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
5
11
  export type GameWsServerOptions = HostRouterOptions & {
6
12
  server?: HttpServer;
7
13
  port?: number;
8
14
  path?: string;
15
+ /** Per-message payload cap in bytes. Defaults to {@link DEFAULT_MAX_PAYLOAD_BYTES}. */
16
+ maxPayloadBytes?: number;
17
+ /** Max concurrent sockets accepted; connections beyond this are closed immediately. Defaults to {@link DEFAULT_MAX_CONNECTIONS}. */
18
+ maxConnections?: number;
19
+ /** Ping/pong heartbeat interval in ms; a socket that misses a round-trip is terminated. Defaults to {@link DEFAULT_HEARTBEAT_INTERVAL_MS}. */
20
+ heartbeatIntervalMs?: number;
9
21
  };
10
22
  export type GameWsServer = {
11
23
  wss: WebSocketServer;
package/dist/wsServer.js CHANGED
@@ -1,11 +1,40 @@
1
1
  import { WebSocketServer } from "ws";
2
2
  import { createHostRouter } from "@jgengine/ws/hostRouter";
3
+ /** Default per-message payload cap (bytes) — `ws` closes the socket with 1009 past this. */
4
+ export const DEFAULT_MAX_PAYLOAD_BYTES = 1_048_576;
5
+ /** Default max concurrent sockets this server accepts before rejecting new ones. */
6
+ export const DEFAULT_MAX_CONNECTIONS = 10_000;
7
+ /** Default ping/pong interval; a socket that misses one round-trip is terminated. */
8
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
3
9
  export function createGameWsServer(options) {
4
10
  const router = createHostRouter(options);
11
+ const maxPayload = options.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES;
12
+ const maxConnections = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
13
+ const heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
5
14
  const wss = options.server !== undefined
6
- ? new WebSocketServer({ server: options.server, path: options.path })
7
- : new WebSocketServer({ port: options.port ?? 0, path: options.path });
15
+ ? new WebSocketServer({ server: options.server, path: options.path, maxPayload })
16
+ : new WebSocketServer({ port: options.port ?? 0, path: options.path, maxPayload });
17
+ const heartbeat = setInterval(() => {
18
+ for (const client of wss.clients) {
19
+ const socket = client;
20
+ if (socket.isAlive === false) {
21
+ socket.terminate();
22
+ continue;
23
+ }
24
+ socket.isAlive = false;
25
+ socket.ping();
26
+ }
27
+ }, heartbeatIntervalMs);
28
+ wss.on("close", () => clearInterval(heartbeat));
8
29
  wss.on("connection", (socket) => {
30
+ if (wss.clients.size > maxConnections) {
31
+ socket.close(1013, "Server at capacity");
32
+ return;
33
+ }
34
+ socket.isAlive = true;
35
+ socket.on("pong", () => {
36
+ socket.isAlive = true;
37
+ });
9
38
  const connection = router.connect({
10
39
  send: (data) => {
11
40
  if (socket.readyState !== socket.OPEN)
@@ -28,6 +57,7 @@ export function createGameWsServer(options) {
28
57
  },
29
58
  rewind: router.rewind,
30
59
  close: () => new Promise((resolve) => {
60
+ clearInterval(heartbeat);
31
61
  router.close();
32
62
  for (const client of wss.clients) {
33
63
  client.terminate();
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@jgengine/node",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Standalone authoritative game host for JGengine: in-memory server snapshots, tick loop, save-cadence flush, WebSocket server, memory/file persistence.",
5
- "license": "AGPL-3.0-only",
5
+ "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
8
  "repository": {
@@ -13,7 +13,7 @@
13
13
  "files": [
14
14
  "dist",
15
15
  "CHANGELOG.md",
16
- "llms.txt"
16
+ "skills"
17
17
  ],
18
18
  "exports": {
19
19
  ".": {
@@ -31,11 +31,12 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.10.0",
35
- "@jgengine/ws": "^0.10.0",
34
+ "@jgengine/core": "^0.12.0",
35
+ "@jgengine/ws": "^0.12.0",
36
36
  "ws": "^8.18.0"
37
37
  },
38
38
  "devDependencies": {
39
+ "@jgengine/assets": "workspace:*",
39
40
  "@jgengine/sql": "workspace:*",
40
41
  "@types/node": "^24",
41
42
  "@types/ws": "^8",
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: jgengine
3
+ description: Route game work to the smallest useful JGengine guidance set.
4
+ ---
5
+
6
+ # JGengine intake and router
7
+
8
+ Read the repository [README](../../../README.md) for package truth, commands, layering, and license. This skill decides what to load; it is not an engine manual.
9
+
10
+ ## 1. Establish the target
11
+
12
+ 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:
13
+
14
+ - player point of view and controls
15
+ - world shape and authored content
16
+ - core loop, interaction, combat, and progression
17
+ - solo, local, hosted, or persistent multiplayer
18
+ - HUD/menu states and **custom** UI art direction (every game owns its presentation — no stock drop-in face)
19
+ - one observable completion scenario
20
+
21
+ For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
22
+
23
+ ## 2. Select domains
24
+
25
+ Load only rows the target needs. Do not preload every domain.
26
+
27
+ | Need | Skill | Owner |
28
+ | --- | --- | --- |
29
+ | player promise, pillars, verbs, loops, economy, progression, balance | `game-design` | experience and rule intent |
30
+ | metrics, topology, encounters, navigation, spatial pacing | `level-design` | playable space intent |
31
+ | scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
32
+ | runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
33
+ | game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
34
+ | targeting, stats, damage, effects, abilities, projectiles, loot | `jgengine-combat` | combat resolution |
35
+ | HUD, menus, layout, accessibility, touch presentation | `jgengine-ui` | player-facing interface |
36
+ | models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
37
+ | authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
38
+
39
+ 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.
40
+
41
+ ## 3. Discover before designing
42
+
43
+ Within each selected domain:
44
+
45
+ 1. Search `capabilities.md` by intent; it maps needs to imports.
46
+ 2. Open `api.md` only when exact signatures or export inventory are needed.
47
+ 3. Open the linked reference only for a deeper recipe or trap.
48
+ 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.
49
+
50
+ 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; if editor-owned content cannot be expressed, file the editor gap first.
51
+
52
+ ## 4. Build and finish
53
+
54
+ 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.