@jgengine/core 0.12.0 → 0.13.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 (81) hide show
  1. package/CHANGELOG.md +26 -0
  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 +25 -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 +2 -1
  29. package/dist/runtime/context/scene.js +39 -1
  30. package/dist/runtime/gameContext.js +50 -4
  31. package/dist/runtime/gameContextTypes.d.ts +37 -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/colliders.d.ts +21 -0
  36. package/dist/scene/colliders.js +62 -0
  37. package/dist/scene/entityStats.js +9 -1
  38. package/dist/stats/statModifiers.d.ts +16 -0
  39. package/dist/stats/statModifiers.js +30 -1
  40. package/dist/stats/statPool.js +5 -2
  41. package/dist/ui/panelModel.d.ts +120 -0
  42. package/dist/ui/panelModel.js +168 -0
  43. package/dist/ui.d.ts +1 -0
  44. package/dist/ui.js +1 -0
  45. package/dist/world/buildingLots.d.ts +84 -0
  46. package/dist/world/buildingLots.js +119 -0
  47. package/dist/world/buildings.d.ts +6 -0
  48. package/dist/world/cityGenerator.d.ts +41 -0
  49. package/dist/world/cityGenerator.js +58 -0
  50. package/dist/world/cityKind.d.ts +2 -2
  51. package/dist/world/cityKind.js +4 -4
  52. package/dist/world/environmentSummary.js +57 -1
  53. package/dist/world/features.d.ts +35 -2
  54. package/dist/world/features.js +5 -2
  55. package/dist/world/roads.d.ts +19 -2
  56. package/dist/world/roads.js +60 -3
  57. package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
  58. package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
  59. package/dist/world/streets.d.ts +74 -1
  60. package/dist/world/streets.js +218 -0
  61. package/dist/world/terrain.d.ts +20 -1
  62. package/dist/world/terrain.js +41 -2
  63. package/package.json +1 -1
  64. package/skills/jgengine/SKILL.md +1 -0
  65. package/skills/jgengine/api.md +13 -5
  66. package/skills/jgengine-combat/SKILL.md +15 -1
  67. package/skills/jgengine-combat/api.md +39 -2
  68. package/skills/jgengine-combat/capabilities.md +17 -1
  69. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  70. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  71. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  72. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  73. package/skills/jgengine-combat/reference.md +22 -3
  74. package/skills/jgengine-gameplay/SKILL.md +8 -0
  75. package/skills/jgengine-gameplay/api.md +35 -5
  76. package/skills/jgengine-gameplay/capabilities.md +18 -0
  77. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  78. package/skills/jgengine-gameplay/reference-systems.md +9 -0
  79. package/skills/jgengine-world/api.md +52 -24
  80. package/skills/jgengine-world/capabilities.md +8 -4
  81. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,197 @@
