@jgengine/core 0.12.0 → 0.14.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/dist/combat/effects.d.ts +11 -4
  3. package/dist/combat/effects.js +24 -17
  4. package/dist/combat/magazine.d.ts +13 -0
  5. package/dist/combat/magazine.js +27 -0
  6. package/dist/combat/vfxPresets.d.ts +176 -0
  7. package/dist/combat/vfxPresets.js +74 -0
  8. package/dist/combat/weaponFire.d.ts +114 -0
  9. package/dist/combat/weaponFire.js +96 -0
  10. package/dist/combat.d.ts +4 -2
  11. package/dist/combat.js +3 -1
  12. package/dist/devtools/urlFlags.d.ts +22 -0
  13. package/dist/devtools/urlFlags.js +62 -0
  14. package/dist/game/clipRoles.d.ts +51 -0
  15. package/dist/game/clipRoles.js +142 -0
  16. package/dist/game/defineGame.d.ts +2 -2
  17. package/dist/game/partAnimation.d.ts +78 -0
  18. package/dist/game/partAnimation.js +147 -0
  19. package/dist/game/playableGame.d.ts +38 -5
  20. package/dist/game/progression.d.ts +2 -2
  21. package/dist/game/progression.js +4 -2
  22. package/dist/inventory/inventoryModel.d.ts +24 -8
  23. package/dist/inventory/inventoryModel.js +47 -8
  24. package/dist/meta/changelog.d.ts +1 -1
  25. package/dist/meta/changelog.js +45 -1
  26. package/dist/runtime/context/combat.js +2 -1
  27. package/dist/runtime/context/combatFx.js +15 -5
  28. package/dist/runtime/context/scene.d.ts +4 -1
  29. package/dist/runtime/context/scene.js +86 -1
  30. package/dist/runtime/gameContext.js +51 -4
  31. package/dist/runtime/gameContextTypes.d.ts +58 -4
  32. package/dist/runtime/headlessRunner.d.ts +13 -0
  33. package/dist/runtime/headlessRunner.js +14 -0
  34. package/dist/scene/assetCatalog.d.ts +2 -0
  35. package/dist/scene/builtinSceneKinds.d.ts +6 -1
  36. package/dist/scene/builtinSceneKinds.js +10 -3
  37. package/dist/scene/colliders.d.ts +41 -0
  38. package/dist/scene/colliders.js +140 -0
  39. package/dist/scene/collisionMesh.d.ts +8 -0
  40. package/dist/scene/collisionMesh.js +48 -0
  41. package/dist/scene/entityStats.js +9 -1
  42. package/dist/scene/sceneKinds.d.ts +19 -0
  43. package/dist/stats/statModifiers.d.ts +16 -0
  44. package/dist/stats/statModifiers.js +30 -1
  45. package/dist/stats/statPool.js +5 -2
  46. package/dist/ui/hudScale.d.ts +16 -1
  47. package/dist/ui/hudScale.js +22 -1
  48. package/dist/ui/panelModel.d.ts +120 -0
  49. package/dist/ui/panelModel.js +168 -0
  50. package/dist/ui.d.ts +1 -0
  51. package/dist/ui.js +1 -0
  52. package/dist/world/buildingLots.d.ts +84 -0
  53. package/dist/world/buildingLots.js +119 -0
  54. package/dist/world/buildings.d.ts +6 -0
  55. package/dist/world/cityGenerator.d.ts +41 -0
  56. package/dist/world/cityGenerator.js +58 -0
  57. package/dist/world/cityKind.d.ts +2 -2
  58. package/dist/world/cityKind.js +12 -6
  59. package/dist/world/environmentSummary.js +57 -1
  60. package/dist/world/features.d.ts +35 -2
  61. package/dist/world/features.js +5 -2
  62. package/dist/world/grassKind.js +4 -1
  63. package/dist/world/roads.d.ts +19 -2
  64. package/dist/world/roads.js +60 -3
  65. package/dist/world/scatterCoverage.d.ts +98 -0
  66. package/dist/world/scatterCoverage.js +101 -0
  67. package/dist/world/scatterRegion.js +6 -0
  68. package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
  69. package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
  70. package/dist/world/streets.d.ts +74 -1
  71. package/dist/world/streets.js +218 -0
  72. package/dist/world/terrain.d.ts +20 -1
  73. package/dist/world/terrain.js +41 -2
  74. package/dist/world.d.ts +1 -0
  75. package/dist/world.js +1 -0
  76. package/package.json +2 -2
  77. package/skills/jgengine/SKILL.md +5 -2
  78. package/skills/jgengine/api.md +13 -5
  79. package/skills/jgengine-combat/SKILL.md +15 -1
  80. package/skills/jgengine-combat/api.md +39 -2
  81. package/skills/jgengine-combat/capabilities.md +17 -1
  82. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  83. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  84. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  85. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  86. package/skills/jgengine-combat/reference.md +22 -3
  87. package/skills/jgengine-gameplay/SKILL.md +8 -0
  88. package/skills/jgengine-gameplay/api.md +35 -5
  89. package/skills/jgengine-gameplay/capabilities.md +18 -0
  90. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  91. package/skills/jgengine-gameplay/reference-systems.md +9 -0
  92. package/skills/jgengine-world/api.md +82 -25
  93. package/skills/jgengine-world/capabilities.md +12 -4
  94. package/skills/jgengine-world/reference.md +1 -1
@@ -13,6 +13,25 @@ Native scene entity stats are a compatibility bridge over the same model. See
13
13
  the [portable stat-pool recipe](recipes/portable-stat-pools.md) for complete
14
14
  imports, adapter code, ownership, and JSON save/restore.
15
15
 
16
+ ## Portable runtime snapshots
17
+
18
+ `createMagazine` (`combat/magazine`) and `createStats`
19
+ (`stats/statModifiers`) retain their convenience APIs while exposing detached
20
+ plain state through `snapshot()` / `restore(...)`. Magazine snapshots include
21
+ loaded rounds, in-progress reload time, and finite/infinite reserve state;
22
+ stat snapshots include base values plus ordered modifier sources and absolute
23
+ expiry timestamps. Re-supply immutable config and the caller's injected clock
24
+ when rebuilding. The [portable runtime-state recipe](recipes/portable-runtime-state.md)
25
+ shows a complete existing-save round trip, including a caller-owned reserve.
26
+ `createEffectSystem` (`combat/effects`) commits direct or area effects through
27
+ the same `StatPoolAccess` contract. Receive rules still define deterministic
28
+ shield/resource spill order, modifiers, and the final lethal pool; results
29
+ carry exact applied deltas and lethal context. The legacy `resolveStats` input
30
+ remains as a compatibility bridge, while native `GameContext` now supplies its
31
+ `EntityStatsApi` adapter. Compose this committer with pure `resolveDamageHit`
32
+ (matchup/received/status provenance) and `resolveDamage` (ordered bounded
33
+ interceptors). See the [portable damage/effects recipe](recipes/portable-damage-effects.md).
34
+
16
35
  ## Effects and projectiles
