incanto 0.76.0 → 0.78.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 (71) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/{src-D823V07k.js → src-DzPtpk1t.js} +1 -1
  3. package/dist/vite.d.ts +47 -3
  4. package/dist/vite.js +49 -17
  5. package/package.json +1 -1
  6. package/skills/incanto-building-2d-games.md +6 -0
  7. package/skills/incanto-building-3d-games.md +6 -0
  8. package/skills/incanto-editor.md +46 -2
  9. package/skills/incanto-multiplayer.md +1 -1
  10. package/skills/incanto-save-slots.md +1 -1
  11. package/skills/incanto-scene-json-authoring.md +1 -1
  12. package/skills/incanto-verifying-your-game.md +2 -2
  13. package/skills/incanto-web-integration.md +40 -2
  14. package/skills/incanto-your-first-game.md +6 -1
  15. package/templates-app/beacon-isle-3d/PROJECT/Structure.md +2 -1
  16. package/templates-app/beacon-isle-3d/index.html +5 -2
  17. package/templates-app/beacon-isle-3d/package.json +6 -1
  18. package/templates-app/beacon-isle-3d/src/App.tsx +77 -0
  19. package/templates-app/beacon-isle-3d/src/main.tsx +9 -0
  20. package/templates-app/beacon-isle-3d/tsconfig.json +2 -1
  21. package/templates-app/beacon-isle-3d/vite.config.ts +4 -1
  22. package/templates-app/molehill-2d/PROJECT/Structure.md +2 -1
  23. package/templates-app/molehill-2d/index.html +5 -2
  24. package/templates-app/molehill-2d/package.json +6 -1
  25. package/templates-app/molehill-2d/src/App.tsx +58 -0
  26. package/templates-app/molehill-2d/src/main.tsx +9 -0
  27. package/templates-app/molehill-2d/tsconfig.json +2 -1
  28. package/templates-app/molehill-2d/vite.config.ts +4 -1
  29. package/templates-app/platformer-2d/PROJECT/Context.md +1 -1
  30. package/templates-app/platformer-2d/PROJECT/Requirements.md +2 -2
  31. package/templates-app/platformer-2d/PROJECT/Status.md +1 -1
  32. package/templates-app/platformer-2d/PROJECT/Structure.md +2 -2
  33. package/templates-app/platformer-2d/docs/project-2d-rules.md +1 -1
  34. package/templates-app/platformer-2d/index.html +5 -2
  35. package/templates-app/platformer-2d/package.json +6 -1
  36. package/templates-app/platformer-2d/src/App.tsx +115 -0
  37. package/templates-app/platformer-2d/src/main.tsx +9 -0
  38. package/templates-app/platformer-2d/tsconfig.json +2 -1
  39. package/templates-app/platformer-2d/vite.config.ts +4 -1
  40. package/templates-app/star-survivor/PROJECT/Context.md +1 -1
  41. package/templates-app/star-survivor/PROJECT/Requirements.md +1 -1
  42. package/templates-app/star-survivor/PROJECT/Status.md +1 -1
  43. package/templates-app/star-survivor/PROJECT/Structure.md +2 -2
  44. package/templates-app/star-survivor/docs/project-2d-rules.md +1 -1
  45. package/templates-app/star-survivor/index.html +5 -2
  46. package/templates-app/star-survivor/package.json +6 -1
  47. package/templates-app/star-survivor/src/App.tsx +94 -0
  48. package/templates-app/star-survivor/src/main.tsx +9 -0
  49. package/templates-app/star-survivor/tsconfig.json +2 -1
  50. package/templates-app/star-survivor/vite.config.ts +4 -1
  51. package/templates-app/tps-3d/PROJECT/Status.md +1 -1
  52. package/templates-app/tps-3d/PROJECT/Structure.md +2 -2
  53. package/templates-app/tps-3d/index.html +5 -2
  54. package/templates-app/tps-3d/package.json +6 -1
  55. package/templates-app/tps-3d/src/App.tsx +124 -0
  56. package/templates-app/tps-3d/src/main.tsx +9 -0
  57. package/templates-app/tps-3d/tsconfig.json +2 -1
  58. package/templates-app/tps-3d/vite.config.ts +4 -1
  59. package/templates-app/village-quest-3d/PROJECT/Structure.md +3 -2
  60. package/templates-app/village-quest-3d/index.html +5 -2
  61. package/templates-app/village-quest-3d/package.json +6 -1
  62. package/templates-app/village-quest-3d/src/App.tsx +79 -0
  63. package/templates-app/village-quest-3d/src/main.tsx +9 -0
  64. package/templates-app/village-quest-3d/tsconfig.json +2 -1
  65. package/templates-app/village-quest-3d/vite.config.ts +4 -1
  66. package/templates-app/beacon-isle-3d/src/main.ts +0 -60
  67. package/templates-app/molehill-2d/src/main.ts +0 -41
  68. package/templates-app/platformer-2d/src/main.ts +0 -97
  69. package/templates-app/star-survivor/src/main.ts +0 -77
  70. package/templates-app/tps-3d/src/main.ts +0 -107
  71. package/templates-app/village-quest-3d/src/main.ts +0 -62