1
+ # Apply damage to caller-owned resources
2
+
3
+ Use this recipe when an existing JavaScript/TypeScript game wants JGengine's
4
+ damage math, ordered interceptors, status resolution, shield spillover, and
5
+ lethal result without adopting `GameContext`, the entity store, renderer, React,
6
+ or JGengine's game loop.
7
+
8
+ ## Install and focused imports
9
+
10
+ ```sh
11
+ bun add @jgengine/core
12
+ ```
13
+
14
+ ```ts
15
+ import {
16
+ createEffectSystem,
17
+ type ReceiveMap,
18
+ } from "@jgengine/core/combat/effects";
19
+ import {
20
+ createDamageClamp,
21
+ resolveDamage,
22
+ } from "@jgengine/core/combat/damageInterceptors";
23
+ import { resolveDamageHit } from "@jgengine/core/combat/damageResolution";
24
+ import {
25
+ createStatPool,
26
+ type StatPool,
27
+ type StatPoolAccess,
28
+ } from "@jgengine/core/stats/statPool";
29
+ import { seededRng } from "@jgengine/core/random/rng";
30
+ ```
31
+
32
+ ## Caller-owned state and adapter
33
+
34
+ This example uses an immutable store-shaped object. A Redux, Zustand, ECS, or
35
+ custom store can implement the same two methods without copying its entities
36
+ into JGengine.
37
+
38
+ ```ts
39
+ interface UnitState {
40
+ pools: Record<string, StatPool>;
41
+ receive: ReceiveMap;
42
+ statuses: Record<string, unknown>;
43
+ }
44
+
45
+ interface ExistingSave {
46
+ units: Record<string, UnitState>;
47
+ }
48
+
49
+ let save: ExistingSave = {
50
+ units: {
51
+ enemy_7: {
52
+ pools: {
53
+ shield: createStatPool({ current: 25, max: 25 }),
54
+ vitality: createStatPool({ current: 80, max: 80 }),
55
+ },
56
+ receive: { damage: { order: ["shield", "vitality"] } },
57
+ statuses: {},
58
+ },
59
+ },
60
+ };
61
+
62
+ const statPools: StatPoolAccess = {
63
+ get(ownerId, statId) {
64
+ return save.units[ownerId]?.pools[statId] ?? null;
65
+ },
66
+ set(ownerId, statId, next) {
67
+ const unit = save.units[ownerId];
68
+ if (unit === undefined) return false;
69
+ save = {
70
+ ...save,
71
+ units: {
72
+ ...save.units,
73
+ [ownerId]: { ...unit, pools: { ...unit.pools, [statId]: next } },
74
+ },
75
+ };
76
+ return true;
77
+ },
78
+ };
79
+ ```
80
+
81
+ `set` receives a complete plain replacement pool. The SDK never asks for the
82
+ store itself and never mutates an entity object behind the adapter.
83
+
84
+ ## Build the effect committer
85
+
86
+ ```ts
87
+ const existingEventQueue: unknown[] = [];
88
+ const existingEvents = {
89
+ emit(name: string, payload: unknown) {
90
+ existingEventQueue.push({ name, payload });
91
+ },
92
+ };
93
+
94
+ const effects = createEffectSystem({
95
+ resolveReceive: (unitId) => save.units[unitId]?.receive,
96
+ statPools,
97
+ getStat: (_itemId, _statId) => null,
98
+ spatial: {
99
+ inRadius: (_center, _radius) => [],
100
+ hasLineOfSight: (_from, _to) => true,
101
+ positionOf: (_unitId) => undefined,
102
+ },
103
+ onLethal(unitId, context) {
104
+ existingEvents.emit("unit-defeated", { unitId, context });
105
+ },
106
+ });
107
+ ```
108
+
109
+ The spatial adapter is only used by area effects. Connect those three methods
110
+ to the existing project's spatial index and line-of-sight query when needed;
111
+ single-target effects do no world scan.
112
+
113
+ ## Resolve and apply a hit
114
+
115
+ Keep resolution and commitment explicit. The pure stages return provenance and
116
+ status state; authority decides what to persist and then routes final
117
+ applications into the structural effect committer.
118
+
119
+ ```ts
120
+ const seededRandom = seededRng("shot-2048");
121
+ const simulationNowMs = 4_000;
122
+
123
+ const hit = resolveDamageHit({
124
+ channel: "arc",
125
+ impact: 40,
126
+ source: "player",
127
+ target: "enemy_7",
128
+ targetTraits: ["shielded"],
129
+ status: { status: "charged", chance: 0.35, durationMs: 2_000 },
130
+ rng: seededRandom, // caller-owned deterministic stream
131
+ });
132
+
133
+ const intercepted = resolveDamage(
134
+ [createDamageClamp({ id: "per-hit-cap", maxPerHit: 30 })],
135
+ {
136
+ source: "player",
137
+ target: "enemy_7",
138
+ amount: hit.impact,
139
+ tag: hit.channel,
140
+ },
141
+ { nowMs: simulationNowMs }, // caller-owned deterministic clock
142
+ );
143
+
144
+ for (const application of intercepted.applications) {
145
+ effects.applyEffect({
146
+ from: application.source,
147
+ to: application.target,
148
+ effect: "damage",
149
+ via: { amount: application.amount },
150
+ });
151
+ }
152
+
153
+ if (hit.status?.instance !== null && hit.status?.instance !== undefined) {
154
+ save.units.enemy_7!.statuses[hit.status.instance.status] = hit.status.instance;
155
+ }
156
+ ```
157
+
158
+ `intercepted.provenance` records each ordered policy decision. `hit.matchup`,
159
+ `hit.received`, and `hit.status` retain the pure resolution evidence. The effect
160
+ result lists exact per-pool deltas in the receive order and reports lethality
161
+ when the final pool reaches its minimum.
162
+
163
+ ## Save and restore
164
+
165
+ The authoritative pools and returned status instances are already plain data:
166
+
167
+ ```ts
168
+ const encoded = JSON.stringify(save);
169
+ save = JSON.parse(encoded) as ExistingSave;
170
+ ```
171
+
172
+ Recreate the small adapters over the restored store. Interceptor functions and
173
+ catalog rules are configuration, so re-supply them rather than serializing
174
+ closures. Persist any stateful interceptor records separately when their API
175
+ exposes serializable state.
176
+
177
+ ## Ownership
178
+
179
+ The existing project retains entity identity, resource names, store updates,
180
+ save schema, status collection, authority, spatial queries, death/despawn,
181
+ events, clock, RNG, content, presentation, and its update loop. JGengine owns
182
+ the pure resolution stages, bounded ordered interception, pool spillover math,
183
+ and inspectable results.
184
+
185
+ ## Common traps
186
+
187
+ - Use positive effect magnitudes to drain pools and negative magnitudes to
188
+ restore them; the receive rule determines which named pools participate.
189
+ - Put shields or other absorbers before the final lethal pool in `order`.
190
+ - Pass final interceptor applications to `applyEffect`; do not apply the
191
+ pre-interceptor impact as well.
192
+ - Persist the returned status `instance`; `resolveDamageHit` is intentionally
193
+ pure and does not hide a status store.
194
+ - Use a bounded spatial index for `inRadius`; do not add a full-world scan per
195
+ area effect.
196
+ - Keep audio, particles, hit flashes, animations, camera shake, and models in
197
+ the existing project. Drive them from the returned results and provenance.
@@ -0,0 +1,154 @@
1
+ # Save and restore closure-backed combat runtimes
2
+
3
+ Use this recipe when an existing project wants JGengine's convenient magazine
4
+ or stat-modifier runtime but keeps its own save system, clock, entities, and
5
+ authority. Both runtimes expose detached plain snapshots; no JGengine store or
6
+ `GameContext` is required.
7
+
8
+ ## Install and focused imports
9
+
10
+ ```sh
11
+ bun add @jgengine/core
12
+ ```
13
+
14
+ ```ts
15
+ import {
16
+ createMagazine,
17
+ type MagazineSnapshot,
18
+ } from "@jgengine/core/combat/magazine";
19
+ import {
20
+ createStats,
21
+ type StatsSnapshot,
22
+ } from "@jgengine/core/stats/statModifiers";
23
+ ```
24
+
25
+ ## Caller-owned save data
26
+
27
+ ```ts
28
+ type VehicleStat = "speed" | "handling";
29
+
30
+ interface VehicleRuntimeSave {
31
+ magazine: MagazineSnapshot;
32
+ stats: StatsSnapshot<VehicleStat>;
33
+ }
34
+
35
+ interface ExistingSave {
36
+ nowMs: number;
37
+ vehicle: VehicleRuntimeSave;
38
+ sharedAmmo: number;
39
+ }
40
+ ```
41
+
42
+ The project owns this schema and decides when it is written, replicated,
43
+ rolled back, replayed, or transferred to a worker.
44
+
45
+ ## Run and snapshot
46
+
47
+ ```ts
48
+ let nowMs = 4_000;
49
+ let sharedAmmo = 18;
50
+
51
+ const magazine = createMagazine({
52
+ capacity: 6,
53
+ reloadMs: 900,
54
+ reserve: {
55
+ current: () => sharedAmmo,
56
+ spend(amount) {
57
+ if (amount > sharedAmmo) return false;
58
+ sharedAmmo -= amount;
59
+ return true;
60
+ },
61
+ gain(amount) {
62
+ sharedAmmo += amount;
63
+ },
64
+ },
65
+ });
66
+
67
+ const stats = createStats<VehicleStat>(
68
+ { speed: 12, handling: 8 },
69
+ { now: () => nowMs },
70
+ );
71
+ stats.addSource("boost", { speed: { multiply: 1.25 } }, { expiresAtMs: 7_000 });
72
+
73
+ magazine.fire(2);
74
+ magazine.startReload();
75
+ magazine.tick(0.3);
76
+
77
+ const save: ExistingSave = {
78
+ nowMs,
79
+ vehicle: {
80
+ magazine: magazine.snapshot(),
81
+ stats: stats.snapshot(),
82
+ },
83
+ sharedAmmo,
84
+ };
85
+
86
+ const serialized = JSON.stringify(save);
87
+ ```
88
+
89
+ Snapshots are detached from the live closures. Mutating or continuing the
90
+ runtime after this point does not rewrite `save`.
91
+
92
+ ## Restore and resume
93
+
94
+ ```ts
95
+ const decoded = JSON.parse(serialized) as ExistingSave;
96
+ nowMs = decoded.nowMs;
97
+ sharedAmmo = decoded.sharedAmmo; // restore caller-owned reserve first
98
+
99
+ const resumedMagazine = createMagazine({
100
+ capacity: 6,
101
+ reloadMs: 900,
102
+ loaded: 0,
103
+ reserve: {
104
+ current: () => sharedAmmo,
105
+ spend(amount) {
106
+ if (amount > sharedAmmo) return false;
107
+ sharedAmmo -= amount;
108
+ return true;
109
+ },
110
+ gain(amount) {
111
+ sharedAmmo += amount;
112
+ },
113
+ },
114
+ });
115
+ if (!resumedMagazine.restore(decoded.vehicle.magazine)) {
116
+ throw new Error("magazine save does not match its reserve configuration");
117
+ }
118
+
119
+ const resumedStats = createStats<VehicleStat>(
120
+ { speed: 0, handling: 0 },
121
+ { now: () => nowMs },
122
+ );
123
+ resumedStats.restore(decoded.vehicle.stats);
124
+
125
+ resumedMagazine.tick(0.6); // finishes the same in-progress 900ms reload
126
+ nowMs = 7_000;
127
+ resumedStats.get("speed"); // injected clock expires the boost deterministically
128
+ ```
129
+
130
+ For a self-managed numeric magazine reserve, the magazine snapshot contains
131
+ and restores that reserve directly. For a caller-owned `MagazineReserve`, save
132
+ and restore the caller's reserve first; `restore` returns `false` rather than
133
+ partially changing loaded/reload state when it cannot reconcile the reserve.
134
+
135
+ ## Ownership
136
+
137
+ The existing project owns save versioning, entity identity, clock value,
138
+ reserve state, networking/rollback policy, content, and when ticks occur.
139
+ JGengine owns the closure convenience methods, snapshot encoding shape,
140
+ detached copies, and deterministic continuation once the caller supplies the
141
+ same config and clock.
142
+
143
+ ## Common traps
144
+
145
+ - Re-supply immutable configuration such as magazine capacity/reload duration
146
+ and the stat id set when rebuilding a runtime; snapshots contain mutable
147
+ state, not content catalogs.
148
+ - Restore externally-owned reserve data before calling `Magazine.restore`.
149
+ - Persist the injected clock alongside expiring modifier snapshots or convert
150
+ expiry timestamps in the project's migration layer.
151
+ - Do not keep a live snapshot object as mutable state. Restore it into a runtime
152
+ and take a fresh snapshot for the next save.
153
+ - Check the boolean returned by `Magazine.restore`; reserve-kind mismatches and
154
+ unreconcilable external reserves are rejected without partial magazine state.
@@ -0,0 +1,156 @@
1
+ # Fire a weapon with your own Three.js raycaster
2
+
3
+ Use this recipe when an existing Three.js/JavaScript game wants JGengine's fire
4
+ cadence, magazine/reload, and deterministic damage resolution but keeps its own
5
+ models, animations, muzzle flash, audio, input, camera, aiming, and — crucially —
6
+ its own raycaster and entity list. There is no default gun: you compose generic
7
+ primitives. No `GameContext`, entity store, renderer, or React is required.
8
+
9
+ ## Install and focused imports
10
+
11
+ ```sh
12
+ bun add @jgengine/core three
13
+ ```
14
+
15
+ ```ts
16
+ import {
17
+ createWeaponRuntime,
18
+ type WeaponRuntime,
19
+ } from "@jgengine/core/combat";
20
+ import type { DamageHitInput } from "@jgengine/core/combat";
21
+ import * as THREE from "three";
22
+ ```
23
+
24
+ ## Caller-owned aim, entities, and raycaster
25
+
26
+ The engine never touches your scene. You own the raycast; it returns whatever hit
27
+ shape you like.
28
+
29
+ ```ts
30
+ interface Enemy {
31
+ id: string;
32
+ mesh: THREE.Object3D;
33
+ armored: boolean;
34
+ }
35
+
36
+ const enemies: Enemy[] = loadEnemies();
37
+ const raycaster = new THREE.Raycaster();
38
+
39
+ function raycastFromCamera(camera: THREE.Camera): Enemy[] {
40
+ raycaster.setFromCamera(new THREE.Vector2(0, 0), camera); // center-screen shot
41
+ const meshes = enemies.map((e) => e.mesh);
42
+ const intersects = raycaster.intersectObjects(meshes, true);
43
+ const first = intersects[0];
44
+ if (first === undefined) return [];
45
+ const enemy = enemies.find((e) => e.mesh === first.object || e.mesh === first.object.parent);
46
+ return enemy === undefined ? [] : [enemy];
47
+ }
48
+ ```
49
+
50
+ ## Compose the weapon runtime
51
+
52
+ `cadenceMs`, `magazine`, and `ready` are plain data; `resolveHits` runs your
53
+ raycaster and `damageFor` maps a hit to a portable damage input (return `null` to
54
+ skip damage, e.g. a wall). Derive `cadenceMs` from a rounds-per-second fire rate
55
+ with `1000 / roundsPerSecond`.
56
+
57
+ ```ts
58
+ const rifle: WeaponRuntime<THREE.Camera, Enemy> = createWeaponRuntime({
59
+ cadenceMs: 1000 / 8, // 8 rounds/sec
60
+ magazine: { capacity: 30, reloadMs: 1800, reserve: 120 },
61
+ resolveHits: (camera) => raycastFromCamera(camera),
62
+ damageFor: (enemy): DamageHitInput => ({
63
+ channel: "kinetic",
64
+ impact: 24,
65
+ target: enemy.id,
66
+ targetTraits: enemy.armored ? ["armored"] : [],
67
+ matchup: { entries: { kinetic: { armored: { impact: 0.5 } } } },
68
+ }),
69
+ });
70
+ ```
71
+
72
+ ## Fire, tick, and apply results
73
+
74
+ ```ts
75
+ // Your input handler:
76
+ function onTriggerDown(camera: THREE.Camera): void {
77
+ const result = rifle.fire(camera);
78
+ if (result.status === "fired") {
79
+ playMuzzleFlash();
80
+ for (const hit of result.resolutions) {
81
+ // Apply the resolved impact to YOUR health store, spawn YOUR hit spark, etc.
82
+ applyDamageToMyEntities(hit); // hit.impact, hit.channel, hit.status, hit.received
83
+ }
84
+ } else if (result.status === "empty") {
85
+ rifle.startReload();
86
+ playReloadAnim();
87
+ } // "cooling" and "reloading" are silent no-ops you can ignore or feed to the HUD
88
+ }
89
+
90
+ // Your frame loop:
91
+ function update(dtSeconds: number): void {
92
+ rifle.tick(dtSeconds); // advances cadence + reload timers
93
+ hud.setAmmo(rifle.magazine?.loaded() ?? Infinity, rifle.magazine?.reserve() ?? null);
94
+ hud.setReload(rifle.magazine?.reloadFraction() ?? 0);
95
+ }
96
+ ```
97
+
98
+ `resolveHits` and `damageFor` are only called on a `"fired"` result, so a blocked
99
+ trigger pull never touches your raycaster or ammo.
100
+
101
+ ## Bring your own ammo economy
102
+
103
+ Point the magazine's `reserve` at a caller-owned pool with a `MagazineReserve`
104
+ adapter instead of a plain number, so reloads draw from your shared inventory:
105
+
106
+ ```ts
107
+ import type { MagazineReserve } from "@jgengine/core/combat";
108
+
109
+ const backpack = { rifleAmmo: 240 };
110
+ const reserve: MagazineReserve = {
111
+ current: () => backpack.rifleAmmo,
112
+ spend: (n) => (n <= backpack.rifleAmmo ? ((backpack.rifleAmmo -= n), true) : false),
113
+ gain: (n) => { backpack.rifleAmmo += n; },
114
+ };
115
+ // magazine: { capacity: 30, reloadMs: 1800, reserve }
116
+ ```
117
+
118
+ ## Save and restore
119
+
120
+ The magazine exposes `loaded()`/`reserve()`/`reloadFraction()` and the cadence
121
+ exposes `elapsedMs()`/`restore()` for HUD and snapshots:
122
+
123
+ ```ts
124
+ const snapshot = {
125
+ cadenceMs: rifle.cadence.elapsedMs(),
126
+ loaded: rifle.magazine?.loaded() ?? null,
127
+ };
128
+ // After load: recreate the runtime from the same config, then:
129
+ rifle.cadence.restore(snapshot.cadenceMs);
130
+ ```
131
+
132
+ Recreate the magazine at its saved `loaded` via `MagazineConfig.loaded`. (Full
133
+ magazine snapshot/restore lands with the serializable-runtime-state slice.)
134
+
135
+ ## Ownership
136
+
137
+ The existing project keeps entities, meshes, the raycaster, aim, input, camera,
138
+ muzzle flash, audio, animation, the health/damage sink, ammo inventory, weapon
139
+ content and stats, and when the trigger fires. JGengine owns fire-rate gating,
140
+ magazine/reload bookkeeping, and deterministic damage resolution (matchup,
141
+ receiver modifiers, status rolls) with full provenance.
142
+
143
+ ## Common traps
144
+
145
+ - `fire()` gates in order **reloading → cooling → empty**; a `"cooling"` result
146
+ means the cadence, not the magazine, blocked the shot. Tick before expecting
147
+ `"empty"`.
148
+ - Only a `"fired"` result spends a round and resets cadence; blocked attempts are
149
+ free, so you can call `fire()` every input frame.
150
+ - Firing does not auto-reload or fire-cancel a reload; call `startReload()` /
151
+ `cancelReload()` from your own input policy.
152
+ - `damageFor` returning `null` keeps the hit in `result.hits` but produces no
153
+ `DamageHitResolution` — use it for terrain/props your raycast also returns.
154
+ - Pass a seeded `rng` in the config for deterministic status rolls in multiplayer
155
+ authority, replays, or tests; per-hit `DamageHitInput.rng` overrides it.
156
+ - Convert fire rate to `cadenceMs` with `1000 / roundsPerSecond`, not the reverse.
@@ -0,0 +1,81 @@
1
+ # Recipe — make an attack you can actually see
2
+
3
+ **Seam:** authoritative hit resolution (`applyEffect` / `fireProjectile`) ↔ the one-shot
4
+ `combat.vfx` visual the shell renders. They are deliberately separate: **resolving damage
5
+ never draws anything on its own.** If you call `applyEffect`/`fireProjectile` and "nothing
6
+ happens on screen," you have the hit but not the picture — emit a `vfx` burst next to it.
7
+
8
+ The easy path is a named **preset**, so you never hand-pick a `VfxKind` or a hex color.
9
+
10
+ ## The one line
11
+
12
+ ```ts
13
+ import type { GameContext } from "@jgengine/core/runtime/gameContext";
14
+
15
+ // caster and enemy are entity instance ids (the shell follows their live pose)
16
+ function shootArrow(ctx: GameContext, caster: string, enemy: string): void {
17
+ ctx.scene.entity.vfx({ preset: "arrow", from: caster, to: enemy });
18
+ }
19
+ ```
20
+
21
+ That renders a visible bolt traveling from the caster to the enemy. Swap the flavor for the
22
+ attack you want — the vocabulary already covers the usual suspects:
23
+
24
+ | Want | `preset` | Archetype |
25
+ | --- | --- | --- |
26
+ | arrow / bolt / bullet | `"arrow"`, `"bolt"`, `"bullet"` | traveling `projectile` |
27
+ | fire / ice / poison / arcane spell | `"fireball"`, `"firebolt"`, `"frostbolt"`, `"poison"`, `"arcane"`, `"shadowbolt"`, `"holybolt"` | traveling `projectile` |
28
+ | lightning / laser / web / chain | `"lightning"`, `"laser"`, `"web"`, `"chain"` | connecting `beam` |
29
+ | sword swing / melee hit | `"slash"`, `"sword"`, `"melee"`, `"hit"` | impact `spark` |
30
+ | explosion / shockwave | `"explosion"`, `"blast"`, `"shockwave"`, `"frostNova"` | ground `nova` |
31
+ | heal / buff / shield aura | `"heal"`, `"buff"`, `"shield"`, `"cast"` | lingering `glow` |
32
+
33
+ Full list lives in `vfxPresets` (`@jgengine/core/combat`).
34
+
35
+ ## Wiring it to a real projectile attack
36
+
37
+ `fireProjectile` returns a shot id; `settleProjectile` resolves the hit. Fire the visual when
38
+ you fire the shot so the bolt is in flight while it travels, then settle to apply damage:
39
+
40
+ ```ts
41
+ function fireAt(ctx: GameContext, caster: string, enemy: string): void {
42
+ const shot = ctx.scene.entity.fireProjectile({
43
+ from: caster,
44
+ via: { item: "shortbow" },
45
+ aim: { yaw: 0, pitch: 0 },
46
+ effect: "arrow-hit",
47
+ });
48
+ ctx.scene.entity.vfx({ preset: "arrow", from: caster, to: enemy }); // <- the picture
49
+ ctx.scene.entity.settleProjectile(shot); // <- the damage
50
+ }
51
+ ```
52
+
53
+ For a melee swing there is no projectile — just resolve the effect and flash the spark:
54
+
55
+ ```ts
56
+ ctx.scene.entity.effect({ from: caster, to: enemy, effect: "cleave" });
57
+ ctx.scene.entity.vfx({ preset: "slash", from: caster, to: enemy });
58
+ ```
59
+
60
+ ## Overrides and custom bursts
61
+
62
+ Any field on the call wins over the preset, so you keep the archetype but retint it:
63
+
64
+ ```ts
65
+ ctx.scene.entity.vfx({ preset: "fireball", color: 0x00ff88, from: caster, to: enemy });
66
+ ```
67
+
68
+ Skip `preset` entirely and pass `kind` + `color` for a fully bespoke burst. A **misspelled**
69
+ flavor still shows a `spark` rather than nothing, so a typo never silently drops the effect.
70
+
71
+ When you need the raw numbers — e.g. to seed a long-lived `vfxInstance` beam from the same
72
+ vocabulary — call `resolveVfxPreset("lightning")` and read `{ kind, color, durationMs?, radius? }`.
73
+
74
+ ## Don't
75
+
76
+ - Don't expect `applyEffect`/`fireProjectile`/`settleProjectile` to draw a visual — they are
77
+ headless authority. The `vfx` call is what the player sees.
78
+ - Don't put damage numbers in a preset. Presets are colors and shapes; combat values live in
79
+ your effect/ability data.
80
+ - Don't disable presentation and wonder where the effect went: `presentationEffects` is on by
81
+ default, but if a game passes `presentationEffects: { vfx: false }` the burst won't render.
@@ -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
-