17
36
 
18
37
  Effect ids are **game-defined strings**. Magnitudes **drain** stats: positive subtracts down `receive.<effect>.order` (spilling to the next stat in the order), negative restores. Heals pass a negative amount (`via: { amount: -flashHeal }`, typically read from a `weapon.heal` stat).
@@ -36,7 +55,7 @@ fireProjectile({ from, via, aim, effect }) // → shotId (pending)
36
55
  settleProjectile(shotId) // authoritative → { at, hits } | rejection
37
56
  ```
38
57
 
39
- `Aim = { origin, direction } | { yaw, pitch, spread? }`. A free `{ yaw, pitch }` aim defaults to `converge`: the bullet leaves the gun `muzzle` (so a tracer visibly comes from the barrel, not the camera) yet bends toward whatever the eye ray's crosshair covers, so the reticle stays truth. An explicit `{ origin, direction }` aim (enemy AI, turrets) passes through unchanged. Shot origin otherwise resolves via `ShotOriginPolicy` (`eye`: shooter position raised to eye height — 90% of the shooter's own hitbox top when colliders are known, ~1.6m otherwise — so shots trace the sightline and the crosshair is truth; `muzzle`/`camera`/`world` for custom rigs, `legacy` = raw entity position). Hitboxes auto-fit the rendered model: an entity kind mapped in `entityModels` (or an object catalog id in `objectModels`) gets a grounded AABB matching its model's measured bounds — footprint x height composed with `scale`/`targetHeight` exactly like the renderer — so the damage box equals the visual without hand-tuning. Unmodeled entities keep the body-covering humanoid AABB default (feet to ~1.8m); unmodeled objects with a `visual.scale` get a matching grounded physical body. Catalog assets opted into `COLLISION_MESH_ASSET_IDS` (`@jgengine/assets`) go one step further: the fit raycasts the asset's actual triangles (BVH over a quantized mesh shipped in the index), so a shot through a torus hole or archway passes clean instead of hitting the fitted box — same shape on client and headless host. Explicit catalog `colliders`/`halfExtents` and per-instance `setColliders` always override the fit. Hitscan settles into per-hit effects; ballistic shots (`weapon.projectile` with `fuseTime`/`settleOn`) settle to a landing point — the handler then calls `effect({ at: settle.at, radius })`. Settling twice rejects. Prediction is never authority.
58
+ `Aim = { origin, direction } | { yaw, pitch, spread? }`. A free `{ yaw, pitch }` aim defaults to `converge`: the bullet leaves the gun `muzzle` (so a tracer visibly comes from the barrel, not the camera) yet bends toward whatever the eye ray's crosshair covers, so the reticle stays truth. An explicit `{ origin, direction }` aim (enemy AI, turrets) passes through unchanged. Shot origin otherwise resolves via `ShotOriginPolicy` (`eye`: shooter position raised to eye height — 90% of the shooter's own hitbox top when colliders are known, ~1.6m otherwise — so shots trace the sightline and the crosshair is truth; `muzzle`/`camera`/`world` for custom rigs, `legacy` = raw entity position). Hitboxes auto-fit the rendered model: an entity kind mapped in `entityModels` (or an object catalog id in `objectModels`) gets a grounded AABB matching its model's measured bounds — footprint x height composed with `scale`/`targetHeight` exactly like the renderer — so the damage box equals the visual without hand-tuning. Custom-rendered content auto-fits too: the shell measures what `renderEntity`/`renderObject` actually mount (and any model with no index `dims`) and reports the entity-local bounds through `ctx.scene.entity.reportBounds` / `ctx.scene.object.reportBounds`, so a procedural mesh's hitbox wraps the render instead of the humanoid default — flag debug gizmos with `userData.jgMeasureExclude` to keep them out of the measurement, and note the measurement happens on the rendering client, so a never-rendering authoritative host should author `colliders` or model `dims` instead. Entities with nothing measurable at all keep the body-covering humanoid AABB default (feet to ~1.8m); unmodeled objects with a `visual.scale` get a matching grounded physical body. Catalog assets opted into `COLLISION_MESH_ASSET_IDS` (`@jgengine/assets`) go one step further: the fit raycasts the asset's actual triangles (BVH over a quantized mesh shipped in the index), so a shot through a torus hole or archway passes clean instead of hitting the fitted box — same shape on client and headless host. Explicit catalog `colliders`/`halfExtents` and per-instance `setColliders` always override the fit. Hitscan settles into per-hit effects; ballistic shots (`weapon.projectile` with `fuseTime`/`settleOn`) settle to a landing point — the handler then calls `effect({ at: settle.at, radius })`. Settling twice rejects. Prediction is never authority.
40
59
 
41
60
  Raycasts are **object-aware**: the default raycast checks placed scene objects as well as entities, discriminated by `RaycastHit.kind` (`"entity" | "object"` — an `ObjectRaycastHit` also carries `catalogId`). A crate or wall between shooter and target blocks or absorbs the shot instead of every projectile passing through scenery; supply `ProjectileSystemDeps.objects` (`{ list(), halfExtents?(catalogId) }`, matching `ObjectStore.list()` structurally) to opt in, with a `[0.5, 0.5, 0.5]` half-extent default per object.
42
61
 
@@ -85,7 +104,8 @@ Layered on top of effects/projectiles/death — none of it replaces them, it add
85
104
  - **Render cues** (`combat/renderCues`, #542) - the pure math behind a custom `renderEntity`/viewmodel rig's motion feel: `EntityRenderCues { speed, bobPhase, firing, reloading, recoil, hit, dead }`, `advanceMotionCues(cues, speed, dt, tuning?)` advances `bobPhase`/decays `recoil` from a live `groundSpeed(entity)` sample, `applyRenderAnimationEvent(cues, event)` folds in `"fire"` / `"reload"` / `"reloadEnd"` from `ctx.game.playEntityAnimation`, `applyRenderHitEvent`/`applyRenderDeathEvent` fold in `combat.hitReaction`/`entity.died`. `RenderCueTuning` overrides `cyclesPerUnit`/`recoilDecayPerSecond`/`firePulseSeconds`/`hitPulseSeconds`. A game never calls these directly — `@jgengine/shell/render`'s `useEntityRenderCues(instanceId)` (see `jgengine-ui`) wires all four into one ref-returning hook for `useFrame` consumers.
86
105
  - **Telegraph** (`combat/telegraph`) — a `TelegraphShape` (`circle | ring | cone | line`) + `windupMs`. `ctx.scene.entity.telegraph({ from, shape, at, dir?, windupMs, kind?, effect? })` emits `combat.telegraph` for the shell to draw a ground decal that fills over the windup, and — if `effect` is bound — applies that effect to everyone inside the shape (`pointInTelegraph`) at activation. Returns a cancel handle. **Turn-scoped variant**, for tactics/turn-based enemy intents instead of a real-time windup: set `TelegraphConfig.turns` (a count of turns instead of `windupMs`), then read `telegraphTurnProgress(config, startedTurn, currentTurn)` (0..1 fill fraction), `telegraphFiredAtTurn(config, startedTurn, currentTurn)` (has it resolved), and `telegraphTurnsRemaining(config, startedTurn, currentTurn)` — the same "enemy will hit this tile" decal, advanced by `turnLoop.round()`/`active()` instead of a clock.
87
106
  - **Damage-number typing** — `ctx.scene.entity.floatText({ …, crit?, element?, hitType?, scale? })` (and the `entity.floatText` event) carry hit metadata; the shell's `resolveFloatTextStyle` (`@jgengine/shell/world/floatTextStyle`) maps crit/element to color + scale + glow.
88
- - **VFX — one-shot vs retained** — `ctx.scene.entity.vfx({ kind, color, from?, to?, radius?, durationMs? })` fires a **one-shot** burst (`combat.vfx`, `kind: projectile | beam | nova | glow | spark`) the shell renders once and expires. For a **long-lived** effect whose endpoints/params move over time (held beam, tether, zone, target line, looping emitter) use the retained registry `ctx.scene.entity.vfxInstance` (`createVfxInstanceStore`, `@jgengine/core/game/vfxInstance`): `upsert({ id?, kind, color, from?, to?, radius?, params?, ttlMs? })` creates or replaces by stable id, `update(id, patch)` nudges dynamic params (a missing id is a no-op, not a throw), `stop(id, { fadeMs? })` disposes with an optional fade, and `tick(now)` enforces the TTL heartbeat; `list()`/`count()` are for debug. Endpoints are `VfxRef`s — an entity **instance id** (the shell resolves its live pose each frame, so the effect follows a moving entity with no per-frame commands) or a fixed `[x, y, z]` world point. State is headless/serializable and the store is renderer-independent; each op emits `combat.vfxInstance` which the shell's `RetainedVfx` overlay binds to render resources (beam is the first shipped retained renderer). Call `vfxInstance.clear()` on scene/level reset. Duet Keys' prism beam is the first adopter (driven from its `onTick`).
107
+ - **VFX — make an attack visible in one line** — the easy, discoverable path is a named `preset`: `ctx.scene.entity.vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with zero color/archetype tuning, and `"lightning"`, `"web"`, `"slash"`/`"sword"`, `"shield"`, `"heal"`, `"fireball"`, `"frostbolt"`, `"explosion"` and the rest of `vfxPresets` (`@jgengine/core/combat`) likewise just work — this is the answer to "I fired a projectile at the enemy but couldn't see anything." A preset is pure presentation vocabulary (a `kind` + color + lifetime, like named CSS colors); it never touches damage or hit resolution, which stay in your effect/projectile data. **The authoritative `fireProjectile`/`settleProjectile` and `applyEffect` do not emit any visual on their own** — emit the `vfx` burst yourself when you fire/settle (`fireProjectile(...)` → `vfx({ preset, from, to })`). Any field you also pass overrides the preset (`vfx({ preset: "fireball", color: 0x00ff00 })`); supply `kind` + `color` directly for a fully custom burst with no preset; an unknown flavor name degrades to a visible `spark` rather than nothing. `resolveVfxPreset(name, overrides?)` returns the raw `{ kind, color, durationMs?, radius? }` when you need the numbers (e.g. to seed a retained instance from the same vocabulary). Full walkthrough: [make an attack you can actually see](recipes/visible-attacks.md).
108
+ - **VFX — one-shot vs retained** — `ctx.scene.entity.vfx({ preset?, kind?, color?, from?, to?, radius?, durationMs? })` fires a **one-shot** burst (`combat.vfx`, `kind: projectile | beam | nova | glow | spark`) the shell renders once and expires. For a **long-lived** effect whose endpoints/params move over time (held beam, tether, zone, target line, looping emitter) use the retained registry `ctx.scene.entity.vfxInstance` (`createVfxInstanceStore`, `@jgengine/core/game/vfxInstance`): `upsert({ id?, kind, color, from?, to?, radius?, params?, ttlMs? })` creates or replaces by stable id, `update(id, patch)` nudges dynamic params (a missing id is a no-op, not a throw), `stop(id, { fadeMs? })` disposes with an optional fade, and `tick(now)` enforces the TTL heartbeat; `list()`/`count()` are for debug. Endpoints are `VfxRef`s — an entity **instance id** (the shell resolves its live pose each frame, so the effect follows a moving entity with no per-frame commands) or a fixed `[x, y, z]` world point. State is headless/serializable and the store is renderer-independent; each op emits `combat.vfxInstance` which the shell's `RetainedVfx` overlay binds to render resources (beam is the first shipped retained renderer). Call `vfxInstance.clear()` on scene/level reset. Duet Keys' prism beam is the first adopter (driven from its `onTick`).
89
109
 