package/dist/index.js CHANGED
@@ -9,5 +9,5 @@ import { t as showBootFailure } from "./boot-failure-CKYrEcGF.js";
9
9
  import { a as logText, i as logReport, o as parseDrive, r as resolveRendering, t as isWebGLAvailable } from "./webgl-unavailable-C8aDbGmR.js";
10
10
  import { t as createNoise2D } from "./noise-D3nPpmFg.js";
11
11
  import { a as PARTICLE_PRESETS, i as ParticleSim, n as resolveFrames, o as PARTICLE_PRESET_NAMES, s as applyParticlePreset, t as resolveAnimation } from "./sprite-animation-SQa5gIu2.js";
12
- import { a as findPath, i as preloadUrls, n as assetUrls, o as gridFromRows, r as preloadSceneAssets, t as VERSION } from "./src-D823V07k.js";
12
+ import { a as findPath, i as preloadUrls, n as assetUrls, o as gridFromRows, r as preloadSceneAssets, t as VERSION } from "./src-DzPtpk1t.js";
13
13
  export { AudioBuses, AudioPlayer, BASE_LOCALE, Behavior, CONST_REF_KEY, EffectLog, Engine, HudLayer, IncantoError, InputMap, Localization, LogManager, MusicManager, Node, ORDER_GROUP_BASE, PARTICLE_PRESETS, PARTICLE_PRESET_NAMES, ParticleSim, ROLLOFF_MODELS, Respawn, Rng, SCENE_FORMAT, SFX_PRESETS, SFX_PRESET_NAMES, SaveSlots, Scene, SceneTree, Settings, SfxEngine, Signal, T_PREFIX, Timer, TouchControls, UiBanner, UiBar, UiButton, UiDialogue, UiFrameCapSelect, UiImage, UiLanguageSelect, UiMinimap, UiMuteToggle, UiPanel, UiQualitySelect, UiRenderScaleSelect, UiSelect, UiSlider, UiText, UiToggle, UiVolumeSlider, UiWaypoint, VERSION, WebAudioMusicBackend, applyParticlePreset, assetUrls, attachTouchControls, auditScene, behaviorSchema, behaviorSignals, behaviorsWithoutSave, captureBehaviors, clearBehaviors, clearRegistry, computeViewport, createNode, createNoise2D, createSaveStore, crossfadeGains, describeRefProblem, duplicateNode, effectiveOrder, fadeGain, findPath, getBehavior, getNodeSchema, getNodeSignals, getNodeType, gridFromRows, isAudioContextAvailable, isConstRef, isWebGLAvailable, joystickVector, jsonClone, jsonEquals, jsonKind, loadScene, logReport, logText, mergeStaticSignals, newUid, nodeRefWarnings, parseDrive, parseNodePath, preloadSceneAssets, preloadUrls, qualityEnvironment, readDeviceHints, registerBehavior, registerCoreNodes, registerNode, registeredBehaviors, registeredTypes, replay, resolveAnimation, resolveConstants, resolveFrames, resolveOrderGroups, resolveRefInJson, resolveRendering, resolveViewport, restoreBehaviors, savesWithoutUid, serializeNode, sfxDuration, showBootFailure, spatialGain, spatialPan, startRecording, suggestLocale, suggestQuality, synthSfx, translationKey };
