@jgengine/convex 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.
package/dist/server.js CHANGED
@@ -1,15 +1,19 @@
1
1
  import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
2
2
  import { ConvexError, v } from "convex/values";
3
3
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
4
- import { decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
4
+ import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
5
5
  import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
6
6
  import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries } from "@jgengine/core/runtime/hostPersistence";
7
+ import { isAutoJoinCandidate, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, statusAfterLeave, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
7
8
  import { clearDirtyFlags, createEmptyServerRow } from "@jgengine/core/runtime/snapshot";
8
9
  import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
10
+ /** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
11
+ export { canJoinPrivateServer, isListablePublicly, } from "@jgengine/core/runtime/hostPolicy";
9
12
  const saveConfigValidator = v.union(v.literal("none"), v.object({
10
13
  auto: v.string(),
11
14
  scope: v.union(v.literal("player"), v.literal("chunks"), v.literal("player+chunks")),
12
15
  }));
16
+ /** @internal */
13
17
  export function jgengineTables() {
14
18
  return {
15
19
  jgGameServers: defineTable({
@@ -30,7 +34,10 @@ export function jgengineTables() {
30
34
  dirtyAt: v.optional(v.number()),
31
35
  createdAt: v.number(),
32
36
  updatedAt: v.number(),
33
- }).index("by_game_and_status", ["gameId", "status"]),
37
+ })
38
+ .index("by_game_and_status", ["gameId", "status"])
39
+ .index("by_status", ["status"])
40
+ .index("by_dirty", ["dirtyAt"]),
34
41
  jgPlayerProfiles: defineTable({
35
42
  userId: v.string(),
36
43
  gameId: v.string(),
@@ -91,7 +98,8 @@ export function jgengineTables() {
91
98
  * Convex table for the hosted GameContext-world path: one row per (gameId, serverId) holding the full
92
99
  * `WorldSnapshot` blob, its revision, the member roster, and each member's held input. A sibling of
93
100
  * {@link jgengineTables} — spread either (or both) into `defineSchema`.
94
- */
101
+ * @internal
102
+ */
95
103
  export function jgengineHostedTables() {
96
104
  return {
97
105
  jgHostedWorlds: defineTable({
@@ -104,21 +112,19 @@ export function jgengineHostedTables() {
104
112
  tickAnchorMs: v.number(),
105
113
  createdAt: v.number(),
106
114
  updatedAt: v.number(),
107
- }).index("by_game_and_server", ["gameId", "serverId"]),
115
+ })
116
+ .index("by_game_and_server", ["gameId", "serverId"])
117
+ .index("by_tick_anchor", ["tickAnchorMs"]),
108
118
  };
109
119
  }
110
120
  const schemaForTypes = defineSchema(jgengineTables());
111
121
  const query = queryGeneric;
112
122
  const mutation = mutationGeneric;
113
123
  const internalMutation = internalMutationGeneric;
114
- export const DEFAULT_CONVEX_POSE_RULES = {
115
- maxSpeed: 12,
116
- maxVerticalOffset: 3,
117
- minElapsedSec: 0.05,
118
- maxElapsedSec: 0.5,
119
- keepAliveRefreshMs: 10_000,
120
- };
121
- /** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`. */
124
+ export const DEFAULT_CONVEX_POSE_RULES = DEFAULT_POSE_SYNC_RULES;
125
+ /** Resolve the acting user id under a {@link JgAuthMode}: the Convex identity when signed in (rejecting a mismatched claim), the claimed external id under `"anonymous"`, else `null`.
126
+ * @internal
127
+ */
122
128
  export async function resolveActor(ctx, claimedExternalId, mode) {
123
129
  const identity = await ctx.auth.getUserIdentity();
124
130
  if (identity !== null) {
@@ -138,10 +144,34 @@ async function requireServerMember(ctx, serverId, actorUserId) {
138
144
  const server = await ctx.db.get("jgGameServers", serverId);
139
145
  if (!server)
140
146
  return null;
141
- if (!server.memberUserIds.includes(actorUserId))
147
+ if (!isServerMember(server.memberUserIds, actorUserId))
142
148
  return null;
143
149
  return server;
144
150
  }
151
+ /** Resolve the acting user for a mutation, throwing the standard error when unauthenticated.
152
+ * @internal
153
+ */
154
+ async function requireActor(ctx, externalId, mode) {
155
+ const actorUserId = await resolveActor(ctx, externalId, mode);
156
+ if (!actorUserId)
157
+ throw new ConvexError("Not authenticated");
158
+ return actorUserId;
159
+ }
160
+ /**
161
+ * Resolve the actor and confirm server membership, invoking `fn` only when both hold and yielding
162
+ * `deniedResult` for every denial (unauthenticated or non-member). The uniform-denial path shared by
163
+ * the read handlers whose not-authenticated and not-member cases return the same value.
164
+ * @internal
165
+ */
166
+ async function withMember(ctx, mode, args, deniedResult, fn) {
167
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
168
+ if (!actorUserId)
169
+ return deniedResult;
170
+ const server = await requireServerMember(ctx, args.serverId, actorUserId);
171
+ if (server === null)
172
+ return deniedResult;
173
+ return fn(server, actorUserId);
174
+ }
145
175
  const builtinRuntimeCommands = {
146
176
  "engine.ping": {
147
177
  validate: (_snapshot) => null,
@@ -223,6 +253,7 @@ async function loadServerSnapshot(ctx, server, runtime) {
223
253
  revision: server.revision,
224
254
  });
225
255
  }
256
+ /** @internal */
226
257
  export async function applyLeaderboardIncrements(ctx, gameId, entries) {
227
258
  const now = Date.now();
228
259
  for (const entry of entries) {
@@ -324,6 +355,7 @@ async function flushServerIfDue(ctx, server, now, runtime) {
324
355
  return true;
325
356
  }
326
357
  export const JG_RUNTIME_TICK_MS = 1_000;
358
+ /** @internal */
327
359
  export function createGameServerFunctions(options) {
328
360
  const mode = options?.auth ?? "required";
329
361
  const hostSlotsPerServer = options?.slotsPerServer ?? 16;
@@ -344,10 +376,7 @@ export function createGameServerFunctions(options) {
344
376
  isNew: v.boolean(),
345
377
  }),
346
378
  handler: async (ctx, args) => {
347
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
348
- if (!actorUserId) {
349
- throw new ConvexError("Not authenticated");
350
- }
379
+ const actorUserId = await requireActor(ctx, args.externalId, mode);
351
380
  const now = Date.now();
352
381
  const runtime = resolveRuntime(registry, args.gameId);
353
382
  const save = runtime.save;
@@ -369,13 +398,12 @@ export function createGameServerFunctions(options) {
369
398
  joinable.push(...rows);
370
399
  }
371
400
  server =
372
- joinable.find((row) => {
373
- if (row.memberUserIds.includes(actorUserId))
374
- return true;
375
- if (row.memberUserIds.length >= row.slotsPerServer)
376
- return false;
377
- return (row.visibility ?? "public") !== "private";
378
- }) ?? null;
401
+ joinable.find((row) => isAutoJoinCandidate({
402
+ memberUserIds: row.memberUserIds,
403
+ slotsPerServer: row.slotsPerServer,
404
+ visibility: row.visibility,
405
+ userId: actorUserId,
406
+ })) ?? null;
379
407
  }
380
408
  if (!server) {
381
409
  const serverId = await ctx.db.insert("jgGameServers", {
@@ -399,13 +427,16 @@ export function createGameServerFunctions(options) {
399
427
  if (!server)
400
428
  throw new ConvexError("Failed to create server");
401
429
  }
402
- if (server.memberUserIds.length >= server.slotsPerServer &&
403
- !server.memberUserIds.includes(actorUserId)) {
430
+ if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
404
431
  throw new ConvexError("Server is full");
405
432
  }
406
- if ((server.visibility ?? "public") === "private" &&
407
- !server.memberUserIds.includes(actorUserId) &&
408
- args.serverId === undefined) {
433
+ if (isPrivateJoinBlocked({
434
+ visibility: server.visibility,
435
+ memberUserIds: server.memberUserIds,
436
+ userId: actorUserId,
437
+ joinCode: server.joinCode,
438
+ suppliedCode: args.joinCode,
439
+ })) {
409
440
  throw new ConvexError("Server is private");
410
441
  }
411
442
  const profile = await ctx.db
@@ -413,9 +444,7 @@ export function createGameServerFunctions(options) {
413
444
  .withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
414
445
  .unique();
415
446
  const isNew = profile === null;
416
- const memberUserIds = server.memberUserIds.includes(actorUserId)
417
- ? server.memberUserIds
418
- : [...server.memberUserIds, actorUserId];
447
+ const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
419
448
  await ctx.db.patch(server._id, {
420
449
  memberUserIds,
421
450
  status: "running",
@@ -438,20 +467,15 @@ export function createGameServerFunctions(options) {
438
467
  },
439
468
  returns: v.null(),
440
469
  handler: async (ctx, args) => {
441
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
442
- if (!actorUserId) {
443
- throw new ConvexError("Not authenticated");
444
- }
445
- const server = await ctx.db.get("jgGameServers", args.serverId);
446
- if (!server)
447
- return null;
448
- if (!server.memberUserIds.includes(actorUserId))
470
+ const actorUserId = await requireActor(ctx, args.externalId, mode);
471
+ const server = await requireServerMember(ctx, args.serverId, actorUserId);
472
+ if (server === null)
449
473
  return null;
450
474
  const now = Date.now();
451
475
  const runtime = resolveRuntime(registry, server.gameId);
452
476
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
453
477
  await persistServerSnapshot(ctx, server, snapshot, server.save);
454
- const memberUserIds = server.memberUserIds.filter((id) => id !== actorUserId);
478
+ const memberUserIds = withoutMember(server.memberUserIds, actorUserId);
455
479
  const sessionPlayers = { ...server.sessionPlayers };
456
480
  delete sessionPlayers[actorUserId];
457
481
  await ctx.db.patch(server._id, {
@@ -459,7 +483,7 @@ export function createGameServerFunctions(options) {
459
483
  sessionPlayers,
460
484
  updatedAt: now,
461
485
  dirtyAt: now,
462
- status: memberUserIds.length === 0 ? "open" : server.status,
486
+ status: statusAfterLeave(memberUserIds.length, server.status),
463
487
  });
464
488
  const pose = await ctx.db
465
489
  .query("jgPoses")
@@ -479,15 +503,12 @@ export function createGameServerFunctions(options) {
479
503
  },
480
504
  returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
481
505
  handler: async (ctx, args) => {
482
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
483
- if (!actorUserId) {
484
- throw new ConvexError("Not authenticated");
485
- }
506
+ const actorUserId = await requireActor(ctx, args.externalId, mode);
486
507
  const server = await ctx.db.get("jgGameServers", args.serverId);
487
508
  if (!server) {
488
509
  return { ok: false, reason: "Server not found" };
489
510
  }
490
- if (!server.memberUserIds.includes(actorUserId)) {
511
+ if (!isServerMember(server.memberUserIds, actorUserId)) {
491
512
  return { ok: false, reason: "Not a member of this server" };
492
513
  }
493
514
  const loadedRevision = server.revision;
@@ -524,14 +545,9 @@ export function createGameServerFunctions(options) {
524
545
  },
525
546
  returns: v.boolean(),
526
547
  handler: async (ctx, args) => {
527
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
528
- if (!actorUserId) {
529
- throw new ConvexError("Not authenticated");
530
- }
531
- const server = await ctx.db.get("jgGameServers", args.serverId);
532
- if (!server)
533
- return false;
534
- if (!server.memberUserIds.includes(actorUserId))
548
+ const actorUserId = await requireActor(ctx, args.externalId, mode);
549
+ const server = await requireServerMember(ctx, args.serverId, actorUserId);
550
+ if (server === null)
535
551
  return false;
536
552
  const runtime = resolveRuntime(registry, server.gameId);
537
553
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
@@ -557,15 +573,7 @@ export function createGameServerFunctions(options) {
557
573
  sessionPlayers: v.any(),
558
574
  updatedAt: v.number(),
559
575
  }), v.null()),
560
- handler: async (ctx, args) => {
561
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
562
- if (!actorUserId)
563
- return null;
564
- const server = await ctx.db.get("jgGameServers", args.serverId);
565
- if (!server)
566
- return null;
567
- if (!server.memberUserIds.includes(actorUserId))
568
- return null;
576
+ handler: (ctx, args) => withMember(ctx, mode, args, null, (server, actorUserId) => {
569
577
  const sessionPlayers = server.sessionPlayers;
570
578
  const ownSession = sessionPlayers[actorUserId];
571
579
  return {
@@ -581,7 +589,7 @@ export function createGameServerFunctions(options) {
581
589
  sessionPlayers: ownSession === undefined ? {} : { [actorUserId]: ownSession },
582
590
  updatedAt: server.updatedAt,
583
591
  };
584
- },
592
+ }),
585
593
  });
586
594
  const getPlayerProfile = query({
587
595
  args: {
@@ -622,17 +630,13 @@ export function createGameServerFunctions(options) {
622
630
  },
623
631
  returns: v.array(v.any()),
624
632
  handler: async (ctx, args) => {
625
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
626
- if (!actorUserId)
627
- return [];
628
- const server = await ctx.db.get("jgGameServers", args.serverId);
629
- if (!server || !server.memberUserIds.includes(actorUserId))
630
- return [];
631
- const row = await ctx.db
632
- .query("jgFeedBuffers")
633
- .withIndex("by_server_and_action", (q) => q.eq("serverId", args.serverId).eq("action", args.action))
634
- .unique();
635
- return row?.entries ?? [];
633
+ return withMember(ctx, mode, args, [], async () => {
634
+ const row = await ctx.db
635
+ .query("jgFeedBuffers")
636
+ .withIndex("by_server_and_action", (q) => q.eq("serverId", args.serverId).eq("action", args.action))
637
+ .unique();
638
+ return row?.entries ?? [];
639
+ });
636
640
  },
637
641
  });
638
642
  const pushFeedEntry = mutation({
@@ -644,16 +648,13 @@ export function createGameServerFunctions(options) {
644
648
  },
645
649
  returns: v.null(),
646
650
  handler: async (ctx, args) => {
647
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
648
- if (!actorUserId) {
649
- throw new ConvexError("Not authenticated");
650
- }
651
+ const actorUserId = await requireActor(ctx, args.externalId, mode);
651
652
  const allowed = validateFeedWrite(feedWriteGate, args.action);
652
653
  if (!allowed.ok) {
653
654
  throw new ConvexError(allowed.reason);
654
655
  }
655
656
  const server = await ctx.db.get("jgGameServers", args.serverId);
656
- if (!server || !server.memberUserIds.includes(actorUserId)) {
657
+ if (!server || !isServerMember(server.memberUserIds, actorUserId)) {
657
658
  throw new ConvexError("Not a member of this server");
658
659
  }
659
660
  const now = Date.now();
@@ -691,11 +692,15 @@ export function createGameServerFunctions(options) {
691
692
  updatedAt: v.number(),
692
693
  })),
693
694
  handler: async (ctx, args) => {
695
+ const limit = args.limit ?? 20;
694
696
  const rows = await ctx.db
695
697
  .query("jgGameServers")
696
698
  .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running"))
697
- .take(args.limit ?? 20);
698
- return rows.map((row) => ({
699
+ .take(Math.min(limit * 4, 200));
700
+ return rows
701
+ .filter((row) => isListablePublicly(row.visibility))
702
+ .slice(0, limit)
703
+ .map((row) => ({
699
704
  _id: row._id,
700
705
  status: row.status,
701
706
  memberCount: row.memberUserIds.length,
@@ -709,12 +714,13 @@ export function createGameServerFunctions(options) {
709
714
  args: {},
710
715
  handler: async (ctx) => {
711
716
  const now = Date.now();
712
- const servers = await ctx.db.query("jgGameServers").collect();
717
+ const servers = await ctx.db
718
+ .query("jgGameServers")
719
+ .withIndex("by_status", (q) => q.eq("status", "running"))
720
+ .collect();
713
721
  let ticked = 0;
714
722
  let saved = 0;
715
723
  for (const server of servers) {
716
- if (server.status !== "running")
717
- continue;
718
724
  if (server.memberUserIds.length === 0)
719
725
  continue;
720
726
  const elapsedMs = now - server.tickAnchorMs;
@@ -738,11 +744,12 @@ export function createGameServerFunctions(options) {
738
744
  args: {},
739
745
  handler: async (ctx) => {
740
746
  const now = Date.now();
741
- const servers = await ctx.db.query("jgGameServers").collect();
747
+ const servers = await ctx.db
748
+ .query("jgGameServers")
749
+ .withIndex("by_dirty", (q) => q.gt("dirtyAt", 0))
750
+ .collect();
742
751
  let saved = 0;
743
752
  for (const server of servers) {
744
- if (server.dirtyAt === undefined)
745
- continue;
746
753
  const runtime = resolveRuntime(registry, server.gameId);
747
754
  const snapshot = await loadServerSnapshot(ctx, server, runtime);
748
755
  await persistServerSnapshot(ctx, server, clearDirtyFlags(snapshot), server.save);
@@ -768,6 +775,7 @@ export function createGameServerFunctions(options) {
768
775
  }
769
776
  const leaderboardScopeValidator = v.union(v.literal("global"), v.literal("server"), v.literal("profile"));
770
777
  const MAX_LEADERBOARD_TOP_LIMIT = 100;
778
+ /** @internal */
771
779
  export function createLeaderboardFunctions(options) {
772
780
  void options;
773
781
  const getTop = query({
@@ -831,6 +839,7 @@ export function createLeaderboardFunctions(options) {
831
839
  });
832
840
  return { getTop, getProfile, incrementMany };
833
841
  }
842
+ /** @internal */
834
843
  export function createPresenceFunctions(options) {
835
844
  const mode = options?.auth ?? "required";
836
845
  const freshWindowMs = options?.freshWindowMs ?? 10_000;
@@ -848,25 +857,22 @@ export function createPresenceFunctions(options) {
848
857
  lastSeenAt: v.number(),
849
858
  })),
850
859
  handler: async (ctx, args) => {
851
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
852
- if (!actorUserId)
853
- return [];
854
- if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
855
- return [];
856
- const threshold = Date.now() - freshWindowMs;
857
- const rows = await ctx.db
858
- .query("jgPoses")
859
- .withIndex("by_server", (q) => q.eq("serverId", args.serverId))
860
- .collect();
861
- return rows
862
- .filter((row) => row.updatedAt >= threshold)
863
- .map((row) => ({
864
- userId: row.userId,
865
- position: { x: row.x, y: row.y, z: row.z },
866
- rotationY: row.rotationY,
867
- rotationPitch: row.rotationPitch,
868
- lastSeenAt: row.updatedAt,
869
- }));
860
+ return withMember(ctx, mode, args, [], async () => {
861
+ const threshold = Date.now() - freshWindowMs;
862
+ const rows = await ctx.db
863
+ .query("jgPoses")
864
+ .withIndex("by_server", (q) => q.eq("serverId", args.serverId))
865
+ .collect();
866
+ return rows
867
+ .filter((row) => row.updatedAt >= threshold)
868
+ .map((row) => ({
869
+ userId: row.userId,
870
+ position: { x: row.x, y: row.y, z: row.z },
871
+ rotationY: row.rotationY,
872
+ rotationPitch: row.rotationPitch,
873
+ lastSeenAt: row.updatedAt,
874
+ }));
875
+ });
870
876
  },
871
877
  });
872
878
  const sync = mutation({
@@ -882,12 +888,7 @@ export function createPresenceFunctions(options) {
882
888
  externalId: v.optional(v.string()),
883
889
  },
884
890
  returns: v.null(),
885
- handler: async (ctx, args) => {
886
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
887
- if (!actorUserId)
888
- return null;
889
- if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
890
- return null;
891
+ handler: (ctx, args) => withMember(ctx, mode, args, null, async (_server, actorUserId) => {
891
892
  const now = Date.now();
892
893
  const existing = await ctx.db
893
894
  .query("jgPoses")
@@ -923,7 +924,7 @@ export function createPresenceFunctions(options) {
923
924
  await ctx.db.insert("jgPoses", { serverId: args.serverId, userId: actorUserId, ...patch });
924
925
  }
925
926
  return null;
926
- },
927
+ }),
927
928
  });
928
929
  const leave = mutation({
929
930
  args: {
@@ -946,6 +947,7 @@ export function createPresenceFunctions(options) {
946
947
  });
947
948
  return { list, sync, leave };
948
949
  }
950
+ /** @internal */
949
951
  export function createChatFunctions(options) {
950
952
  const mode = options?.auth ?? "required";
951
953
  const historyLimit = options?.historyLimit ?? 100;
@@ -964,12 +966,7 @@ export function createChatFunctions(options) {
964
966
  body: v.string(),
965
967
  at: v.number(),
966
968
  })),
967
- handler: async (ctx, args) => {
968
- const actorUserId = await resolveActor(ctx, args.externalId, mode);
969
- if (!actorUserId)
970
- return [];
971
- if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null)
972
- return [];
969
+ handler: (ctx, args) => withMember(ctx, mode, args, [], async () => {
973
970
  const rows = await ctx.db
974
971
  .query("jgChatMessages")
975
972
  .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
@@ -985,7 +982,7 @@ export function createChatFunctions(options) {
985
982
  body: row.body,
986
983
  at: row.at,
987
984
  }));
988
- },
985
+ }),
989
986
  });
990
987
  const sendMessage = mutation({
991
988
  args: {
@@ -1041,6 +1038,7 @@ const JG_CRON_SPECS = [
1041
1038
  { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers" },
1042
1039
  { name: "jg flush", intervalSeconds: 60, functionKey: "flushDirtyServers" },
1043
1040
  ];
1041
+ /** @internal */
1044
1042
  export function jgengineCronSpecs() {
1045
1043
  return JG_CRON_SPECS;
1046
1044
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@jgengine/convex",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Convex adapters for JGengine: game transport, presence transport, and backend wiring over @jgengine/core.",
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
  ".": {
@@ -30,7 +30,7 @@
30
30
  "check-types": "tsgo --noEmit -p tsconfig.json"
31
31
  },
32
32
  "dependencies": {
33
- "@jgengine/core": "^0.10.0"
33
+ "@jgengine/core": "^0.12.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "convex": "^1.31.2",
@@ -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.