90
110
  ## Abilities, resources, auto-target, resistance, run drafts
91
111
 
@@ -105,4 +125,3 @@ Genre systems layered over the same effects/projectiles/targeting/loot primitive
105
125
  - **Completion predicates** — `completion: { kind, params }` names a registered evaluator; `BUILTIN_COMPLETION_PREDICATES` ships `immediate`, `manual`/`flag`, `timer` (phase seconds), `cleared` (tagged count drained), `metric` (live threshold), `event` (cumulative count), and `quorum` (N of nested predicates). Signals (`counts`/`metrics`/`events`/`flags`) come in through `ctx.signals`; evaluation is deterministic. Register your own via `config.predicates`.
106
126
  - **Spawn providers** — spawning is one optional node adapter: `spawn: { kind, params }` invoked on enter. `BUILTIN_SPAWN_PROVIDERS.points` reads a named authored scene point list from `ctx.spawnPoints` (coordinates stay in the scene, referenced by key — never embedded in the sequence); `list` echoes inline requests. Register catalog/director-backed providers via `config.spawnProviders`.
107
127
  - **Injection & scripted control** — `injectPhase(state, phase, { after | before | childOf })` inserts a phase or branch at runtime (e.g. a boss after the final wave) into the serializable tree without disturbing active/completed nodes; `forceCompletePhase` completes the active phase regardless of its predicate for scripted skips and failure/retry, restoring the authored predicate afterward.
