@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/CHANGELOG.md +1401 -0
- package/dist/createConvexBackend.d.ts +2 -2
- package/dist/createConvexGameTransport.d.ts +10 -10
- package/dist/createConvexGameTransport.js +13 -0
- package/dist/hostedServer.d.ts +11 -11
- package/dist/hostedServer.js +3 -2
- package/dist/resolveConvexMultiplayer.d.ts +1 -1
- package/dist/server.d.ts +126 -102
- package/dist/server.js +49 -24
- package/dist/worldPresence.d.ts +1 -1
- package/dist/worldPresence.js +2 -1
- package/package.json +2 -2
- package/skills/jgengine/SKILL.md +43 -63
- package/skills/jgengine/api.md +29 -14
- package/skills/jgengine/capabilities.md +8 -0
- package/skills/jgengine/recipes/cookbook.md +752 -0
- package/skills/jgengine-multiplayer/SKILL.md +9 -0
- package/skills/jgengine-multiplayer/api.md +49 -32
- package/skills/jgengine-multiplayer/references/peer-worlds.md +1 -1
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
|
-
|
|
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
|
|
777
|
+
async findServer(ctx, gameId) {
|
|
776
778
|
if (!shared || matchmaking !== "singleton")
|
|
777
|
-
throw new Error("
|
|
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
|
|
1458
|
-
|
|
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 (
|
|
1668
|
-
await ctx.db.delete(
|
|
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
|
},
|
package/dist/worldPresence.d.ts
CHANGED
|
@@ -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 {
|
package/dist/worldPresence.js
CHANGED
|
@@ -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.
|
|
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.
|
|
33
|
+
"@jgengine/core": "^0.51.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"convex": "^1.31.2",
|
package/skills/jgengine/SKILL.md
CHANGED
|
@@ -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
|
|
6
|
+
# JGengine router
|
|
7
7
|
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
12
|
+
## Start
|
|
13
13
|
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
+
The cookbook shows each common feature built from these in a few lines: [recipes/cookbook.md](recipes/cookbook.md).
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
## Domains
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
## Discover before writing
|
|
44
55
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
62
|
+
## Finish
|
|
50
63
|
|
|
51
|
-
|
|
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`.
|