incanto 0.1.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 (138) hide show
  1. package/LICENSE +30 -0
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.md +88 -0
  4. package/assets/audio/attacked.mp3 +0 -0
  5. package/assets/audio/explosion.mp3 +0 -0
  6. package/assets/audio/gold_loot.mp3 +0 -0
  7. package/assets/audio/heal.mp3 +0 -0
  8. package/assets/audio/hit_metal_bang.mp3 +0 -0
  9. package/assets/audio/ice_spear.mp3 +0 -0
  10. package/assets/audio/monster_died.mp3 +0 -0
  11. package/assets/audio/slash.mp3 +0 -0
  12. package/assets/audio/smite.mp3 +0 -0
  13. package/assets/audio/spells_cast.mp3 +0 -0
  14. package/assets/audio/ui_click.wav +0 -0
  15. package/assets/audio/walk.mp3 +0 -0
  16. package/assets/catalog.json +390 -0
  17. package/assets/characters/2dbasic.json +41 -0
  18. package/assets/characters/2dbasic.png +0 -0
  19. package/assets/characters/ghost.json +46 -0
  20. package/assets/characters/ghost.png +0 -0
  21. package/assets/characters/goblin.json +40 -0
  22. package/assets/characters/goblin.png +0 -0
  23. package/assets/characters/medieval-knight.json +41 -0
  24. package/assets/characters/medieval-knight.png +0 -0
  25. package/assets/effects/swoosh.png +0 -0
  26. package/assets/items/box.png +0 -0
  27. package/assets/items/buff_potion.png +0 -0
  28. package/assets/items/coin.png +0 -0
  29. package/assets/items/gem.png +0 -0
  30. package/assets/items/gold.png +0 -0
  31. package/assets/items/hp_potion.png +0 -0
  32. package/assets/items/locked_item_box.png +0 -0
  33. package/assets/items/map.png +0 -0
  34. package/assets/items/resurrection_potion.png +0 -0
  35. package/assets/items/super_box.png +0 -0
  36. package/assets/items/trap.png +0 -0
  37. package/assets/tiles/floor00.jpg +0 -0
  38. package/assets/tiles/minecraft-tiles.png +0 -0
  39. package/assets/tiles/wall00.jpg +0 -0
  40. package/assets/vegetation/ash_color.png +0 -0
  41. package/assets/vegetation/aspen_color.png +0 -0
  42. package/assets/vegetation/bark/birch_color_1k.jpg +0 -0
  43. package/assets/vegetation/bark/birch_normal_1k.jpg +0 -0
  44. package/assets/vegetation/bark/birch_roughness_1k.jpg +0 -0
  45. package/assets/vegetation/bark/oak_color_1k.jpg +0 -0
  46. package/assets/vegetation/bark/oak_normal_1k.jpg +0 -0
  47. package/assets/vegetation/bark/oak_roughness_1k.jpg +0 -0
  48. package/assets/vegetation/bark/pine_color_1k.jpg +0 -0
  49. package/assets/vegetation/bark/pine_normal_1k.jpg +0 -0
  50. package/assets/vegetation/bark/pine_roughness_1k.jpg +0 -0
  51. package/assets/vegetation/ground/dirt_color.jpg +0 -0
  52. package/assets/vegetation/ground/dirt_normal.jpg +0 -0
  53. package/assets/vegetation/ground/grass.jpg +0 -0
  54. package/assets/vegetation/oak_color.png +0 -0
  55. package/assets/vegetation/pine_color.png +0 -0
  56. package/bin/incanto-assets.mjs +107 -0
  57. package/bin/incanto-check.mjs +107 -0
  58. package/bin/incanto-editor.mjs +343 -0
  59. package/bin/incanto-env.mjs +144 -0
  60. package/bin/incanto-model.mjs +296 -0
  61. package/bin/incanto-play.mjs +219 -0
  62. package/bin/incanto-skills.mjs +71 -0
  63. package/dist/2d.d.ts +642 -0
  64. package/dist/2d.js +44 -0
  65. package/dist/3d.d.ts +1860 -0
  66. package/dist/3d.js +5 -0
  67. package/dist/agent8-DzU2fFyH.js +129 -0
  68. package/dist/audio-player-DqUR3XFs.d.ts +110 -0
  69. package/dist/behavior-BAQq7HGM.d.ts +851 -0
  70. package/dist/create-game-BdjpTHrW.js +1725 -0
  71. package/dist/create-game-CZHROKcT.js +527 -0
  72. package/dist/debug-draw-CZmOYjL2.js +13 -0
  73. package/dist/debug.d.ts +66 -0
  74. package/dist/debug.js +658 -0
  75. package/dist/duplicate-DP2WPYom.js +22 -0
  76. package/dist/env.d.ts +430 -0
  77. package/dist/env.js +3152 -0
  78. package/dist/errors-BMFaY68Q.d.ts +33 -0
  79. package/dist/errors-BpWbnbb_.js +13 -0
  80. package/dist/gameplay-Ccruc3Wd.js +1501 -0
  81. package/dist/gameplay.d.ts +543 -0
  82. package/dist/gameplay.js +2 -0
  83. package/dist/heightmap-CroQPEER.js +185 -0
  84. package/dist/index.d.ts +305 -0
  85. package/dist/index.js +62 -0
  86. package/dist/json-BLk7H2Qa.js +30 -0
  87. package/dist/loader-CGs_G-r0.js +919 -0
  88. package/dist/loader-Mo0KghCv.d.ts +41 -0
  89. package/dist/net.d.ts +427 -0
  90. package/dist/net.js +772 -0
  91. package/dist/noise-CGUMx44x.js +82 -0
  92. package/dist/particle-sim-CbN4YUuH.d.ts +63 -0
  93. package/dist/particle-sim-DYuSUxvK.js +1319 -0
  94. package/dist/physics-2d-KuMWPTf6.js +288 -0
  95. package/dist/physics-3d-Dl67vOLT.js +434 -0
  96. package/dist/react.d.ts +65 -0
  97. package/dist/react.js +209 -0
  98. package/dist/register-BuUV1_KB.js +561 -0
  99. package/dist/register-CNlYAS1_.js +10634 -0
  100. package/dist/register-DPEV9_9t.js +851 -0
  101. package/dist/register-Dasmnurl.js +374 -0
  102. package/dist/registry-BVJ2HbCn.js +132 -0
  103. package/dist/rng-DP-SR7eg.js +38 -0
  104. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  105. package/dist/schema-CcoWb32N.d.ts +104 -0
  106. package/dist/test.d.ts +158 -0
  107. package/dist/test.js +275 -0
  108. package/dist/touch-031PxtCR.js +208 -0
  109. package/dist/vite.d.ts +26 -0
  110. package/dist/vite.js +57 -0
  111. package/editor/assets/GameServer-C56iOUgF.js +1 -0
  112. package/editor/assets/agent8-Bp7QFI7v.js +1 -0
  113. package/editor/assets/index-DF3tMeKJ.css +1 -0
  114. package/editor/assets/index-Dl2pjA8e.js +7365 -0
  115. package/editor/assets/rapier-CEuLKeCu.js +1 -0
  116. package/editor/assets/rapier-DE6a0vmv.js +1 -0
  117. package/editor/index.html +169 -0
  118. package/package.json +97 -0
  119. package/schemas/scene.schema.json +4254 -0
  120. package/skills/README.md +9 -0
  121. package/skills/incanto-3d-character.md +229 -0
  122. package/skills/incanto-3d-models.md +151 -0
  123. package/skills/incanto-assets.md +118 -0
  124. package/skills/incanto-audio.md +309 -0
  125. package/skills/incanto-behaviors-and-scripts.md +169 -0
  126. package/skills/incanto-building-2d-games.md +242 -0
  127. package/skills/incanto-building-3d-games.md +245 -0
  128. package/skills/incanto-editor.md +163 -0
  129. package/skills/incanto-environment.md +743 -0
  130. package/skills/incanto-gameplay-behaviors.md +707 -0
  131. package/skills/incanto-multiplayer.md +264 -0
  132. package/skills/incanto-node-reference.md +797 -0
  133. package/skills/incanto-physics-and-input.md +164 -0
  134. package/skills/incanto-scene-json-authoring.md +325 -0
  135. package/skills/incanto-verifying-your-game.md +191 -0
  136. package/skills/incanto-web-integration.md +96 -0
  137. package/templates/agent8-server.js +84 -0
  138. package/templates/agent8-server.ts +138 -0