108
-
@@ -11,6 +11,10 @@ This skill owns non-combat rules and state: systems, catalogs, inventory/items,
11
11
 
12
12
  Use [capabilities.md](capabilities.md) for intent discovery, [api.md](api.md) for signatures, and [reference-systems.md](reference-systems.md) for scheduling details before hand-rolling a subsystem.
13
13
 
14
+ Existing projects can keep player state and saves behind `LevelingStatAccess`.
15
+ Follow the [portable XP/leveling recipe](recipes/portable-xp-leveling.md) for
16
+ custom stat ids, immutable store writes, multiple level events, and JSON resume.
17
+
14
18
  ## Canonical workflow
15
19
 
16
20
  1. Define plain serializable state and stable ids.
@@ -30,6 +34,10 @@ Prefer `defineSystem` and `defineGame({ systems })` for scheduled capabilities.
30
34
  - Random selection accepts injected RNG and stable ordering.
31
35
  - Large collections use indexes, queues, or bounded reconciliation rather than repeated scans.
32
36
 
37
+ ## Part-composed character motion
38
+
39
+ A character kit-bashed from primitives/`ModelPart`s (no skeleton, no clips) animates procedurally: tag parts with `role` (`leg.l`, `arm.r`, `head`, `tail`, `wing.l`…) and the shell's part-motion rig drives counter-phase limb swing from the entity's live movement speed, idle breathe/sway, hit flinch on `combat.hitReaction`, and a death topple on `entity.died` — no game-side frame loops. Tune with `ModelConfig.partMotion`; curves are pure and deterministic (`@jgengine/core/game/partAnimation`), so crowds de-sync by instance-id phase and replays stay stable. Soft characters (blobs, slimes) opt into volume-conserving squash-and-stretch with `partMotion: { squashAmp }` — footfall squash, jelly idle breathe, flinch pulse — and `deathStyle: "splat"` flattens out on death instead of the default sideways topple. Rigged GLBs keep using `ModelConfig.animation` — roles are only for rig-less compositions.
40
+
33
41
  ## Traps
34
42
 
35
43
  - A bare export mention is not an example; use capabilities for discovery and real imports/tests for adoption.
@@ -336,6 +336,16 @@
336
336
  - `createChatFilter` (function): function createChatFilter(config: ChatFilterConfig): ChatFilter — ⚠ undocumented
337
337
  - `normalizeChatText` (function): function normalizeChatText(text: string): string — ⚠ undocumented
338
338
 
339
+ ## @jgengine/core/game/clipRoles
340
+
341
+ - `ClipRole` (type): type ClipRole = | "idle" | "walk" | "run" | "attack" | "hit" | "death" | "jump" | "spawn" | "interact" | "cheer" — Semantic animation roles a GLB clip name can map to. Rigged packs name the same motion differently (`Idle` / `Walking_A` / `Armature|Run`); roles are the pack-agnostic vocabulary the engine defaults from.
342
+ - `ClipRoleTable` (interface): interface ClipRoleTable — Keyword table driving {@link classifyClip}: lowercase keywords matched against a clip name's tokens (exact token match beats prefix match). Extend or replace per game/pack — the table is plain data, no genre assumptions.
343
+ - `DEFAULT_CLIP_ROLE_TABLE` (const): const DEFAULT_CLIP_ROLE_TABLE: ClipRoleTable — Default vocabulary covering KayKit, Quaternius, and Mixamo-style clip naming.
344
+ - `classifyClip` (function): function classifyClip(name: string, table: ClipRoleTable = DEFAULT_CLIP_ROLE_TABLE): ClipRole | null — Classify a single GLB clip name into a {@link ClipRole}, or `null` when nothing matches (or an exclusion token like `pose` disqualifies it). Deterministic: exact token matches beat prefix matches; remaining ties resolve by fixed role priority (death > hit > attack > … > idle).
345
+ - `defaultAnimationForClips` (function): function defaultAnimationForClips(clips: readonly string[], table: ClipRoleTable = DEFAULT_CLIP_ROLE_TABLE): ModelAnimationConfig | undefined — Build the default `ModelConfig.animation` for a rigged asset from its catalog clip names: speed-driven idle/walk/run states plus hit/death/attack one-shots where identifiable. Returns `undefined` when no idle clip is identifiable — an ambiguous rig renders its bind pose rather than guessing. Walk falls back to idle when the pack has no walk clip.
346
+ - `resolveAnimationConfig` (function): function resolveAnimationConfig(animation: ModelAnimationConfig | "auto" | "none" | undefined, clips: readonly string[] | undefined, table: ClipRoleTable = DEFAULT_CLIP_ROLE_TABLE): ModelAnimationConfig | undefined — Collapse `ModelConfig.animation`'s widened union to a concrete playback config: explicit configs pass through, `"none"`/absent render the bind pose, and `"auto"` derives states and one-shots from the model's clip names via {@link defaultAnimationForClips}. The shell calls this with the loaded GLB's actual clip names, so `"auto"` works on any rigged model — catalog-resolved ids are stamped `"auto"` automatically.
347
+ - `rolesFromClips` (function): function rolesFromClips(clips: readonly string[], table: ClipRoleTable = DEFAULT_CLIP_ROLE_TABLE): Partial<Record<ClipRole, string[]>> — Group a GLB's clip names by role. Each role's variants are sorted simplest-first — fewest name tokens, then lexicographic — so the canonical variant (`Idle` over `2H_Melee_Idle`, KayKit `Walking_A` over `Walking_B`) is always first: stable output for the same clip set regardless of source order.
348
+
339
349
  ## @jgengine/core/game/connectedPlayers
340
350
 
341
351
  - `ConnectedPlayer` (interface): interface ConnectedPlayer — A player currently joined to a hosted world — the unit a shared-world loop iterates instead of `ctx.player`. Frozen by the registry (see {@link ConnectedPlayers.get}); fields are `readonly` so a caller can't edit its own copy and assume the change stuck.
@@ -367,7 +377,7 @@
367
377
  - `InventoryDeclaration` (interface): interface InventoryDeclaration — Shape of one named inventory a game declares — slot count, accepted item types, HUD binding.
