@jgengine/sql 0.18.1 → 0.21.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 +504 -7
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/jgengine/SKILL.md +28 -6
- package/skills/jgengine/api.md +139 -99
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine/recipes/minimal-game.md +10 -2
- package/skills/jgengine-multiplayer/SKILL.md +3 -3
- package/skills/jgengine-multiplayer/api.md +91 -83
- package/skills/jgengine-multiplayer/reference.md +27 -3
- package/skills/jgengine-multiplayer/references/peer-worlds.md +62 -0
|
@@ -6,6 +6,25 @@ Reference module for the [`jgengine-multiplayer` API](SKILL.md) skill. Load this
|
|
|
6
6
|
|
|
7
7
|
## Multiplayer and the backend seam
|
|
8
8
|
|
|
9
|
+
**Hosted authoritative saves.** `createHostedGameRunner().state()` captures complete persistence state; `.snapshot(viewer)` is replication only. `restore` accepts both complete saves and older replication-only records. `createHostedWorldSession` persists the complete state, replication cursor, known player identities and command retry receipts. `hasPlayer(userId)` survives leave/restart; active socket membership is rebuilt on join. Hosted ticks advance the game clock and pass scaled time to game ticks, with movement retaining real step time.
|
|
10
|
+
|
|
11
|
+
`session.command(userId, name, input, operationId?)` deduplicates a stable operation id with its command and JSON input per player. WS object commands receive a unique client-session namespace automatically; the same in-flight envelope retries on reconnect. Receipts and registered state are one save record; failed/rejected commands restore that registered state. External side effects and unregistered closures are outside that rollback. Default receipt retention is 64 per player (`operationHistoryLimit`); a retry after eviction is a new operation. A conflicting reuse returns `operation-conflict`.
|
|
12
|
+
|
|
13
|
+
The world WS host serializes joins/commands per world and awaits durable storage before replying or broadcasting. Ticks defer while a command is saving. `session.save()` returns `void | Promise<void>`; always await it at a shutdown or acknowledgement boundary. Automatic saves defer while a write is pending, and `persistenceError()` exposes failure until a successful retry. Node `flush()`/`close()` await persistence. Convex stores complete state atomically but queries return only viewer-projected replication. See [gameplay save semantics](../jgengine-gameplay/reference-systems.md#authoritative-state).
|
|
14
|
+
|
|
15
|
+
`WorldGameHost.unload(serverId)` explicitly retires an idle world's host cache, tick and save references after its final save. It returns `unloaded`, `missing` or `occupied`; admitted players, spectators and still-present factory-resident members refuse unloading. A failed admission profile alone is not occupancy. Save failure rejects and keeps the live world for retry. Existing per-world queues serialize accepted work, unload and subsequent reload; `stop()` drains work accepted before closing. Choose idle timing in the caller: no automatic eviction policy runs. The session factory must create a fresh session on reload and bind it to the same authoritative game/server storage. Unload releases host references only; factory-owned subscriptions, timers and externally retained sessions remain caller-owned.
|
|
16
|
+
|
|
17
|
+
Node `WorldGameServer.close()` permanently fences ticks and socket intake, drains
|
|
18
|
+
accepted router work, then stops and saves the world host. Repeated calls share
|
|
19
|
+
the same completion, including a final persistence failure. `stop()` only pauses
|
|
20
|
+
the interval; `start()` can resume it until `close()`. Standalone
|
|
21
|
+
`GameWsServer.close()` drains its router before retiring sockets; callers still
|
|
22
|
+
own their host's final flush or stop.
|
|
23
|
+
|
|
24
|
+
**Player UI commands.** `useGame().commands.run(name, input)` dispatches through the shell's bound authoritative transport. Await its result before displaying success; rejection before join never mutates the replica. Offline dispatch stays synchronous. `ctx.game.commands.run` and `runAs` remain trusted local execution for hosts and game initialization. A custom host can bind the same UI seam with `bindCommandTransport` from `@jgengine/core/runtime/commandDispatch`; dispose the returned binding on teardown. Registered target commands and HUD-only input use the host too. Input coalescing is scoped to each transport and realm, so two actors never share a pending input queue.
|
|
25
|
+
|
|
26
|
+
The authoritative clock replicates through `time`; client clock hooks read that hydrated clock. Predictive movement never advances it or runs authoritative game stages.
|
|
27
|
+
|
|
9
28
|
**Convex is an adapter, not a dependency.** The engine owns the contracts; any backend implements them:
|
|
10
29
|
|
|
11
30
|
```ts
|
|
@@ -66,11 +85,18 @@ Persistence tiers (`@jgengine/core/runtime/hostPersistence` — `HostPersistence
|
|
|
66
85
|
|
|
67
86
|
**Browser-safe host + router** (`@jgengine/ws/host`, `@jgengine/ws/hostRouter`) — `createGameHost({ runtimes, persistence, tickMs? })` and `memoryPersistence()` moved here from `@jgengine/node` (which still re-exports both, unchanged, from `@jgengine/node/host` / `@jgengine/node/persistence` — not a breaking change); the host itself has zero Node dependencies, so a browser tab can host a session. `createHostRouter({ host, authenticate?, poseRules?, positionHistoryMs?, chatRateLimit?, chatHistoryLimit?, chatMaxBodyLength?, now? }): HostRouter` is the ws wire-protocol session logic extracted out of `createGameWsServer` — `.connect(transport: { send, close }) → { handleRaw, close }` binds one connection, `.rewind` replays lag-comp history, `.close` tears the router down. `loopbackPipe(router): TransportPipeFactory` wires a `createWsBackend` straight into an in-process router — how a host player plays over their own hosted session with no socket at all.
|
|
68
87
|
|
|
88
|
+
Host notifications keep one read in flight and one pending refresh per connection/subscription. Slow subscribers coalesce intermediate notifications; world cursors advance only after a frame is sent. Unsubscribe, disconnect, and baseline requests invalidate older reads. Subscription acknowledgements precede their initial update and do not wait for the read.
|
|
89
|
+
|
|
90
|
+
Disconnect cleanup also covers admissions that finish after their connection closes. The router releases those memberships after `graceMs`, waiting for an already accepted reconnect before checking whether another live connection owns the membership. Router `drain()` waits for cleanup that has started; grace timers remain pending until they fire or router `close()` cancels them. The host owns final persistence and world retention; these hosts do not automatically evict idle worlds.
|
|
91
|
+
|
|
69
92
|
**Socket.IO transport** (`@jgengine/ws/socketIoPipe`, `@jgengine/node/socketIoServer`) — `SocketIoLikeSocket` is a structural client-socket shape (`connected`/`on`/`off`/`send`/`disconnect`; no socket.io dependency). `socketIoPipe(socket): TransportPipeFactory` + `createSocketIoBackend({ socket, userId, … }): WsBackend` ride the existing wire protocol over socket.io's `send`/`message` frames. Server side, `@jgengine/node`'s `attachGameSocketIoServer({ io, host, …router options }): { rewind, close }` binds a structural `SocketIoLikeServer`/`SocketIoLikeServerSocket` to the router — no socket.io dependency in the type, just the shape.
|
|
70
93
|
|
|
71
|
-
**WebRTC P2P** (`@jgengine/ws/peer`) — one browser tab is the authoritative host; no server process. `encodePeerSignal`/`decodePeerSignal` turn an SDP offer/answer into a copy-pasteable base64url code. `createPeerHost({ userId, host?, runtimes?, persistence?, tickMs?, router?, rtc? }): PeerHost` (persistence defaults to `memoryPersistence()`) runs a `GameHost` + `HostRouter` in the host tab, exposing `backend` (the host player's own loopback `WsBackend`) and `accept(offerCode) → Promise<answerCode>` per joining guest. `createPeerGuest({ userId, token?, rtc? }): PeerGuest` exposes `backend`, `offer()`, `connect(answerCode)`; both close via `.close()`. Signaling is a swappable seam — `PeerSignaling { publishOffer, onOffer, close }` — with `broadcastChannelSignaling(room)` covering same-origin multi-tab automatically; `announcePeerHost(host, signaling)` / `joinPeerSession(guest, signaling) → Promise<WsBackend>` wire a host/guest to a signaling channel in one call. Cross-machine play is manual copy/paste of the offer/answer codes; there is no auto-reconnect.
|
|
94
|
+
**WebRTC P2P** (`@jgengine/ws/peer`) — one browser tab is the authoritative host; no server process. `encodePeerSignal`/`decodePeerSignal` turn an SDP offer/answer into a copy-pasteable base64url code. `createPeerHost({ userId, host?, runtimes?, persistence?, tickMs?, router?, rtc? }): PeerHost` (persistence defaults to `memoryPersistence()`) runs a `GameHost` + `HostRouter` in the host tab, exposing `backend` (the host player's own loopback `WsBackend`) and `accept(offerCode) → Promise<answerCode>` per joining guest. `createPeerGuest({ userId, token?, rtc? }): PeerGuest` exposes `backend`, `offer()`, `connect(answerCode)`; both close via `.close()`. A rejected host `accept()` closes and releases only that failed negotiation connection; existing peers remain owned by the host until teardown. Signaling is a swappable seam — `PeerSignaling { publishOffer, onOffer, close }` — with `broadcastChannelSignaling(room)` covering same-origin multi-tab automatically; `announcePeerHost(host, signaling)` / `joinPeerSession(guest, signaling) → Promise<WsBackend>` wire a host/guest to a signaling channel in one call. Cross-machine play is manual copy/paste of the offer/answer codes; there is no auto-reconnect. `broadcastChannelSignaling.close()` is idempotent and permanent: it rejects outstanding offers with `Peer signaling is closed`, refuses new offers/listeners, and ignores answer callbacks that finish after close. Create a fresh signaling instance for another session. A shell guest whose bootstrap fails releases its signaling and guest transport through the existing failure cleanup path.
|
|
72
95
|
|
|
73
96
|
Backends:
|
|
97
|
+
|
|
98
|
+
`filePersistence.loadChunks` treats a missing chunk directory (`ENOENT`) as no saved chunks. Other directory read errors reject restoration before hydration or admission. Repair the directory and retry on the same host; a failed read never hydrates an empty replacement for an existing checkpoint.
|
|
99
|
+
|
|
74
100
|
- **Convex** — `@jgengine/convex` `createConvexBackend({ client, gameId, userId, api?, poseTuning?, presence? })` (a `LiveGameBackend`, `api` defaults to `anyApi`); server side is `@jgengine/convex/server`'s factories (tables `jgGameServers`, `jgPlayerProfiles`, `jgWorldChunks`, `jgLeaderboardRows`, `jgFeedBuffers`, `jgPoses`, `jgChatMessages`); a 1s tick cron runs loop ticks + auto-save, a 60s cron flushes dirty servers.
|
|
75
101
|
- **Node host** — `createGameHost({ runtimes, persistence, tickMs? })` now lives in `@jgengine/ws/host` (browser-safe); `@jgengine/node` re-exports it unchanged from `@jgengine/node/host` (same for `memoryPersistence` from `@jgengine/node/persistence`) — runs the authoritative loop in any JS process (in-memory snapshots, save-cadence flush), plus `browseServers({ gameId, filter? })` / `joinByCode({ userId, gameId, code })` and `joinServer({ …, attributes })` for coded/private lobbies. `memoryPersistence()` / `filePersistence(dir)` (Node-only, still `@jgengine/node`) implement `HostPersistence`. `createGameWsServer({ host, port | server, authenticate?, poseRules?, positionHistoryMs? })` is now a thin `@jgengine/node` binding of `@jgengine/ws/hostRouter`'s `createHostRouter` onto the `ws` npm package (same public API + `RewoundPosition` re-export, versioned JSON protocol in `@jgengine/ws/protocol`, poses clamped server-side via `decidePoseSync`) and retains presence history for `rewind`. `toNodeHandler(webHandler)` (`@jgengine/node/webHandler`) bridges a fetch-standard `(Request) => Promise<Response>` handler onto `(IncomingMessage, ServerResponse)` for express/node servers — e.g. mounting `createReadsHandler(...)` on an express route.
|
|
76
102
|
- **WebSocket client** — `@jgengine/ws` `createWsBackend({ userId, url?, pipe? })` returns a `GameBackend` (plus `pushFeedEntry`, `browse` / `joinByCode` / `createSession`, `presenceSync` with client-side `poseSyncGate`); one of `url`/`pipe` is required — `url` opens the default `webSocketPipe`, `pipe` accepts any `TransportPipeFactory` (socket.io, WebRTC, loopback, custom). Browser-safe, imports core only. `createHttpReads({ baseUrl, gameId })` gives plain-fetch reads (`getTop / getLeaderboardProfile / getPlayerProfile / listOpenServers`) — no live-query dependency. `createReadsHandler({ persistence, basePath?, listOpenServers? })` (`@jgengine/ws/readsHandler`) is the server side of the same contract: a fetch-standard `(Request) => Promise<Response>` serving those four read routes, mountable directly in any framework's route handler (Next catch-all, TanStack Start server route); `persistence` accepts a lazy factory memoized on first request; `listOpenServers` lets a live `createGameHost` serve fresher listings than persistence has flushed.
|
|
@@ -79,5 +105,3 @@ Backends:
|
|
|
79
105
|
- **Shell multiplayer** — every resolver produces a `MultiplayerSession` (`ShellMultiplayer` is an alias of it). `resolveShellMultiplayer` resolves straight off `defineGame`'s `multiplayer` adapter config: `ws(...)` connects to `url ?? adapter.url ?? ws://localhost:8080/ws`; `lan(...)` derives `ws(s)://<page hostname>:<port ?? 8080><path ?? /ws>` from `window.location`, so any browser on the LAN auto-connects to whichever machine served the page; other adapter kinds resolve to `null` (or fall back to the plain ws URL under `force` / the web dev route's `?ws` / desktop's `VITE_JG_WS_URL`). `resolvePeerShellMultiplayer({ gameId, role: "host" | "join", room?, userId?, feedActions? }): Promise<ShellMultiplayer & { close }>` is the `p2p` counterpart — hosts or joins over `broadcastChannelSignaling`, no ws URL involved; `apps/dev` wires it behind `?p2p=host` / `?p2p=join`. `resolveConvexMultiplayer` is the `convex` counterpart (forced by `VITE_CONVEX_URL` the same way). `<GameHost multiplayer={session}>` then joins a server, pose-syncs the local player, renders remote players from the presence roster, bridges feed actions (default `entity.died`) both ways with echo suppression, and — when the backend exposes `chatSyncFor` — relays `global`-kind chat channels too. Game code unchanged either way.
|
|
80
106
|
- **Appearance replication.** Presence rows (`WsPresenceRow`) carry an optional per-slot `appearance?: WsAppearance` (`Record<string, string | number | boolean>`) channel alongside pose — cosmetic ids, `"#rrggbb"` tints, model keys; slot semantics are game-defined, but the convention is slot `"tint"` = a hex tint the shell's `RemotePlayers` applies to recolor the remote capsule in place of the default hash color. Appearance rides the existing pose message — no protocol bump. The client pose-sync gate force-sends when appearance differs (shallow compare) even with zero movement, still rate-limited by `minIntervalMs`/heartbeat; the server accepts appearance-only changes and echoes the last-known appearance on every presence row. Wire `ctx.player.cosmetics.get(userId)` into the outgoing pose's `appearance` to replicate a player's equipped cosmetics for free.
|
|
81
107
|
- **Voice channels** — `@jgengine/ws/voiceChannel` (`createVoiceChannelRouter(channels?)`) is a thin, coarse layer on top of the same transport/presence model: it ships the channel/falloff **routing model**, not a WebRTC media stack (no audio transport — the media plane stays behind `multiplayer/voiceContract`'s `VoiceTransport` signaling seam: `join / leave / publish(streamId) / subscribers`; `createWsBackend(...).voiceSync` / `.voiceTransportFor(serverId)` implement it over `voiceJoin`/`voiceLeave`/`voicePublish` frames with host-relayed channel rosters, and `createLocalVoiceTransport()` covers local/dev; peers exchange stream **descriptors** here and negotiate actual media host-side). Mic capture lives in `@jgengine/react/voice` (`useVoice`, plus `PushToTalkButton` / `MicToggle` / `SpeakingIndicator` / `VoiceRoster`); the push-to-talk state model (`createPushToTalk`: hold / toggle / openMic, mute-gated) is a core export — `@jgengine/core/multiplayer/voiceContract`. `VoiceChannelDef = { id, positional, falloff?, gain? }` — `positional: true` channels (proximity voice) attenuate by distance using the same `@jgengine/core/audio/audioFalloff` curve as positional SFX; `positional: false` channels (walkie/crew) play at flat gain regardless of distance. A member `join`s any number of channels at once (a Sea of Thieves–style crew channel *and* nearby-ship proximity, simultaneously); `updatePosition(userId, xyz)` feeds positions (typically mirrored from `WsPresenceRow`); `setMuted(userId, bool)` silences every channel from that speaker at once. `resolveRoutes(listenerUserId)` returns one `{ fromUserId, channelId, gain }` per shared channel — the mixer plays each route independently, so the same speaker can be loud on `walkie` and near-silent on `proximity` at the same time.
|
|
82
|
-
|
|
83
|
-
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Playable browser peer worlds
|
|
2
|
+
|
|
3
|
+
`resolvePeerShellMultiplayer` from `@jgengine/shell/multiplayer` accepts a
|
|
4
|
+
`playable` game with `p2p({ authority: "server" })`. The host constructs one
|
|
5
|
+
`HostedWorldSession` and `createWorldGameHost`; host and guest command transports
|
|
6
|
+
operate on that shared simulation. Passing only `gameId` retains the existing
|
|
7
|
+
presence-only reducer-host composition.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const session = await resolvePeerShellMultiplayer({
|
|
11
|
+
gameId: "my-game", playable: game, role: "host", userId: identity.userId,
|
|
12
|
+
room: "my-room", slotsPerServer: 2,
|
|
13
|
+
});
|
|
14
|
+
// Mount <GameHost playable={game} multiplayer={session} />.
|
|
15
|
+
// The joining tab uses role: "join" with the same room and its own identity.
|
|
16
|
+
await session.close();
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The resolver owns one tick timer, signaling, peers and persistence teardown.
|
|
20
|
+
Ticks skip worlds with no active members; only the canonical game/server id is
|
|
21
|
+
admitted. `tickMs` defaults to 1000 / 30. Automatic saves default to a 1000 ms interval (`saveIntervalMs`); commands and
|
|
22
|
+
close retain their immediate save boundaries. `store` accepts an asynchronous
|
|
23
|
+
`HostedWorldStore` for restart recovery; omitted storage is memory-only.
|
|
24
|
+
`slotsPerServer` caps players inside the serialized world join queue; known live
|
|
25
|
+
members can reconnect at capacity and spectators do not consume player slots.
|
|
26
|
+
|
|
27
|
+
Admission becomes readable only after the join save succeeds. Rejected callers
|
|
28
|
+
cannot subscribe to world state or use a null sync result to request a full
|
|
29
|
+
snapshot. Each later subscription push rechecks membership; leaving revokes it.
|
|
30
|
+
A failed asynchronous load is retryable; successful hydration remains cached.
|
|
31
|
+
Capacity counts admitted players and initially resident host members, rather
|
|
32
|
+
than dirty players created by a failed join save. Failed admission or spectator
|
|
33
|
+
promotion retains player state for retry without reserving a new seat; retry
|
|
34
|
+
still checks current capacity. This is admission accounting, not world rollback.
|
|
35
|
+
|
|
36
|
+
Closing cancels ticks and signaling, closes the peer/router admission gate and
|
|
37
|
+
discards message handlers that have not started. The resolver awaits
|
|
38
|
+
`router.drain()` before the final world save. In-flight handlers and subscription
|
|
39
|
+
reads settle first, and repeated close calls return the same promise. A pending
|
|
40
|
+
backend operation keeps close pending; a final save failure rejects it. Custom
|
|
41
|
+
hosts using `createHostRouter` call `close()`, await `drain()`, then flush/stop
|
|
42
|
+
their owned host. `WorldGameHost.stop()` permanently fences admission and ticks,
|
|
43
|
+
drains already accepted world operations, and saves; repeated calls share its
|
|
44
|
+
completion. A stopped host must be replaced rather than restarted. The router
|
|
45
|
+
owns no host persistence policy.
|
|
46
|
+
|
|
47
|
+
Game joins use `onNewPlayer(ctx, player)`'s player argument. The authoritative
|
|
48
|
+
context's `ctx.player` identifies the host, while commands read their trusted
|
|
49
|
+
actor from `ctx.game.commands.actor()`. Persist game-owned seat and progression
|
|
50
|
+
state through registered stores; mutable module closures do not survive host
|
|
51
|
+
restart. A host player should use the same command/replica path as guests.
|
|
52
|
+
|
|
53
|
+
Default room signaling uses same-origin BroadcastChannel; it does not provide
|
|
54
|
+
cross-machine room discovery. Injectable `signaling`, peer factories and tick
|
|
55
|
+
scheduling support other host integrations and deterministic transport tests.
|
|
56
|
+
Tests through injected pipes prove command/replication behavior, not native
|
|
57
|
+
WebRTC browser connectivity. Verify that separately with two browser clients.
|
|
58
|
+
|
|
59
|
+
Resonant Crossing is the first intended adopter. Its published 0.18.x shell
|
|
60
|
+
resolver cannot supply a playable authoritative world. Keep that game on
|
|
61
|
+
published dependencies and wait for the coordinator's verified package release
|
|
62
|
+
before adopting this API; never substitute monorepo source aliases.
|