@jgengine/ws 0.11.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.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,32 @@ Agents building on the published SDK can also read this programmatically:
12
12
  same data as typed values, so an updater can diff its installed version against
13
13
  the latest and surface the migration steps.
14
14
 
15
+ ## 0.12.0
16
+
17
+ ### Migrate
18
+
19
+ - **Author games through `defineGame` from `@jgengine/shell`** — it is the single public authoring entry (and carries the define-game capability tag). Core's constructor was renamed `defineGame` → `defineGameDefinition` (`@jgengine/core/game/defineGame`); only hosts and tooling call it. If you imported `defineGame` from core, switch the import to `@jgengine/shell`. `GamePlayer`/`GamePlayerShell` are `@internal` now — `GameHost` is the documented mount, and it owns the editor summon (`?mode=editor`, F2+E, dev save endpoint), so delete any per-game editor bootstrap.
20
+ - **Declare your world with `world()`** (`@jgengine/core/world/place`): `world({ id, ground: { mode, size, surface? }, physics? })` with `flat` / `round` / `voxel` / `board` ground. New scaffolds emit a thin infinite flat place instead of the old 96 m meadow. `environment()` still works but is demoted to the legacy consumption path for editor-written scene data — **nothing required** for existing games; new worlds should use `world()` and author all dressing in the editor.
21
+ - Everything else is additive — stat pools, minimap marker sources, auction house, city/path generation, editor workspaces require no changes to existing games.
22
+
23
+ ### Added
24
+
25
+ - **World = place: `world()` API** (#1178) — a world is substrate + laws, not a coded diorama. `@jgengine/core/world/place` ships `world({ id, ground: { mode, size, surface?, generator? }, physics? })`; size is discriminated by mode (flat `{x,z}` with `Infinity` axes, round `{radius}`, voxel generator domain, board `{x,y}` cells — TS and runtime both reject mismatches). `ground.surface` carries matter/feel laws (metal vs slime vs felt) read by the same rule systems; per-place `physics` resolves over the game default; multiple worlds per game are first-class. The shell renders place substrates (`PlaceScene`) under the engine default sky; seeds derive from world id + save via `seedForPlace`.
26
+ - **`@jgengine/shell/gameKit`** — happy-path authoring surface: authoring, mount, stores, systems, authored-scene helpers, HUD primitives, seeded rng in one import.
27
+ - **Portable generic stat pools** — `@jgengine/core/stats/statPool`: bounded named pools (health, energy, heat, ammo…) with the entity-stats integration rebuilt on them; recipe in skill `jgengine-combat`.
28
+ - **Auction-house economy primitives** (#1086) — `@jgengine/core/economy/auctionBook` (timed bid auctions, escrowed bids, outbid refunds, optional buyout, anti-snipe close extension, seller settlement minus house cut) plus market price history.
29
+ - **Portable minimap marker sources** — core world seam feeding game minimaps; recipe in skill `jgengine-ui`.
30
+ - **Unified seed-driven path network + city generation v2** (#1103, #1106) — streets/blocks/buildings with grid-ness, curviness, branching and block-size dials; zone bands, weighted building-class mixes, massing pieces, balance presets, junction-centered clustering, road-derived block polygons and parcels. Shell renders realistic roads: real intersections, curbs, lane lines, tunnels, and roads riding bridge decks.
31
+ - **Physical fidelity** — mesh-accurate hitboxes (shots pass through holes in concave models, #1075), auto-fit entity/object colliders from measured model bounds (#1072), movement obstruction reading fitted boxes + compound-box openings (#1077).
32
+ - **Editor overhaul** (#1110) — materials / lighting / scripting / animation-timeline / multiplayer workspaces, command palette, minimap bake wired end-to-end (#1036), real offscreen GLB thumbnails in the content browser, hierarchy drag-drop + keyboard + context menus + per-object visibility/lock, multi-select inspector, console RPC, asset drag-place, ortho projection, pivot modes, play chrome.
33
+ - **Editor authoring pipeline** — author entity definitions and starter catalogs (#1012), author catalogs/schema fields in the Data tab (#1043), promote an authored scene folder into a runnable game (#1011), authored triggers promoted to a shared primitive with goal/win rules (#1013), asset import rewrites the game's `game/assets.ts` (#1042).
34
+ - **Skills** — research-backed `game-design` and `level-design` skills (#962); every-game-owns-custom-UI invariant codified (#1148).
35
+
36
+ ### Changed
37
+
38
+ - `npx jgengine create` scaffolds a thin infinite flat `world()` place; the editor `blankWorld` no longer pre-bakes seeded grass. Dressing belongs in `editor.scene.json`.
39
+ - Environment studio visual-quality pass — turf tufts, depth-aware water, relief soil, weathered poles; grass fields no longer rebuild geometry per frame (#1090).
40
+
15
41
  ## 0.11.0
16
42
 
17
43
  ### Migrate
package/dist/host.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
2
2
  import { applyLeaderboardRows, buildHydratePlayers, planServerPersist, profileLeaderboardStats, shouldAutoSave, topLeaderboardRows, toOpenServerListings, toServerListing, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
3
- import { browseSessions, findByJoinCode, normalizeJoinCode } from "@jgengine/core/multiplayer/matchmaking";
3
+ import { isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
4
+ import { browseSessions, findByJoinCode } from "@jgengine/core/multiplayer/matchmaking";
4
5
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
5
6
  import { clearDirtyFlags, createEmptyServerRow, splitProfilePlayer } from "@jgengine/core/runtime/snapshot";
6
7
  const builtinCommands = {
@@ -219,15 +220,14 @@ export function createGameHost(options) {
219
220
  throw new Error("Server belongs to a different game");
220
221
  }
221
222
  if (entry !== null &&
222
- entry.record.visibility === "private" &&
223
- !entry.record.memberUserIds.includes(args.userId)) {
224
- const requiredCode = entry.record.joinCode;
225
- const matches = requiredCode !== undefined &&
226
- args.code !== undefined &&
227
- normalizeJoinCode(args.code) === normalizeJoinCode(requiredCode);
228
- if (!matches) {
229
- throw new Error("Server is private");
230
- }
223
+ isPrivateJoinBlocked({
224
+ visibility: entry.record.visibility,
225
+ memberUserIds: entry.record.memberUserIds,
226
+ userId: args.userId,
227
+ joinCode: entry.record.joinCode,
228
+ suppliedCode: args.code,
229
+ })) {
230
+ throw new Error("Server is private");
231
231
  }
232
232
  if (entry === null) {
233
233
  const attributes = args.attributes;
@@ -253,17 +253,14 @@ export function createGameHost(options) {
253
253
  entry = await hydrate(record);
254
254
  }
255
255
  const { record } = entry;
256
- if (record.memberUserIds.length >= record.slotsPerServer &&
257
- !record.memberUserIds.includes(args.userId)) {
256
+ if (isServerFull(record.memberUserIds, record.slotsPerServer, args.userId)) {
258
257
  throw new Error("Server is full");
259
258
  }
260
259
  const profile = await persistence.loadProfile({ userId: args.userId, gameId: args.gameId });
261
260
  const isNew = profile === null;
262
261
  entry.record = {
263
262
  ...record,
264
- memberUserIds: record.memberUserIds.includes(args.userId)
265
- ? record.memberUserIds
266
- : [...record.memberUserIds, args.userId],
263
+ memberUserIds: withJoinedMember(record.memberUserIds, args.userId),
267
264
  status: "running",
268
265
  updatedAt: timestamp,
269
266
  dirtyAt: timestamp,
@@ -292,11 +289,11 @@ export function createGameHost(options) {
292
289
  const entry = await getLive(args.serverId);
293
290
  if (entry === null)
294
291
  return;
295
- if (!entry.record.memberUserIds.includes(args.userId))
292
+ if (!isServerMember(entry.record.memberUserIds, args.userId))
296
293
  return;
297
294
  await flushServer(entry);
298
295
  const timestamp = now();
299
- const memberUserIds = entry.record.memberUserIds.filter((id) => id !== args.userId);
296
+ const memberUserIds = withoutMember(entry.record.memberUserIds, args.userId);
300
297
  const sessionPlayers = { ...entry.record.sessionPlayers };
301
298
  delete sessionPlayers[args.userId];
302
299
  const players = { ...entry.snapshot.players };
@@ -306,7 +303,7 @@ export function createGameHost(options) {
306
303
  memberUserIds,
307
304
  sessionPlayers,
308
305
  updatedAt: timestamp,
309
- status: memberUserIds.length === 0 ? "open" : entry.record.status,
306
+ status: statusAfterLeave(memberUserIds.length, entry.record.status),
310
307
  };
311
308
  entry.snapshot = { ...entry.snapshot, players };
312
309
  await persistence.saveServer(entry.record);
@@ -321,7 +318,7 @@ export function createGameHost(options) {
321
318
  if (entry === null) {
322
319
  return { ok: false, reason: "Server not found" };
323
320
  }
324
- if (!entry.record.memberUserIds.includes(args.userId)) {
321
+ if (!isServerMember(entry.record.memberUserIds, args.userId)) {
325
322
  return { ok: false, reason: "Not a member of this server" };
326
323
  }
327
324
  const envelope = unwrapOpEnvelope(args.input);
@@ -351,13 +348,13 @@ export function createGameHost(options) {
351
348
  const entry = await getLive(args.serverId);
352
349
  if (entry === null)
353
350
  return false;
354
- return entry.record.memberUserIds.includes(args.userId);
351
+ return isServerMember(entry.record.memberUserIds, args.userId);
355
352
  },
356
353
  getServerView: async (args) => {
357
354
  const entry = await getLive(args.serverId);
358
355
  if (entry === null)
359
356
  return null;
360
- if (!entry.record.memberUserIds.includes(args.userId))
357
+ if (!isServerMember(entry.record.memberUserIds, args.userId))
361
358
  return null;
362
359
  return {
363
360
  serverId: entry.record.serverId,
@@ -372,7 +369,7 @@ export function createGameHost(options) {
372
369
  const entry = await getLive(args.serverId);
373
370
  if (entry === null)
374
371
  return null;
375
- if (!entry.record.memberUserIds.includes(args.userId))
372
+ if (!isServerMember(entry.record.memberUserIds, args.userId))
376
373
  return null;
377
374
  const player = entry.snapshot.players[args.userId];
378
375
  if (player !== undefined) {
@@ -397,7 +394,7 @@ export function createGameHost(options) {
397
394
  const entry = await getLive(args.serverId);
398
395
  if (entry === null)
399
396
  return [];
400
- if (!entry.record.memberUserIds.includes(args.userId))
397
+ if (!isServerMember(entry.record.memberUserIds, args.userId))
401
398
  return [];
402
399
  return persistence.loadFeed({ serverId: args.serverId, action: args.action });
403
400
  },
@@ -407,7 +404,7 @@ export function createGameHost(options) {
407
404
  throw new Error(allowed.reason);
408
405
  }
409
406
  const entry = await getLive(args.serverId);
410
- if (entry === null || !entry.record.memberUserIds.includes(args.userId)) {
407
+ if (entry === null || !isServerMember(entry.record.memberUserIds, args.userId)) {
411
408
  throw new Error("Not a member of this server");
412
409
  }
413
410
  await persistence.appendFeed({ serverId: args.serverId, action: args.action, entry: args.entry });
@@ -416,14 +413,14 @@ export function createGameHost(options) {
416
413
  listOpenServers: async (args) => {
417
414
  const byId = new Map();
418
415
  for (const record of await persistence.listServers(args.gameId)) {
419
- if ((record.visibility ?? "public") === "private")
416
+ if (!isListablePublicly(record.visibility))
420
417
  continue;
421
418
  byId.set(record.serverId, toServerListing(record));
422
419
  }
423
420
  for (const entry of live.values()) {
424
421
  if (entry.record.gameId !== args.gameId)
425
422
  continue;
426
- if ((entry.record.visibility ?? "public") === "private")
423
+ if (!isListablePublicly(entry.record.visibility))
427
424
  continue;
428
425
  byId.set(entry.record.serverId, toServerListing(entry.record));
429
426
  }
@@ -1,15 +1,9 @@
1
- import { decidePoseSync, spawnPresenceState } from "@jgengine/core/multiplayer/presenceModel";
1
+ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
2
2
  import { createPositionHistory } from "@jgengine/core/multiplayer/lagCompensation";
3
3
  import { createChatRateLimiter, DEFAULT_CHAT_BODY_LENGTH, DEFAULT_CHAT_HISTORY_LIMIT, DEFAULT_CHAT_RATE_LIMIT, } from "@jgengine/core/game/chat";
4
4
  import { createCommandMiddleware, } from "./commandMiddleware.js";
5
5
  import { decodeWsClientMessage, encodeWsMessage, inspectWsDecodeFailure, subscriptionKey, } from "./protocol.js";
6
- export const DEFAULT_POSE_RULES = {
7
- maxSpeed: 12,
8
- maxVerticalOffset: 3,
9
- minElapsedSec: 0.05,
10
- maxElapsedSec: 0.5,
11
- keepAliveRefreshMs: 10_000,
12
- };
6
+ export const DEFAULT_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
13
7
  /** Cap on frames queued behind a connection's in-flight message; beyond this a flood gets rejected instead of piling up unbounded promises. */
14
8
  export const MAX_QUEUED_MESSAGES = 64;
15
9
  function appearanceEqual(a, b) {
@@ -23,6 +17,29 @@ function appearanceEqual(a, b) {
23
17
  return false;
24
18
  return aKeys.every((key) => a[key] === b[key]);
25
19
  }
20
+ function getOrCreate(map, key, factory) {
21
+ let value = map.get(key);
22
+ if (value === undefined) {
23
+ value = factory();
24
+ map.set(key, value);
25
+ }
26
+ return value;
27
+ }
28
+ /**
29
+ * Remove `userId` from every visited `server|scope` bucket, drop buckets that empty out, and notify
30
+ * per changed key. Backs both the disconnect-wide and per-server drops for presence and voice.
31
+ */
32
+ function dropUserFrom(store, userId, shouldVisit, onChanged) {
33
+ for (const [key, inner] of store) {
34
+ if (!shouldVisit(key))
35
+ continue;
36
+ if (!inner.delete(userId))
37
+ continue;
38
+ if (inner.size === 0)
39
+ store.delete(key);
40
+ onChanged(key);
41
+ }
42
+ }
26
43
  export function createHostRouter(options) {
27
44
  const host = options.host;
28
45
  const now = options.now ?? Date.now;
@@ -59,14 +76,7 @@ export function createHostRouter(options) {
59
76
  const presence = new Map();
60
77
  const positionHistoryMs = options.positionHistoryMs ?? 1_000;
61
78
  const histories = new Map();
62
- const historyFor = (serverId) => {
63
- let history = histories.get(serverId);
64
- if (history === undefined) {
65
- history = createPositionHistory({ historyMs: positionHistoryMs });
66
- histories.set(serverId, history);
67
- }
68
- return history;
69
- };
79
+ const historyFor = (serverId) => getOrCreate(histories, serverId, () => createPositionHistory({ historyMs: positionHistoryMs }));
70
80
  const send = (connection, message) => {
71
81
  connection.transport.send(encodeWsMessage(message));
72
82
  };
@@ -89,16 +99,21 @@ export function createHostRouter(options) {
89
99
  appearance: pose.appearance,
90
100
  }));
91
101
  };
92
- const broadcastPresence = (serverId) => {
93
- const key = subscriptionKey("presence", serverId);
102
+ const broadcast = (key, build) => {
94
103
  const targets = subscribersOf(key);
95
104
  if (targets.length === 0)
96
105
  return;
97
- const data = presenceRows(serverId);
98
- for (const connection of targets) {
99
- send(connection, { v: 1, t: "update", channel: "presence", serverId, data });
100
- }
106
+ const message = build();
107
+ for (const connection of targets)
108
+ send(connection, message);
101
109
  };
110
+ const broadcastPresence = (serverId) => broadcast(subscriptionKey("presence", serverId), () => ({
111
+ v: 1,
112
+ t: "update",
113
+ channel: "presence",
114
+ serverId,
115
+ data: presenceRows(serverId),
116
+ }));
102
117
  const commandMiddleware = createCommandMiddleware({
103
118
  limits: options.limits,
104
119
  authorize: options.authorize,
@@ -125,69 +140,37 @@ export function createHostRouter(options) {
125
140
  const chatHistoryLimit = options.chatHistoryLimit ?? DEFAULT_CHAT_HISTORY_LIMIT;
126
141
  const chatMaxBodyLength = options.chatMaxBodyLength ?? DEFAULT_CHAT_BODY_LENGTH;
127
142
  let chatCounter = 0;
128
- const chatRing = (serverId, channelId) => {
129
- const key = `${serverId}|${channelId}`;
130
- let ring = chatRings.get(key);
131
- if (ring === undefined) {
132
- ring = [];
133
- chatRings.set(key, ring);
134
- }
135
- return ring;
136
- };
137
- const broadcastChat = (serverId, channelId) => {
138
- const key = subscriptionKey("chat", serverId, channelId);
139
- const targets = subscribersOf(key);
140
- if (targets.length === 0)
141
- return;
142
- const data = chatRing(serverId, channelId).slice();
143
- for (const connection of targets) {
144
- send(connection, { v: 1, t: "update", channel: "chat", serverId, action: channelId, data });
145
- }
146
- };
143
+ const chatRing = (serverId, channelId) => getOrCreate(chatRings, `${serverId}|${channelId}`, () => []);
144
+ const broadcastChat = (serverId, channelId) => broadcast(subscriptionKey("chat", serverId, channelId), () => ({
145
+ v: 1,
146
+ t: "update",
147
+ channel: "chat",
148
+ serverId,
149
+ action: channelId,
150
+ data: chatRing(serverId, channelId).slice(),
151
+ }));
147
152
  const voiceRosters = new Map();
148
- const voiceRoster = (serverId, channelId) => {
149
- const key = `${serverId}|${channelId}`;
150
- let roster = voiceRosters.get(key);
151
- if (roster === undefined) {
152
- roster = new Map();
153
- voiceRosters.set(key, roster);
154
- }
155
- return roster;
156
- };
153
+ const voiceRoster = (serverId, channelId) => getOrCreate(voiceRosters, `${serverId}|${channelId}`, () => new Map());
157
154
  const voiceParticipants = (serverId, channelId) => [...voiceRoster(serverId, channelId).values()].map((participant) => ({ ...participant }));
158
- const broadcastVoice = (serverId, channelId) => {
159
- const key = subscriptionKey("voice", serverId, channelId);
160
- const targets = subscribersOf(key);
161
- if (targets.length === 0)
162
- return;
163
- const data = voiceParticipants(serverId, channelId);
164
- for (const connection of targets) {
165
- send(connection, { v: 1, t: "update", channel: "voice", serverId, action: channelId, data });
166
- }
167
- };
155
+ const broadcastVoice = (serverId, channelId) => broadcast(subscriptionKey("voice", serverId, channelId), () => ({
156
+ v: 1,
157
+ t: "update",
158
+ channel: "voice",
159
+ serverId,
160
+ action: channelId,
161
+ data: voiceParticipants(serverId, channelId),
162
+ }));
168
163
  const dropVoice = (connection) => {
169
164
  if (connection.userId === null)
170
165
  return;
171
- for (const [key, roster] of voiceRosters) {
172
- if (!roster.delete(connection.userId))
173
- continue;
174
- if (roster.size === 0)
175
- voiceRosters.delete(key);
166
+ dropUserFrom(voiceRosters, connection.userId, () => true, (key) => {
176
167
  const [serverId, channelId] = key.split("|");
177
168
  broadcastVoice(serverId, channelId);
178
- }
169
+ });
179
170
  };
180
171
  const dropVoiceForServer = (userId, serverId) => {
181
172
  const prefix = `${serverId}|`;
182
- for (const [key, roster] of voiceRosters) {
183
- if (!key.startsWith(prefix))
184
- continue;
185
- if (!roster.delete(userId))
186
- continue;
187
- if (roster.size === 0)
188
- voiceRosters.delete(key);
189
- broadcastVoice(serverId, key.slice(prefix.length));
190
- }
173
+ dropUserFrom(voiceRosters, userId, (key) => key.startsWith(prefix), (key) => broadcastVoice(serverId, key.slice(prefix.length)));
191
174
  };
192
175
  const requireMembership = async (connection, id, serverId) => {
193
176
  const userId = connection.userId;
@@ -337,21 +320,10 @@ export function createHostRouter(options) {
337
320
  const dropPresence = (connection) => {
338
321
  if (connection.userId === null)
339
322
  return;
340
- for (const [serverId, rows] of presence) {
341
- if (rows.delete(connection.userId)) {
342
- if (rows.size === 0)
343
- presence.delete(serverId);
344
- broadcastPresence(serverId);
345
- }
346
- }
323
+ dropUserFrom(presence, connection.userId, () => true, (serverId) => broadcastPresence(serverId));
347
324
  };
348
325
  const dropPresenceForServer = (userId, serverId) => {
349
- const rows = presence.get(serverId);
350
- if (rows === undefined || !rows.delete(userId))
351
- return;
352
- if (rows.size === 0)
353
- presence.delete(serverId);
354
- broadcastPresence(serverId);
326
+ dropUserFrom(presence, userId, (key) => key === serverId, () => broadcastPresence(serverId));
355
327
  };
356
328
  const handleMessage = async (connection, message) => {
357
329
  if (message.t === "hello") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/ws",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Browser-safe WebSocket client backend for JGengine: protocol codec, createWsBackend, and HTTP read helpers.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.11.0"
34
+ "@jgengine/core": "^0.12.0"
35
35
  },
36
36
  "publishConfig": {
37
37
  "access": "public"
@@ -9,16 +9,16 @@ Read the repository [README](../../../README.md) for package truth, commands, la
9
9
 
10
10
  ## 1. Establish the target
11
11
 
12
- Capture only decisions that change architecture:
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
13
 
14
14
  - player point of view and controls
15
15
  - world shape and authored content
16
16
  - core loop, interaction, combat, and progression
17
17
  - solo, local, hosted, or persistent multiplayer
18
- - HUD/menu states and art direction
18
+ - HUD/menu states and **custom** UI art direction (every game owns its presentation — no stock drop-in face)
19
19
  - one observable completion scenario
20
20
 
21
- For a new in-repo game, scaffold with `bun run new:game <id> --name "Title"`. Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
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
22
 
23
23
  ## 2. Select domains
24
24
 
@@ -26,6 +26,8 @@ Load only rows the target needs. Do not preload every domain.
26
26
 
27
27
  | Need | Skill | Owner |
28
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 |
29
31
  | scene placement, terrain painting, paths, asset authoring | `jgengine-editor` | editor document and authoring tools |
30
32
  | runtime world, movement, input, interaction, AI, navigation, audio | `jgengine-world` | spatial simulation and world runtime |
31
33
  | game state, systems, inventory, crafting, quests, economy, turns | `jgengine-gameplay` | non-combat rules and state |
@@ -34,7 +36,7 @@ Load only rows the target needs. Do not preload every domain.
34
36
  | models, sprites, materials, audio files, source licensing | `jgengine-assets` | asset discovery and catalogs |
35
37
  | authority, transport, replication, sessions, host persistence | `jgengine-multiplayer` | network topology and persistence adapters |
36
38
 
37
- Scenes normally require both `jgengine-editor` for authoring and `jgengine-world` for runtime consumption. Combat and multiplayer are opt-in, not default intake.
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.
38
40
 
39
41
  ## 3. Discover before designing
40
42
 
@@ -43,6 +45,7 @@ Within each selected domain:
43
45
  1. Search `capabilities.md` by intent; it maps needs to imports.
44
46
  2. Open `api.md` only when exact signatures or export inventory are needed.
45
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.
46
49
 
47
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.
48
51