368
378
  - `LifecycleConfig` (interface): interface LifecycleConfig<TState = unknown> — Declarative start/restart run lifecycle: the state transitions a game's run phase every genre repeats (title screen → live run → live run → title screen again), expressed as pure functions over one typed {@link StoreHandle} slot instead of hand-rolled `commands.define("start"/"restart")` glue that re-derives phase after every mutation. `start`/`restart` receive the store's own value type — the store's `TState`, never `ctx.game.store.get(key) as T` — and return the next value; the runtime writes it back and derives {@link GamePhase} from it via `phaseOf` in one place, so every adopting game gets identical, correct phase-sync for free.
369
379
  - `LoopPlayer` (interface): interface LoopPlayer — Identity of a player joining or leaving a hosted world — passed to the multiplayer loop hooks.
370
- - `PersistConfig` (interface): interface PersistConfig — Tunes offline whole-world save (`defineGameDefinition({ persist })`). Defaults: continuous `autosave` to `localStorage`, one slot, no version.
380
+ - `PersistConfig` (interface): interface PersistConfig — Tunes offline whole-world save (`defineGameDefinition({ persist })`). Offline games already autosave to `localStorage` by default — pass this only to tune it (or `persist: false` to opt out). Defaults: continuous `autosave` to `localStorage`, one slot, no version.
371
381
  - `PhysicsConfig` (interface): interface PhysicsConfig — World gravity and jump tuning, plus scene-object collision opt-ins, for the game's physics step.
372
382
  - `defineGameDefinition` (function): function defineGameDefinition<TAssetRef extends ModelAssetRef, TMultiplayer>(config: GameDefinitionConfig<TAssetRef, TMultiplayer>): GameDefinition<TAssetRef, TMultiplayer> — Host-level constructor for a bare `GameDefinition`: fills in `scene` and default `assets`, validates `name`, OR-merges `features` from installed systems, and composes `loop` from `systems` + any classic hooks. Games author through `defineGame` from `@jgengine/shell/defineGame`, which calls this and adds the presentation layer; call this directly only from headless hosts that mount no shell.
373
383
 
@@ -556,6 +566,17 @@
556
566
  - `resolveSelection` (function): function resolveSelection(base: Readonly<Record<string, number>>, registry: LayerRegistry, ids: LayerSelection): { readonly snapshot: ParamSnapshot; readonly unknown: readonly string[] } — Reproducible session setup in one call: resolve a saved {@link LayerSelection} through a registry, fold the found layers over `base`, and return the snapshot alongside any unknown ids. The same base + registry + selection always yields the same snapshot.
557
567
  - `validateLayers` (function): function validateLayers(layers: readonly ParamLayer[]): readonly LayerConflict[] — Detect design conflicts in a layer set before resolving: duplicate ids, and competing `set` ops on the same parameter at the same priority (order-dependent, so worth surfacing). Returns an empty array when the set is clean, so callers can gate a difficulty/session build on it.
558
568
 
569
+ ## @jgengine/core/game/partAnimation
570
+
571
+ - `PartMotionInput` (interface): interface PartMotionInput — Live signals a driver feeds the samplers each frame. All values are render-side only.
572
+ - `PartMotionParams` (interface): interface PartMotionParams — Tuning for {@link samplePartPose}/{@link sampleBodyPose}; every field has a sensible default.
573
+ - `PartPose` (interface): interface PartPose — Sampled offsets to add onto a part's authored transform (or the character root). `scale` is multiplicative (1 = unchanged).
574
+ - `PartRole` (type): type PartRole = "head" | "arm.l" | "arm.r" | "leg.l" | "leg.r" | "tail" | "wing.l" | "wing.r" — Procedural animation for part-composed (rig-less) models — characters kit-bashed from primitives/`ModelPart`s with no skeleton or clips. Pure, deterministic curve sampling: the same (time, speed, phase, flinch, death) input always yields the same pose, so replays and multi-client renders stay in lockstep and the hot path allocates nothing when an `out` pose is reused.
575
+ - `createPartPose` (function): function createPartPose(): PartPose — A reusable identity pose (zero offsets, unit scale).
576
+ - `partMotionPhase` (function): function partMotionPhase(id: string): number — Deterministic per-character stride phase in [0, 1) from an id (e.g. the entity instance id), so a crowd of identical part-composed characters doesn't animate in lockstep.
577
+ - `sampleBodyPose` (function): function sampleBodyPose(input: PartMotionInput, params?: PartMotionParams, out: PartPose = createPartPose()): PartPose — Sample the character-root pose: walk bob, idle breathe/sway, backward hit flinch, and death — a sideways topple by default, or a flatten-out splat with `deathStyle: "splat"`. With `squashAmp > 0` the body also squashes and stretches (footfall squash, jelly idle breathe, flinch squash pulse), volume-conserving. A driver applies this to the group wrapping the whole composition (base model plus parts) — limbs ride along, as they would on a real torso.
578
+ - `samplePartPose` (function): function samplePartPose(role: PartRole, input: PartMotionInput, params?: PartMotionParams, out: PartPose = createPartPose()): PartPose — Sample one part's pose offset by role: counter-phase leg/arm swing driven by speed, a head counter-sway, tail wag, and wing flap. Offsets are added onto the part's authored position/rotation. Motion fades to zero as `death` ramps so the topple reads as one piece.
579
+
559
580
  ## @jgengine/core/game/ping
560
581
 
561
582
  - `DEFAULT_PING_CATEGORIES` (const): const DEFAULT_PING_CATEGORIES: Record<PingCategory, PingCategoryDef> — Content-agnostic default ping wheel: enemy / loot / location / danger.
@@ -629,10 +650,10 @@
629
650
  - `LevelProgress` (interface): interface LevelProgress — Result of resolving a level+xp pair: the settled `level`, leftover `xp` into the current level, the `xpMax` threshold for the next level, and how many `levelsGained` this resolution produced.
630
651
  - `LevelingConfig` (interface): interface LevelingConfig — Configuration for a {@link leveling} track: the `xpForLevel` {@link Curve}, the `maxLevel` cap, and optional `startLevel`, stat ids (`xpStat`/`levelStat`, default `"xp"`/`"level"`), and `thresholdMode` — `"perLevel"` (each level costs its own curve value) or `"cumulative"` (curve gives the total xp to reach a level).
631
652
  - `LevelingStatAccess` (interface): interface LevelingStatAccess — Adapter a {@link LevelingTrack} uses to read and write a user's level/xp stats, so the track stays decoupled from any concrete stats store.