@@ -0,0 +1,707 @@
1
+ ---
2
+ name: incanto-gameplay-behaviors
3
+ description: The batteries-included game-logic library (incanto/gameplay) — ready-made Behaviors you wire from scene JSON with ZERO custom code. State & interaction: Health (hit points, i-frames, regen), Lifetime (self-destruct), ScoreKeeper (score/lives/win-lose hub), Pickup (collectibles), Collector (collect-N tally), DamageOnContact (projectiles/hazards), Interactable (press-to-use doors/levers/NPCs). Movement, AI, camera, spawning & juice: FollowCamera (camera tracks a target), Patrol (waypoint paths), Chase (homing enemy AI), Wander (seeded roaming), ZombieAI (wander-then-charge monster AI with proximity aggro), MoveTo (eased position tween), Oscillate (sine float/spin/pulse), Spawner (interval spawning), WaveSpawner (sequenced enemy waves), Projectile (straight-line motion). Use when a game needs health, score, pickups, damage, timers, interaction, enemy movement/AI, camera-follow, tweens, or spawning — BEFORE hand-rolling them as custom behaviors.
4
+ ---
5
+
6
+ # Gameplay behaviors — the built-in logic library
7
+
8
+ > Shipped inside the `incanto` npm package — this document always matches the
9
+ > installed engine version. Sibling skills live in `node_modules/incanto/skills/`.
10
+
11
+ Incanto ships the game logic every game re-invents, as ready-made `Behavior`s.
12
+ You compose them through scene JSON — `"script"` blobs attach them, `connections`
13
+ wire one behavior's signal to another's method. **No TypeScript required.**
14
+
15
+ `createGame2D`/`createGame3D` (and the headless `runScript`/`createPlaySession`)
16
+ **auto-register all of them** — just reference the names in scene JSON. Opt out
17
+ with `gameplay: false`, or register a subset yourself:
18
+
19
+ ```ts
20
+ import { registerGameplayBehaviors } from 'incanto/gameplay';
21
+ registerGameplayBehaviors(); // all of them (default in createGame)
22
+
23
+ import { Health, Pickup, registerBehavior } from 'incanto/gameplay';
24
+ registerBehavior('Health', Health); // or pick just what you use
25
+ ```
26
+
27
+ A same-named user behavior always wins: `createGame({ behaviors: { Health: MyHealth } })`
28
+ registers AFTER the built-ins (with `replace: true`), overriding `Health`.
29
+
30
+ All behaviors are **dimension-agnostic** (operate on `position` arrays / Area
31
+ trigger signals, never three) and **pure logic** — they verify headlessly.
32
+
33
+ ## How they compose (signals → methods)
34
+
35
+ A behavior declares **signals** it emits and exposes **methods** other behaviors
36
+ or `connections` call. Wiring is just JSON:
37
+
38
+ ```json
39
+ { "connections": [
40
+ { "signal": "collected", "from": "Coin", "to": "/root", "handler": "addScore" },
41
+ { "signal": "died", "from": "Player", "to": "/root", "handler": "loseLife" }
42
+ ] }
43
+ ```
44
+
45
+ The signal's args become the handler's args (e.g. `collected(value, other)` →
46
+ `addScore(value)`).
47
+
48
+ ### Node paths — connections vs. behavior props (READ THIS)
49
+
50
+ Two kinds of node path resolve from DIFFERENT origins — mixing them up is the
51
+ #1 wiring bug:
52
+
53
+ - **Connection `from`/`to` resolve from the SCENE ROOT.** Use a name relative to
54
+ the root (`Player`, `Player/Weapon`), `/root` / `.` for the root node itself,
55
+ or `/root/<name>` absolute. `/root` is the tree root **whatever it is named**.
56
+ - **A behavior's node-path prop** (`Chase.target`, `FollowCamera.target`,
57
+ Spawner `prefab`) **resolves from the node the behavior sits on.** A spawned
58
+ enemy (a child of the Spawner) reaches the player with the ABSOLUTE
59
+ `/root/Player`, never a bare `Player` (which would look under the enemy).
60
+
61
+ **Never write `/root/<RootName>/...`.** If your root node is named `Game`, the
62
+ path is `/root/Player` — NOT `/root/Game/Player`: `/root/` already *is* the
63
+ `Game` root, so `/root/Game/...` double-counts it and dangles at load. Likewise
64
+ a connection `to` the root is `/root` (or `.`), never `/root/Game`.
65
+
66
+ > Behaviors that react to collisions (`Pickup`, `DamageOnContact`) listen to the
67
+ > unified `triggerEnter(other)` signal, so their node **must be an `Area2D`/`Area3D`**
68
+ > (or another body that emits it). Attach them to a non-Area node and they fail
69
+ > loudly at load (`UNKNOWN_SIGNAL`) — see `incanto-physics-and-input.md` for Areas.
70
+
71
+ ---
72
+
73
+ ## Health
74
+
75
+ Hit points with regen and post-hit invulnerability (i-frames). The universal
76
+ "can be hurt / can die" behavior. Hurt it via `damage(n)` (e.g. from
77
+ `DamageOnContact`); heal/kill via `heal(n)` / `kill()`. Clamps to `0..max`,
78
+ dies once.
79
+
80
+ | Prop | Default | Meaning |
81
+ |---|---|---|
82
+ | `max` | `100` | maximum HP; `current` starts here |
83
+ | `regenPerSec` | `0` | HP regained per second (0 = off) |
84
+ | `invulnerableFor` | `0` | seconds of immunity after a hit (i-frames; 0 = off) |
85
+ | `freeOnDeath` | `false` | `queueFree()` this node when it dies (clone-safe) |
86
+
87
+ Signals: `damaged(amount, current)` · `healed(amount, current)` · `died`
88
+ Methods: `damage(n)` · `heal(n)` · `kill()` — state: `current`, `isDead`
89
+
90
+ ```json
91
+ { "name": "Player", "type": "CharacterBody2D", "groups": ["player"],
92
+ "props": { "collider": { "shape": "capsule", "radius": 12, "height": 32 } },
93
+ "script": { "name": "Health", "props": { "max": 50, "invulnerableFor": 1 } } }
94
+ ```
95
+
96
+ > **Freeing dying SPAWNED entities — use `freeOnDeath`, not a connection.**
97
+ > Scene `connections` are NOT copied onto Spawner/WaveSpawner clones, so a
98
+ > `{ signal: "died", … handler: "queueFree" }` connection only ever wires the
99
+ > TEMPLATE, never the live clones — enemies would never disappear on death. Put
100
+ > `Health` (with `freeOnDeath: true`) on the **entity ROOT** instead: it is
101
+ > node-local, so it clones perfectly and frees the whole enemy with no wiring.
102
+ > (Connections are still the right tool for NON-cloned nodes, e.g. the player's
103
+ > `died → ScoreKeeper.loseLife`.)
104
+
105
+ ## Lifetime
106
+
107
+ Self-destruct after a fixed time — bullets, particles, temporary spawns. Emits
108
+ `expired` then `queueFree()`s its node. `startOnSignal: true` defers the
109
+ countdown until `startTimer()` (wire a signal → `startTimer`).
110
+
111
+ | Prop | Default | Meaning |
112
+ |---|---|---|
113
+ | `seconds` | `1` | lifespan before self-free |
114
+ | `startOnSignal` | `false` | defer the countdown until `startTimer()` |
115
+
116
+ Signals: `expired` — Methods: `startTimer()`
117
+
118
+ ```json
119
+ { "name": "Bullet", "type": "Area2D",
120
+ "props": { "collider": { "shape": "circle", "radius": 3 } },
121
+ "script": { "name": "Lifetime", "props": { "seconds": 2 } } }
122
+ ```
123
+
124
+ ## ScoreKeeper
125
+
126
+ The game's **state hub** — score, lives, win/lose. Put it on the Root/Game node
127
+ and feed gameplay signals into its methods. Reaching `scoreToWin` emits `won`
128
+ once; reaching 0 lives emits `lost` once.
129
+
130
+ | Prop | Default | Meaning |
131
+ |---|---|---|
132
+ | `score` | `0` | starting score |
133
+ | `lives` | `0` | starting lives (0 = lives disabled) |
134
+ | `scoreToWin` | `0` | score that triggers `won` (0 = disabled) |
135
+
136
+ Signals: `scoreChanged(score)` · `won` · `lost` · `lifeLost(lives)`
137
+ Methods: `addScore(n)` · `setScore(n)` · `loseLife()`
138
+
139
+ ```json
140
+ { "name": "Game", "type": "Node",
141
+ "script": { "name": "ScoreKeeper", "props": { "scoreToWin": 10, "lives": 3 } } }
142
+ ```
143
+
144
+ ## Pickup
145
+
146
+ A collectible that vanishes when a collector overlaps it. **Goes on an Area.**
147
+ On `triggerEnter` with a node in `collectorGroup`, emits `collected(value, other)`
148
+ then `queueFree()`s itself. Wire `collected → ScoreKeeper.addScore` (value is
149
+ arg 0) or `→ Collector.collect`.
150
+
151
+ | Prop | Default | Meaning |
152
+ |---|---|---|
153
+ | `value` | `1` | worth (passed as `collected`'s first arg) |
154
+ | `kind` | `"coin"` | free-form label ('coin', 'gem', 'key', …) |
155
+ | `collectorGroup` | `"player"` | only nodes in this group collect it |
156
+
157
+ Signals: `collected(value, other)`
158
+
159
+ ```json
160
+ { "name": "Coin", "type": "Area2D",
161
+ "props": { "collider": { "shape": "circle", "radius": 8 } },
162
+ "script": { "name": "Pickup", "props": { "value": 5 } } }
163
+ ```
164
+ ```json
165
+ { "connections": [
166
+ { "signal": "collected", "from": "Coin", "to": "/root", "handler": "addScore" }
167
+ ] }
168
+ ```
169
+
170
+ ## Collector
171
+
172
+ A per-actor "collect N" tally, on the collector itself (usually the player). Adds
173
+ its node to `group` at ready (so matching Pickups count it as a collector). Wire
174
+ a Pickup's `collected → Collector.collect`.
175
+
176
+ | Prop | Default | Meaning |
177
+ |---|---|---|
178
+ | `group` | `"player"` | group it joins (Pickups match `collectorGroup` against this) |
179
+
180
+ Signals: `totalChanged(total)` — Methods: `collect(value)` — state: `total`
181
+
182
+ ```json
183
+ { "name": "Player", "type": "CharacterBody2D",
184
+ "script": { "name": "Collector", "props": { "group": "player" } } }
185
+ ```
186
+
187
+ ## DamageOnContact
188
+
189
+ Deals damage to whatever it touches — projectiles, spikes, lava, enemy hitboxes.
190
+ **Goes on an Area.** On `triggerEnter`, finds the contacted entity's `Health` and
191
+ calls `damage(amount)`, then emits `dealtDamage(amount, healthOwnerNode)`.
192
+ `oncePerTarget` (default) prevents re-hitting a resting body every frame;
193
+ `destroySelf` frees the hazard after a hit (single-use projectiles).
194
+
195
+ | Prop | Default | Meaning |
196
+ |---|---|---|
197
+ | `amount` | `10` | HP removed per contact |
198
+ | `targetGroup` | `""` | only damage targets whose Health-owner is in this group (`""` = any) |
199
+ | `oncePerTarget` | `true` | damage each target at most once |
200
+ | `destroySelf` | `false` | `queueFree()` after the first hit |
201
+
202
+ Signals: `dealtDamage(amount, target)`
203
+
204
+ **`targetGroup` — stop enemies killing each other.** With no collision layers in
205
+ v0 (everything overlaps everything), an enemy's contact hitbox would damage any
206
+ `Health` it touches, including other enemies clustered at the spawn point. Gate
207
+ it by group: a player weapon uses `"targetGroup": "enemy"`, each enemy hitbox
208
+ uses `"targetGroup": "player"` — so only the intended side is hurt. The group is
209
+ checked on the node that OWNS the `Health`, not the collider that delivered the
210
+ contact.
211
+
212
+ **Robust `Health` resolution.** A real entity rarely carries `Health` on the
213
+ exact collider that overlaps. `DamageOnContact` searches, nearest-first: the
214
+ contacted node and its descendants, then up the ANCESTOR CHAIN (so `Health`
215
+ on the entity ROOT is found from a child collider). It never searches an
216
+ ancestor's sibling subtrees, so a hazard can't reach an unrelated entity
217
+ across the scene — put `Health` on the entity root (recommended) or on the
218
+ collider's own subtree.
219
+ The first `Health` wins; its node is the `dealtDamage` target and the
220
+ `targetGroup` subject. So you can put `Health` on the enemy ROOT and a collider
221
+ on a child `Hit` Area — a weapon contacting either finds the root's `Health`.
222
+
223
+ ```json
224
+ { "name": "Spike", "type": "Area2D",
225
+ "props": { "collider": { "shape": "rect", "size": [32, 8] } },
226
+ "script": { "name": "DamageOnContact", "props": { "amount": 20 } } }
227
+ ```
228
+
229
+ **Scoring on kill (clone-safe).** Wire the KILLER's `dealtDamage → addScore`.
230
+ The weapon is a non-cloned player child, so its connection survives — unlike a
231
+ connection on a spawned enemy, which never clones:
232
+ `{ "signal": "dealtDamage", "from": "Player/Weapon", "to": "/root", "handler": "addScore" }`.
233
+
234
+ ## Interactable
235
+
236
+ A proximity-gated "press to use" — doors, levers, chests, NPCs. Each frame, if an
237
+ actor in `actorGroup` is within `range` (distance on `position` arrays, 2D or 3D)
238
+ and the `action` input was just pressed, emits `interacted(actor)` (nearest
239
+ in-range actor). Declare the input action in the scene `input{}`.
240
+
241
+ | Prop | Default | Meaning |
242
+ |---|---|---|
243
+ | `action` | `"interact"` | input action (button) that triggers it |
244
+ | `range` | `2` | max distance an actor may be |
245
+ | `actorGroup` | `"player"` | only nodes in this group can interact |
246
+
247
+ Signals: `interacted(actor)`
248
+
249
+ ```json
250
+ { "input": { "interact": { "type": "button", "keys": ["KeyE"] } },
251
+ "root": { "name": "Level", "type": "Node2D", "children": [
252
+ { "name": "Door", "type": "Node2D", "props": { "position": [200, 100] },
253
+ "script": { "name": "Interactable", "props": { "range": 48 } } }
254
+ ] } }
255
+ ```
256
+
257
+ ---
258
+
259
+ # Movement, AI, camera & spawning
260
+
261
+ These act on the node's transform (`position` array — 2D `[x,y]`, 3D `[x,y,z]`;
262
+ `rotation`; `scale`). **Dimension-agnostic** and **pure** — they verify
263
+ headlessly. A node carries ONE script, so compose by splitting roles across a
264
+ small subtree (e.g. an enemy with `Chase` on the body and `Health` on a child
265
+ hitbox) and wiring signals.
266
+
267
+ ## FollowCamera
268
+
269
+ THE camera behavior. Put it on a `Camera2D`/`Camera3D` (whose `position` is the
270
+ view center/eye) and point `target` at the player — each frame the camera lerps
271
+ toward `target.position + offset`. Works in 2D and 3D.
272
+
273
+ | Prop | Default | Meaning |
274
+ |---|---|---|
275
+ | `target` | `""` | node path to follow (required) |
276
+ | `offset` | `[]` | constant offset added to the target position |
277
+ | `smoothing` | `0` | 0..1 retention: 0 = instant snap, 0.85–0.95 = smooth drag (60fps-independent) |
278
+ | `deadzone` | `0` | don't move until the target drifts this far from the desired point |
279
+
280
+ ```json
281
+ { "name": "Camera", "type": "Camera2D", "props": { "current": true },
282
+ "script": { "name": "FollowCamera", "props": { "target": "/root/Player", "smoothing": 0.9 } } }
283
+ ```
284
+
285
+ ## Patrol
286
+
287
+ Walk a node along fixed waypoints at constant `speed` — guards, moving platforms.
288
+ `points` are position arrays OR node paths (read live each frame, so you can
289
+ author markers). Emits `reachedPoint(index)` on arrival.
290
+
291
+ | Prop | Default | Meaning |
292
+ |---|---|---|
293
+ | `points` | `[]` | waypoints: position arrays or node-path strings (required) |
294
+ | `speed` | `60` | units per second along the path |
295
+ | `loop` | `true` | wrap to the first point after the last (loop mode) |
296
+ | `mode` | `"loop"` | `'loop'` wraps · `'pingpong'` reverses at the ends |
297
+ | `pauseAt` | `0` | seconds to wait at each reached point |
298
+
299
+ Signals: `reachedPoint(index)`
300
+
301
+ ```json
302
+ { "name": "Guard", "type": "Node2D",
303
+ "script": { "name": "Patrol", "props": { "points": [[100, 0], [300, 0]], "speed": 80, "mode": "pingpong" } } }
304
+ ```
305
+
306
+ ## Chase
307
+
308
+ Homing enemy AI: each frame moves toward `target.position` at `speed`, stopping
309
+ within `stopRange` (melee reach). `reachedTarget` fires once on entering range
310
+ (re-arms on leaving); `loseRange` gives up the chase (emits `lostTarget`).
311
+
312
+ | Prop | Default | Meaning |
313
+ |---|---|---|
314
+ | `target` | `""` | node path to chase, resolved from THIS node (required) |
315
+ | `speed` | `60` | units per second |
316
+ | `stopRange` | `0` | stop (and emit reachedTarget) within this distance |
317
+ | `loseRange` | `0` | give up beyond this distance (0 = never lose) |
318
+ | `moveParent` | `false` | move the PARENT node instead of this one |
319
+
320
+ Signals: `reachedTarget` · `lostTarget`
321
+
322
+ `target` is a node-path prop, so it resolves from THIS behavior's node — a
323
+ spawned enemy reaches the player with the absolute `/root/Player` (a bare
324
+ `Player` would look under the enemy).
325
+
326
+ **`moveParent` — Chase + Health on one entity.** A node carries ONE behavior. An
327
+ enemy whose ROOT must hold `Health(freeOnDeath)` (for clone-safe death) puts
328
+ `Chase` on a CHILD `AI` node with `moveParent: true`; the child then drives the
329
+ whole entity toward the target. (Distance is measured from the moved parent.)
330
+
331
+ ```json
332
+ { "name": "Enemy", "type": "Node2D", "groups": ["enemy"],
333
+ "script": { "name": "Chase", "props": { "target": "/root/Player", "speed": 120, "stopRange": 20 } } }
334
+ ```
335
+
336
+ ## Wander
337
+
338
+ Seeded random roaming inside a circle around the spawn point — idle critters,
339
+ ambient wildlife. Uses `this.rng`, so a seeded engine wanders identically every
340
+ run (replayable, test-stable).
341
+
342
+ | Prop | Default | Meaning |
343
+ |---|---|---|
344
+ | `speed` | `40` | units per second while roaming |
345
+ | `radius` | `50` | roam radius around the spawn point |
346
+ | `changeEvery` | `2` | seconds before forcibly picking a new destination |
347
+
348
+ ```json
349
+ { "name": "Critter", "type": "Node2D",
350
+ "script": { "name": "Wander", "props": { "speed": 30, "radius": 60, "changeEvery": 1.5 } } }
351
+ ```
352
+
353
+ ## ZombieAI
354
+
355
+ The staple monster AI in ONE behavior: SHAMBLE around on its own, then LOCK ON and
356
+ CHARGE `aggroTarget` (the player) once it wanders within `aggroRange` — giving up
357
+ again past `deAggroRange` (hysteresis, no boundary flicker). Use this instead of
358
+ `Wander`+`Chase` (a node carries ONE behavior, so they can't co-exist) whenever you
359
+ want enemies that idle/roam until the player gets close, then rush in.
360
+
361
+ | Prop | Default | Meaning |
362
+ |---|---|---|
363
+ | `aggroTarget` | `""` | node path to charge once near (usually `/root/Player`) |
364
+ | `aggroRange` | `12` | start charging within this distance |
365
+ | `deAggroRange` | `0` | give up beyond this (0 = `aggroRange` × 1.5) |
366
+ | `chaseSpeed` | `4` | units/second while charging |
367
+ | `wanderSpeed` | `1.4` | units/second while roaming |
368
+ | `wanderRadius` | `8` | roam jitter radius |
369
+ | `wanderChangeEvery` | `3` | seconds before picking a new roam goal |
370
+ | `goalTarget` | `""` | optional node path to DRIFT toward while roaming |
371
+ | `stopRange` | `1.2` | stop (idle) this close to the active target |
372
+ | `moveParent` | `false` | move the PARENT node instead of this one |
373
+
374
+ Signals: `enteredAggro` · `exitedAggro` · `movementStateChanged('idle'|'walk'|'run')`
375
+
376
+ - **Wire `movementStateChanged` to a skin** to swap animation clips — shamble (`walk`)
377
+ while roaming, sprint (`run`) while charging, `idle` when stopped at the target.
378
+ - **`goalTarget` = "advance on the objective".** With it set, the enemy DRIFTS toward
379
+ that node (e.g. a base/obelisk the horde is attacking) while still wandering, so it
380
+ reads as a roaming horde that nonetheless closes on the objective — but peels off to
381
+ CHARGE you when you stray within `aggroRange`. Leave it `""` for pure roam-around-spawn.
382
+ - **`moveParent`** is the same AI-on-a-child pattern as `Chase`: the entity ROOT holds
383
+ `Health(freeOnDeath)`, a child `AI` node carries `ZombieAI(moveParent:true)`.
384
+ - 3D: moves only in the GROUND PLANE (x,z) and leaves the up axis (y) to YOU.
385
+ `aggroRange` is measured on the ground (terrain height ignored). **It does NOT
386
+ auto-fall:** an AI-driven body whose position is set from script is kinematic, so on
387
+ rolling terrain you must ground it yourself each frame (`y = terrain.heightAt(x,z)` —
388
+ see incanto-3d-models.md "Grounding a model"). On a FLAT floor or for a HOVERING enemy
389
+ (drone), keeping the up axis is exactly right — it stays at its spawn height for free.
390
+
391
+ ```json
392
+ { "name": "Zombie", "type": "CharacterBody3D", "groups": ["enemy"],
393
+ "script": { "name": "Health", "props": { "max": 30, "freeOnDeath": true } },
394
+ "children": [
395
+ { "name": "AI", "type": "Node3D", "script": { "name": "ZombieAI",
396
+ "props": { "aggroTarget": "/root/Player", "aggroRange": 11, "chaseSpeed": 5,
397
+ "goalTarget": "/root/Obelisk", "wanderSpeed": 1.6, "moveParent": true } } } ] }
398
+ ```
399
+
400
+ ### Driving a model's animation from a custom AI
401
+
402
+ Built-in AIs emit a STATE signal; YOU map it to clips on the skin (the engine never
403
+ guesses your clip names). Two equivalent patterns — both clone-safe because the behavior
404
+ instance is per-entity (so they work for `WaveSpawner` clones, whose scene `connections`
405
+ are NOT cloned):
406
+
407
+ - **(A) self-wire in `onReady`** (what the showcases use — robust for spawned clones):
408
+ ```ts
409
+ export class ZombieSkin extends Behavior { // attach to the GLB Skin node
410
+ onReady() {
411
+ const skin = this.node as unknown as ModelInstance3D;
412
+ const ai = this.node.parent?.getNodeOrNull('AI'); // the sibling carrying ZombieAI
413
+ const clips = { idle: '$idle', walk: '$walk', run: '$runFast' };
414
+ ai?.on('movementStateChanged', (s) => { const c = clips[s]; if (c) skin.animation = c; });
415
+ }
416
+ }
417
+ ```
418
+ - **(B) scene `connection`** (fine for a single authored entity, NOT for clones):
419
+ `{ "signal": "movementStateChanged", "from": "Enemy/AI", "to": "Enemy/Skin", "handler": "setClip" }`.
420
+
421
+ Same `ZombieSkin` is the place to also FACE the heading and GROUND the body each frame
422
+ (see incanto-3d-character.md "+Z-FORWARD rule" and incanto-3d-models.md "Grounding").
423
+
424
+ ## MoveTo
425
+
426
+ Eased position tween from the start to a fixed `to` over `duration` seconds —
427
+ opening doors, sliding platforms, scripted moves. Emits `arrived` once.
428
+ `startOnSignal: true` arms it via `start()` (wire a signal → `start`).
429
+
430
+ | Prop | Default | Meaning |
431
+ |---|---|---|
432
+ | `to` | `[]` | destination position (required) |
433
+ | `duration` | `1` | seconds the move takes |
434
+ | `ease` | `"easeInOut"` | one of `linear` · `easeIn` · `easeOut` · `easeInOut` |
435
+ | `startOnSignal` | `false` | defer until `start()` (default: begin at ready) |
436
+
437
+ Signals: `arrived` — Methods: `start()`
438
+
439
+ ```json
440
+ { "name": "Platform", "type": "Node2D", "props": { "position": [0, 100] },
441
+ "script": { "name": "MoveTo", "props": { "to": [400, 100], "duration": 2, "ease": "easeInOut" } } }
442
+ ```
443
+
444
+ ## Oscillate
445
+
446
+ Continuous sine motion around the start value — floating platforms, bobbing
447
+ pickups, spinning/pulsing coins. Drives one `axis` of `position`, `rotation`
448
+ (`mode: 'rotation'` — a "spin"), or `scale` (a "pulse"):
449
+ `value = start + amplitude · sin(2π · frequency · t)`.
450
+
451
+ | Prop | Default | Meaning |
452
+ |---|---|---|
453
+ | `axis` | `"y"` | component to drive (`x`/`y`/`z`; 2D rotation is `z`) |
454
+ | `amplitude` | `1` | peak displacement from the baseline |
455
+ | `frequency` | `1` | cycles per second |
456
+ | `mode` | `"position"` | one of `position` · `rotation` · `scale` |
457
+
458
+ ```json
459
+ { "name": "Coin", "type": "Sprite2D",
460
+ "script": { "name": "Oscillate", "props": { "axis": "z", "amplitude": 180, "frequency": 0.5, "mode": "rotation" } } }
461
+ ```
462
+
463
+ ## Spawner
464
+
465
+ Drip-feed clones of a TEMPLATE into the scene on a timer — enemy generators,
466
+ pickup fountains. `prefab` is a node PATH to a template (usually a hidden
467
+ `visible:false` child); each `interval` it's cloned via the same serialize→rebuild
468
+ clone the editor uses (scripts and children come along; the clone gets a fresh
469
+ identity). `max` caps LIVE instances (drops as spawned children free, so it
470
+ refills); `total` caps LIFETIME spawns then emits `finished`.
471
+
472
+ **The template never ticks.** On first resolution the Spawner DETACHES the
473
+ template node from the live tree (holding it only as a clone source), so its
474
+ behaviors never run and it never renders — a `visible:false` template no longer
475
+ walks its `Chase` onto the player. Clones go INTO the tree; the template stays
476
+ out. (Because it's detached, the first clone keeps the template's exact name;
477
+ later clones get `EnemyTemplate2`, `EnemyTemplate3`, …)
478
+
479
+ | Prop | Default | Meaning |
480
+ |---|---|---|
481
+ | `prefab` | `""` | node path of the template to clone (required) |
482
+ | `interval` | `1` | seconds between spawns |
483
+ | `max` | `0` | max LIVE instances (0 = unlimited) |
484
+ | `at` | `[]` | offset added to the spawner position ([x,y(,z)]) |
485
+ | `autoStart` | `true` | begin ticking at ready |
486
+ | `total` | `0` | total LIFETIME spawns (0 = infinite) |
487
+
488
+ Signals: `spawned(node)` · `finished` — Methods: `spawn()` · `start()` · `stop()`
489
+
490
+ ```json
491
+ { "name": "Spawner", "type": "Node2D",
492
+ "script": { "name": "Spawner", "props": { "prefab": "EnemyTemplate", "interval": 1.5, "max": 8 } },
493
+ "children": [
494
+ { "name": "EnemyTemplate", "type": "Area2D", "groups": ["enemy"], "props": { "visible": false },
495
+ "script": { "name": "Chase", "props": { "target": "/root/Player", "speed": 90 } } }
496
+ ] }
497
+ ```
498
+
499
+ ## WaveSpawner
500
+
501
+ Sequenced enemy waves — the survivor / tower-defense backbone. Each `waves` entry
502
+ is `{ prefab, count, interval, delayBefore }`: after `delayBefore` it spawns
503
+ `count` clones (one every `interval`), then waits until they're ALL cleared
504
+ (freed) before the next. Free dying enemies clone-safely with
505
+ **`Health(freeOnDeath: true)` on the enemy root** — NOT a `died → queueFree`
506
+ connection, which never clones onto spawned instances (see Health). `Lifetime`
507
+ also frees timed spawns.
508
+
509
+ | Prop | Default | Meaning |
510
+ |---|---|---|
511
+ | `waves` | `[]` | array of `{ prefab, count, interval, delayBefore }` (required) |
512
+ | `autoStart` | `true` | begin the first wave at ready |
513
+
514
+ Signals: `waveStarted(index)` · `waveCleared(index)` · `allCleared`
515
+
516
+ ```json
517
+ { "name": "Waves", "type": "Node2D",
518
+ "script": { "name": "WaveSpawner", "props": { "waves": [
519
+ { "prefab": "Grunt", "count": 5, "interval": 0.5, "delayBefore": 1 },
520
+ { "prefab": "Brute", "count": 2, "interval": 1, "delayBefore": 3 }
521
+ ] } },
522
+ "children": [ { "name": "Grunt", "type": "Area2D", "props": { "visible": false } } ] }
523
+ ```
524
+
525
+ ## Projectile
526
+
527
+ Straight-line, constant-velocity motion — bullets, arrows, thrown rocks. Pure
528
+ MOVEMENT by design: **compose** it with `DamageOnContact` (deal damage), `Lifetime`
529
+ (auto-despawn) and an Area collider on the SAME node — don't conflate them.
530
+ `direction` is a vector OR `'forward'` (from the node `rotation`); it's a
531
+ union-typed prop so its default is `null` (= `'forward'`).
532
+
533
+ | Prop | Default | Meaning |
534
+ |---|---|---|
535
+ | `speed` | `300` | units per second along `direction` |
536
+ | `direction` | `null` | a vector `[x,y(,z)]` OR `'forward'` (`null` = forward) |
537
+ | `gravity` | `0` | constant +y (downward, 2D y-down) pull for arcing shots |
538
+
539
+ ```json
540
+ { "name": "Bullet", "type": "Area2D", "props": { "rotation": 0 },
541
+ "script": { "name": "Projectile", "props": { "speed": 600, "direction": "forward" } } }
542
+ ```
543
+ > Compose: add `DamageOnContact` and `Lifetime` to make it actually hurt and
544
+ > despawn. One node carries one script, so attach `Projectile` here and wire the
545
+ > others onto sibling/child Area nodes (or use a Spawner that clones a fully-built
546
+ > bullet template).
547
+
548
+ ---
549
+
550
+ ## A complete tiny game (only built-in behaviors + JSON)
551
+
552
+ Player collects 2 coins to win; touching a spike kills them and loses the game.
553
+ Zero custom TypeScript — every node's logic is a built-in behavior, every
554
+ interaction is a `connection`.
555
+
556
+ ```json
557
+ {
558
+ "format": 1, "type": "scene", "name": "TinyGame", "dimension": "2d",
559
+ "input": { "move": { "type": "vector2",
560
+ "keys": { "up": ["KeyW"], "down": ["KeyS"], "left": ["KeyA"], "right": ["KeyD"] } } },
561
+ "root": {
562
+ "name": "Game", "type": "Node",
563
+ "script": { "name": "ScoreKeeper", "props": { "scoreToWin": 2, "lives": 1 } },
564
+ "children": [
565
+ { "name": "Player", "type": "CharacterBody2D", "groups": ["player"],
566
+ "props": { "position": [0, 0], "collider": { "shape": "circle", "radius": 12 } },
567
+ "script": { "name": "Health", "props": { "max": 30 } },
568
+ "children": [{ "name": "Ctl", "type": "CharacterController2D",
569
+ "props": { "mode": "topDown" } }] },
570
+ { "name": "Coin1", "type": "Area2D", "props": { "position": [80, 0],
571
+ "collider": { "shape": "circle", "radius": 8 } },
572
+ "script": { "name": "Pickup", "props": { "value": 1 } } },
573
+ { "name": "Coin2", "type": "Area2D", "props": { "position": [160, 0],
574
+ "collider": { "shape": "circle", "radius": 8 } },
575
+ "script": { "name": "Pickup", "props": { "value": 1 } } },
576
+ { "name": "Spike", "type": "Area2D", "props": { "position": [120, 80],
577
+ "collider": { "shape": "rect", "size": [24, 24] } },
578
+ "script": { "name": "DamageOnContact", "props": { "amount": 40, "oncePerTarget": false } } }
579
+ ]
580
+ },
581
+ "connections": [
582
+ { "signal": "collected", "from": "Coin1", "to": "/root", "handler": "addScore" },
583
+ { "signal": "collected", "from": "Coin2", "to": "/root", "handler": "addScore" },
584
+ { "signal": "died", "from": "Player", "to": "/root", "handler": "loseLife" }
585
+ ]
586
+ }
587
+ ```
588
+
589
+ Boot it with `createGame2D({ canvas, scene })` — the behaviors are already
590
+ registered. Listen to the win/lose from your page:
591
+
592
+ ```ts
593
+ const game = await createGame2D({ canvas, scene });
594
+ game.scene.root.on('won', () => alert('You win!'));
595
+ game.scene.root.on('lost', () => alert('Game over'));
596
+ ```
597
+
598
+ Verify it headlessly (no browser) with `incanto/test` — see
599
+ `incanto-verifying-your-game.md`.
600
+
601
+ ## The "survivor" recipe (movement + AI + spawn + camera + win/lose, ZERO custom code)
602
+
603
+ A complete top-down survivor that **actually survives a headless run** (mirrored
604
+ by `survivor-game.test.ts`): a `WaveSpawner` drips enemies that `Chase` the
605
+ player; the player's weapon one-shots them and they free + score themselves;
606
+ enemies deal contact damage that drains the player; a `FollowCamera` tracks the
607
+ player; reaching the kill target WINS, dying LOSES. Every node's logic is a
608
+ built-in, every interaction a `connection` — **no custom `Behavior` classes**.
609
+
610
+ The five features that make it compose from the box (and obsolete the custom
611
+ glue an early sim had to hand-write):
612
+
613
+ 1. **`DamageOnContact.targetGroup`** — the weapon hits only `enemy`, each enemy
614
+ hitbox hits only `player`. Without it, enemies clustered at spawn kill each
615
+ other before reaching the player.
616
+ 2. **`Health.freeOnDeath`** — node-local, so it clones; the dying enemy frees
617
+ ITSELF (no per-clone `died → queueFree` connection, which never clones).
618
+ 3. **weapon `dealtDamage → addScore`** — the connection is on the WEAPON (a
619
+ non-cloned player child), so it is clone-safe; scoring per kill just works.
620
+ 4. **`Chase.moveParent`** — a node has ONE behavior, so the enemy ROOT holds
621
+ `Health(freeOnDeath)` while a child `AI` carries `Chase(moveParent:true)` and
622
+ drives the whole entity.
623
+ 5. **correct paths** — behavior targets are absolute (`/root/Player`, resolved
624
+ from the enemy/camera node); connection `to` the root is `/root` or `.`.
625
+
626
+ ```json
627
+ {
628
+ "format": 1, "type": "scene", "name": "Survivor", "dimension": "2d",
629
+ "input": { "move": { "type": "vector2",
630
+ "keys": { "up": ["KeyW"], "down": ["KeyS"], "left": ["KeyA"], "right": ["KeyD"] } } },
631
+ "root": {
632
+ "name": "Game", "type": "Node",
633
+ "script": { "name": "ScoreKeeper", "props": { "scoreToWin": 3, "lives": 1 } },
634
+ "children": [
635
+ { "name": "Player", "type": "CharacterBody2D", "groups": ["player"],
636
+ "props": { "position": [0, 0], "collider": { "shape": "circle", "radius": 12 } },
637
+ "script": { "name": "Health", "props": { "max": 100 } },
638
+ "children": [
639
+ { "name": "Ctl", "type": "CharacterController2D", "props": { "mode": "topDown" } },
640
+ { "name": "Weapon", "type": "Area2D",
641
+ "props": { "collider": { "shape": "circle", "radius": 24 } },
642
+ "script": { "name": "DamageOnContact",
643
+ "props": { "amount": 1, "targetGroup": "enemy" } } }
644
+ ] },
645
+ { "name": "Camera", "type": "Camera2D", "props": { "current": true },
646
+ "script": { "name": "FollowCamera", "props": { "target": "/root/Player", "smoothing": 0.9 } } },
647
+ { "name": "Spawner", "type": "Node2D",
648
+ "script": { "name": "WaveSpawner", "props": { "waves": [
649
+ { "prefab": "EnemyTemplate", "count": 3, "interval": 0.6, "delayBefore": 0 }
650
+ ] } },
651
+ "children": [
652
+ { "name": "EnemyTemplate", "type": "CharacterBody2D", "groups": ["enemy"],
653
+ "props": { "position": [300, 0], "visible": false,
654
+ "collider": { "shape": "circle", "radius": 12 } },
655
+ "script": { "name": "Health", "props": { "max": 1, "freeOnDeath": true } },
656
+ "children": [
657
+ { "name": "AI", "type": "Node2D",
658
+ "script": { "name": "Chase",
659
+ "props": { "target": "/root/Player", "speed": 90, "moveParent": true } } },
660
+ { "name": "Hit", "type": "Area2D",
661
+ "props": { "collider": { "shape": "circle", "radius": 12 } },
662
+ "script": { "name": "DamageOnContact",
663
+ "props": { "amount": 10, "targetGroup": "player", "oncePerTarget": false } } }
664
+ ] }
665
+ ] }
666
+ ]
667
+ },
668
+ "connections": [
669
+ { "signal": "dealtDamage", "from": "Player/Weapon", "to": "/root", "handler": "addScore" },
670
+ { "signal": "died", "from": "Player", "to": "/root", "handler": "loseLife" }
671
+ ]
672
+ }
673
+ ```
674
+
675
+ > **Role-splitting (one script per node):** the enemy ROOT carries
676
+ > `Health(freeOnDeath)` (so freeing it removes the whole enemy on death); the
677
+ > `AI` child carries `Chase(moveParent:true)` to move that root; the `Hit` child
678
+ > carries the enemy's contact damage. The weapon contacts the enemy root or any
679
+ > child — `DamageOnContact` resolves `Health` from the contacted node, its
680
+ > descendants, then up the ancestor chain, so it finds the entity root's
681
+ > `Health` either way (it won't cross into a sibling entity).
682
+ > The weapon's `dealtDamage(amount, target)` scores `amount` (1) per hit; with
683
+ > 1-HP enemies one hit = one kill = +1; 3 kills → `won`. The player dying →
684
+ > `loseLife` → `lost`.
685
+
686
+ > **Canonical full example:** `examples/star-survivor/` is this recipe grown into
687
+ > a real, playable 2D game (real Area2D/CharacterBody2D nodes, FollowCamera,
688
+ > `Oscillate` spinning star pickups, a `coin` sprite, and a HUD). It is built
689
+ > from these built-ins with ZERO custom gameplay code — the only TypeScript is a
690
+ > tiny `HudUpdater` that paints `ScoreKeeper.score`/`Health.current` into Labels
691
+ > (the gameplay library is renderer-agnostic and intentionally doesn't draw HUD
692
+ > text). Its `verify.ts` proves the win AND lose paths headlessly via
693
+ > `runScript`. Copy it as the starting point for any survivor/arena game.
694
+
695
+ ## When to write a custom behavior instead
696
+
697
+ These cover the common scaffolding. Write your own `Behavior` (see
698
+ `incanto-behaviors-and-scripts.md`) for game-specific logic — enemy AI, custom
699
+ movement, puzzle rules — and compose it WITH these via signals (e.g. your AI
700
+ behavior calls `getNode('Player').behavior` ... or simpler, wire your behavior's
701
+ signal into a built-in's method through a `connection`).
702
+
703
+ ## Exact contract
704
+
705
+ Generated, always-current prop/signal tables for every behavior live in
706
+ `incanto-node-reference.md` (the "Gameplay behaviors" section).
707
+ ```