@@ -199,6 +199,6 @@ async function preloadSceneAssets(assets, title) {
199
199
  //#endregion
200
200
  //#region src/index.ts
201
201
  /** Engine version. Kept in sync with package.json by the release pipeline. */
202
- const VERSION = "0.76.0";
202
+ const VERSION = "0.78.0";
203
203
  //#endregion
204
204
  export { findPath as a, preloadUrls as i, assetUrls as n, gridFromRows as o, preloadSceneAssets as r, VERSION as t };
package/dist/vite.d.ts CHANGED
@@ -151,6 +151,50 @@ interface IncantoScenesOptions {
151
151
  * Nothing outside it is readable or writable, whatever a request asks for.
152
152
  */
153
153
  root?: string;
154
+ /**
155
+ * Called after a scene is written through this server — the editor's save,
156
+ * and the editor's create. In-process, with the scene itself; the same
157
+ * event goes to stdout for anything watching the dev server from outside
158
+ * (see {@link SceneEvent}).
159
+ */
160
+ onSave?(event: SceneEvent): void;
161
+ }
162
+ /**
163
+ * What this server announces when a scene lands, on stdout AND to `onSave`.
164
+ *
165
+ * ONE line per event, `[incanto] ` and a JSON object — readable by a person in
166
+ * the terminal they are already watching, unambiguous to a machine, and
167
+ * extensible without breaking a parser that only reads the fields it knows:
168
+ *
169
+ * [incanto] {"event":"scene:saved","file":"src/game.scene.json","bytes":13284,"at":"2026-09-08T10:19:57.412Z"}
170
+ *
171
+ * It exists because a container had no way to hear a save that happened inside
172
+ * it. The editor a running game hosts writes through `PUT /api/scene`, and the
173
+ * only signals available to the process supervising that container were
174
+ * accidents: watching the filesystem, or scraping vite's `page reload` line —
175
+ * which existed only because saving used to reload the page, and went away
176
+ * when that was fixed.
177
+ *
178
+ * `abs` and `json` are for `onSave` only; the printed line carries neither (an
179
+ * absolute container path means nothing outside it, and a scene does not
180
+ * belong on one log line).
181
+ */
182
+ interface SceneEvent {
183
+ event: "scene:saved" | "scene:created";
184
+ /** Project-relative, forward slashes on every OS. */
185
+ file: string;
186
+ /** Bytes written. */
187
+ bytes: number;
188
+ /** ISO-8601, when it landed. */
189
+ at: string;
190
+ /** Absolute path — `onSave` only, never printed. */
191
+ abs?: string;
192
+ /** The scene as written — `onSave` only, never printed. */
193
+ json?: unknown;
194
+ }
195
+ /** Hooks the middlewares carry (exported for tests, not for vite). */
196
+ interface SceneHooks {
197
+ onSave?(event: SceneEvent): void;
154
198
  }
155
199
  declare function incantoScenes(opts?: IncantoScenesOptions): {
156
200
  name: string;
@@ -241,9 +285,9 @@ declare function discoverScenes(root: string): ProjectFileEntry[];
241
285
  */
242
286
  declare function resolveSceneFile(root: string, rel: unknown): string | null;
243
287
  /** @internal Exported for tests — `GET`/`POST /api/scenes`. */
244
- declare function serveSceneList(req: ApiReq, res: LibraryRes, root: string): Promise<void>;
288
+ declare function serveSceneList(req: ApiReq, res: LibraryRes, root: string, hooks?: SceneHooks): Promise<void>;
245
289
  /** @internal Exported for tests — `GET`/`PUT /api/scene?file=`. */
246
- declare function serveSceneFile(req: ApiReq, res: LibraryRes, root: string): Promise<void>;
290
+ declare function serveSceneFile(req: ApiReq, res: LibraryRes, root: string, hooks?: SceneHooks): Promise<void>;
247
291
  interface DevServer {
248
292
  middlewares: {
249
293
  use(path: string, handler: (req: LibraryReq, res: LibraryRes) => void): void;
@@ -317,4 +361,4 @@ declare function serveDeviceAuth(req: LibraryReq, res: LibraryRes, opts?: Incant
317
361
  /** @internal Exported for tests — the whole request/response behaviour. */
318
362
  declare function serveLibrary(req: LibraryReq, res: LibraryRes, opts?: IncantoLibraryOptions, doFetch?: typeof fetch): Promise<void>;
319
363
  //#endregion
320
- export { type FrameHost, type FrameReq, type FrameRes, IncantoLibraryOptions, IncantoScenesOptions, type IncantoTrimOptions, ProjectFileEntry, type StaticInspectHost, createStaticInspectHost, discoverScenes, incantoLibrary, incantoScenes, incantoTrim, listeningPorts, maskToken, onlyKnownOptions, parseProcNetTcp, projectDimension, resolveSceneFile, sceneFacts, serveDeviceAuth, serveFrameEndpoints, serveLibrary, serveSceneFile, serveSceneList, serveTokenStatus };
364
+ export { type FrameHost, type FrameReq, type FrameRes, IncantoLibraryOptions, IncantoScenesOptions, type IncantoTrimOptions, ProjectFileEntry, SceneEvent, SceneHooks, type StaticInspectHost, createStaticInspectHost, discoverScenes, incantoLibrary, incantoScenes, incantoTrim, listeningPorts, maskToken, onlyKnownOptions, parseProcNetTcp, projectDimension, resolveSceneFile, sceneFacts, serveDeviceAuth, serveFrameEndpoints, serveLibrary, serveSceneFile, serveSceneList, serveTokenStatus };
package/dist/vite.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { a as parseJsonText } from "./json-CfTjpvW8.js";
2
2
  import { P as newUid } from "./pose-ByFB_J3O.js";
3
3
  import { t as IncantoError } from "./errors-BpWbnbb_.js";
4
- import { t as VERSION } from "./src-D823V07k.js";
4
+ import { t as VERSION } from "./src-DzPtpk1t.js";
5
5
  import { n as diffSignatures } from "./frame-report-D-_7YF2G.js";
6
6
  import { s as validateScene } from "./test-CVbxnXlv.js";
7
7
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
@@ -525,6 +525,29 @@ function incantoTrim(opts = {}) {
525
525
  * vite's Plugin interface.
526
526
  */
527
527
  /**
528
+ * Announce a scene that LANDED. Never called for a refused write: the line
529
+ * means the bytes are on disk, which is the only reading that is any use to a
530
+ * process deciding whether to rebuild.
531
+ */
532
+ function announce(kind, base, abs, raw, json, hooks) {
533
+ const event = {
534
+ event: kind,
535
+ file: relative(base, abs).split(sep).join("/"),
536
+ bytes: Buffer.byteLength(raw),
537
+ at: (/* @__PURE__ */ new Date()).toISOString()
538
+ };
539
+ console.log(`[incanto] ${JSON.stringify(event)}`);
540
+ try {
541
+ hooks?.onSave?.({
542
+ ...event,
543
+ abs,
544
+ json
545
+ });
546
+ } catch (error) {
547
+ console.error(`[incanto] onSave threw: ${message(error)}`);
548
+ }
549
+ }
550
+ /**
528
551
  * The BUILT-IN art, at the path the scenes already name.
529
552
  *
530
553
  * `incanto-assets` prints `incanto/assets/items/coin.png` as an asset's url and
@@ -604,7 +627,11 @@ const ASSET_MIME = {
604
627
  */
605
628
  const OWN_WRITES = /* @__PURE__ */ new Map();
606
629
  function incantoScenes(opts = {}) {
607
- onlyKnownOptions("incantoScenes", opts, ["strictBehaviors", "root"]);
630
+ onlyKnownOptions("incantoScenes", opts, [
631
+ "strictBehaviors",
632
+ "root",
633
+ "onSave"
634
+ ]);
608
635
  return {
609
636
  name: "incanto-scenes",
610
637
  /**
@@ -615,11 +642,12 @@ function incantoScenes(opts = {}) {
615
642
  configureServer(server) {
616
643
  serveFrameEndpoints(server, VERSION);
617
644
  const root = opts.root ?? server.config?.root ?? process.cwd();
645
+ const hooks = opts.onSave ? { onSave: opts.onSave } : {};
618
646
  server.middlewares.use("/api/scenes", (req, res) => {
619
- serveSceneList(req, res, root);
647
+ serveSceneList(req, res, root, hooks);
620
648
  });
621
649
  server.middlewares.use("/api/scene", (req, res) => {
622
- serveSceneFile(req, res, root);
650
+ serveSceneFile(req, res, root, hooks);
623
651
  });
624
652
  serveBuiltInAssets(server);
625
653
  },
@@ -838,7 +866,7 @@ function readBody(req) {
838
866
  });
839
867
  }
840
868
  /** @internal Exported for tests — `GET`/`POST /api/scenes`. */
841
- async function serveSceneList(req, res, root) {
869
+ async function serveSceneList(req, res, root, hooks) {
842
870
  const base = normalize(resolve(root));
843
871
  if (req.method === void 0 || req.method === "GET") return json(res, 200, discoverScenes(base));
844
872
  if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
@@ -855,22 +883,25 @@ async function serveSceneList(req, res, root) {
855
883
  if (existsSync(abs)) return json(res, 409, { error: `already exists: ${String(wanted)}` });
856
884
  const name = basename(abs).replace(/\.scene\.json$|\.json$/, "") || "Scene";
857
885
  const dimension = (body.dimension === "3d" || body.dimension === "2d" ? body.dimension : null) ?? projectDimension(base) ?? "2d";
886
+ const created = {
887
+ format: 1,
888
+ type: "scene",
889
+ dimension,
890
+ name,
891
+ root: {
892
+ name,
893
+ type: dimension === "3d" ? "Node3D" : "Node2D",
894
+ uid: newUid()
895
+ }
896
+ };
897
+ const raw = `${JSON.stringify(created, null, 2)}\n`;
858
898
  try {
859
899
  mkdirSync(dirname(abs), { recursive: true });
860
- writeFileSync(abs, `${JSON.stringify({
861
- format: 1,
862
- type: "scene",
863
- dimension,
864
- name,
865
- root: {
866
- name,
867
- type: dimension === "3d" ? "Node3D" : "Node2D",
868
- uid: newUid()
869
- }
870
- }, null, 2)}\n`);
900
+ writeFileSync(abs, raw);
871
901
  } catch (error) {
872
902
  return json(res, 400, { error: message(error) });
873
903
  }
904
+ announce("scene:created", base, abs, raw, created, hooks);
874
905
  return json(res, 201, {
875
906
  rel: relative(base, abs).split(sep).join("/"),
876
907
  abs,
@@ -879,7 +910,7 @@ async function serveSceneList(req, res, root) {
879
910
  });
880
911
  }
881
912
  /** @internal Exported for tests — `GET`/`PUT /api/scene?file=`. */
882
- async function serveSceneFile(req, res, root) {
913
+ async function serveSceneFile(req, res, root, hooks) {
883
914
  const base = normalize(resolve(root));
884
915
  const file = new URL(req.url ?? "/", "http://localhost").searchParams.get("file");
885
916
  const abs = resolveSceneFile(base, file);
@@ -937,6 +968,7 @@ async function serveSceneFile(req, res, root) {
937
968
  return json(res, 400, { error: message(error) });
938
969
  }
939
970
  OWN_WRITES.set(normalize(abs), written);
971
+ announce("scene:saved", base, abs, written, parsed, hooks);
940
972
  return json(res, 200, {
941
973
  ok: true,
942
974
  output: abs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incanto",
3
- "version": "0.76.0",
3
+ "version": "0.78.0",
4
4
  "description": "Vibe-coding-first web game engine SDK — JSON-driven scenes on three.js",
5
5
  "keywords": [
6
6
  "game-engine",
@@ -44,6 +44,12 @@ const game = await createGame2D({
44
44
  // game.dispose() — ONE call tears everything down (SPA unmount)
45
45
  ```
46
46
 
47
+ A scaffolded project calls this from `src/App.tsx` — React owns the page, the
48
+ component owns the canvas, and the boot runs in an effect. `canvas` comes from a
49
+ ref there rather than a `querySelector`. See `incanto-web-integration.md` for
50
+ that shell and for the `<IncantoCanvas>` component you use instead when the game
51
+ is one part of a larger site.
52
+
47
53
  One call does register → load → physics → input → renderer → start. Physics is
48
54
  `'auto'`: Rapier is enabled iff the tree has physics bodies (force with
49
55
  `physics: true|false`). Keyboard attaches to `window` (`keyboard: false`
@@ -52,6 +52,12 @@ const game = await createGame3D({
52
52
  // game.dispose() — ONE call tears everything down (SPA unmount)
53
53
  ```
54
54
 
55
+ A scaffolded project calls this from `src/App.tsx` — React owns the page, the
56
+ component owns the canvas, and the boot runs in an effect. `canvas` comes from a
57
+ ref there rather than a `querySelector`. See `incanto-web-integration.md` for
58
+ that shell and for the `<IncantoCanvas>` component you use instead when the game
59
+ is one part of a larger site.
60
+
55
61
  One call does register → load → physics → input → renderer → start. Physics is
56
62
  `'auto'`: Rapier is enabled iff the tree has physics bodies (force with
57
63
  `physics: true|false`). Keyboard attaches to `window` (`keyboard: false`
@@ -363,7 +363,7 @@ import { incantoLibrary, incantoScenes } from 'incanto/vite';
363
363
  export default defineConfig({ plugins: [incantoScenes(), incantoLibrary()] });
364
364
  ```
365
365
  ```ts
366
- // main.ts
366
+ // App.tsx
367
367
  createGame3D({
368
368
  ...,
369
369
  debug: import.meta.env.VITE_INCANTO_DEBUG === '1',
@@ -407,7 +407,7 @@ pass `editor.save` — otherwise edits live as long as the session does.
407
407
 
408
408
  **Saving keeps you in the editor.** It used to throw you out of it: the save
409
409
  writes the scene through `incantoScenes()`'s `PUT /api/scene`, vite saw its own
410
- watched file change, and a JSON module imported by `main.ts` cannot be
410
+ watched file change, and a JSON module imported by `App.tsx` cannot be
411
411
  hot-swapped — so vite full-page-RELOADED, the page came back as the game, and
412
412
  the selection, the camera and the panel you were working in were gone. The
413
413
  plugin now recognises a write it just made and lets the page be. A hand edit in
@@ -493,6 +493,50 @@ embedded editor posts nothing. The page posts to its parent window:
493
493
  { type: 'incanto-editor:error', message } // loader/save errors
494
494
  ```
495
495
 
496
+ ### Hearing a save from OUTSIDE the browser
497
+
498
+ A preview running in a container saves into that container, and the process
499
+ supervising it — the one that rebuilds, commits or syncs — is not in the page.
500
+ It has two ways to hear about it, and neither needs the iframe:
501
+
502
+ **1. stdout, zero configuration.** Every scene this dev server writes announces
503
+ itself on the terminal you are already tailing, as ONE line: a fixed `[incanto] `
504
+ prefix and a JSON object.
505
+
506
+ ```
507
+ [incanto] {"event":"scene:saved","file":"src/game.scene.json","bytes":13284,"at":"2026-09-08T10:19:57.412Z"}
508
+ [incanto] {"event":"scene:created","file":"src/levels/arena.scene.json","bytes":214,"at":"…"}
509
+ ```
510
+
511
+ `file` is project-relative with forward slashes on every OS, `bytes` is what was
512
+ written, `at` is ISO-8601. The line is printed only when the bytes LANDED — a
513
+ refused write (not a scene, would overwrite a non-scene) says nothing, so the
514
+ line means "there is something new on disk". Read the fields you know and ignore
515
+ the rest: more may be added, these will not change. Nothing to configure; a
516
+ project with `incantoScenes()` in its vite config already prints it.
517
+
518
+ Do not scrape vite's own `page reload <file>` line for this. It appears for a
519
+ HAND edit and deliberately does not for the editor's own save (that reload used
520
+ to throw you out of the editor mid-edit).
521
+
522
+ **2. `onSave`, in-process, with the scene itself.** When the host wants the JSON
523
+ rather than the path:
524
+
525
+ ```ts
526
+ incantoScenes({
527
+ onSave: ({ event, file, abs, json }) => {
528
+ // event: 'scene:saved' | 'scene:created'
529
+ void fetch('http://127.0.0.1:9000/scene-saved', {
530
+ method: 'POST',
531
+ body: JSON.stringify({ file, json }),
532
+ });
533
+ },
534
+ })
535
+ ```
536
+
537
+ It runs after the write, and a throw from it is reported and swallowed — a
538
+ broken hook must not take the dev server down or silence the stdout line.
539
+
496
540
  The page can never choose filesystem paths — `input`/`output` are fixed at launch;
497
541
  the API only reads the input and writes the output. Saves are rejected (422, file
498
542
  untouched) unless the body is scene-shaped (`format: 1`, `type: "scene"`, `name`,
@@ -447,7 +447,7 @@ the composition:
447
447
  in the browser that offline pose went out as the client's FIRST replicated
448
448
  state, on top of the other player's car, which the kinematic copy then lifted
449
449
  off the ground. Boot with `autoStart: false` and call `game.engine.start()`
450
- once the manager exists (`examples/race-mp-3d/src/main.ts`):
450
+ once the manager exists (`examples/race-mp-3d/src/App.tsx`):
451
451
 
452
452
  ```ts
453
453
  const game = await createGame3D({ canvas, scene, behaviors, autoStart: false });
@@ -267,7 +267,7 @@ signature of a save format change, so the hunt started in the wrong place
267
267
  entirely.
268
268
 
269
269
  **To keep the continue INSIDE the game** (where a harness can reach it —
270
- `runScript` boots the scene, never your `main.ts`), do it on the first frame:
270
+ `runScript` boots the scene, never your `App.tsx`), do it on the first frame:
271
271
 
272
272
  ```ts
273
273
  class Game extends Behavior {
@@ -428,7 +428,7 @@ it does not load. Every CLI (`incanto-check`, `-playtest`, `-verify`, `-feel`,
428
428
  game and your harness are the two places you write it yourself:
429
429
 
430
430
  ```ts
431
- // main.ts — vite. Every *.scene.json beside this file, keyed by the path an
431
+ // App.tsx — vite. Every *.scene.json beside this file, keyed by the path an
432
432
  // `instance:` names.
433
433
  const prefabs = import.meta.glob('./*.scene.json', { eager: true, import: 'default' });
434
434
  const resolveScene = (path: string): unknown => prefabs[`./${path}`] ?? null;
@@ -914,8 +914,8 @@ thing gating it: keep it `false` (or dev-gated) in anything you publish.
914
914
  Everything above measures a dev server. The artifact a player gets is
915
915
  `bun run build` — a bundled, minified `dist/` with different asset paths and a
916
916
  different module graph — and the boot path is the only code a BUILD adds over a
917
- SCENE. Two of the nastiest defects this engine has had lived exactly there: a
918
- `main.ts` whose scene swap left the game with no physics, and a boot that threw
917
+ SCENE. Two of the nastiest defects this engine has had lived exactly there: a boot
918
+ file whose scene swap left the game with no physics, and a boot that threw
919
919
  behind a loading overlay that never came down.
920
920
 
921
921
  `incanto-frame` and `incanto-logs` ask a dev server, and the dev server asks the
@@ -9,8 +9,46 @@ description: Embed an Incanto game in a React/Next/any web app — the IncantoCa
9
9
  > installed engine version. Sibling skills live in `node_modules/incanto/skills/`.
10
10
 
11
11
  Incanto's core is framework-free and every entry imports cleanly in Node (SSR
12
- safe). `incanto/react` is the official last mile React is an OPTIONAL peer:
13
- `bun add react` only if you use it.
12
+ safe). React is an OPTIONAL peer of the package but every project
13
+ `incanto-new` scaffolds already ships it, because the whole stack is
14
+ **TypeScript + Vite + React**.
15
+
16
+ ## Two shapes, and which one you want
17
+
18
+ | | `src/App.tsx` (what a scaffold gives you) | `<IncantoCanvas>` (`incanto/react`) |
19
+ |---|---|---|
20
+ | owns | the whole page | one box inside a bigger app |
21
+ | canvas | yours, one `<canvas ref>` | the component's |
22
+ | boot | your own `createGame2D/3D` call | the component's |
23
+ | gives you | every boot option — `editor`, asset preloading with a progress bar, `showBootFailure` | scene, behaviors, and the common options |
24
+ | HUD | `HudLayer` nodes, or DOM in `index.html` | `children`, with `useGame()` inside |
25
+
26
+ **A scaffolded game keeps its own `App.tsx`.** It is ten lines and it can pass
27
+ anything `createGame3D` takes:
28
+
29
+ ```tsx
30
+ export function App() {
31
+ const canvasRef = useRef<HTMLCanvasElement>(null);
32
+ useEffect(() => {
33
+ const canvas = canvasRef.current;
34
+ // StrictMode runs this twice against the SAME element — boot once.
35
+ if (!canvas || (canvas as { _incanto?: true })._incanto) return;
36
+ (canvas as { _incanto?: true })._incanto = true;
37
+ void (async () => {
38
+ const game = await createGame3D({ canvas, scene: sceneJson, behaviors });
39
+ (window as unknown as { game: typeof game }).game = game;
40
+ document.querySelector('#loading')?.remove();
41
+ })();
42
+ }, []);
43
+ return <canvas ref={canvasRef} id="game" />;
44
+ }
45
+ ```
46
+
47
+ `index.html` holds `<div id="root"></div>` styled `display: contents`, so every
48
+ CSS rule that used to target the canvas as a body child still applies.
49
+
50
+ Reach for `IncantoCanvas` instead when the game is a COMPONENT of a site you
51
+ already have — a page with a header, a route in a Next app, a card in a grid.
14
52
 
15
53
  ## React: the component
16
54
 
@@ -51,10 +51,15 @@ point-and-click or an RTS starts from).
51
51
  ```
52
52
  src/game.scene.json ← the game. Nodes, props, connections. This is most of it.
53
53
  src/behaviors.ts ← the parts JSON cannot express. Usually very little.
54
- src/main.ts ← boot. You rarely touch it.
54
+ src/App.tsx ← boot. The React component that owns the canvas. Rarely touched.
55
+ src/main.tsx ← mounts <App /> into #root. Never touched.
55
56
  verify.ts ← the harness that proves it works. You WILL touch it.
56
57
  ```
57
58
 
59
+ Every project is **TypeScript + Vite + React**: `main.tsx` mounts the app,
60
+ `App.tsx` renders one `<canvas>` and boots the engine into it from an effect.
61
+ React is the shell, not the game — the game is still the JSON.
62
+
58
63
  The claim that "all structure is JSON" is not marketing: one of the four builds
59
64
  shipped a whole 3D world with **zero** gameplay TypeScript, and another needed
60
65
  90 lines for one melee swing. Reach for a built-in behavior before you write a
@@ -5,7 +5,8 @@ generate-world.ts the WORLD AUTHORING script (bun run world):
5
5
  island terrain + sea + groves/grass/flowers +
6
6
  quest sites on walkable ground → src/game.scene.json
7
7
  index.html canvas + loading bar
8
- src/main.ts preload createGame3D
8
+ src/main.tsx React entry — mounts <App /> into #root
9
+ src/App.tsx preload → createGame3D
9
10
  src/game.scene.json GENERATED — do not hand-edit; edit generate-world.ts
10
11
  src/behaviors.ts IsleDirector (quest FSM, terrain-nav shade AI,
11
12
  ward relighting, fireworks, respawn) + SwordStrike
@@ -54,15 +54,18 @@
54
54
  font-size: 0.85rem;
55
55
  color: #aaa;
56
56
  }
57
+ #root {
58
+ display: contents;
59
+ }
57
60
  </style>
58
61
  </head>
59
62
  <body>
60
- <canvas></canvas>
63
+ <div id="root"></div>
61
64
  <div id="loading" class="loading-container">
62
65
  <div class="loading-title">Beacon Isle</div>
63
66
  <div class="progress-track"><div id="progress"></div></div>
64
67
  <div id="progress-text">0%</div>
65
68
  </div>
66
- <script type="module" src="/src/main.ts"></script>
69
+ <script type="module" src="/src/main.tsx"></script>
67
70
  </body>
68
71
  </html>
@@ -14,12 +14,17 @@
14
14
  "@dimforge/rapier2d-compat": "0.19.3",
15
15
  "@dimforge/rapier3d-compat": "0.19.3",
16
16
  "@pixiv/three-vrm": "^3.5.3",
17
- "incanto": "^0.76.0",
17
+ "incanto": "^0.78.0",
18
+ "react": "^18.3.1",
19
+ "react-dom": "^18.3.1",
18
20
  "three": "^0.184.0"
19
21
  },
20
22
  "devDependencies": {
21
23
  "@types/node": "^24.0.0",
24
+ "@types/react": "^18.3.12",
25
+ "@types/react-dom": "^18.3.1",
22
26
  "@types/three": "^0.184.1",
27
+ "@vitejs/plugin-react": "^6.0.3",
23
28
  "typescript": "^6.0.3",
24
29
  "vite": "^8.0.16"
25
30
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Beacon Isle — the incanto 3D template: a generated island (terrain, sea,
3
+ * groves, grass, flowers), a quest with a real NPC, shades that HUNT you
4
+ * across the surface via terrain navigation, melee with a bone-mounted sword,
5
+ * golden-hour grade + bloom, touch controls on phones — regenerate the whole
6
+ * world with `bun run world`, the game logic never changes.
7
+ */
8
+ import { assetUrls, preloadUrls } from 'incanto';
9
+ import { createGame3D, showBootFailure } from 'incanto/3d';
10
+ import { useEffect, useRef } from 'react';
11
+ import { BEHAVIORS } from './behaviors';
12
+ import gameJson from './game.scene.json';
13
+
14
+ export function App() {
15
+ const canvasRef = useRef<HTMLCanvasElement>(null);
16
+
17
+ useEffect(() => {
18
+ const canvas = canvasRef.current;
19
+ // StrictMode runs this effect twice against the SAME canvas element, and a
20
+ // second engine on one canvas is two renderers fighting for one context.
21
+ // The flag lives on the element, so it lasts exactly as long as the thing
22
+ // it guards — a page-level game like this one never unmounts otherwise.
23
+ if (!canvas || (canvas as { _incanto?: true })._incanto) return;
24
+ (canvas as { _incanto?: true })._incanto = true;
25
+
26
+ void (async () => {
27
+ const fill = document.querySelector('#progress') as HTMLElement | null;
28
+ const text = document.querySelector('#progress-text') as HTMLElement | null;
29
+ await preloadUrls(assetUrls(gameJson.assets), (done, total) => {
30
+ const pct = total > 0 ? Math.round((done / total) * 100) : 100;
31
+ if (fill) fill.style.width = `${pct}%`;
32
+ if (text) text.textContent = `${pct}%`;
33
+ });
34
+ /** Dev-only surfaces: the ☰ debug overlay, and the editor behind it. */
35
+ const DEBUG = import.meta.env.VITE_INCANTO_DEBUG === '1';
36
+
37
+ const game = await createGame3D({
38
+ debug: DEBUG,
39
+ // The editor's 📚 buttons, served by `incantoLibrary()` in vite.config.ts.
40
+ // BOTH halves are the opt-in, and this half was missing from every shipped
41
+ // template: the plugin alone left a vite config whose own comment promised
42
+ // "the agent8 asset catalog behind the 📚 buttons" and no button anywhere.
43
+ // Gated on the same flag, because `editor` DEFAULTS to `debug` and an object
44
+ // here would turn the editor on in a production build.
45
+ editor: DEBUG && { library: true },
46
+ canvas,
47
+ scene: gameJson,
48
+ behaviors: BEHAVIORS,
49
+ pointer: true,
50
+ }).catch((e) => {
51
+ // Without this the player watches the loading overlay sit at 100% forever:
52
+ // `#loading` is removed on the line below, so anything that rejects here (no
53
+ // WebGL context, a scene that will not load) leaves the bar up and the reason
54
+ // in a console nobody opens.
55
+ showBootFailure(e);
56
+ throw e;
57
+ });
58
+
59
+ // The console handle FIRST, before any wiring of your own can throw.
60
+ // `game.stats()`, `game.assetErrors()`, `game.frame()` and `game.engine.log`
61
+ // are the entire in-page diagnostic surface, and a static build has no other.
62
+ // Assigned after the wiring, one mistake below took all of them with it.
63
+ (window as unknown as { game: typeof game }).game = game;
64
+
65
+ // The overlay comes down LAST, after any wiring of your own. There is none in
66
+ // this template — when you add some, put it above this line and wrap it:
67
+ //
68
+ // try { …your wiring… } catch (e) { showBootFailure(e); throw e; }
69
+ //
70
+ // A throw between the overlay coming down and the handle going up used to leave
71
+ // a level that renders perfectly with no player and no error of any kind.
72
+ document.querySelector('#loading')?.remove();
73
+ })();
74
+ }, []);
75
+
76
+ return <canvas ref={canvasRef} id="game" />;
77
+ }
@@ -0,0 +1,9 @@
1
+ import { StrictMode } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import { App } from './App';
4
+
5
+ createRoot(document.getElementById('root') as HTMLElement).render(
6
+ <StrictMode>
7
+ <App />
8
+ </StrictMode>,
9
+ );
@@ -7,7 +7,8 @@
7
7
  "noEmit": true,
8
8
  "skipLibCheck": true,
9
9
  "resolveJsonModule": true,
10
- "types": ["vite/client", "node"]
10
+ "types": ["vite/client", "node"],
11
+ "jsx": "react-jsx"
11
12
  },
12
13
  "include": ["src", "verify.ts", "vite.config.ts"]
13
14
  }
@@ -1,12 +1,15 @@
1
+ import react from '@vitejs/plugin-react';
1
2
  import { incantoLibrary, incantoScenes } from 'incanto/vite';
2
3
  import { defineConfig } from 'vite';
3
4
 
4
5
  export default defineConfig({
5
6
  base: './',
7
+ // react(): the app entry is React — src/main.tsx mounts <App />, and App.tsx
8
+ // owns the canvas the engine draws into.
6
9
  // Dev-server only, and both are why the editor is useful here:
7
10
  // - incantoScenes(): validates every *.scene.json the moment you save it,
8
11
  // AND serves this project's scenes, so the editor (☰ debug ▸ edit this
9
12
  // scene ▸ scenes) can open, create and save any scene in the project.
10
13
  // - incantoLibrary(): the agent8 asset catalog behind the 📚 buttons.
11
- plugins: [incantoScenes(), incantoLibrary()],
14
+ plugins: [react(), incantoScenes(), incantoLibrary()],
12
15
  });
@@ -3,7 +3,8 @@
3
3
  ```
4
4
  src/game.scene.json the whole game's structure — holes, moles, HUD, wiring
5
5
  src/behaviors.ts Molehill (when a mole pops) + Scoreboard (what the HUD says)
6
- src/main.ts createGame2D, the pointer, the two behaviours
6
+ src/main.tsx React entry mounts <App /> into #root
7
+ src/App.tsx createGame2D, the pointer, the two behaviours
7
8
  verify.ts the headless proof — drives the MOUSE
8
9
  coverage.json the gate's two questions: can it hurt you, can it be lost
9
10
  ```
@@ -59,6 +59,9 @@
59
59
  transform: rotate(360deg);
60
60
  }
61
61
  }
62
+ #root {
63
+ display: contents;
64
+ }
62
65
  </style>
63
66
  </head>
64
67
  <body>
@@ -67,7 +70,7 @@
67
70
  <div class="spinner"></div>
68
71
  <div class="hint">click the moles — twelve to win, three misses and they win</div>
69
72
  </div>
70
- <canvas id="game"></canvas>
71
- <script type="module" src="/src/main.ts"></script>
73
+ <div id="root"></div>
74
+ <script type="module" src="/src/main.tsx"></script>
72
75
  </body>
73
76
  </html>