632
- - `LevelingTrack` (interface): interface LevelingTrack — A resolved leveling track: the immutable `maxLevel`/`startLevel`, the `xpForLevel` threshold lookup, a pure `resolve`, and `grantXp` which writes back through a {@link LevelingStatAccess} and fires an `onLevelUp` callback.
653
+ - `LevelingTrack` (interface): interface LevelingTrack — A resolved leveling track: the immutable `maxLevel`/`startLevel`, the `xpForLevel` threshold lookup, a pure `resolve`, and `grantXp` which writes back through a {@link LevelingStatAccess} and fires `onLevelUp` once for every reached level in ascending order.
633
654
  - `curve` (function): function curve(spec: Curve): (x: number) => number — Bind a {@link Curve} into a reusable `(x) => value` evaluator — a partial application of {@link evalCurve}.
634
655
  - `evalCurve` (function): function evalCurve(spec: Curve, x: number): number — Evaluate a progression {@link Curve} at position `x`, applying its rounding mode and min/max clamp. This is the shared numeric backbone for XP-per-level, cost ramps, difficulty scaling, and any other level/rank → value mapping — use it instead of hand-rolling growth formulas.
635
- - `leveling` (function): function leveling(config: LevelingConfig): LevelingTrack — Build a leveling track from an xp-per-level {@link Curve} and a max level. The returned {@link LevelingTrack} resolves how many levels a given xp total earns (rolling over surplus xp, capping at `maxLevel`) and, via `grantXp`, writes the new level/xp back through a {@link LevelingStatAccess} and fires `onLevelUp` for each level gained. Reach for this instead of hand-tracking xp thresholds; pair it with `resource-pool` (mana/stamina) and `ability-bar` (cooldowns) for a hero.
656
+ - `leveling` (function): function leveling(config: LevelingConfig): LevelingTrack — Build a leveling track from an xp-per-level {@link Curve} and a max level. The returned {@link LevelingTrack} resolves how many levels a given xp total earns (rolling over surplus xp, capping at `maxLevel`) and, via `grantXp`, writes the new level/xp back through a {@link LevelingStatAccess} and fires `onLevelUp` for each level gained in ascending order. Reach for this instead of hand-tracking xp thresholds; pair it with `resource-pool` (mana/stamina) and `ability-bar` (cooldowns) for a hero.
636
657
 
637
658
  ## @jgengine/core/game/quest
638
659
 
@@ -1007,7 +1028,7 @@
1007
1028
  - `LevelProgress` (interface): interface LevelProgress — Result of resolving a level+xp pair: the settled `level`, leftover `xp` into the current level, the `xpMax` threshold for the next level, and how many `levelsGained` this resolution produced.
1008
1029
  - `LevelSequence` (interface): interface LevelSequence<TLevelConfig> — ⚠ undocumented
1009
1030
  - `LevelingConfig` (interface): interface LevelingConfig — Configuration for a {@link leveling} track: the `xpForLevel` {@link Curve}, the `maxLevel` cap, and optional `startLevel`, stat ids (`xpStat`/`levelStat`, default `"xp"`/`"level"`), and `thresholdMode` — `"perLevel"` (each level costs its own curve value) or `"cumulative"` (curve gives the total xp to reach a level).
1010
- - `LevelingTrack` (interface): interface LevelingTrack — A resolved leveling track: the immutable `maxLevel`/`startLevel`, the `xpForLevel` threshold lookup, a pure `resolve`, and `grantXp` which writes back through a {@link LevelingStatAccess} and fires an `onLevelUp` callback.
1031
+ - `LevelingTrack` (interface): interface LevelingTrack — A resolved leveling track: the immutable `maxLevel`/`startLevel`, the `xpForLevel` threshold lookup, a pure `resolve`, and `grantXp` which writes back through a {@link LevelingStatAccess} and fires `onLevelUp` once for every reached level in ascending order.
1011
1032
  - `LifecycleConfig` (interface): interface LifecycleConfig<TState = unknown> — Declarative start/restart run lifecycle: the state transitions a game's run phase every genre repeats (title screen → live run → live run → title screen again), expressed as pure functions over one typed {@link StoreHandle} slot instead of hand-rolled `commands.define("start"/"restart")` glue that re-derives phase after every mutation. `start`/`restart` receive the store's own value type — the store's `TState`, never `ctx.game.store.get(key) as T` — and return the next value; the runtime writes it back and derives {@link GamePhase} from it via `phaseOf` in one place, so every adopting game gets identical, correct phase-sync for free.
1012
1033
  - `LightingConfig` (interface): interface LightingConfig — Declarative lighting replacing the shell's hardcoded ambient/directional default (#207.5); mounts regardless of world kind, only when supplied.
1013
1034
  - `Listing` (interface): interface Listing — One active post in a {@link ListingBook}: an item stack a seller offered at a fixed price until it expires.
@@ -1320,7 +1341,7 @@
1320
1341
  - `jobById` (function): function jobById<TSpec, TReserve>(state: WorkQueueState<TSpec, TReserve>, id: JobId): Job<TSpec, TReserve> | null — Look up a job by id, or `null` if absent/terminal.
1321
1342
  - `jobProgress` (function): function jobProgress<TSpec, TReserve>(job: Job<TSpec, TReserve>): number — Fractional progress of a job (0…1); a zero-duration job reads as complete.
1322
1343
  - `lapDurations` (function): function lapDurations(splits: readonly number[], gatesPerLap: number): number[] — Per-lap durations from a cumulative split book with `gatesPerLap` checkpoints per lap — each lap's time is its finish-gate split minus the previous lap's finish. Only complete laps are returned.
1323
- - `leveling` (function): function leveling(config: LevelingConfig): LevelingTrack — Build a leveling track from an xp-per-level {@link Curve} and a max level. The returned {@link LevelingTrack} resolves how many levels a given xp total earns (rolling over surplus xp, capping at `maxLevel`) and, via `grantXp`, writes the new level/xp back through a {@link LevelingStatAccess} and fires `onLevelUp` for each level gained. Reach for this instead of hand-tracking xp thresholds; pair it with `resource-pool` (mana/stamina) and `ability-bar` (cooldowns) for a hero.
1344
+ - `leveling` (function): function leveling(config: LevelingConfig): LevelingTrack — Build a leveling track from an xp-per-level {@link Curve} and a max level. The returned {@link LevelingTrack} resolves how many levels a given xp total earns (rolling over surplus xp, capping at `maxLevel`) and, via `grantXp`, writes the new level/xp back through a {@link LevelingStatAccess} and fires `onLevelUp` for each level gained in ascending order. Reach for this instead of hand-tracking xp thresholds; pair it with `resource-pool` (mana/stamina) and `ability-bar` (cooldowns) for a hero.
1324
1345
  - `listRuleEffects` (function): function listRuleEffects(): RuleEffectDefinition[] — Every declared rule effect, for populating inspector dropdowns and validating a content set.
