@jgengine/node 0.7.0 → 0.9.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 +63 -0
- package/dist/host.d.ts +1 -84
- package/dist/host.js +1 -375
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/persistence.d.ts +1 -1
- package/dist/persistence.js +49 -115
- package/dist/socketIoServer.d.ts +21 -0
- package/dist/socketIoServer.js +16 -0
- package/dist/webHandler.d.ts +5 -0
- package/dist/webHandler.js +40 -0
- package/dist/wsServer.d.ts +3 -17
- package/dist/wsServer.js +15 -262
- package/llms.txt +907 -0
- package/package.json +9 -4
package/llms.txt
ADDED
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
# @jgengine/node
|
|
2
|
+
> Standalone authoritative game host for JGengine: in-memory server snapshots, tick loop, save-cadence flush, WebSocket server, memory/file persistence.
|
|
3
|
+
|
|
4
|
+
Version: 0.9.0
|
|
5
|
+
License: AGPL-3.0-only
|
|
6
|
+
Repository: https://github.com/Noisemaker111/jgengine
|
|
7
|
+
Docs: https://jgengine.com
|
|
8
|
+
Imports use deep paths: `@jgengine/node/<path>`.
|
|
9
|
+
|
|
10
|
+
## Exported surface
|
|
11
|
+
|
|
12
|
+
### @jgengine/node/index
|
|
13
|
+
|
|
14
|
+
- GameSocketIoServer (type): type GameSocketIoServer = { rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => void; }
|
|
15
|
+
- GameSocketIoServerOptions (type): type GameSocketIoServerOptions = HostRouterOptions & { io: SocketIoLikeServer }
|
|
16
|
+
- GameWsServer (type): type GameWsServer = { wss: WebSocketServer; port: () => number; rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => Promise<void>; }
|
|
17
|
+
- GameWsServerOptions (type): type GameWsServerOptions = HostRouterOptions & { server?: HttpServer; port?: number; path?: string; }
|
|
18
|
+
- NodeHandler (type): type NodeHandler = (req: IncomingMessage, res: ServerResponse) => void
|
|
19
|
+
- SocketIoLikeServer (type): type SocketIoLikeServer = { on: (event: "connection", listener: (socket: SocketIoLikeServerSocket) => void) => unknown; }
|
|
20
|
+
- SocketIoLikeServerSocket (type): type SocketIoLikeServerSocket = { on: (event: string, listener: (payload: string) => void) => unknown; send: (data: string) => unknown; disconnect: (close?: boolean) => unknown; }
|
|
21
|
+
- WebHandler (type): type WebHandler = (request: Request) => Promise<Response>
|
|
22
|
+
- attachGameSocketIoServer (function): function attachGameSocketIoServer(options: GameSocketIoServerOptions): GameSocketIoServer
|
|
23
|
+
- clearFilePersistence (function): function clearFilePersistence(dir: string): Promise<void>
|
|
24
|
+
- createGameWsServer (function): function createGameWsServer(options: GameWsServerOptions): GameWsServer
|
|
25
|
+
- filePersistence (function): function filePersistence(dir: string, now: () => number = Date.now): HostPersistence
|
|
26
|
+
- toNodeHandler (function): function toNodeHandler(handler: WebHandler): NodeHandler
|
|
27
|
+
- toWebRequest (function): function toWebRequest(req: IncomingMessage): Promise<Request>
|
|
28
|
+
|
|
29
|
+
### @jgengine/node/persistence
|
|
30
|
+
|
|
31
|
+
- clearFilePersistence (function): function clearFilePersistence(dir: string): Promise<void>
|
|
32
|
+
- filePersistence (function): function filePersistence(dir: string, now: () => number = Date.now): HostPersistence
|
|
33
|
+
|
|
34
|
+
### @jgengine/node/socketIoServer
|
|
35
|
+
|
|
36
|
+
- GameSocketIoServer (type): type GameSocketIoServer = { rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => void; }
|
|
37
|
+
- GameSocketIoServerOptions (type): type GameSocketIoServerOptions = HostRouterOptions & { io: SocketIoLikeServer }
|
|
38
|
+
- SocketIoLikeServer (type): type SocketIoLikeServer = { on: (event: "connection", listener: (socket: SocketIoLikeServerSocket) => void) => unknown; }
|
|
39
|
+
- SocketIoLikeServerSocket (type): type SocketIoLikeServerSocket = { on: (event: string, listener: (payload: string) => void) => unknown; send: (data: string) => unknown; disconnect: (close?: boolean) => unknown; }
|
|
40
|
+
- attachGameSocketIoServer (function): function attachGameSocketIoServer(options: GameSocketIoServerOptions): GameSocketIoServer
|
|
41
|
+
|
|
42
|
+
### @jgengine/node/testFixtures
|
|
43
|
+
|
|
44
|
+
- createChunkTestRuntime (function): function createChunkTestRuntime(gameId = "chunk-game"): GameRuntime
|
|
45
|
+
- createTestRuntime (function): function createTestRuntime(gameId = "test-game"): GameRuntime
|
|
46
|
+
|
|
47
|
+
### @jgengine/node/webHandler
|
|
48
|
+
|
|
49
|
+
- NodeHandler (type): type NodeHandler = (req: IncomingMessage, res: ServerResponse) => void
|
|
50
|
+
- WebHandler (type): type WebHandler = (request: Request) => Promise<Response>
|
|
51
|
+
- toNodeHandler (function): function toNodeHandler(handler: WebHandler): NodeHandler
|
|
52
|
+
- toWebRequest (function): function toWebRequest(req: IncomingMessage): Promise<Request>
|
|
53
|
+
|
|
54
|
+
### @jgengine/node/wsServer
|
|
55
|
+
|
|
56
|
+
- GameWsServer (type): type GameWsServer = { wss: WebSocketServer; port: () => number; rewind: (args: { serverId: string; atMs: number }) => RewoundPosition[]; close: () => Promise<void>; }
|
|
57
|
+
- GameWsServerOptions (type): type GameWsServerOptions = HostRouterOptions & { server?: HttpServer; port?: number; path?: string; }
|
|
58
|
+
- createGameWsServer (function): function createGameWsServer(options: GameWsServerOptions): GameWsServer
|
|
59
|
+
|
|
60
|
+
## Guides
|
|
61
|
+
|
|
62
|
+
# JGengine
|
|
63
|
+
|
|
64
|
+
JGengine is a pure-TypeScript game-engine SDK for games built by AI coding agents. Its skills teach the agent how to use the SDK. When a user says “make X with JGengine” or “make X with jgengine.com,” treat that as sufficient intake. If the skills are not installed, run `npx jgengine skills`; then use this skill and build on the SDK. Do not make the user discover or name skills.
|
|
65
|
+
|
|
66
|
+
## Intake
|
|
67
|
+
|
|
68
|
+
State the reading as a short numbered list that is easy to correct, then proceed unless the user changes it. Prefer concrete values and actions over prose:
|
|
69
|
+
|
|
70
|
+
1. **POV:** first-person
|
|
71
|
+
2. **World:** custom 3D wasteland with three settlements
|
|
72
|
+
3. **Core loop:** get quests by talking to people → defeat enemies → return to redeem quests
|
|
73
|
+
4. **Interaction:** collect ground items by walking over them; interact with people and doors at close range
|
|
74
|
+
5. **Combat:** ranged weapons, damage, death, loot
|
|
75
|
+
6. **Progression:** inventory, currency, quest rewards, upgrades
|
|
76
|
+
7. **Players:** single-player, or name the multiplayer topology and synchronized systems
|
|
77
|
+
8. **UI:** visible controls, objective tracker, health, inventory feedback
|
|
78
|
+
9. **Art direction:** one aesthetic, palette, asset family, and UI voice
|
|
79
|
+
10. **Done looks like:** one observable end-to-end play scenario
|
|
80
|
+
|
|
81
|
+
Keep this compact—roughly one line per item. It is a build map, not a large specification or an approval gate. Infer conventional details from the named game or genre. Ask only when two plausible readings would fundamentally change the game.
|
|
82
|
+
|
|
83
|
+
## Route selectively
|
|
84
|
+
|
|
85
|
+
This skill is the foundation for every task (packages, project shape, defineGame, context, catalogs). After intake, also read only the domain skills the work needs:
|
|
86
|
+
|
|
87
|
+
| Need | Read |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| Terrain, scenes, camera, movement, physics, maps, sensors | `jgengine-world` |
|
|
90
|
+
| Seeded generation, terrain/environment generation, grids, buildings, simulation | `jgengine-procedural` |
|
|
91
|
+
| Damage, effects, weapons, targeting, projectiles, loot, death | `jgengine-combat` |
|
|
92
|
+
| Items, quests, dialogue, economy, crafting, objectives, turns, social systems | `jgengine-gameplay` |
|
|
93
|
+
| Networking adapters, authority, rooms/topology, persistence/backend seams | `jgengine-multiplayer` |
|
|
94
|
+
| React HUD, shell affordances, controls, feedback, accessibility | `jgengine-ui` |
|
|
95
|
+
| Models, sprites, textures, audio, catalogs, attribution | `jgengine-assets` |
|
|
96
|
+
| Proof and screenshots | `jgengine-verify` after implementation |
|
|
97
|
+
|
|
98
|
+
Do not read every domain by default. Build through documented engine surfaces; do not infer APIs from gallery games. Inspect engine source only when a documented surface appears wrong or a missing primitive blocks the work.
|
|
99
|
+
|
|
100
|
+
## Build behavior
|
|
101
|
+
|
|
102
|
+
Scaffold with `npx jgengine create game-name --name "Game Name"` when needed. Build the requested game continuously from the intake, keeping systems end-to-end rather than leaving registered-but-unusable pieces. Use real assets and visible feedback early. Verify at completion with `jgengine-verify`.
|
|
103
|
+
|
|
104
|
+
Cartridge-shaped games (declarative config, engine-owned loop): see [reference-cartridge.md](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine/reference-cartridge.md).
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
The engine ships **verbs and primitives**; your game ships **nouns** (catalogs) and thin handlers. This skill is that foundation plus intake. Use domain skills only when needed; use `jgengine-verify` afterward. UI guidance lives in [`../jgengine-ui/reference.md`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-ui/reference.md); assets live in `jgengine-assets`.
|
|
109
|
+
|
|
110
|
+
Load detailed references only for the selected domain: [`jgengine-combat`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-combat/reference.md), [`jgengine-world`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-world/reference.md), [`jgengine-multiplayer`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-multiplayer/reference.md), and [`jgengine-ui`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-ui/reference.md). Each domain skill explains when its reference is needed.
|
|
111
|
+
|
|
112
|
+
## Packages
|
|
113
|
+
|
|
114
|
+
All published on npm, source at [github.com/Noisemaker111/jgengine](https://github.com/Noisemaker111/jgengine) (AGPL-3.0):
|
|
115
|
+
|
|
116
|
+
| Package | Role | May import |
|
|
117
|
+
|---------|------|------------|
|
|
118
|
+
| `@jgengine/core` | Everything below: defineGame, GameContext, scene, combat, game systems, movement, input, world features, runtime/transport contracts | nothing platform-specific — no React, Convex, three.js, browser |
|
|
119
|
+
| `@jgengine/react` | `GameProvider`, hooks, headless UI primitives | react + core |
|
|
120
|
+
| `@jgengine/shell` | `GamePlayerShell` — R3F canvas, camera rig library (orbit, first-person, top-down/iso, RTS, over-the-shoulder, lock-on, chase, cinematic + shake channel), input tracking, HUD mounting, `GameUiPreview`, demo game; you supply a `GameRegistry` | react + three + core |
|
|
121
|
+
| `@jgengine/ws` | Browser-safe `GameBackend` over a pluggable string-pipe transport (WebSocket/socket.io/WebRTC/loopback), protocol codec, HTTP reads, browser-safe authoritative host + router (`host`/`hostRouter`), and P2P (`peer`) | core |
|
|
122
|
+
| `@jgengine/node` | Node process bindings over `@jgengine/ws`'s host/router: `ws`-package server, socket.io server attach, file persistence (re-exports `createGameHost`/`memoryPersistence` unchanged) | node + ws + core |
|
|
123
|
+
| `@jgengine/sql` | `HostPersistence` on Postgres (structural pool, no hard `pg` dep) | core |
|
|
124
|
+
| `@jgengine/convex` | The Convex **adapter** behind the `GameBackend` seam | react + convex + core |
|
|
125
|
+
|
|
126
|
+
Import by deep path: `@jgengine/core/<domain>/<file>` (e.g. `@jgengine/core/runtime/gameContext`).
|
|
127
|
+
|
|
128
|
+
## Hit a snag? File an issue
|
|
129
|
+
|
|
130
|
+
Any hiccup with JGengine — a doc that's wrong, a missing primitive, a rough edge, or a feature/improvement idea — file it fast at [github.com/Noisemaker111/jgengine/issues](https://github.com/Noisemaker111/jgengine/issues). A 30-second issue (what you were building, the glue it forced, the API you wanted) is worth more than a silent workaround — that's the fastest way gaps and doc errors get closed. Don't reverse-engineer around a broken doc in silence; report it.
|
|
131
|
+
|
|
132
|
+
## Upgrading? Read the changelog
|
|
133
|
+
|
|
134
|
+
All eight packages version in lockstep. When you bump (e.g. `0.6` → `0.7`) to pick up new capabilities, read [`CHANGELOG.md`](https://github.com/Noisemaker111/jgengine/blob/main/CHANGELOG.md) — each release leads with a **Migrate** block listing the concrete steps to move a game onto the new APIs. It ships inside every package too (`node_modules/@jgengine/core/CHANGELOG.md`), and as typed values: `import { VERSION, CHANGELOG } from "@jgengine/core/meta/changelog"` to diff your installed version against the latest programmatically.
|
|
135
|
+
|
|
136
|
+
## Concept → Type Reference
|
|
137
|
+
|
|
138
|
+
Exact import paths and export names — **do not invent paths**; every row below resolves to a real file under `packages/core/src`. Import the deep path form `@jgengine/core/<path>`.
|
|
139
|
+
|
|
140
|
+
| Concept | Import path (`@jgengine/core/…`) | Export(s) |
|
|
141
|
+
|---------|----------------------------------|-----------|
|
|
142
|
+
| Game boot | `game/defineGame` | `defineGame`, `GameDefinition`, `GameLoop`, `InventoryDeclaration`, `PhysicsConfig`, `GameServerConfig`, `TimeConfig` |
|
|
143
|
+
| Simulation clock | `time/simClock` | `createSimClock`, `SimClock`, `TimeConfig`, `ClockSnapshot`, `CalendarTime` |
|
|
144
|
+
| Runner contract | `game/playableGame` | `PlayableGame`, `GameCameraConfig`, `CameraRigKind`, `CameraProjection`, `SideScrollCameraConfig`, `TopDownCameraConfig`, `RtsCameraConfig`, `ShoulderCameraConfig`, `LockOnCameraConfig`, `ChaseCameraConfig`, `ObserverCameraConfig`, `CameraShakeConfig`, `CinematicCameraConfig`, `CameraKeyframe`, `EntitySpriteConfig` |
|
|
145
|
+
| Runtime ctx | `runtime/gameContext` | `createGameContext`, `GameContext`, `GameContextContent`, `GameContextItemEntry`, `GameContextEntityEntry`, `GameContextObjectEntry`, `CatalogEntityRole` |
|
|
146
|
+
| Behaviour lifecycle | `behaviour/behaviour` | `Behaviour` (`onAwake`→`onEnable`→`onStart`→`onUpdate(dt)`→`onDisable`→`onDestroy`), `BehaviourModule`, `createBehaviourWorld`, `BehaviourWorld`, `JGEngineRegister`, `RegisterField`, `BehaviourModules` — Unity-style lifecycle over an id-keyed node tree (`setActive` cascade, lazy update dispatch); key nodes by entity instance ids. Games augment `JGEngineRegister` via `declare module "@jgengine/core/behaviour/behaviour"` for typed `world.modules`. Three.js binding: `Object3DBehaviour`, `attachObject3D`, `useBehaviourWorld` from `@jgengine/shell/behaviour` |
|
|
147
|
+
| Reactive keyed store | `store/observableKeyedStore` | `createObservableKeyedStore`, `ObservableKeyedStore` — backs `ctx.game.store` |
|
|
148
|
+
| Scene instance role | `scene/entityStore` | `EntityRole`, `SceneEntity`, `SpawnOptions`, `EntityPose` |
|
|
149
|
+
| Object spatial queries | `scene/objectQuery` | `raycastObjects`, `raycastObjectsAll`, `ObjectRaycastInput`, `ObjectRaycastHit` — backs `ctx.scene.object.raycast`/`raycastAll` |
|
|
150
|
+
| Runtime paint layer | `scene/paintLayer` | `createPaintLayer`, `PaintLayer`, `PaintStroke` — backs `ctx.scene.entity.paint` |
|
|
151
|
+
| Possession | `scene/possession` | `createPossession`, `Possession`, `PossessionDeps`, `PossessionSwappedEvent` |
|
|
152
|
+
| Form / shapeshift | `scene/form` | `createForms`, `Forms`, `FormDef`, `FormsDeps`, `FormChangedEvent` |
|
|
153
|
+
| Multiplayer adapters | `runtime/adapter` | `offline`, `ws`, `convex`, `socketIo`, `p2p`, `lan`, `fly`, `servers`, `MultiplayerTopology`, `ServersPoolConfig` |
|
|
154
|
+
| Loot | `game/lootTable` | `lootTable`, `LootTableDef`, `LootEntry`, `Drop` |
|
|
155
|
+
| Dropped-item entity | `game/worldItem` | `WORLD_ITEM_ENTITY_NAME`, `WorldItemRecord`, `WorldItemSpawnInput`, `createWorldItemStore`, `resolveDeathDrops`, `scatterOffset`, `scatterPosition`, `selectNearestWorldItem`, `resolveWorldItemPresentation`, `RarityStyle`, `WorldItemPresentation`, `DEFAULT_RARITY`, `DEFAULT_PICKUP_RADIUS`, `DEFAULT_SCATTER` |
|
|
156
|
+
| Loot filter | `game/lootFilter` | `lootFilter`, `evaluateLootFilter`, `LootFilterRule`, `LootFilterCondition`, `LootFilterItem`, `LootFilterOverride` |
|
|
157
|
+
| Loadout | `game/loadout` | `LoadoutDef`, `LoadoutItemEntry`, `Loadouts` |
|
|
158
|
+
| Cosmetic loadout | `game/cosmetics` | `createCosmetics`, `Cosmetics`, `CosmeticLoadoutDef` |
|
|
159
|
+
| Quest | `game/quest` | `QuestDef`, `QuestRewards`, `QuestObjective`, `QuestJournal` |
|
|
160
|
+
| World features | `world/features` | `WorldFeature`, `biomes`, `voxel`, `plots`, `tilemap`, `flat`, `environment`, `terrain`, `rain`, `snow`, `grass`, `ocean`, `building` |
|
|
161
|
+
| Voxel field | `world/voxelField` | `createVoxelField`, `VoxelField`, `VoxelCell`, `VoxelHit`, `VoxelBounds`, `VoxelFieldSummary`, `VoxelFace`, `VOXEL_FACES`, `VOXEL_FACE_NORMALS` — a chunked block lattice, distinct from the `voxel()` `WorldFeature` descriptor |
|
|
162
|
+
| Terrain field | `world/terrain` | `TerrainField`, `noiseField`, `resolveTerrainField`, `rollingField`, `fractalNoise`, `valueNoise`, `withNormal`, `arenaField`, `flatField`, `resolveGroundStep`, `snapToGround`, `snapEntityToGround`, `resolveTerrainPalette`, `TERRAIN_MATERIAL_PALETTES` |
|
|
163
|
+
| Seeded RNG | `random/rng` | `seededRng`, `seededStreams` |
|
|
164
|
+
| Seed share link | `random/seedLink` | `withSeedParam`, `seedFromUrl`, `seedFromSearch`, `dailySeed`, `DEFAULT_SEED_PARAM` — encode/decode a world seed to/from a shareable URL query param; `dailySeed` is the UTC daily-run seed |
|
|
165
|
+
| Name generator | `random/nameGen` | `createNameGenerator`, `pickFrom`, `fillTemplate`, `NameGenerator`, `NameGeneratorOptions`, `SyllableBank` |
|
|
166
|
+
| Regions | `world/regions` | `createRegionField`, `isRegionField`, `RegionDef`, `RegionField`, `RegionSample` |
|
|
167
|
+
| Wind field | `world/wind` | `windField`, `WindField`, `WindFieldConfig`, `WindVector` |
|
|
168
|
+
| Water surface | `world/water` | `waterSurface`, `waterSurfaceFromDescriptor`, `synthesizeWaves`, `WaterSurface`, `GerstnerWave` |
|
|
169
|
+
| Scatter | `world/scatter` | `scatter`, `scatterAabb`, `ScatterConfig`, `ScatterPoint` |
|
|
170
|
+
| Content scatter | `world/scatterItems` | `scatterItems`, `pickWeighted`, `ScatterLayer`, `ScatterInstance` |
|
|
171
|
+
| Building generator | `world/buildings` | `generateBuilding`, `generateBuildingDistrict`, `createBuildingGrid`, `GeneratedBuilding` |
|
|
172
|
+
| Building index | `world/buildingIndex` | `buildingIndex`, `BuildingIndex`, `BuildingHit` |
|
|
173
|
+
| Scene summary | `world/environmentSummary` | `summarizeEnvironment`, `resolveStructureBuildings`, `EnvironmentSummary` |
|
|
174
|
+
| Map markers | `world/markers` | `createMarkerSet`, `MarkerSet`, `MapMarker`, `MarkerInput`, `MarkerKindStyle`, `DEFAULT_MARKER_KINDS`, `markerKindStyle` |
|
|
175
|
+
| Fog of war | `world/fog` | `createFogField`, `FogField`, `FogConfig`, `FogBounds`, `FogCells` |
|
|
176
|
+
| Minimap math | `world/minimap` | `projectToMinimap`, `clampToMinimapEdge`, `compassBearing`, `headingToBearing`, `bearingToCardinal`, `relativeBearing`, `MinimapView` |
|
|
177
|
+
| Ping | `game/ping` | `createPingSystem`, `classifyPing`, `PingSystem`, `PingPayload`, `PingCategory`, `PingCategoryDef`, `DEFAULT_PING_CATEGORIES`, `PING_FEED_ACTION` |
|
|
178
|
+
| Proximity prompt | `interaction/proximityPrompt` | `proximityPrompt`, `ProximityPrompt`, `ProximityPromptDisplay`, `keybind`, `gauge`, `label`, `command` |
|
|
179
|
+
| Skill-check minigame | `interaction/skillCheck` | `evaluateSkillCheck`, `skillCheckMarkerPosition`, `skillCheckZoneAt`, `SkillCheckConfig`, `SkillCheckZone`, `SkillCheckResult` |
|
|
180
|
+
| QTE sequencer | `interaction/qte` | `evaluateQteSequence`, `pendingQteStep`, `qteProgress`, `QteStep`, `QteInputEvent`, `QteOutcome` |
|
|
181
|
+
| Item use | `item/use` | `createItemUse`, `ItemUseHandler`, `ItemUseInput`, `ItemUseResult`, `ItemUseRejection` |
|
|
182
|
+
| Durability | `item/durability` | `createDurability`, `wear`, `repairQuote`, `isDisabled`, `createDurabilityTracker`, `DurabilitySpec`, `DurabilityState`, `RepairSpec`, `RepairQuote` |
|
|
183
|
+
| Affix roller | `item/affix` | `createAffixRoller`, `seededRng`, `AffixRoller`, `RollerConfig`, `AffixPool`, `AffixDef`, `RarityTier`, `ItemBaseDef`, `RolledItem`, `RolledAffix` |
|
|
184
|
+
| Modular item | `item/modularItem` | `createModularItem`, `install`, `computeEffectiveStats`, `missingRequiredSlots`, `ModularItemDef`, `MountSlotDef`, `PartDef`, `InstalledPart` |
|
|
185
|
+
| Storage tier | `inventory/storageTier` | `partitionOnDeath`, `createDeliveryQueue`, `insureLost`, `resolveConsolation`, `tierOf`, `StorageTier`, `ContainerSnapshot`, `DeathPartition`, `DeliveryQueue`, `InsurancePolicy`, `ConsolationPolicy` |
|
|
186
|
+
| Contested channel | `session/contestedChannel` | `createContestedChannel`, `ContestedChannel`, `ContestedChannelConfig`, `ContestedEvent`, `ContestedPhase`, `ContestedSnapshot` |
|
|
187
|
+
| Round state | `session/roundState` | `createRoundState`, `lossBonusFor`, `RoundState`, `RoundConfig`, `RoundPhase`, `RoundTeam`, `RoundEvent`, `RoundEconomy`, `RoundSnapshot`, `LossBonusRule` |
|
|
188
|
+
| Shrinking ring | `session/ring` | `createRing`, `ringSampleAt`, `Ring`, `RingConfig`, `RingPhase`, `RingSample`, `RingHit`, `RingPoint` |
|
|
189
|
+
| Extraction session | `session/extraction` | `createRaidSession`, `RaidSession`, `RaidSessionConfig`, `ExtractPoint`, `ExtractionResult`, `DeathResult`, `RaidStatus` |
|
|
190
|
+
| Role assignment | `session/roles` | `assignRoles`, `RoleSpec` |
|
|
191
|
+
| Downed / revive | `combat/downed` | `createDownedState`, `DownedState`, `DownedConfig`, `DownedPhase`, `DownedEntry`, `DownedEvent` |
|
|
192
|
+
| Persistence scopes | `runtime/persistenceScope` | `partitionScopes`, `resetRun`, `mergeScopes`, `clearRunFields`, `applyRunReset`, `planScenarioReset`, `ScopeSchema`, `ScenarioReset`, `PersistenceScope` |
|
|
193
|
+
| Inventory | `inventory/inventoryModel` | `InventoryLayout`, `InventorySet`, `ItemTraits` |
|
|
194
|
+
| Progression | `game/progression` | `curve`, `evalCurve`, `leveling`, `Curve`, `LevelingTrack`, `LevelProgress` |
|
|
195
|
+
| Talent tree | `game/talents` | `createTalentTree`, `TalentTree`, `TalentTreeConfig`, `TalentNodeDef`, `TalentRequirement`, `TalentAllocateResult`, `ResolvedTalents`, `TalentSnapshot` — point spends gated by prereqs + points-in-branch, resolved once into a cached flat `StatModifierSet` + ability grants |
|
|
196
|
+
| Inventory slots | `inventory/slotModel` | `createSlots`, `placeAt`, `removeAt`, `moveSlot`, `firstEmpty`, `compactSlots`, `Slot`, `SlotGrid` |
|
|
197
|
+
| Shaped inventory | `inventory/shapedGrid` | `createShapedGrid`, `placeShaped`, `moveShaped`, `removeShaped`, `canPlace`, `rotateFootprint`, `occupiedCells`, `gridAdjacencyQuery`, `cellFromPoint`, `ShapedGrid`, `Footprint`, `Placement`, `Rotation` |
|
|
198
|
+
| Card piles | `cards/cardPile` | `createCardPile`, `createCardPileState`, `draw`, `moveCards`, `shuffleZone`, `pileRng`, `CardPile`, `CardPileState`, `CardPileConfig` |
|
|
199
|
+
| Modifier pipeline | `cards/modifierPipeline` | `createModifierPipeline`, `runPipeline`, `Modifier`, `TraceStep`, `PipelineResult` |
|
|
200
|
+
| Lane board | `board/laneBoard` | `createLaneBoard`, `laneAggregate`, `laneOutcome`, `boardTotals`, `lanesWon`, `LaneBoard`, `LaneRule`, `LaneBoardConfig` |
|
|
201
|
+
| Timeline board | `board/timelineBoard` | `createTimelineBoard`, `tickTimeline`, `TimelineBoard`, `TimelineSlot`, `TimelineFire` |
|
|
202
|
+
| World geometry | `world/geometry` | `footprintAabb`, `aabbOverlap`, `snapToGrid`, `resolveMove`, `Aabb`, `Footprint` |
|
|
203
|
+
| Placement | `world/placement` | `validatePlacement`, `footprintObstacle`, `PlacementRules`, `PlacementResult` |
|
|
204
|
+
| Placement ghost | `world/placementController` | `createPlacementController`, `PlacementController`, `PlacementPreview`, `PlacementCommit`, `SnapMode`, `quarterTurnsToRotationY` |
|
|
205
|
+
| Connector sockets | `world/connectors` | `snapToNearest`, `socketsCompatible`, `worldSockets`, `socketWorldPosition`, `ConnectorSocket`, `ConnectorPieceDef`, `PlacedPiece`, `SnapResult` |
|
|
206
|
+
| Structural support | `world/support` | `solveSupport`, `toDebrisBodies`, `SupportPiece`, `SupportLink`, `SupportResult` |
|
|
207
|
+
| Wall/roof authoring | `world/walls` | `createWallDrawTool`, `footprintFromWalls`, `autoRoof`, `wallSegments`, `createSurfacePaint`, `WallDrawTool`, `RoofPlan`, `EnclosedFootprint` |
|
|
208
|
+
| Placed structures | `world/placedStructureStore` | `createPlacedStructureStore`, `PlacedStructure`, `PlacedStructureStore`, `PlacedStructureSnapshot` |
|
|
209
|
+
| Terraform | `world/terraform` | `createEditableTerrain`, `createTerraformBrush`, `brushWeight`, `EditableTerrain`, `TerraformBrush`, `TerraformEdit`, `TerraformMode` |
|
|
210
|
+
| Build permissions | `world/buildPermissions` | `createPlotPermissions`, `createContributionPool`, `PlotPermissions`, `ContributionPool`, `BuildRole`, `ContributionGoal` |
|
|
211
|
+
| Interiors | `world/interiors` | `createInteriors`, `Interior`, `Exterior`, `SpaceRef` |
|
|
212
|
+
| Game clock | `time/gameClock` | `getScaledElapsedMs`, `computeGameDay`, `SECONDS_PER_GAME_DAY` |
|
|
213
|
+
| Idle / offline catch-up | `time/idleProgress` | `idleWindow`, `linearCatchUp`, `exponentialCatchUp`, `steppedCatchUp`, `IdleWindow`, `IdleWindowConfig`, `SteppedCatchUpResult` — elapsed-real-time production/growth/decay for a game reopened after being closed |
|
|
214
|
+
| Scene behaviors | `scene/behaviors` | `wander`, `patrol`, `promptable`, `talkable`, `player` |
|
|
215
|
+
| Capture check | `scene/captureCheck` | `captureChance`, `rollCapture`, `CaptureCheckInput` |
|
|
216
|
+
| Owned roster | `scene/roster` | `createRoster`, `Roster`, `RosterEntry`, `RosterCaptureOptions` |
|
|
217
|
+
| Economy wallet | `economy/wallet` | `createEmptyWallet`, `balance`, `grant`, `charge`, `canAfford`, `chargeAll` |
|
|
218
|
+
| Tech tree | `economy/techTree` | `createTechTree`, `TechTree`, `TechNodeDef`, `canUnlockTech`, `availableTech`, `unlockedRecipes`, `grantTech`, `techPrerequisitesMet` |
|
|
219
|
+
| Recipe graph | `crafting/recipe` | `createRecipeGraph`, `RecipeGraph`, `RecipeDef`, `RecipeItem`, `canCraft`, `craft`, `missingInputs`, `stationSatisfied`, `craftSeconds` |
|
|
220
|
+
| Production building | `crafting/production` | `productionBuilding`, `ProductionBuildingDef`, `createProductionState`, `tickProduction`, `feedProduction`, `drainOutput`, `advanceTransport`, `resolvePowerGrid` |
|
|
221
|
+
| Crop tile / farming | `crafting/crop` | `createCropField`, `CropField`, `CropDef`, `CropTileState`, `tillTile`, `plantCrop`, `waterTile`, `advanceCropDay`, `harvestCrop`, `applyToolToTiles`, `squarePattern`, `diamondPattern`, `createDayTicker` |
|
|
222
|
+
| Skill-check roll | `stats/rollCheck` | `rollCheck`, `CheckInput`, `CheckResult`, `CheckAdvantage` |
|
|
223
|
+
| Input bindings (full) | `input/actionBindings` | `hotbarSlotBindings`, `actionLabel`, `bindingLabel`, `resolveActionCommand`, `bindingMatches`, `createActionStateTracker` |
|
|
224
|
+
| Touch controls | `input/touchScheme` | `deriveTouchScheme`, `touchCode`, `touchActionLabel`, `withTouchCodes`, `TouchControlsConfig`, `TouchGestureBindings`, `TouchDragBinding`, `TouchButtonSpec`, `TouchScheme`, `TouchJoystick`, `TouchButton` |
|
|
225
|
+
| Pointer hit | `input/pointer` | `PointerHit`, `PointerButton`, `aimToPoint`, `moveTargetFromHit`, `groundOf`, `PointerVec3` |
|
|
226
|
+
| Navmesh + A* | `nav/navGrid` | `createNavGrid`, `findPath`, `smoothPath`, `NavGrid`, `NavGridConfig`, `NavPoint`, `FindPathOptions` |
|
|
227
|
+
| Path follow | `nav/pathFollow` | `createPathFollow`, `advancePathFollow`, `pathFromNav`, `PathFollowConfig`, `PathFollowState`, `Waypoint` |
|
|
228
|
+
| Nav-grid movement constraint | `nav/navConstrain` | `constrainToNavGrid`, `NavConstrainProposed`, `NavConstrainEntity`, `NavConstrainOptions` — a standalone walkable-pass-through + wall-slide helper; adapt its `(proposed, entity)` shape to `PlayerMovementConfig.beforeCommit`'s `(frame) => [x,y,z]` with a small closure |
|
|
229
|
+
| Selection set | `scene/selection` | `createSelectionSet`, `SelectionSet`, `screenRect`, `selectWithinRect`, `rectContainsPoint`, `isMarquee`, `ScreenRect` |
|
|
230
|
+
| Context menu | `interaction/contextMenu` | `contextVerb`, `buildContextMenu`, `contextVerbInput`, `ContextVerb`, `ContextMenu` |
|
|
231
|
+
| Shared / group wallet | `economy/sharedWallet` | `createWalletBook`, `WalletBook`, `WalletScope`, `userScope`, `groupScope`, `balanceIn`, `grantTo`, `chargeFrom`, `contributionOf`, `contributorsOf` |
|
|
232
|
+
| Analog axis input | `input/axisInput` | `AxisInput`, `AxisChannel`, `AxisBindingMap`, `DRIVE_AXIS_BINDINGS`, `clampAxis`, `rampToward`, `NEUTRAL_AXIS` |
|
|
233
|
+
| Raw control polling | `runtime/inputSnapshot` | `createInputSnapshot`, `InputSnapshot` — backs `ctx.input` |
|
|
234
|
+
| Physics world | `physics/physicsWorld` | `PhysicsWorld`, `PhysicsWorldConfig`, `PhysicsBounds`, `PhysicsStats`, `AddBodyOptions` (`{ shape: "box", halfExtents }` \| `{ shape: "sphere", radius }`), `JointOptions`, `JointKind`, `CollisionEvent` |
|
|
235
|
+
| Ballistic collision sweep | `physics/ballisticSweep` | `createBallisticSweep`, `BallisticSweep`, `BallisticSweepHit`, `BallisticSweepOptions` |
|
|
236
|
+
| Tweening / easing | `anim/easing` | `Easing`, `lerp`, `clamp01`, `smoothstep`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeOutBack`, `easeOutElastic`, `tween`, `timedProgress` |
|
|
237
|
+
| Async data source | `data/dataSource` | `createDataSource`, `DataSource`, `DataSourceState`, `DataSourceStatus`, `DataSourceOptions`, `DataSourceClock`, `RefreshOptions` |
|
|
238
|
+
| JSON fetch | `data/fetchJson` | `fetchJson`, `FetchJsonOptions`, `FetchImpl`, `HttpStatusError`, `JsonParseError` |
|
|
239
|
+
| JSON data source | `data/jsonDataSource` | `createJsonDataSource`, `JsonDataSourceOptions` |
|
|
240
|
+
| Dev proxy routing | `data/devProxy` | `proxiedUrl`, `parseDevProxyTable`, `DevProxyTable`, `ProxiedUrlOptions`, `DEFAULT_DEV_PROXY_PREFIX` |
|
|
241
|
+
| Grid-cell world rendering | `world/gridInstances` | `resolveGridInstances`, `GridInstanceTransform` |
|
|
242
|
+
| Swarm LOD scheduler | `world/lod` | `createLodScheduler`, `LodScheduler`, `LodSchedulerConfig`, `LodBand` — distance→band index for render detail, `step(id, distance, dt)` throttles per-entity updates by band interval (staggered, accumulates skipped time); pairs with `@jgengine/shell/world/SpriteBatch` for 1000+ entity swarms |
|
|
243
|
+
| Turn loop | `turn/turnLoop` | `createTurnLoop`, `TurnLoop`, `TurnLoopConfig`, `TurnState`, `PoolConfig`, `PoolState`, `TurnLoopSnapshot` |
|
|
244
|
+
| Declared-action intent board | `turn/intent` | `createIntentBoard`, `IntentBoard`, `DeclaredIntent` — `declare(participantId, intent)`, `peek`, `all`, `consume`, `clear` |
|
|
245
|
+
| Commit modes | `turn/commit` | `createCommitController`, `CommitController`, `CommitMode`, `CommitOutcome`, `SubmittedAction` |
|
|
246
|
+
| Intent board | `turn/intent` | `createIntentBoard`, `IntentBoard`, `DeclaredIntent` |
|
|
247
|
+
| Tactical grid | `tactics/tacticalGrid` | `createTacticalGrid`, `TacticalGrid`, `TacticalGridConfig`, `Tile`, `ReachableTile`, `PushResult`, `PushCollision` |
|
|
248
|
+
| Predictive query | `tactics/predictiveQuery` | `predictAreaEffect`, `predictArcEffect`, `predictTiles`, `PredictiveDeps`, `PredictedTarget` |
|
|
249
|
+
| Sim snapshot | `tactics/snapshot` | `createSnapshotStore`, `SnapshotStore`, `SnapshotSlice`, `Snapshot`, `deepClone` |
|
|
250
|
+
| Surfaces | `tactics/surface` | `createSurfaceLayer`, `SurfaceLayer`, `SurfaceLayerConfig`, `SurfaceKindDef`, `SurfaceReaction`, `SurfaceEvent` |
|
|
251
|
+
| Area targeting | `combat/effects` | `resolveAreaTargets`, `AreaTarget`, `AreaTargetInput` (shared AoE targeting behind `effect` + the predictive query) |
|
|
252
|
+
| Environment field | `world/envField` | `createEnvironmentField`, `EnvironmentField`, `EnvironmentSample`, `EnvironmentFieldConfig`, `OccluderRect`, `HeatSource` |
|
|
253
|
+
| Weather + fire | `world/weather` | `resolveWeather`, `WeatherState`, `WeatherModifier`, `WeatherModifierTable`, `ResolvedWeather`, `createFireGrid`, `FireGrid`, `FireCell`, `FireGridConfig` |
|
|
254
|
+
| Realm composition | `world/realm` | `composeRealm`, `RealmCard`, `RealmBase`, `ComposedRealm`, `RealmEnvironmentParams`, `SpawnTableOverride` |
|
|
255
|
+
| Decay meters | `survival/decayMeter` | `createDecayMeterSet`, `DecayMeterSet`, `DecayMeterConfig`, `MeterThreshold`, `DecayMeterState` |
|
|
256
|
+
| Status moodles | `survival/moodle` | `createMoodleStack`, `stackMoodles`, `MoodleStack`, `Moodle`, `MoodleSeverity`, `TimedMoodleInput` |
|
|
257
|
+
| Multi-region health | `survival/regionHealth` | `createMultiRegionHealth`, `MultiRegionHealth`, `HealthRegionConfig`, `AilmentConfig`, `RegionHealthState`, `AilmentInstance` |
|
|
258
|
+
| Audio contract | `audio/audioFalloff` | `computeFalloffGain`, `resolveEmitterGain`, `distance3`, `AudioFalloffConfig`, `FalloffCurve`, `SoundDef`, `AudioBusDef`, `AudioBusId` |
|
|
259
|
+
| Beat clock | `time/beatClock` | `createBeatClock`, `createBeatInputBuffer`, `nextBeatTime`, `BeatClock`, `BeatClockConfig`, `BeatSnapshot`, `BeatInputBuffer`, `BufferedAction` |
|
|
260
|
+
| Spawn director | `ai/spawnDirector` | `createSpawnDirectorState`, `advanceSpawnDirector`, `advanceWave`, `raiseAlert`, `pickSpawnPoint`, `SpawnDirectorConfig`, `WaveManifest`, `SpawnEntry`, `SpawnRequest`, `DirectorContext` |
|
|
261
|
+
| Threat table | `ai/threat` | `createThreatTable`, `ThreatTable`, `ThreatTableConfig`, `ThreatEntry`, `HighestThreatOptions` |
|
|
262
|
+
| Group-assist aggro | `ai/groupAssist` | `createAssistNetwork`, `AssistNetwork`, `AssistNetworkConfig`, `AssistMember` — propagates one member's threat gains to same-group members (optional radius + `distanceBetween` gating) so a single pull rallies the group |
|
|
263
|
+
| Job board | `ai/jobBoard` | `createJobBoard`, `JobBoard`, `JobDef`, `Job`, `JobPhase`, `WorkerState`, `JobReport`, `JobTickContext` |
|
|
264
|
+
| Crowd flow | `ai/crowd` | `computeFlowField`, `createCrowdField`, `selectPoi`, `FlowField`, `FlowFieldOptions`, `CrowdField`, `Poi`, `SelectPoiOptions` |
|
|
265
|
+
| Factions & reputation | `faction/factions`, `faction/reputation` | `createFactionGraph`, `createFactionRoster`, `FactionRelation`, `FactionDef`, `FactionGraph`, `FactionRoster`, `createReputationLedger`, `DEFAULT_REPUTATION_TIERS`, `tierForStanding`, `effectiveRelation`, `ReputationTier`, `ReputationLedger` |
|
|
266
|
+
| Physics actors | `physics/ragdoll`, `physics/carryable`, `physics/forceVolume`, `physics/spatialGrid` | `createRagdoll`, `Ragdoll`, `Carryable`, `carrySpeedMultiplier`, `ForceVolume`, `PlatformCarry`, `SpatialGrid` |
|
|
267
|
+
| Traversal (grapple/glide) | `physics/traversal` | `Grapple`, `GrappleConfig`, `Glide`, `GlideConfig` |
|
|
268
|
+
| Structural destruction | `physics/structure` | `StructureGraph`, `StructureNodeSpec`, `StructureEdgeSpec`, `StructureMaterial`, `StructureMaterialTable`, `CollapseEvent`, `DebrisConfig` |
|
|
269
|
+
| Destructible terrain | `world/carve` | `VoxelVolume`, `VoxelMaterial`, `VoxelMaterialTable`, `CarvableField`, `carvableTerrain`, `CarveOp`, `DepositOp`, `CraterOp`, `MoundOp`, `EMPTY_VOXEL` |
|
|
270
|
+
| Vehicle body | `physics/vehicleBody` | `createVehicleBody`, `VehicleBody`, `VehicleBodyConfig`, `WheelSpec`, `GripCurve`, `sampleGripCurve`, `DEFAULT_GRIP_CURVE` |
|
|
271
|
+
| Buoyant boat | `physics/buoyancy` | `createBuoyantBody`, `BuoyantBody`, `BuoyantBodyConfig` |
|
|
272
|
+
| Crash damage | `physics/damageZones` | `createDamageModel`, `DamageModel`, `DamageZoneDef`, `DamageTransition` |
|
|
273
|
+
| Mounts / rideables | `scene/mount` | `createMountController`, `MountController`, `MountKit`, `MountSeat`, `RideableConfig` |
|
|
274
|
+
| Shared-vehicle stations | `scene/stationClaim` | `createStationClaim`, `StationClaim`, `Station`, `SharedVehicleConfig`, `ClaimResult` |
|
|
275
|
+
| Lag compensation | `multiplayer/lagCompensation` | `createPositionHistory`, `PositionHistory`, `rewindTimestamp`, `resolveHitscan`, `raySphereDistance`, `HitscanRay`, `HitscanTarget` |
|
|
276
|
+
| Simultaneous commit | `multiplayer/simultaneousCommit` | `createCommitRound`, `CommitRound`, `SealedCommit`, `resolveCommits` |
|
|
277
|
+
| Combat-snapshot replay | `multiplayer/combatSnapshot` | `serializeBoard`, `cloneSnapshot`, `replayCombat`, `BoardSnapshot`, `SnapshotUnit`, `CombatRules`, `ReplayResult` |
|
|
278
|
+
| Session matchmaking | `multiplayer/matchmaking` | `browseSessions`, `findByJoinCode`, `quickMatch`, `matchesFilter`, `normalizeJoinCode`, `generateJoinCode`, `SessionListing`, `MatchFilter` |
|
|
279
|
+
| Auth identity | `multiplayer/identity` | `AuthSession`, `PlayerIdentity`, `sessionPlayer`, `resolveGuestSession` |
|
|
280
|
+
| Text chat | `game/chat`, `multiplayer/chatContract` | `createChat`, `Chat`, `ChatMessage`, `ChatChannelDef`, `whisperChannelId`, `createChatRateLimiter`, `ChatTransport`, `ChatSync`, `createLocalChatTransport` |
|
|
281
|
+
| Chat filter | `game/chatFilter` | `createChatFilter`, `normalizeChatText`, `ChatFilter`, `ChatFilterConfig`, `ChatFilterResult` — mask/reject blocked words (leet-normalized token match); wire via `ChatDeps.filter` (word lists are game data, the engine ships the mechanism) |
|
|
282
|
+
| Voice seam | `multiplayer/voiceContract` | `VoiceTransport`, `VoiceParticipant`, `VoiceRoute`, `createLocalVoiceTransport`, `createPushToTalk`, `PushToTalkMode` |
|
|
283
|
+
| Race state | `game/race` | `raceTrack`, `RaceTrack`, `createRaceState`, `RaceState`, `RaceEvent`, `RaceWinCondition`, `firstPastPost`, `topK`, `lastStanding`, `everyoneFinishes` |
|
|
284
|
+
| Reveal query | `sensor/revealQuery` | `createRevealQuery`, `RevealQuery`, `RevealQueryOptions`, `RevealHit` |
|
|
285
|
+
| Hidden-state probe | `sensor/hiddenStateProbe` | `probeHiddenState`, `probeHiddenStateAll`, `HiddenStateSource`, `HiddenStateValue`, `SensorProbeOptions`, `SensorReading` |
|
|
286
|
+
| View-frustum sensor | `sensor/frustumSensor` | `createFrustumSensor`, `projectToView`, `framingScore`, `FrustumCamera`, `FrustumTarget`, `FrustumProjection`, `FrustumSample`, `FrustumSensor`, `FramingConfig` |
|
|
287
|
+
| Recording buffer | `sensor/recordingBuffer` | `createRecordingBuffer`, `RecordingBuffer`, `RecordingFrame`, `RecordingBufferOptions` |
|
|
288
|
+
| Concealment scoring | `sensor/concealment` | `colorDistance`, `concealmentScore`, `createConcealmentSensor`, `ColorHex`, `ConcealmentTarget`, `ConcealmentSample`, `ConcealmentSensor` |
|
|
289
|
+
| Freeze violation monitor | `sensor/freezeMonitor` | `createFreezeMonitor`, `FreezeMonitor`, `FreezeSubject`, `FreezeViolation` |
|
|
290
|
+
| Animation SM | `combat/animationState` | `createAnimationState`, `AnimationState`, `AnimationClip`, `FramePhase`, `FrameRange`, `phasesAtFrame`, `activeRangeAtFrame`, `frameAtMs` |
|
|
291
|
+
| Accumulator meter | `stats/accumulatorMeter` | `createAccumulatorMeter`, `AccumulatorMeter`, `AccumulatorMeterConfig`, `MeterTier`, `MeterAddResult`, `tierAt` |
|
|
292
|
+
| Stagger / buildup | `combat/breakMeters` | `createStaggerMeter`, `createBuildupMeter`, `StaggerMeter`, `BuildupMeter`, `BuildupProc` |
|
|
293
|
+
| Attack tags | `combat/attackTags` | `attackMeta`, `AttackTag`, `AttackMeta`, `hasTag`, `isBlockable`, `isParryable`, `isDodgeable`, `counters` |
|
|
294
|
+
| Defensive window | `combat/defensiveWindow` | `createDefensiveWindow`, `resolveDefense`, `DefensiveWindowConfig`, `DefenseKind`, `DefenseOutcome`, `windowActiveAt`, `iframeActiveAt` |
|
|
295
|
+
| Combo string | `combat/comboString` | `createComboRunner`, `advanceCombo`, `ComboString`, `ComboStep`, `AdvanceComboResult` |
|
|
296
|
+
| Hit reaction | `combat/hitReaction` | `resolveHitReaction`, `HitReaction`, `HitReactionConfig`, `CameraShake`, `applyImpulse` |
|
|
297
|
+
| Telegraph | `combat/telegraph` | `pointInTelegraph`, `telegraphProgress`, `telegraphFired`, `telegraphTurnProgress`, `telegraphFiredAtTurn`, `telegraphTurnsRemaining`, `TelegraphShape`, `TelegraphConfig` |
|
|
298
|
+
| Dash / dodge | `movement/dash` | `createDashState`, `DashState`, `DashConfig`, `DashBurst`, `iframeActive`, `dashOffset` |
|
|
299
|
+
| Ability kit | `combat/abilityKit` | `createAbilityKit`, `AbilityKit`, `AbilitySlotConfig`, `AbilitySlotSnapshot`, `AbilitySlotState`, `AbilityCastType`, `AbilityCastResult`, `AbilitySlotRetune` |
|
|
300
|
+
| Resource pool | `combat/resourcePool` | `createResourcePool`, `ResourcePool`, `ResourcePoolConfig` — current/max with per-second regen/decay and spend/gain; `pool.current()` is the ability kit's `resourceAvailable` |
|
|
301
|
+
| Combo points | `combat/comboPoints` | `createComboPoints`, `ComboPoints`, `ComboPointsConfig` — discrete points accrued on action, expiring after a timeout from the last gain, spent in bulk |
|
|
302
|
+
| Event meter | `stats/eventMeter` | `createEventMeter`, `EventMeter`, `EventMeterConfig`, `EventMeterFeedResult` |
|
|
303
|
+
| Auto-target policy | `scene/autoTarget` | `selectAutoTarget`, `createAutoTargeter`, `AutoTargetPolicy`, `AutoTargeter`, `AutoTargetDeps` |
|
|
304
|
+
| Resistance matrix | `combat/resistance` | `resolveResistance`, `resistanceScale`, `ResistanceMatrix`, `ResistVerdict`, `ResistanceResult` |
|
|
305
|
+
| Run draft | `game/runDraft` | `createRunDraft`, `createRunModifierStack`, `RunDraft`, `RunModifierStack`, `RunModifierOffer` |
|
|
306
|
+
| Uniform-cell grid | `puzzle/cellGrid` | `CellGrid`, `CellRun`, `createCellGrid`, `cellAt`, `inGridBounds`, `withCell`, `withCells`, `fullRows`, `clearRows`, `collapseColumns`, `findRuns` |
|
|
307
|
+
| Falling piece | `puzzle/fallingPiece` | `FallingPiece`, `ShapeTable`, `LockDelayState`, `pieceCells`, `pieceCollides`, `mergePiece`, `dropDistance`, `gravityInterval`, `levelForLines`, `lineScore`, `createLockDelay`, `stepLockDelay` |
|
|
308
|
+
| Falling tile grid | `tactics/fallingGrid` | `createFallingGrid`, `FallingGrid`, `FallingGridConfig`, `FallingGridCell`, `FallingGridSnapshot`, `LockState`, `gravityIntervalMs`, `GravityIntervalConfig` — a generic tile-drop grid (any `TCell` payload), distinct from `puzzle/cellGrid`+`puzzle/fallingPiece`'s row-clear/shape-table pair |
|
|
309
|
+
| Spawn/respawn points | `game/spawnPoints` | `createSpawnPoints`, `SpawnPoints`, `SpawnPointPose`, `RespawnTarget` |
|
|
310
|
+
| Level sequence | `game/levelSequence` | `createLevelSequence`, `LevelSequence`, `LevelSequenceConfig`, `LevelDescriptor`, `CurrentLevel`, `LevelSequenceStatus`, `LevelSequenceProgress` |
|
|
311
|
+
| Devtools overlay + tunables | `devtools/devtools` | `devtools`, `createDevtools`, `tunable`, `snapshotDevtools`, `instrumentLatency`, `Tunable`, `TunableOptions`, `TunableAccessor`, `DevtoolsControl`, `DiscoveredEntry`, `DevtoolsOverrides`, `DevtoolsSnapshot` |
|
|
312
|
+
| Tunable auto-discovery | `devtools/transformTunables` | `transformTunableExports`, `tunableModuleTable`, `tunableDiscoveryPlugin`, `TunableTransformResult` |
|
|
313
|
+
|
|
314
|
+
## Getting started (new project)
|
|
315
|
+
|
|
316
|
+
Fastest path — the `jgengine` CLI scaffolds the entire canonical shape below (harness, skeleton, stub game, verify test, AGENTS.md) as a booting game:
|
|
317
|
+
|
|
318
|
+
```sh
|
|
319
|
+
npx jgengine create my-game # then: cd my-game && bun dev
|
|
320
|
+
npx jgengine doctor # later, if the setup drifts (version skew, unstyled HUD, shape strays)
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Manual equivalent:
|
|
324
|
+
|
|
325
|
+
```sh
|
|
326
|
+
bun add @jgengine/core @jgengine/react @jgengine/shell react react-dom three three-stdlib @react-three/fiber @react-three/drei
|
|
327
|
+
bun add -d @tailwindcss/vite tailwindcss # HUD styling (Vite + Tailwind v4)
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
A single game's standalone entry mounts `GameHost` (`@jgengine/shell/GameHost`) over the `game` your `game.config.ts` exports. The full standalone harness is four small files plus a script — this is exactly the shape every `Games/*` game ships, so `bun dev` plays it on its own with no host app:
|
|
331
|
+
|
|
332
|
+
```html
|
|
333
|
+
<!-- index.html -->
|
|
334
|
+
<!doctype html>
|
|
335
|
+
<html lang="en">
|
|
336
|
+
<head>
|
|
337
|
+
<meta charset="UTF-8" />
|
|
338
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
339
|
+
<title>My Game</title>
|
|
340
|
+
</head>
|
|
341
|
+
<body>
|
|
342
|
+
<div id="root"></div>
|
|
343
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
344
|
+
</body>
|
|
345
|
+
</html>
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
// vite.config.ts — monorepo-aware: the alias branch only fires when this folder
|
|
350
|
+
// sits inside the engine repo checkout; copied anywhere else, @jgengine/* resolves
|
|
351
|
+
// from npm dist and the alias list is empty
|
|
352
|
+
import { existsSync } from "node:fs";
|
|
353
|
+
import { fileURLToPath } from "node:url";
|
|
354
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
355
|
+
import react from "@vitejs/plugin-react";
|
|
356
|
+
import { defineConfig } from "vite";
|
|
357
|
+
|
|
358
|
+
const engineSrc = (pkg: string) => fileURLToPath(new URL(`../../packages/${pkg}/src`, import.meta.url));
|
|
359
|
+
|
|
360
|
+
export default defineConfig({
|
|
361
|
+
plugins: [react(), tailwindcss()],
|
|
362
|
+
resolve: {
|
|
363
|
+
alias: existsSync(engineSrc("core"))
|
|
364
|
+
? [
|
|
365
|
+
{ find: /^@jgengine\/core\/(.*)$/, replacement: `${engineSrc("core")}/$1` },
|
|
366
|
+
{ find: /^@jgengine\/react\/(.*)$/, replacement: `${engineSrc("react")}/$1` },
|
|
367
|
+
{ find: /^@jgengine\/ws\/(.*)$/, replacement: `${engineSrc("ws")}/$1` },
|
|
368
|
+
{ find: /^@jgengine\/shell\/(.*)$/, replacement: `${engineSrc("shell")}/$1` },
|
|
369
|
+
{ find: /^@jgengine\/assets$/, replacement: `${engineSrc("assets")}/index.ts` },
|
|
370
|
+
{ find: /^@jgengine\/assets\/(.*)$/, replacement: `${engineSrc("assets")}/$1` },
|
|
371
|
+
]
|
|
372
|
+
: [],
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
```css
|
|
378
|
+
/* src/index.css */
|
|
379
|
+
@import "tailwindcss";
|
|
380
|
+
@source "../node_modules/@jgengine/react/dist";
|
|
381
|
+
@source "../node_modules/@jgengine/shell/dist";
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Inside the engine repo the two `@source` lines point at `../../../packages/react/src` and `../../../packages/shell/src` instead (see any `Games/*/src/index.css`) — same file, different `@source` targets depending on where dist lives.
|
|
385
|
+
|
|
386
|
+
```tsx
|
|
387
|
+
// main.tsx
|
|
388
|
+
import "./index.css";
|
|
389
|
+
|
|
390
|
+
import { createRoot } from "react-dom/client";
|
|
391
|
+
import { GameHost } from "@jgengine/shell/GameHost";
|
|
392
|
+
import { game } from "./game.config";
|
|
393
|
+
|
|
394
|
+
const root = document.getElementById("root");
|
|
395
|
+
if (root === null) throw new Error("main: missing #root mount element");
|
|
396
|
+
createRoot(root).render(<GameHost playable={game} />);
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Add `"dev": "vite"` to `package.json`'s `scripts` — `bun dev` then launches the game standalone.
|
|
400
|
+
|
|
401
|
+
`GameHost` resolves multiplayer itself from `game.multiplayer` (falling back to offline when the adapter can't resolve, with a console warning) — pass `multiplayer` (a prebuilt `ShellMultiplayer | null`, used as-is with no resolution attempted) or `resolveMultiplayer` (`(args) => ShellMultiplayer | null`, tried before the built-in resolver, falling back to it on `null`) only when the host app needs to supply its own session, e.g. trying several transports in sequence.
|
|
402
|
+
|
|
403
|
+
A multi-game host (a launcher, a dev registry) wires `GamePlayer` over a `GameRegistry`:
|
|
404
|
+
|
|
405
|
+
```tsx
|
|
406
|
+
import { GamePlayer } from "@jgengine/shell/GamePlayer";
|
|
407
|
+
import type { GameRegistry } from "@jgengine/shell/registry";
|
|
408
|
+
|
|
409
|
+
const games: GameRegistry = {
|
|
410
|
+
"my-game": () => import("./my-game").then((m) => m.game),
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
function App() {
|
|
414
|
+
return <GamePlayer gameId="my-game" registry={games} loading={<p>Loading…</p>} />;
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
`GamePlayer({ gameId, registry, fallbackGameId?, loading?, multiplayer? })` (`@jgengine/shell/GamePlayer`) is `GamePlayerShell` plus the lazy-load glue: it looks up `gameId` in `registry`, awaits the loader, renders `loading` (default `null`) until it resolves, then mounts `GamePlayerShell playable={...} multiplayer={...}`; switching `gameId` re-triggers the load, and an in-flight load is discarded if the id changes again first. `resolveGameLoader(registry, gameId, fallbackGameId?)` (`@jgengine/shell/registry`) is the underlying lookup — `registry[gameId] ?? registry[fallbackGameId]` — for hosts that want the fallback behavior without the component.
|
|
419
|
+
|
|
420
|
+
HUD styling is Tailwind v4 via the `index.css` above — without its `@source` lines the HUD renders unstyled. Then build the game itself under `src/` per the layout below — `src/game.config.ts` is the single entry, defined with `defineGame` from `@jgengine/shell/defineGame`.
|
|
421
|
+
|
|
422
|
+
## Scope
|
|
423
|
+
|
|
424
|
+
This file documents engine primitives and conventions only — never game domain. Example ids (`iron_block`, `mob_grunt`, `shop_town`) are placeholders, not content to copy.
|
|
425
|
+
|
|
426
|
+
| Engine owns | Your game owns |
|
|
427
|
+
|-------------|----------------|
|
|
428
|
+
| Weighted loot RNG, trade validation, loadout application, quest journal state, social graph, stat clamp math, effect absorption, projectile geometry, death resolution, event bus, feeds, leaderboards, input capture, pose hitboxes | Catalog entries and ids, effect id names, XP curves, shop/item/quest/loadout definitions, use-handlers, AI logic, UI content |
|
|
429
|
+
|
|
430
|
+
**Rules:**
|
|
431
|
+
|
|
432
|
+
1. **Catalog-first** — shape and behavior of every id lives in game-owned catalog files. Runtime calls pass ids, positions, instance keys.
|
|
433
|
+
2. **Three buckets** — inventory items, scene objects, scene entities. Never merge them.
|
|
434
|
+
3. **Dumb place/spawn** — no behaviors on `place()`/`spawn()`; the catalog owns them.
|
|
435
|
+
4. **Commands for verbs** — input maps to actions, actions to commands/handlers; no raw keys in game logic.
|
|
436
|
+
5. **Primitives over glue** — a loop several games need (loot roll, shop buy, kit seeding) belongs in the engine, not copy-pasted per game.
|
|
437
|
+
6. **No speculative config** — `defineGame` fields exist only with a live engine consumer.
|
|
438
|
+
7. **This file stays domain-free.**
|
|
439
|
+
|
|
440
|
+
## The three buckets
|
|
441
|
+
|
|
442
|
+
| Bucket | What | API |
|
|
443
|
+
|--------|------|-----|
|
|
444
|
+
| **Inventory** | Stackable ids in containers | `ctx.player.inventory.put / take / move / has / count` |
|
|
445
|
+
| **Scene object** | Static world content | `ctx.scene.object.place / remove / move / rotate / list` |
|
|
446
|
+
| **Scene entity** | Movers driven per tick | `ctx.scene.entity.spawn / despawn / setPose / effect / …` |
|
|
447
|
+
|
|
448
|
+
A voxel block is an object. A rack is an object with a slot inventory. A GPU is an inventory item inside it. A player, mob, or car is an entity. A dropped-item lying on the ground is also an entity — `ctx.scene.worldItem` (position + item ref + rarity, spawned under `game/worldItem`'s `WORLD_ITEM_ENTITY_NAME`) — never a fourth bucket and never merged into inventory or object.
|
|
449
|
+
|
|
450
|
+
## Game repo layout
|
|
451
|
+
|
|
452
|
+
Every game is one shape, enforced by `check-game-shape` (part of `check-types`): the top of `src/` holds only the skeleton, and every game-specific module, UI component, and test lives under `src/game/`. Dense files — one `catalog.ts` per domain, never one file per entry.
|
|
453
|
+
|
|
454
|
+
```
|
|
455
|
+
src/
|
|
456
|
+
game.config.ts single entry — export const game = defineGame({...}) from "@jgengine/shell/defineGame"
|
|
457
|
+
index.tsx barrel — export { game } from "./game.config" (+ any UI-preview scenario re-export)
|
|
458
|
+
main.tsx standalone host — mounts <GameHost playable={game}/> from "@jgengine/shell/GameHost"
|
|
459
|
+
loop.ts onInit, onNewPlayer, onTick
|
|
460
|
+
world.ts WorldFeature + PhysicsConfig (only for games that have one)
|
|
461
|
+
game/
|
|
462
|
+
keybinds.ts ActionCodesMap — named actions + hotbarSlotBindings(n)
|
|
463
|
+
inventories.ts inventory declarations
|
|
464
|
+
assets.ts Render catalog
|
|
465
|
+
content.ts itemById / entityById lookups over all catalogs
|
|
466
|
+
loadouts.ts Loadout ids → items/economy/unlocks per inventory
|
|
467
|
+
world/ zones.ts, setup.ts (place/spawn from onInit)
|
|
468
|
+
items/ <domain>/catalog.ts + use-handlers.ts
|
|
469
|
+
objects/ catalog.ts (+ loot tables beside their domain)
|
|
470
|
+
entities/ players/ enemies/ npcs/ — catalog.ts per role (never actors/)
|
|
471
|
+
quests/catalog.ts when using game.quest
|
|
472
|
+
progression/ curves.ts — game-owned XP curve numbers fed to game/progression
|
|
473
|
+
ui/GameUI.tsx ALL layout/positioning
|
|
474
|
+
ui/components/ content-only pieces GameUI places
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
## `defineGame` — the single authoring entry
|
|
478
|
+
|
|
479
|
+
`@jgengine/shell/defineGame` is the game-authoring entry: one call in `game.config.ts` takes both engine fields (`name`, `assets`, `world`, `physics`, `inventories`, `input`, `server`, `save`, `time`, `feed`, `multiplayer`) and presentation fields (`content`, `loop`, `GameUI`, `camera`, `environment`, `WorldOverlay`, `renderEntity`, `renderObject`, `entitySprites`, `entityModels`, `objectModels`, `hotbarSelection`, `prompts`, `pointer`, `touch`, `worldHealthBars`, `audio`, `entitySounds`, `objectSounds`, `worldItem`, `shadows`, `collision`, `movement`, `devtools`) and returns a ready `PlayableGame` — no separate object to assemble. It is a thin wrapper over the core `defineGame` primitive (below) plus the `PlayableGame` runner assembly; see `packages/shell/src/defineGame.tsx` for the exact accepted fields. Never game tuning (walk speeds, damage, prompts — those live in catalogs).
|
|
480
|
+
|
|
481
|
+
**Smart defaults** — omit any of these and the call still resolves: `multiplayer` → `offline()`; `assets` → an empty asset catalog; `loop` hooks (`onInit`/`onNewPlayer`/`onTick`) → no-ops; `content` → `{}`; `GameUI` → an empty component; `camera` → third-person orbit; `feed` → 20-entry ring buffers per action; a `world` of kind `environment()` auto-renders as the backdrop with no `environment` component supplied — a non-`environment()` world (`flat()`, `voxel()`, …) still needs the game to hand it one.
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
// game.config.ts — imports only, nothing inline
|
|
485
|
+
import { defineGame } from "@jgengine/shell/defineGame";
|
|
486
|
+
import { assets } from "./game/assets";
|
|
487
|
+
import { content } from "./game/content";
|
|
488
|
+
import { GameUI } from "./game/ui/GameUI";
|
|
489
|
+
import { inventories } from "./game/inventories";
|
|
490
|
+
import { keybinds } from "./game/keybinds";
|
|
491
|
+
import { loop } from "./loop";
|
|
492
|
+
import { physics, world } from "./world";
|
|
493
|
+
|
|
494
|
+
export const game = defineGame({
|
|
495
|
+
name: "My Game",
|
|
496
|
+
assets,
|
|
497
|
+
world,
|
|
498
|
+
physics,
|
|
499
|
+
inventories,
|
|
500
|
+
input: keybinds,
|
|
501
|
+
server: "persistent", // or { mode: "ffa", scoreLimit: 30 } — rules live in game code
|
|
502
|
+
save: { auto: "5m", scope: "player+chunks" }, // or "none"
|
|
503
|
+
multiplayer: offline(), // or ws({ topology, url? }) / fly({ app }) / convex({ topology }) / socketIo({ topology, url? }) / p2p({ room? }) / lan({ port?, path? }) / servers({ …, adapter }) — defaults to offline()
|
|
504
|
+
content,
|
|
505
|
+
loop, // Partial<GameLoop<GameContext>> — missing hooks default to no-ops
|
|
506
|
+
GameUI,
|
|
507
|
+
camera: { perspective: "third" }, // optional — this is the default
|
|
508
|
+
});
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
// game/keybinds.ts — named actions + generated hotbar slots; one key, one action
|
|
513
|
+
import { hotbarSlotBindings, type ActionCodesMap } from "@jgengine/core/input/actionBindings";
|
|
514
|
+
|
|
515
|
+
export const keybinds: ActionCodesMap = {
|
|
516
|
+
moveForward: ["KeyW"], moveBack: ["KeyS"], moveLeft: ["KeyA"], moveRight: ["KeyD"],
|
|
517
|
+
jump: ["Space"], sprint: ["ShiftLeft"],
|
|
518
|
+
interact: ["KeyE"],
|
|
519
|
+
crouch: { hold: ["KeyC"], toggle: ["KeyZ"] },
|
|
520
|
+
aim: { hold: ["mouse2"], toggle: ["KeyV"] },
|
|
521
|
+
tabTarget: ["Tab"], clearTarget: ["Escape"],
|
|
522
|
+
...hotbarSlotBindings(9), // hotbarSlot1..9 → Digit1..9 (a 10th slot gets Digit0)
|
|
523
|
+
};
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
```ts
|
|
527
|
+
// game/inventories.ts
|
|
528
|
+
import type { InventoryDeclaration } from "@jgengine/core/game/defineGame";
|
|
529
|
+
export const inventories: Record<string, InventoryDeclaration> = {
|
|
530
|
+
hotbar: { slots: 9, hud: "hotbar" },
|
|
531
|
+
backpack: { slots: 28, traits: itemTraits },
|
|
532
|
+
equipment: { slots: 4, accepts: ["weapon", "armor"], applyModifiers: true },
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// world.ts — top of src/, not under game/
|
|
536
|
+
import type { PhysicsConfig } from "@jgengine/core/game/defineGame";
|
|
537
|
+
import { biomes, type WorldFeature } from "@jgengine/core/world/features";
|
|
538
|
+
export const world: WorldFeature = biomes({ map: "world/biomes", zones: "world/zones" });
|
|
539
|
+
export const physics: PhysicsConfig = { gravity: -32 };
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
- `PhysicsConfig.gravity`/`jumpVelocity` tune the shell's built-in walk controller's fall/jump feel (defaults ~`-24`/`7.1`) — the only two levers on gravity and jump height; everything else about movement (speed, poses) stays catalog `movement` fields.
|
|
543
|
+
- Input bindings are string arrays (hold semantics) or `{ hold, toggle, repeatMs? }` for the same verb. `repeatMs` turns a held action into an auto-repeat fire (build-mode place-on-drag, rapid-fire without a separate `wasPressed`/interval combo in game code) — the shell refires the command every `repeatMs` while the binding stays down, on top of its normal press-edge fire.
|
|
544
|
+
- **Keybind → command convention.** The shell fires a command for any bound action that isn't reserved: pressing an action runs a command of the **same name** if one is defined, else a `ui.<action>` fallback (so `openBackpack` → `ui.openBackpack`). Just declare the binding and a matching command — no per-game `keydown` listener. Reserved actions the shell consumes natively and never routes to a command: `moveForward/moveBack/moveLeft/moveRight`, `turnLeft/turnRight`, `sprint`, `jump`, `tabTarget`, `clearTarget`, `useAbility`, `interact`, and any `hotbarSlotN`/`slotN`. `tabTarget`/`clearTarget` run `target.cycle`/`target.clear` (native `cycleTarget`/`setTarget` fallback).
|
|
545
|
+
- **`interact`** is special: pressing it resolves the active proximity prompt from the `prompts` field of `defineGame({...})` and runs that prompt's `invoke` command. A prompt with `invoke: null` is display-only and does nothing on the key.
|
|
546
|
+
- UI keybind badges derive from `keybinds` via `actionLabel(keybinds, "openBackpack")` — `bindingLabel` maps codes to short labels (`Digit1`→`1`, `KeyB`→`B`, `mouse0`→`LMB`, `Escape`→`Esc`). Never hardcode label strings; they drift the moment a binding changes.
|
|
547
|
+
- `server.mode` is a string your loop/commands interpret — the engine ships no gamemode presets.
|
|
548
|
+
- Never in defineGame: player tuning, catalog helpers (`defineItems` etc.), game nouns, behaviors, prompts, or inline binding/inventory/world blobs. The one exception is `physics.gravity`/`physics.jumpVelocity` — global controller tuning, not a catalog value (see "Controller kinematics" below).
|
|
549
|
+
- `assets` may be omitted for a game with no models (a HUD-only card/board game, say) — `defineGame` injects an empty catalog, so `GameDefinition.assets` is always present downstream with no per-caller `?.` checks.
|
|
550
|
+
- `devtools` defaults to `true` — every game gets the F2-toggled debug overlay (Perf/Tune/Logs/Net/Keys) for free, and every top-level `export const` number/boolean/color and every exported flat table of them under `src/` is auto-discovered into the Tune tab with zero game code; set `false` to disable the toggle entirely. See "Devtools — F2 overlay and tunables" below.
|
|
551
|
+
|
|
552
|
+
### `@jgengine/core/game/defineGame` — the underlying primitive
|
|
553
|
+
|
|
554
|
+
The low-level engine boot call the shell `defineGame` composes internally: engine fields only (`name`, `assets`, `world`, `physics`, `inventories`, `input`, `server`, `save`, `time`, `feed`, `multiplayer`, `loop`) — no `content`/`GameUI`/`camera`/render fields, those are the shell layer's job.
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
import { defineGame as defineEngineGame } from "@jgengine/core/game/defineGame";
|
|
558
|
+
import { offline } from "@jgengine/core/runtime/adapter";
|
|
559
|
+
|
|
560
|
+
const game = defineEngineGame({
|
|
561
|
+
name: "My Game",
|
|
562
|
+
assets, world, physics, inventories,
|
|
563
|
+
input: keybinds,
|
|
564
|
+
server: "persistent",
|
|
565
|
+
save: { auto: "5m", scope: "player+chunks" },
|
|
566
|
+
multiplayer: offline(),
|
|
567
|
+
loop, // GameLoop<GameContext>
|
|
568
|
+
});
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
Reach for this directly only outside a React host — a headless server, a non-shell runner; a browser game authors through `@jgengine/shell/defineGame` above, which calls this and returns the `PlayableGame` a runner needs.
|
|
572
|
+
|
|
573
|
+
## `PlayableGame` — how a game plugs into a runner
|
|
574
|
+
|
|
575
|
+
The type `@jgengine/shell/defineGame` returns and every runner (`GameHost`, `GamePlayerShell`) consumes. A game never builds this object by hand — `defineGame({...})` assembles it from the merged config. Source type at `@jgengine/core/game/playableGame`:
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
export type PlayableGame<TUi = unknown> = {
|
|
579
|
+
game: GameDefinition;
|
|
580
|
+
content: GameContextContent; // { itemById?, entityById?, objectById? }
|
|
581
|
+
loop: Required<GameLoop<GameContext>>; // onInit, onNewPlayer, onTick
|
|
582
|
+
GameUI: TUi; // React component in web runners
|
|
583
|
+
prompts?: (ctx: GameContext) => readonly PositionedPrompt[]; // interact-key + HUD source
|
|
584
|
+
};
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
`prompts` is the **single source** of positioned proximity prompts: the shell reads it to fire the `interact` key, and the HUD should read the same list through `useActivePrompt(playable.prompts?.(ctx))` rather than building its own — one list, no drift. A prompt is only actionable if its `invoke` is non-null.
|
|
588
|
+
|
|
589
|
+
Optional render/world fields the shell also reads: `entitySprites` / `entityModels` (billboards / GLBs keyed by entity kind), `objectModels` (GLBs keyed by object catalog id), `renderObject` (per-object visual override — return your own mesh for a placed object and the shell still positions it; falls back to `objectModels` → colored box), `WorldOverlay` (canvas-layer VFX), `environment` (canvas-layer scenery — ground/sky/structures; when set, replaces the default ground plane + debug grid + rock field), `camera`, `shadows` (cast/receive shadows across the R3F canvas; default true), and `worldHealthBars` (`boolean | { statId?, roles? }` — `roles` restricts bars to entities whose catalog `role` is in the given `CatalogEntityRole` list, e.g. skip friendly NPCs). A model value is a catalog id (`string`, resolved via `game.assets`) or an inline `ModelConfig { url, scale?, y?, anchor?, dims?, material?, animation? }` (`material` overrides color/metalness/roughness/emissive/emissiveIntensity on the cloned mesh, leaving shared GLTF caches untouched; `animation?: { clip?, loop?, timeScale?, paused?, time? }` plays a GLTF clip — `clip` defaults to the first clip, `loop` defaults true — and `paused: true` + `time: <seconds>` holds the rig on one fixed frame, a pose library for inventory previews or held cutscene poses). Catalog-resolved models carry measured `dims` (`catalog.resolve(id).dims = { footprint:{w,d}, center:{x,z}, minY }`); with the default `anchor: "center"` the shell centers the footprint on the placement point and ground-snaps `minY` to it, so corner-pivot kit models place correctly with no per-game pivot math. Applies through both `entityModels` and `objectModels`.
|
|
590
|
+
|
|
591
|
+
`renderEntity?: (entity: SceneEntity) => ReactNode` and `renderObject?: (object: SceneObject) => ReactNode` hand you the mesh for one entity/object while the shell still positions it and keeps it tagged for picking/selection; return null/undefined to fall through to model → sprite/box. `objectStyles?: Record<catalogId, { color?, opacity?, hidden? }>` styles the default colored-box object render — `color` overrides the hash color, `opacity < 1` sets transparent, `hidden` skips the mesh but keeps the picking tag.
|
|
592
|
+
|
|
593
|
+
**Presentation mode.** `PlayableGame.presentation`: `"3d"` (default) mounts the canvas, camera rig, and pointer; `"hud"` mounts none of that — the game is `GameUI` plus the command/input loop, for board/card/menu games that need no 3D camera at all.
|
|
594
|
+
|
|
595
|
+
**Auto environment.** When `world` is an `environment()` descriptor and `PlayableGame.environment` is unset, the shell renders that descriptor as the backdrop automatically — no manual `environment` wiring needed for the common case. Set `environment` explicitly only to override that default (a custom canvas component always wins). The same auto-render convention covers grid-cell worlds (`biomes`/`voxel`/`plots`/`tilemap`) — see "World features" below.
|
|
596
|
+
|
|
597
|
+
**Lighting and backdrop.** `PlayableGame.lighting` (`LightingConfig`, `@jgengine/core/game/playableGame`) replaces the shell's hardcoded ambient/directional default when present, regardless of world kind: `ambient?: { color?, intensity? }`, `directional?: { color?, intensity?, position, castShadow? }[]`, `hemisphere?: { skyColor?, groundColor?, intensity? }`. `PlayableGame.backdrop` (`BackdropConfig`) is a generic background/sky/fog for **any** world kind, including a custom `environment` component: `background?: string` (CSS color), `sky?: SkyEnvironmentConfig` (same descriptor `environment()`'s `sky` field takes), `fog?: { color?, near?, far?, density? }` (`density` set switches to exponential `FogExp2` and `near`/`far` are ignored). Both are optional and additive to whatever the world/`environment` renderer already draws.
|
|
598
|
+
|
|
599
|
+
**Visibility & streaming (automatic).** Every 3D game gets camera frustum + distance culling for free — the shell's `CullingProvider` reads the live camera each frame, runs the engine `createVisibilitySystem` (`@jgengine/core/visibility/visibilitySystem`) over the scene's entities and placed objects, and toggles `group.visible` so off-screen objects are never submitted to the renderer (never unmounted — gameplay and simulation are untouched). Defaults are conservative (a preload margin larger than the view, hysteresis, `Infinity` default render distance) so existing games only benefit. Tune or opt out via `PlayableGame.visibility` (`VisibilityConfig`, `@jgengine/core/visibility/config`): `enabled: false` disables it; `culling`/`streaming` patch the global `CullingSettings`/`StreamingSettings` (`@jgengine/core/visibility/settings`); `scene` sets scene-wide overrides; `entities`/`objects` override by kind name / catalog id (`alwaysVisible`, `maxRenderDistance`, custom `bounds`, `pinned`, `cullingDisabled`, …). The engine layer also ships `@jgengine/core/visibility/spatialIndex` (the 3D hash the culler queries instead of scanning every object), `@jgengine/core/visibility/assetStreaming` (dedup/budget/grace-period asset loading), and `@jgengine/core/visibility/simulationCulling` (opt-in, off by default — throttles low-priority off-screen updates, never protected entities). Full reference: `packages/core/src/visibility/README.md`.
|
|
600
|
+
|
|
601
|
+
**Player movement tuning** — the `movement` field (`PlayerMovementConfig`) tunes the shell's local-player walk controller beyond `physics.gravity`/`jumpVelocity`: `mode` (`"free"` camera-relative default, `"axis"` locks travel to one world `axis`, `"grid"` snaps each committed step to `cellSize` centers), `collideObjects` (collide against placed scene objects as unit-box AABBs even without `collision.voxel`), and `beforeCommit(frame)` — an escape hatch called each frame with `{ entityId, current, next, dt }` that can return a replacement `[x, y, z]` to constrain or redirect the step (rails, bounds, custom collision) before it commits and before `onTick` runs.
|
|
602
|
+
|
|
603
|
+
The runner boots `createGameContext({ definition, content, player: { userId, isNew } })`, calls `loop.onInit(ctx)` then `loop.onNewPlayer(ctx)`, and drives `loop.onTick(ctx, dt)` per frame. **Convention: `onNewPlayer` spawns the player entity with `id === ctx.player.userId`** — bounded stats, targeting, and kill attribution key off that.
|
|
604
|
+
|
|
605
|
+
### Object spatial queries, entity patching, and surface sampling
|
|
606
|
+
|
|
607
|
+
```ts
|
|
608
|
+
ctx.scene.object.at(x, y, z) // cell lookup, cell size 1, most-recent wins
|
|
609
|
+
ctx.scene.object.inBox(min, max) // inclusive AABB query
|
|
610
|
+
ctx.scene.object.raycast({ origin, direction, maxDistance, halfExtents?, filter? }) // → nearest hit or null
|
|
611
|
+
ctx.scene.object.raycastAll({ origin, direction, maxDistance, halfExtents?, filter? }) // → hits, nearest-first
|
|
612
|
+
ctx.scene.entity.update(id, patch) // name/position/rotations/role/movement/behaviors/meta; false for unknown id
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Placed objects are unit boxes (half-extents `[0.5, 0.5, 0.5]`) centered on position, matching the shell's default render, so `raycast`/`raycastAll` (`scene/objectQuery`) match what a player sees. `entity.update` notifies subscribers and bumps `ctx.version()` the same as `spawn`/`despawn`/`setPose` — it's the general-purpose patch the more specific methods (`setPose`, `form.shapeshift`, `possession.possess`) build on.
|
|
616
|
+
|
|
617
|
+
`ctx.scene.object.place(catalogId, x, y, z, { instanceId?, parentSpace?, rotation?, visual? })` takes an optional `visual: ObjectVisual` (`@jgengine/core/scene/objectStore`) — `{ scale?: number | [x,y,z], color?, opacity? }` — a per-instance render override independent of the catalog entry; `ctx.scene.object.setVisual(instanceId, visual | undefined)` changes it after placement (`undefined` clears back to the catalog default). Distinct from `objectStyles` on `PlayableGame` (styles a catalog id for every instance); `visual`/`setVisual` targets one placed instance (a damaged crate, a dyed banner, a resized prop).
|
|
618
|
+
|
|
619
|
+
`pointerService.worldHitCenter()` (shell) casts from the viewport center regardless of cursor presence (pointer-lock aim) — combine with `pointer.worldHit()` (cursor-driven) to support both mouse-look and free-cursor games from the same probe. `PointerHit` also carries an optional `uv?: { u, v }` on UV-mapped mesh hits (absent for the ground fallback), `material?: { color, metalness?, roughness? } | null` sampled off the hit mesh's `MeshStandardMaterial` (`null`/unset for non-standard materials, e.g. the ground plane) — combine `uv` + `material` for paint tools, decals, and material-aware interaction — and `instanceId?: number`, the hit index when the intersected mesh is a `THREE.InstancedMesh` (grid-world cells, `InstancedBodies` debris, any instanced render), absent otherwise.
|
|
620
|
+
|
|
621
|
+
**Runtime paint layer** (`ctx.scene.entity.paint`, backed by `scene/paintLayer`) — a `PaintLayer` keyed by instance id (entity or object): `paint(instanceId, { u, v, radius, color })`, `strokes(instanceId)`, `clear(instanceId?)`, `version(instanceId)` (bumps per paint/clear), `subscribe(listener)`. The shell auto-renders painted instances through a lazily-created 512×512 canvas texture kept in sync — no per-game render wiring. Clearing refills with the material's base color; the original texture pixels are not restored.
|
|
622
|
+
|
|
623
|
+
### Audio — positional emitters, listener falloff, buses
|
|
624
|
+
Catalog-first, no per-game audio glue. The `audio` field of `defineGame({...})` — `{ sounds: Record<string, SoundDef>, buses?: Record<string, AudioBusDef> }` — declares the sound catalog (`SoundDef = { id, url, bus, gain?, loop?, positional?, falloff? }`) and mix buses (`music`/`sfx`/`ambient`/…, `AudioBusDef = { id, gain? }`) — both types from `@jgengine/core/audio/audioFalloff`. `entitySounds?: Record<string, string>` maps an entity **kind name** (same convention as `entitySprites`/`entityModels`) to a sound id: while a matching entity exists, the shell keeps a looping positional emitter on it, repositioned every frame. `objectSounds?: Record<string, string>` does the same keyed by placed-object catalog id. The pure distance→gain math (`computeFalloffGain(distance, config)`, curves `"linear" | "inverse" | "none"`) lives in core so it is unit-tested without a browser; `@jgengine/shell` (`shell/audio/audioEngine`, `shell/audio/AudioComponents`) is the only package that touches Web Audio — it owns an `AudioContext`, mounts `AudioListener` on the camera every frame, and `EntityAudioEmitters`/`ObjectAudioEmitters` drive per-instance emitter gain from the core falloff function. `GamePlayerShell` wires all of this automatically from `playable.audio`/`entitySounds`/`objectSounds` — a game never touches `AudioContext` directly.
|
|
625
|
+
### Camera rigs (`camera` field of `defineGame({...})`)
|
|
626
|
+
The shell ships a **rig library**; a game picks and tunes one through `camera` config, never by writing camera positions from `onTick`. Select with `camera.rig` (or the `perspective: "third" | "first"` shorthand) — or by config block alone (#207.8): supplying `camera.<rig>` selects that rig with no redundant `rig` field, checked in the table's order; an explicit `rig` wins when several blocks are present:
|
|
627
|
+
| `rig` | For | Key config (`camera.<rig>`) |
|
|
628
|
+
|-------|-----|------------------------------|
|
|
629
|
+
| `orbit` (default) | Third-person chase | `initialDistance`, `targetHeight`, `min/maxPolarAngle`, drag/zoom |
|
|
630
|
+
| `first` | FPS mouse-look | `firstPerson: { eyeHeight, sensitivity, maxPitch, reticle, viewmodel }` |
|
|
631
|
+
| `topDown` | ARPG iso / top-down (Diablo IV, Hades II) | `topDown: { height, pitch, yaw, followSmoothing, zoom }` — decoupled follow; `pitch` is camera elevation (PI/2 = straight down, near 0 = grazing and boom-distance blows up past `frustum.far`) |
|
|
632
|
+
| `rts` | Free-pan / edge-scroll (The Sims, Manor Lords) | `rts: { panSpeed, edgeScroll, rotateSpeed, bounds, start, pan }` — `pan: false` turns it into a static backdrop camera: no WASD/arrow pan, no edge-scroll, no Q/E rotate, no wheel zoom, still re-centers on `followEntityId` if one resolves |
|
|
633
|
+
| `shoulder` | Over-the-shoulder (Helldivers 2, Remnant II) | `shoulder: { shoulderOffset, distance, ads, side }` — ADS + shoulder-swap (V) |
|
|
634
|
+
| `lockOn` | Souls-like strafe (Elden Ring) | `lockOn: { targetEntityId?, distance, framingBias, yawSmoothing }` — yaw binds to player→target; WASD becomes strafe |
|
|
635
|
+
| `chase` | Vehicle chase (Forza, Rocket League) | `chase: { distance, springDamping, fov: { base, max, speedForMax }, shakePerSpeed, view: "chase"|"cockpit"|"hood"|"rear" }` |
|
|
636
|
+
| `sideScroll` | Fixed lateral follow — 2.5D platformer/beat-'em-up | `sideScroll: { distance, height, lookHeight, axis: "x"\|"z", followSmoothing, fov }` — reads no player input, follows like the other follow rigs (defaults to the local player) |
|
|
637
|
+
| `observer` | Detached spectator/photo/kill-cam (#120) | `observer: { bind: { kind: "entity", entityId } \| { kind: "point", position }, distance, height, orbitSpeed }` — reads no player input, auto-orbits the bound subject |
|
|
638
|
+
| `inspection` | Model-viewer / data-viz orbit (#207.7) | `inspection: { anchor: "target"\|"cursor"\|"center", target, initialDistance, initialPosition, min/maxDistance, min/maxPolarAngle, pan, rotateSpeed, zoomSpeed, dampingFactor }` — left-drag orbit, middle/right-drag pan (pan defaults on for this rig only), scroll zoom toward the anchor (`cursor` = zoom-to-cursor); orbits a fixed `target`, never reads player/entity state |
|
|
639
|
+
| `none` | No camera rig mounted | HUD-only presentations or a game that manages its own camera; see `presentation: "hud"` below |
|
|
640
|
+
**Frustum:** `camera.frustum: { fov?, near?, far? }` overrides the canvas camera; `far` defaults to 300, so any world whose content spans more than a few hundred units must raise it or distant settlements/terrain silently clip out of view. **Every rig accepts `followEntityId: null`** so avatar-less games (city-builders, card games, auto-battlers) still mount a camera. Leave `followEntityId` unset and the shell defaults it to `ctx.player.possession.active(userId)` every frame, so a possession swap (party control-swap, BG3-style) or a form's mesh/camera-relevant change re-targets the camera automatically — set it explicitly only to override that default. **Shake / trauma (#28):** every rig reads a shake channel; feed it from anywhere with `import { cameraShake } from "@jgengine/shell/camera"` — `cameraShake(amplitude, decayPerSecond?)` (amplitude 0..1) — or from React via `useCameraShake()`. Tune with `camera.shake: { maxOffset, maxRoll, decayPerSecond, exponent, frequency }`. **Cinematic (#29):** set `camera.cinematic: { keyframes: [{ position, lookAt, fov?, duration?, ease? }], loop? }` to play a scripted path over the active rig, and `camera.transitionSeconds` cross-fades the camera when the rig changes so mode swaps don't hard-cut. The pure rig math (shake decay, spring-arm, speed→FOV, offset/strafe, keyframe lerp) is exported from `@jgengine/shell/camera` for testing.
|
|
641
|
+
|
|
642
|
+
## `GameContext` — the ctx surface
|
|
643
|
+
|
|
644
|
+
`createGameContext` (in `@jgengine/core/runtime/gameContext`) wires every system:
|
|
645
|
+
|
|
646
|
+
```
|
|
647
|
+
ctx.scene.object place, remove, move, rotate, get, list, subscribe,
|
|
648
|
+
at, inBox, raycast, raycastAll, catalog
|
|
649
|
+
ctx.scene.entity spawn, despawn, setPose, update, get, list,
|
|
650
|
+
stats.{get,set,delta}, setTarget, getTarget, cycleTarget,
|
|
651
|
+
canReceive, preview, effect, paint,
|
|
652
|
+
willHitProjectile, fireProjectile, settleProjectile,
|
|
653
|
+
distance, inRadius, hasLineOfSight, queryArc, moveToward,
|
|
654
|
+
spawnPoseOf, resetToSpawn, resetAllToSpawn,
|
|
655
|
+
form.{register,get,active,abilities,shapeshift,revert}
|
|
656
|
+
ctx.game commands, events, feed, loot, trade, quest, social, chat,
|
|
657
|
+
unlocks, economy, leaderboard, roster, store, cards, turn
|
|
658
|
+
ctx.game.social friends, party, presence, emotes.play, worldInvites
|
|
659
|
+
ctx.game.store set, delete, get, has, subscribe, mapSnapshot, arraySnapshot — game-defined
|
|
660
|
+
keyed reactive store slot (any value type); mutations bump ctx.version()
|
|
661
|
+
ctx.game.cards pile(id, config?) — lazily creates (config required on first call) or returns
|
|
662
|
+
the existing notify-wrapped CardPile for id
|
|
663
|
+
ctx.game.turn loop(id, config?) — lazily creates (config required on first call) or returns
|
|
664
|
+
the existing notify-wrapped TurnLoop for id
|
|
665
|
+
ctx.player userId, isNew, inventory, stats (modifiers), loadout,
|
|
666
|
+
applyLoadout, movement (pose/aim), motion (impulse/setVerticalVelocity/setY/takePending),
|
|
667
|
+
possession, cosmetics
|
|
668
|
+
ctx.player.motion impulse(vy), setVerticalVelocity(vy), setY(y), takePending() — game-code
|
|
669
|
+
seam into the shell's vertical-motion integrator; drained once per frame
|
|
670
|
+
before gravity, so a jump pad or grapple release calls this from
|
|
671
|
+
onTick/commands instead of touching y directly
|
|
672
|
+
ctx.item use, weapon
|
|
673
|
+
ctx.input publish(held), isDown(action), held() — per-frame held-action snapshot, polled from onTick
|
|
674
|
+
ctx.world ground (TerrainField), groundHeightAt(x, z) — the canonical
|
|
675
|
+
sampler for the game's declared world; environment worlds
|
|
676
|
+
resolve their terrain field, every other world kind is 0.
|
|
677
|
+
Use it for every spawn/placement/waypoint y — never
|
|
678
|
+
hand-roll a noise sampler or hardcode y = 0 on relief
|
|
679
|
+
ctx.camera follow(entityId | null), followedEntityId(), setCinematic(config), cinematic(),
|
|
680
|
+
subscribe — runtime camera-follow/cinematic override; the shell reads
|
|
681
|
+
followedEntityId() each frame, falling back to the static
|
|
682
|
+
playable.camera.followEntityId when it returns undefined
|
|
683
|
+
ctx.time advance, now, calendar, snapshot; pause, play, toggle,
|
|
684
|
+
setSpeed, cycleSpeed; after, every, at (game-time timers)
|
|
685
|
+
ctx.subscribe / ctx.version change signal — UI layers bind via useSyncExternalStore
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
`content.itemById(id)` supplies `{ use?, weapon?, trade? }`; `content.entityById(id)` supplies `{ stats?, receive?, onDeath?, movement?, role? }`; `content.objectById(id)` supplies `GameContextObjectEntry` `{ proximityPrompt?, breakable?, slotInventory? }`. Build all three from your catalogs in `content.ts`. A placed object resolves its catalog entry via `ctx.scene.object.catalog(instanceId)`; `ctx.scene.object.at(x, y, z, tolerance?)` finds placed objects near a point (grid interaction, click resolution beyond the pointer service). `ctx.scene.entity.update(id, patch)` writes a shallow patch onto a spawned entity's mutable fields (e.g. `movement.walkSpeed`) without a full respawn — `scene/movementSpeed`'s `applyStatDrivenSpeed(deps, id, { baseSpeed, multiplierStat?, flatBonusStat? })` is the catalog-driven helper that recomputes and writes `movement.walkSpeed` from a stat-modifier pair each time a buff changes.
|
|
689
|
+
|
|
690
|
+
### Two tiers: `ctx` runtime vs pure factories
|
|
691
|
+
|
|
692
|
+
The `ctx` surface above is the **stateful runtime** — it's what game code uses. Every subsystem it wires is *also* exported as a **pure factory** that `createGameContext` composes internally: `createTradeSystem`, `createDeathSystem`, `createEffectSystem`, `createProjectileSystem`, `createSpatialApi`, `createEntityStatsApi`, `createEntityStore`, `createObjectStore`, `createStats`, `createLoadouts`, `createLootRegistry`, `createQuestJournal`, `createSocial`, `createSlots`, `createInteriors` (plus stateless helpers beside each — `canAffordCosts`/`resolveBuy` in `game/trade`, `getStatValue`/`applyPoolDelta` in `scene/entityStats`, and so on). **Build a game through `ctx`, not these** — reach for the factories only for unit tests of pure game math, headless servers, or a custom runtime. Import the domain deep path (`@jgengine/core/combat/death`, `@jgengine/core/game/trade`, `@jgengine/core/stats/statModifiers`, …) and read the `.d.ts`; each is a real export in the published package.
|
|
693
|
+
|
|
694
|
+
`createSpatialApi`'s optional `grid: { cellSize }` opts `inRadius`/`queryArc` into a lazily-built x/z broadphase index over `candidates()` instead of a linear scan — worth it once candidate counts run into the hundreds+. The index is reused across calls until `invalidate()` is called, so call it after any position change (move, spawn, despawn); a candidate outside the index at query time still resolves exactly (never silently skipped), only a *moved* one can be missed until invalidated.
|
|
695
|
+
|
|
696
|
+
## `loop` — lifecycle
|
|
697
|
+
|
|
698
|
+
```ts
|
|
699
|
+
export function onInit(ctx: GameContext) {
|
|
700
|
+
ctx.item.use.register(itemUseHandlers);
|
|
701
|
+
ctx.player.loadout.register(loadouts);
|
|
702
|
+
for (const table of lootTables) ctx.game.loot.register(table);
|
|
703
|
+
ctx.game.quest.register(quests);
|
|
704
|
+
ctx.game.quest.bind("entity.died");
|
|
705
|
+
ctx.game.feed.bind("entity.died");
|
|
706
|
+
ctx.game.events.on("entity.died", (evt) => onEntityDied(ctx, evt));
|
|
707
|
+
setupWorld(ctx);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export function onNewPlayer(ctx: GameContext) {
|
|
711
|
+
ctx.scene.entity.spawn("player_default", { id: ctx.player.userId, position: spawnPoint });
|
|
712
|
+
if (ctx.player.isNew) ctx.player.applyLoadout(ctx.player.userId, "starterKit");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export function onTick(ctx: GameContext, dt: number) {
|
|
716
|
+
// AI, regen, respawn timers — dt is GAME time (see ctx.time). Never death detection (see entity.died)
|
|
717
|
+
}
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
`onInit` runs once per boot; register everything there. Loot tables register through `ctx.game.loot.register` — `lootTable()` is a pure validating factory, there is no global side-effect registry.
|
|
721
|
+
|
|
722
|
+
## `ctx.time` — the simulation clock
|
|
723
|
+
|
|
724
|
+
`onTick`'s `dt` is **game time, not real time**: the shell scales each frame's real delta by `definition.time.scale` (real→game seconds at 1×) and the live speed multiplier, so writing decay/regen/AI as `rate * dt` makes it obey pause and fast-forward for free — never read wall-clock in a tick. Configure via `defineGame({ time: { scale?, speeds?, dayLength?, start?, startPaused?, daysPerYear?, seasons? } })` (all optional; default is real-time 1:1 with speeds `[1,2,3,4]`, 365-day years).
|
|
725
|
+
|
|
726
|
+
- **Continuous** work scales through `dt`. **Scheduled** work uses game-time timers: `ctx.time.after(sec, cb)`, `ctx.time.every(sec, cb)`, `ctx.time.at(gameSec, cb)` — measured in game-seconds, so 4× fires them 4× sooner and pause freezes them. Each returns a cancel handle.
|
|
727
|
+
- **Controls** (drive from a HUD or a command): `pause()`, `play()`, `toggle()`, `setSpeed(mult)` (0 pauses), `cycleSpeed()`. Read state with `ctx.time.snapshot()` / `ctx.time.calendar()` (`{ day, hour, minute, second, dayFraction, year, dayOfYear, yearFraction, season? }`), or in React with `useGameClock()` → snapshot + `controls`. Speeding to 4× or pausing affects **everything** on the tick — no per-system wiring.
|
|
728
|
+
- **Calendar year/season** rides the same day counter, no separate clock: `year`/`dayOfYear` fall out of `day` divided by `TimeConfig.daysPerYear` (default 365), `yearFraction` is progress through the current year (0..1). Setting `TimeConfig.seasons: string[]` (e.g. `["spring","summer","fall","winter"]`) slices the year into equal named segments and populates `calendar().season`; omit `seasons` and the field is absent — a living-world sim names its own season boundaries this way instead of a hand-rolled `dayOfYear % ...` module.
|
|
729
|
+
|
|
730
|
+
### Beat clock — BPM signal + input quantization
|
|
731
|
+
|
|
732
|
+
`@jgengine/core/time/beatClock` is a separate, purpose-built signal from `simClock` — a BPM tick generator for rhythm games (Hi-Fi Rush–style quantized combat), not a day/pause clock. `createBeatClock({ bpm, beatsPerBar? }, onBeat?)` returns a `BeatClock`: call `advance(gameDt)` from `onTick` with the same **game-time** `dt` (never wall-clock) — it fires `onBeat(beatIndex)` once per newly crossed integer beat and returns a `BeatSnapshot` (`beat`, `beatIndex`, `bar`, `beatInBar`, `phase`). `createBeatInputBuffer<T>(beatDurationSec)` is the auto-correct input buffer: `buffer(action, nowSec)` quantizes an off-beat press to fire on the next beat tick (or immediately if pressed exactly on one); `advance(nowSec)` drains and returns every action whose beat has arrived. `nextBeatTime(nowSec, beatDurationSec)` is the underlying pure quantization function. Feed a music track's actual BPM in; the buffer is what makes an early/late input still land on-beat.
|
|
733
|
+
|
|
734
|
+
## Content catalogs
|
|
735
|
+
## `ctx.game.store` — reactive game state
|
|
736
|
+
|
|
737
|
+
```ts
|
|
738
|
+
ctx.game.store.set("health", 100) // any key, any value type
|
|
739
|
+
ctx.game.store.get("health") // T | undefined
|
|
740
|
+
ctx.game.store.has("health")
|
|
741
|
+
ctx.game.store.delete("health")
|
|
742
|
+
ctx.game.store.subscribe(listener) // change-signal fires on set/delete
|
|
743
|
+
ctx.game.store.mapSnapshot() / arraySnapshot()
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
A reactive per-game keyed store (`ObservableKeyedStore<unknown>`) attached to `GameContext` — reach for it instead of a module-level singleton store for ad-hoc reactive game state (turn trackers, deck UIs, anything that doesn't already have a `ctx` surface). `set`/`delete` bump `ctx.version()` and notify `ctx.subscribe` listeners; `get`/`has` are plain reads. Unlike a per-slot handle, there is no `define`/seed step — a key simply doesn't exist until the first `set`.
|
|
747
|
+
|
|
748
|
+
## `ctx.game.cards` / `ctx.game.turn` — lazily-created piles and turn loops
|
|
749
|
+
|
|
750
|
+
`ctx.game.cards.pile(id, config?)` and `ctx.game.turn.loop(id, config?)` lazily create (config required on first call) or return the existing notify-wrapped `CardPile`/`TurnLoop` for `id` — call with just the id after the first `onInit` seed to fetch the same instance; every mutating method is wrapped so it bumps `ctx.version()`/notifies `ctx.subscribe` automatically, same as every other `ctx` surface. This replaces manually constructing `createCardPile`/`createTurnLoop` and wiring notification yourself.
|
|
751
|
+
|
|
752
|
+
## Movement, pose, input
|
|
753
|
+
## External data — `data/dataSource` and the dev proxy
|
|
754
|
+
|
|
755
|
+
Renderer-free async-state primitives (`@jgengine/core/data`) for a game that reads a live external source (a leaderboard API, a session browser, remote config) — distinct from `ctx.game.store`/multiplayer, which are for the game's own authoritative state.
|
|
756
|
+
|
|
757
|
+
- **`createDataSource(load, options?)`** (`data/dataSource`) → `DataSource<T>` wraps one `load(signal)` async call as `{ status: "idle"|"loading"|"ready"|"error", data, error }`. `getState()` reads the current snapshot, `subscribe(listener)` fires on every change, `refresh({ force? })` re-runs `load` (de-duplicates a call already in flight unless `force`; aborts the prior call first when forced), `startPolling(intervalMs?)`/`stopPolling()` run `refresh` on an interval (`intervalMs` falls back to the one passed at construction; throws if neither is given), `dispose()` tears down polling and in-flight requests for good. Pass `options.clock` (`{ setInterval, clearInterval }`) to swap the timer source in tests.
|
|
758
|
+
- **`fetchJson<T>(url, options?)`** (`data/fetchJson`) — `fetch` + JSON-parse in one call; throws `HttpStatusError` (`status`, `statusText`, `url`) on a non-OK response and `JsonParseError` (`url`, `cause`) on unparsable JSON, so a `DataSource`'s `error` is always one of these two typed shapes, never a bare `Error`. `options.fetchImpl` swaps the fetch implementation for tests/SSR.
|
|
759
|
+
- **`createJsonDataSource<T>(url, options?)`** (`data/jsonDataSource`) — sugar combining the two above: a `DataSource<T>` whose `load` calls `fetchJson(url, options)`.
|
|
760
|
+
- **Dev proxy (`data/devProxy`)** — same-origin routing for external APIs during `bun dev` so browser CORS never blocks a game's `fetchJson` call against a third-party host. `parseDevProxyTable(raw)` parses a `VITE_JGENGINE_DEV_PROXY` env value (a JSON object of `{ routeName: "https://api.example.com" }`) into a `DevProxyTable`; `proxiedUrl(target, { dev?, table?, prefix? })` rewrites a `target` URL whose prefix matches a table entry into `/proxy/<routeName>/<rest>` (default prefix `/proxy`) when `dev` is true (defaults to `import.meta.env.DEV`) — else returns `target` unchanged, so the same call hits the real host in production. `apps/dev`'s `vite.config.ts` reads the same env var and wires a matching Vite server `proxy` entry per route (`changeOrigin: true`, strips the `/proxy/<routeName>` prefix) — set `VITE_JGENGINE_DEV_PROXY` once and both sides (the URL rewrite and the actual proxy route) agree.
|
|
761
|
+
|
|
762
|
+
## Multiplayer and the backend seam
|
|
763
|
+
## Genre cheat sheet
|
|
764
|
+
|
|
765
|
+
- **Voxel/crafting**: objects for blocks/machines, `voxel()`, `object.break`/`object.placeFromInventory`.
|
|
766
|
+
- **Tycoon/lab**: objects + `slotInventory`, `plots()`, configure via prompt → command.
|
|
767
|
+
- **Shooter**: `fireProjectile`/`settleProjectile`; grenades settle → `effect({ at, radius })`; `movement.poses`/`aim` + zoom modifier; `servers({ … })` + game-owned `server.mode`; loadout classes from commands.
|
|
768
|
+
- **MMO/RPG**: bounded stats + `leveling()` over a game XP curve; `tabTarget` → `cycleTarget`; handlers read `getTarget`; quests bound to `entity.died`/`inventory.added`; social party + `partyShare`; `server: "persistent"`.
|
|
769
|
+
- **All combat games**: react to `entity.died` (feed/leaderboard/score) — never poll HP.
|
|
770
|
+
|
|
771
|
+
## Anti-patterns
|
|
772
|
+
|
|
773
|
+
| Wrong | Right |
|
|
774
|
+
|-------|-------|
|
|
775
|
+
| Player tuning in `defineGame` | Entity catalog `movement` + stats |
|
|
776
|
+
| `behaviors: […]` on place/spawn | Catalog entry |
|
|
777
|
+
| Engine `weapon.fire` / `consumable.use` / `combat.*` | `item.use` + catalog `use` → game handler |
|
|
778
|
+
| `ItemUseInput.to` for targets | `getTarget(from)` in handlers |
|
|
779
|
+
| `effect({ to })` for gunshots | `fireProjectile` + `settleProjectile` |
|
|
780
|
+
| Polling HP in `onTick` for kills | `entity.died` event |
|
|
781
|
+
| `combat.lootTable` / `loot.enemy` | `onDeath` on the entity that died |
|
|
782
|
+
| Hand-rolled `Math.random()` loot in commands | `lootTable()` + `ctx.game.loot.roll` |
|
|
783
|
+
| Hand-rolled `xpForLevel`/`levelFromXp` | `game/progression` `curve()` + `leveling()` |
|
|
784
|
+
| Hardcoded shop arrays | `item.trade.shops` + `tradableAt` |
|
|
785
|
+
| Kit seeding via scattered `put`/`grant` | `applyLoadout` |
|
|
786
|
+
| Per-user quest state hand-rolled | `game.quest.register` + binds |
|
|
787
|
+
| `useKillFeed` / per-domain feed hooks | `useFeed({ action })` |
|
|
788
|
+
| Raw keys in game logic | `defineGame` input actions |
|
|
789
|
+
| Positioning inside `ui/components/` or on primitives (`CurrencyPill className="absolute …"`) | Screen wrappers in `GameUI.tsx` only |
|
|
790
|
+
| Game UI classes without `@source` in host CSS | `@source` entries for your game dirs + `node_modules/@jgengine/{shell,react}` |
|
|
791
|
+
| One file per catalog entry / per brand | Dense `<domain>/catalog.ts` |
|
|
792
|
+
| Convex mutations called from game code | `commands.run` through the `GameBackend` transport |
|
|
793
|
+
| Half a system: quest without tracker, cooldown without sweep, keybind never shown, stub "coming soon" modal | Finish the system end to end — or cut it whole (see `jgengine`) |
|
|
794
|
+
| Game-side workaround for a missing engine primitive | File the gap at github.com/Noisemaker111/jgengine/issues (or PR the primitive) and cut or scope the dependent system honestly |
|
|
795
|
+
| Game nouns in this skill | Engine primitives + placeholder ids only |
|
|
796
|
+
|
|
797
|
+
## New-game definition of done
|
|
798
|
+
|
|
799
|
+
This is a gate, not a suggestion — every box, in one pass (workflow: **`jgengine`** skill). "Compiles and the hooks are wired" is not done; a declared system with no UI, no feedback, or no way to exercise it is not done — finish the system or cut it whole.
|
|
800
|
+
|
|
801
|
+
- [ ] `game.config.ts` (`defineGame` from `@jgengine/shell/defineGame`) + `index.tsx` (barrel) + `main.tsx` (standalone host) + `loop.ts` + `game/content.ts`
|
|
802
|
+
- [ ] Catalogs: `game/entities/<role>/catalog.ts`, `game/items/<domain>/catalog.ts`, `game/objects/catalog.ts`; loot tables beside their domain
|
|
803
|
+
- [ ] Entity `stats` + `receive` orders aligned on the same stat ids; `role` set (drives targeting + camera)
|
|
804
|
+
- [ ] `game/items/use-handlers.ts` registered in `onInit`; handlers read `getTarget`/`aim`, never a target input
|
|
805
|
+
- [ ] `game/loadouts.ts` + `applyLoadout` in `onNewPlayer` (gated on `isNew`)
|
|
806
|
+
- [ ] `game/quests/catalog.ts` + binds; if using xp/level, a game-owned curve fed to `game/progression` (`curve`/`leveling`) — **with their HUD/tracker, or cut**
|
|
807
|
+
- [ ] `onInit`: register handlers/loadouts/loot/quests, event listeners, feed binds, leaderboard tracks; `setupWorld`
|
|
808
|
+
- [ ] Player spawns with `id === ctx.player.userId`
|
|
809
|
+
- [ ] `game/ui/GameUI.tsx` owns layout; components use `@jgengine/react` hooks
|
|
810
|
+
- [ ] UI passes the **quality bar** above (contrast, scale, framing, genre fit) — not just hook wiring
|
|
811
|
+
- [ ] Camera tuned via `camera` in `defineGame({...})` — defaults untouched means the feel was never checked
|
|
812
|
+
- [ ] For an `environment()` world: a `<game>.world.test.ts` asserts `summarizeEnvironment(world)` (`@jgengine/core/world/environmentSummary`) is non-empty with the expected counts — the browserless scene-correctness gate
|
|
813
|
+
- [ ] HUD screenshotted over a staged `GameUiPreview` scenario and **judged by looking at the image** against the UI quality bar in [`../jgengine-ui/reference.md`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-ui/reference.md) — the final human glance, not the verification loop
|
|
814
|
+
- [ ] Co-located bun tests for pure game math (curves, cooldowns, spawn logic)
|
|
815
|
+
- [ ] Multiplayer via adapter config only; no direct backend calls
|
|
816
|
+
|
|
817
|
+
## Quick reference
|
|
818
|
+
|
|
819
|
+
```
|
|
820
|
+
defineGame (shell) engine fields (assets, world, physics, inventories, input, server, save, time, feed, multiplayer)
|
|
821
|
+
+ presentation fields (content, loop, GameUI, camera, environment, shadows, movement, devtools, …) in one call — smart defaults fill the rest
|
|
822
|
+
defineGame (core) the underlying engine-only primitive: assets, world, physics, inventories, input, server, save, time, feed, multiplayer, loop
|
|
823
|
+
PlayableGame { game, content, loop, GameUI, camera, … } — the runner contract `defineGame` (shell) returns
|
|
824
|
+
GameContext ctx.scene / ctx.game / ctx.player / ctx.item / ctx.camera / ctx.input + subscribe/version
|
|
825
|
+
scene.object place, remove, move, rotate, at, setVisual (per-instance ObjectVisual: scale/color/opacity override)
|
|
826
|
+
scene.entity spawn (anchor/offset), despawn, setPose, update; stats; targeting; effects;
|
|
827
|
+
|
|
828
|
+
---
|
|
829
|
+
name: jgengine-multiplayer
|
|
830
|
+
description: Multiplayer API: adapters, topology, authority, rooms, persistence.
|
|
831
|
+
---
|
|
832
|
+
|
|
833
|
+
# jgengine-multiplayer
|
|
834
|
+
|
|
835
|
+
## Multiplayer and the backend seam
|
|
836
|
+
|
|
837
|
+
The transport/host/persistence seam — `createWsBackend`, protocol codec, browser-safe authoritative host + router, WebRTC P2P, the Node/Convex/SQL adapters, presence, and save cadence. Full surface: **[reference.md](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-multiplayer/reference.md)**.
|
|
838
|
+
|
|
839
|
+
## UI — `@jgengine/react`
|
|
840
|
+
|
|
841
|
+
# jgengine domain API — Multiplayer and the backend seam
|
|
842
|
+
|
|
843
|
+
Reference module for the [`jgengine-multiplayer` API](SKILL.md) skill. Load this when you need the transport/host/persistence seam.
|
|
844
|
+
|
|
845
|
+
## Multiplayer and the backend seam
|
|
846
|
+
|
|
847
|
+
**Convex is an adapter, not a dependency.** The engine owns the contracts; any backend implements them:
|
|
848
|
+
|
|
849
|
+
```ts
|
|
850
|
+
// @jgengine/core/runtime/transport
|
|
851
|
+
type GameBackend = {
|
|
852
|
+
transport: GameRuntimeTransport; // joinServer, leaveServer, runCommand
|
|
853
|
+
feeds?: GameRuntimeFeeds; // subscribeServer/Player/Feed(args, onChange) => unsubscribe
|
|
854
|
+
presence?: PresenceTransport; // multiplayer/presenceContract
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
type LiveGameBackend = GameBackend & {
|
|
858
|
+
presenceSync: PresenceSync; // subscribe(serverId, onChange) + syncPose(serverId, pose)
|
|
859
|
+
pushFeedEntry: (args: { serverId: string; action: string; entry: unknown }) => Promise<void>;
|
|
860
|
+
chatSyncFor?: (serverId: string) => ChatSync; // present ⇒ the shell also bridges global chat
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
type MultiplayerSession = { gameId: string; userId: string; backend: LiveGameBackend; feedActions: string[] };
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
`GameRuntimeFeeds` is a callback contract (`subscribe*(args, onChange) => FeedUnsubscribe`) — backend-neutral, no reactive-query shapes. Swapping backends = implement `GameBackend` (or the richer `LiveGameBackend` a shell session needs) + host authoritative `runCommand` elsewhere; game `commands` and `loop` do not change. Adapter configs in defineGame: `offline()`, `convex({ topology })`, `ws({ topology, url? })`, `fly({ app, topology?, path? })` (ws sugar → url `wss://<app>.fly.dev<path ?? "/ws">`), `socketIo({ topology?, url? })`, `p2p({ topology?, room? })` (topology defaults `"private"`), `lan({ topology?, port?, path? })`, `servers({ maxServers, slotsPerServer, minPlayersToStart, adapter })`. `topology` is exactly `"shared" | "lobbies" | "private"` — no other values exist; a persistent MMO world is `server: "persistent"` + topology `"shared"`.
|
|
867
|
+
|
|
868
|
+
**Resolvers turn a game + adapter config into a `MultiplayerSession`, or `null` when the config doesn't match** — offline stays the default whenever none resolves: `resolveShellMultiplayer({ game, gameId, url?, userId?, force?, feedActions? })` (`@jgengine/shell/multiplayer`) resolves `ws` (url from arg ?? adapter ?? `ws://localhost:8080/ws`) and `lan` (url derived from `window.location`); `resolvePeerShellMultiplayer({ gameId, role, room? })` is the `p2p` counterpart over `broadcastChannelSignaling`; `resolveConvexMultiplayer({ game, gameId, url?, client?, api?, userId?, force?, feedActions?, poseTuning? })` (`@jgengine/convex/resolveConvexMultiplayer`) resolves on `"convex"`, wrapping `createConvexBackend`. `adapterOf` / `multiplayerAdapterKind` (`@jgengine/core/runtime/adapter`) are the shared classifiers every resolver calls. A host that supports several transports tries them in sequence and hands whichever resolves (or `null`) to `<GamePlayerShell multiplayer={...}>` — see `apps/dev/src/main.tsx` for `resolveConvexMultiplayer(...) ?? resolveShellMultiplayer(...)`.
|
|
869
|
+
|
|
870
|
+
Once a session resolves, `GamePlayerShell` wires it up with **no game code changes**: pose presence (subscribes `presenceSync`, renders every other member as `RemotePlayers`), `feedActions` (default `entity.died`) bridged both ways through `pushFeedEntry` / `feeds.subscribeFeed` with echo suppression, and — only when the backend exposes `chatSyncFor` — `global`-kind chat channels relayed through it (`whisper` / `party` / `proximity` stay local regardless of backend). Everything else in `GameContext` (inventories, quests, world state) stays client-local unless the game also registers a server-side `GameRuntime`.
|
|
871
|
+
|
|
872
|
+
A synced pose (`@jgengine/ws/protocol`'s `WsPose`/`WsPresenceRow`) carries an optional `appearance: Record<string, string | number | boolean>` alongside position/rotation — primitive-valued client-set tags (skin id, mount id, active emote, team color) riding the same pose-sync frames as position, so a remote player's cosmetic state updates on the existing presence channel instead of a second round-trip.
|
|
873
|
+
|
|
874
|
+
**Server side** — `@jgengine/convex/server` ships an entire authoritative Convex backend as factories, not a template to copy: `jgengineTables()` (schema spread), `createGameServerFunctions({ runtimes?, auth? })`, `createLeaderboardFunctions({ auth? })`, `createPresenceFunctions({ auth?, freshWindowMs? })`, `createChatFunctions({ auth?, historyLimit?, maxBodyLength?, minIntervalMs? })`, and `jgengineCronSpecs()` (tick/flush interval metadata for a `crons.ts`). A consumer's `convex/` directory is ~25 lines: `schema.ts` (`defineSchema({ ...jgengineTables() })`), four one-line re-export files (`runtime.ts`, `leaderboard.ts`, `presence.ts`, `chat.ts`, each just calling its factory), and `crons.ts` registering the tick (1s) + flush (60s) internal mutations — see `examples/convex-host` for the reference shape. No game-specific code lives there; any JGengine game can point at the same deployment. Games without a registered `GameRuntime` fall back to a no-save runtime that only understands `engine.ping`; pass `createGameServerFunctions({ runtimes: [createGameRuntime({ gameId, commands, loop, save })] })` to make `runCommand` / tick / save actually do something.
|
|
875
|
+
|
|
876
|
+
Auth defaults to `"anonymous"` (`JgAuthMode`) on every factory — the client's `externalId` is trusted as claimed, fine for local dev but spoofable. Pass `{ auth: "required" }` to every factory for production; the resolved actor becomes `ctx.auth.getUserIdentity()`'s `subject` and `externalId` is only cross-checked against it, never trusted alone.
|
|
877
|
+
|
|
878
|
+
**Flip a game online, step by step:** (1) add `multiplayer: convex({ topology: "shared" })` to `game.config.ts` (see `Games/voxel-mine`); (2) stand up a Convex deployment — `bunx convex dev` inside `examples/convex-host` codegens `convex/_generated/` and prints the dev URL; (3) run the client with `VITE_CONVEX_URL=<url>` (env or `.env.local`) — `apps/dev/src/main.tsx` reads it and forces `resolveConvexMultiplayer`. No Convex Cloud account is required: `examples/convex-host/docker-compose.yml` runs the open-source backend (FSL-1.1-Apache-2.0) locally or on any Docker host (Fly.io/Railway templates upstream; Vercel can host only the game client, never the backend) — set `CONVEX_SELF_HOSTED_URL` + `CONVEX_SELF_HOSTED_ADMIN_KEY` in `.env.local` and the same `bunx convex dev` deploys there with full parity (crons, scheduling, file storage). The ws path is the same shape: `multiplayer: ws({ topology })`, a `@jgengine/node` host (`createGameHost` + `createGameWsServer`), and `VITE_JG_WS_URL` forcing `resolveShellMultiplayer`.
|
|
879
|
+
|
|
880
|
+
**Game code never calls backend functions for gameplay verbs.** The generic server surface (no game nouns): `joinServer / leaveServer / runCommand / getServer / getPlayerProfile / getFeed / listOpenServers`, leaderboard `getTop / getProfile` (writes are internal — increments stage under `LEADERBOARD_PENDING_KEY` in server session and drain through the persistence seam on flush).
|
|
881
|
+
|
|
882
|
+
Persistence tiers (`@jgengine/core/runtime/hostPersistence` — `HostPersistence` interface, `GameServerRecord` / `PlayerProfileRecord` / `WorldChunkRecord`, `planServerPersist` / `buildHydratePlayers` / `shouldAutoSave` / `trimFeedEntries`): server session, player profile (split on join — `isNew` = no profile), world chunks, leaderboards, feeds (ring of 20). Saves store ids/counts/positions; catalogs stay live so balance patches apply retroactively. Register runnable games host-side via `createGameRuntime({ gameId, commands, loop, save })` — those server hooks are `ServerLoopHooks` (snapshot-based), distinct from the client `GameLoop<GameContext>`.
|
|
883
|
+
|
|
884
|
+
**Netcode-depth primitives (pure core, backend-neutral).** These sit *above* the transport — game code drives them inside `commands`/`loop`; the host retains what must be authoritative:
|
|
885
|
+
- **Lag-compensated hit reg** (`multiplayer/lagCompensation`, #104). `createPositionHistory({ historyMs })` is an N-sample ring per entity; the authoritative `@jgengine/node` ws host records every accepted presence pose and exposes `server.rewind({ serverId, atMs })`. A hitscan command rewinds to `rewindTimestamp(now, rtt, interpDelay)` (= `now − rtt/2 − interpDelay`) and calls `resolveHitscan(history, targets, ray, atMs)` — coarse server-side rewind, **not** full rollback. Valorant/Apex twitch hit reg.
|
|
886
|
+
- **Simultaneous hidden-commit + reveal** (`multiplayer/simultaneousCommit`, #105). `createCommitRound({ participants })`: each side `seal`s a sealed action; nothing is readable until `allSealed()`, then `reveal()` returns commits in **participant order** (deterministic regardless of arrival), which `resolveCommits` folds. Marvel Snap face-down-then-reveal.
|
|
887
|
+
- **Combat-snapshot replay** (`multiplayer/combatSnapshot`, #106). `serializeBoard({ ownerId, units, stats, seed })` deep-freezes a build into a portable `BoardSnapshot`; `replayCombat(a, b, rules)` resolves it **deterministically** (seeded PRNG) against a live opponent's snapshot — distinct from live-sync adapters. The Bazaar async PvP.
|
|
888
|
+
- **Auth identity seam** (`multiplayer/identity`). `AuthSession { userId, displayName?, avatarUrl?, email?, isNew? }` is the one shape every social/multiplayer system keys off. `sessionPlayer(session)` maps it onto the `player: { userId, isNew }` argument of `createGameContext`; `resolveGuestSession(seed?)` mints a stable anonymous id for local/dev. Clerk and better-auth wire in through the `@jgengine/react/identity` adapters (`clerkIdentity(useUser())`, `betterAuthIdentity(authClient.useSession())`) — structural mappers over the shapes those hooks return, so neither SDK is a dependency of any engine package.
|
|
889
|
+
- **Session matchmaking** (`multiplayer/matchmaking`, #109). Filters are DATA: `browseSessions(listings, filter, { limit })` hides private/closed, `findByJoinCode` (loose-normalized codes), `quickMatch` fills the fullest joinable lobby. The node host carries generic `SessionAttributes` (`label`/`mode`/`visibility`/`joinCode`/`tags`) on `GameServerRecord`/`ServerListing` and adds `browseServers` + `joinByCode`; the ws backend exposes `browse` / `joinByCode` / `createSession`. Fortnite island browse, Web Fishing join-by-code.
|
|
890
|
+
|
|
891
|
+
**Transport pipe seam** (`@jgengine/ws/pipe`) — `createWsBackend` and the host-side `createHostRouter` don't require a raw WebSocket; both run over any bidirectional string channel. `TransportPipe { send(data: string), close() }` + `TransportPipeHandlers { onOpen, onMessage(data), onClose }`; a `TransportPipeFactory = (handlers) => TransportPipe` opens one connection. `webSocketPipe(url, webSocketFactory?)` is the browser-`WebSocket` default. `createWsBackend({ userId, url?, pipe? })` takes either (one of the two is required) — this seam is what lets the same JSON wire protocol ride a raw ws socket, socket.io, an in-process loopback, or a WebRTC data channel with one implementation.
|
|
892
|
+
|
|
893
|
+
**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.
|
|
894
|
+
|
|
895
|
+
**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.
|
|
896
|
+
|
|
897
|
+
**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.
|
|
898
|
+
|
|
899
|
+
Backends:
|
|
900
|
+
- **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.
|
|
901
|
+
- **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.
|
|
902
|
+
- **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.
|
|
903
|
+
- **Postgres** — `@jgengine/sql` `ensureSchema(pool)` + `sqlPersistence(pool)` implement `HostPersistence` over any pg-compatible pool (structural interface, no hard `pg` dep; tables `jg_game_servers`, `jg_player_profiles`, `jg_world_chunks`, `jg_leaderboard_rows`, `jg_feed_buffers`). `HostPersistence.savePlan` applies a whole `ServerPersistPlan` in one transaction (leaderboard drain included); hosts fall back to per-tier calls when absent.
|
|
904
|
+
- **Clients** — `@jgengine/shell` (`GamePlayerShell`; each client supplies its own `GameRegistry`) is the shared player: it works in Vite, Next.js, or a Tauri webview; the authoritative ws host stays a standalone process (or, over `@jgengine/ws/peer`, the host player's own browser tab).
|
|
905
|
+
- **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). `<GamePlayerShell 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.
|
|
906
|
+
- **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.
|
|
907
|
+
- **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 + push-to-talk live in `@jgengine/react/voice` (`useVoice`, `createPushToTalk` state model: hold / toggle / openMic, mute-gated). `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.
|