1325
1346
  - `loadBindingOverrides` (function): function loadBindingOverrides(gameId: string, storage: Pick<WebStorageLike, "getItem"> | null | undefined = defaultStorage()): BindingOverrides — ⚠ undocumented
1326
1347
  - `localSaveBackend` (function): function localSaveBackend(storage?: KeyValueStorage | null): SaveBackend — A {@link SaveBackend} over a synchronous {@link KeyValueStorage} — the browser's `localStorage` by default (offline, on-device saves), a test stub, or `null` for memory-only. Storage errors (quota exceeded, private mode, no DOM) degrade to no-ops, so a save never throws into a game tick.
@@ -1420,7 +1441,16 @@
1420
1441
  - `ItemTraits` (interface): interface ItemTraits — ⚠ undocumented
1421
1442
  - `MoveResult` (type): type MoveResult = | { status: "ok"; from: InventoryState; to: InventoryState } | { status: "rejected"; reason: "invalid-slot" | "empty-slot" | "wrong-kind" | "no-space" } — ⚠ undocumented
1422
1443
  - `PutResult` (type): type PutResult = | { status: "ok"; state: InventoryState } | { status: "rejected"; reason: "no-space" | "wrong-kind" | "slot-occupied" | "invalid-slot" } — ⚠ undocumented
1444
+ - `SplitResult` (type): type SplitResult = | { status: "ok"; state: InventoryState } | { status: "rejected"; reason: "invalid-slot" | "empty-slot" | "invalid-amount" | "no-space" | "slot-occupied" } — Outcome of {@link splitStack}: the new state, or a rejection reason.
1423
1445
  - `TakeResult` (type): type TakeResult = { status: "ok"; state: InventoryState } | { status: "rejected"; reason: "insufficient" } — ⚠ undocumented
1446
+ - `countItem` (function): function countItem(state: InventoryState, itemId: string): number — Total quantity of `itemId` summed across every stack in the inventory.
1447
+ - `createEmptyInventory` (function): function createEmptyInventory(layout: InventoryLayout): InventoryState — A bag of stackable items supporting add, remove, count, and move — the base inventory model.
1448
+ - `createInventorySet` (function): function createInventorySet<TId extends string>(layouts: Record<TId, InventoryLayout>, traits: ItemTraits): InventorySet<TId> — Build a keyed set of named inventories from their `layouts`, sharing one `traits` table — the stateful façade over put/take/move/count.
1449
+ - `hasItem` (function): function hasItem(state: InventoryState, itemId: string, count: number): boolean — True when the inventory holds at least `count` of `itemId`.
1450
+ - `moveItem` (function): function moveItem(from: InventoryState, fromSlot: number, to: InventoryState, toLayout: InventoryLayout, traits: ItemTraits, toSlot?: number): MoveResult — Move the stack at `fromSlot` to `toSlot` (or auto-stack when omitted) — swapping, merging with remainder, or relocating across two inventories. Rejects on empty source, wrong kind, or no space.
1451
+ - `putItem` (function): function putItem(state: InventoryState, layout: InventoryLayout, traits: ItemTraits, itemId: string, count: number, options?: { slot?: number }): PutResult — Add `count` of `itemId` — into a specific `options.slot`, or auto-stacked across free/partial slots. Rejects on no space or wrong kind.
1452
+ - `splitStack` (function): function splitStack(state: InventoryState, slot: number, amount: number, toSlot?: number): SplitResult — Splits `amount` off the stack in `slot` into `toSlot` (or the first empty slot when `toSlot` is omitted). Limit-agnostic: it does not consult {@link ItemTraits}, so a same-item target merges by simple addition. Rejects an empty source, `amount <= 0`, `amount >= stack count` (a split must leave both sides non-empty), no free slot, or a target holding a different item.
1453
+ - `takeItem` (function): function takeItem(state: InventoryState, itemId: string, count: number): TakeResult — Remove `count` of `itemId`, draining from the last matching stacks first. Rejects if fewer than `count` are held.
1424
1454
 
1425
1455
  ## @jgengine/core/inventory/shapedGrid
1426
1456
 
@@ -36,6 +36,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
36
36
 
37
37
  - `totalLoad` (function) · `import { totalLoad } from "@jgengine/core/inventory/encumbrance"`
38
38
 
39
+ ## clip-roles — map a rigged asset's pack-specific clip name to its semantic animation role
40
+
41
+ - `classifyClip` (function) · `import { classifyClip } from "@jgengine/core/game/clipRoles"`
42
+ - `defaultAnimationForClips` (function) · `import { defaultAnimationForClips } from "@jgengine/core/game/clipRoles"`
43
+
39
44
  ## compose-game-loop — fold composable systems into the game loop without a manual tick fan-out
40
45
 
41
46
  - `composeGameLoop` (function) · `import { composeGameLoop } from "@jgengine/core/gameplay"`
@@ -123,6 +128,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
123
128
 
124
129
  - `incubationViable` (function) · `import { incubationViable } from "@jgengine/core/game/breeding"`
125
130
 
131
+ ## inventory-grid — a bag of stackable items with add, remove, and move
132
+
133
+ - `createEmptyInventory` (function) · `import { createEmptyInventory } from "@jgengine/core/inventory/inventoryModel"`
134
+
135
+ ## inventory-split — split a stack into a target or first-empty slot
136
+
137
+ - `splitStack` (function) · `import { splitStack } from "@jgengine/core/inventory/inventoryModel"`
138
+
126
139
  ## item-generation — compose constraints, weighted pools, transforms, and provenance into a deterministic generated item
127
140
 
128
141
  - `generate` (function) · `import { generate } from "@jgengine/core/gameplay"`
@@ -234,6 +247,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
234
247
  - `resolveSelection` (function) · `import { resolveSelection } from "@jgengine/core/gameplay"`
235
248
  - `validateLayers` (function) · `import { validateLayers } from "@jgengine/core/gameplay"`
236
249
 
250
+ ## part-motion — sample the root bob/sway/squash/flinch/death pose for a rig-less part-composed character
251
+
252
+ - `sampleBodyPose` (function) · `import { sampleBodyPose } from "@jgengine/core/game/partAnimation"`
253
+ - `samplePartPose` (function) · `import { samplePartPose } from "@jgengine/core/game/partAnimation"`
254
+
237
255
  ## pause-scheduled-rule — stop a scheduled rule from firing while retaining its cursor
238
256
 
239
257
  - `pauseRule` (function) · `import { pauseRule } from "@jgengine/core/gameplay"`
@@ -0,0 +1,168 @@
1
+ # Add XP and leveling to an existing store
2
+
3
+ Use this recipe when an existing JavaScript/TypeScript project owns its player
4
+ state and save format but wants JGengine's deterministic XP curves,
5
+ multi-level grants, and per-level events. No `GameContext`, renderer, React,
6
+ entity store, or JGengine loop is required.
7
+
8
+ ## Install and focused imports
9
+
10
+ ```sh
11
+ bun add @jgengine/core
12
+ ```
13
+
14
+ ```ts
15
+ import {
16
+ leveling,
17
+ type LevelingStatAccess,
18
+ } from "@jgengine/core/game/progression";
19
+ ```
20
+
21
+ ## Caller-owned save data
22
+
23
+ ```ts
24
+ interface SavedPool {
25
+ current: number;
26
+ max: number;
27
+ }
28
+
29
+ interface PlayerSave {
30
+ experience: SavedPool;
31
+ rank: SavedPool;
32
+ unlockedAbilities: string[];
33
+ }
34
+
35
+ interface ExistingSave {
36
+ players: Record<string, PlayerSave>;
37
+ }
38
+
39
+ let save: ExistingSave = {
40
+ players: {
41
+ player_1: {
42
+ experience: { current: 0, max: 100 },
43
+ rank: { current: 1, max: 20 },
44
+ unlockedAbilities: [],
45
+ },
46
+ },
47
+ };
48
+ ```
49
+
50
+ ## Minimal adapter
51
+
52
+ ```ts
53
+ const stats: LevelingStatAccess = {
54
+ get(userId, statId) {
55
+ const player = save.players[userId];
56
+ if (player === undefined) return null;
57
+ if (statId === "experience") return player.experience;
58
+ if (statId === "rank") return player.rank;
59
+ return null;
60
+ },
61
+ set(userId, statId, patch) {
62
+ const player = save.players[userId];
63
+ if (player === undefined) return false;
64
+ const previous = statId === "experience"
65
+ ? player.experience
66
+ : statId === "rank"
67
+ ? player.rank
68
+ : null;
69
+ if (previous === null) return false;
70
+ const next = {
71
+ current: patch.current ?? previous.current,
72
+ max: patch.max ?? previous.max,
73
+ };
74
+ const nextPlayer = statId === "experience"
75
+ ? { ...player, experience: next }
76
+ : { ...player, rank: next };
77
+ save = {
78
+ ...save,
79
+ players: {
80
+ ...save.players,
81
+ [userId]: nextPlayer,
82
+ },
83
+ };
84
+ return true;
85
+ },
86
+ };
87
+ ```
88
+
89
+ The adapter may call a Zustand `set`, dispatch a Redux action, write an ECS
90
+ component, or replace plain data as above. It never mirrors the player into a
91
+ JGengine store.
92
+
93
+ ## Configure and grant XP
94
+
95
+ ```ts
96
+ const emitted: unknown[] = [];
97
+ const existingEvents = {
98
+ emit(name: string, payload: unknown) {
99
+ emitted.push({ name, payload });
100
+ },
101
+ };
102
+
103
+ const playerLevel = leveling({
104
+ xpForLevel: {
105
+ kind: "power",
106
+ base: 80,
107
+ exponent: 1.35,
108
+ round: "floor",
109
+ },
110
+ maxLevel: 20,
111
+ xpStat: "experience",
112
+ levelStat: "rank",
113
+ });
114
+
115
+ const levelEvents: number[] = [];
116
+ const levelsGained = playerLevel.grantXp(
117
+ stats,
118
+ "player_1",
119
+ 900,
120
+ (level) => {
121
+ levelEvents.push(level);
122
+ existingEvents.emit("player-level-up", { playerId: "player_1", level });
123
+ },
124
+ );
125
+ ```
126
+
127
+ `levelsGained` is the count returned to the caller. The callback fires once for
128
+ every reached level in ascending order, so a large reward can unlock each
129
+ milestone deterministically instead of collapsing them into one final event.
130
+
131
+ Use `playerLevel.resolve(level, xp)` when prediction/UI needs the same math
132
+ without writing through the adapter.
133
+
134
+ ## Save and restore
135
+
136
+ The adapter's authoritative values already live in the existing save:
137
+
138
+ ```ts
139
+ const encoded = JSON.stringify(save);
140
+ save = JSON.parse(encoded) as ExistingSave;
141
+
142
+ // Recreate an adapter over the restored store, then continue normally.
143
+ playerLevel.grantXp(stats, "player_1", 75, (level) => {
144
+ existingEvents.emit("player-level-up", { playerId: "player_1", level });
145
+ });
146
+ ```
147
+
148
+ The leveling track is immutable configuration; recreate it from the same curve
149
+ after load. Version curve changes in the existing project's migration layer.
150
+
151
+ ## Ownership
152
+
153
+ The existing project retains player identity, store updates, save schema,
154
+ curve/content choice, reward sources, event bus, unlock handling, UI, backend,
155
+ and when XP is granted. JGengine owns curve evaluation, surplus rollover,
156
+ level caps, ordered level events, and the two-method stat adapter contract.
157
+
158
+ ## Common traps
159
+
160
+ - Initialize both configured stat ids; `grantXp` returns `0` when either is
161
+ missing.
162
+ - Treat positive and negative XP policy as game-owned input validation;
163
+ sanitize rewards before calling when XP loss is not allowed.
164
+ - The default threshold mode is `perLevel`. Use `cumulative` only when the curve
165
+ represents total lifetime XP required to reach each level.
166
+ - Keep `rank.max` aligned with `maxLevel` in the caller's schema.
167
+ - Apply milestone rewards from each callback invocation; do not assume a large
168
+ grant emits only the final reached level.
@@ -2,6 +2,15 @@
2
2
 
3
3
  Single public authoring path: `defineGame({ systems, loop?, … })` from `@jgengine/shell/defineGame`. Systems are meaningful capabilities — not micro-ticks.
4
4
 
5
+ ## Portable XP and leveling
6
+
7
+ `leveling` (`@jgengine/core/game/progression`) is usable without a scheduled
8
+ system or game context. Its `LevelingStatAccess` reads and replaces two
9
+ caller-named pools in an existing store; `grantXp` writes settled XP/threshold
10
+ and level state, then emits every reached level in ascending order. State stays
11
+ in the caller's save format and round-trips as ordinary JSON. See the
12
+ [portable XP/leveling recipe](recipes/portable-xp-leveling.md).
13
+
5
14
  ## API
6
15
 
7
16
  ```ts