---
name: incanto-web-integration
description: Embed an Incanto game in a React/Next/any web app — the IncantoCanvas component, useGame/useNodeProp/useSignal hooks, DOM HUD overlays vs UILayer, SSR safety, responsive sizing. Use when putting a game inside an existing website, building a React HUD, or wiring game state to web UI.
---

# Web integration (React and beyond)

> Shipped inside the `incanto` npm package — this document always matches the
> installed engine version. Sibling skills live in `node_modules/incanto/skills/`.

Incanto's core is framework-free and every entry imports cleanly in Node (SSR
safe). React is an OPTIONAL peer of the package — but every project
`incanto-new` scaffolds already ships it, because the whole stack is
**TypeScript + Vite + React**.

## Two shapes, and which one you want

| | `src/App.tsx` (what a scaffold gives you) | `<IncantoCanvas>` (`incanto/react`) |
|---|---|---|
| owns | the whole page | one box inside a bigger app |
| canvas | yours, one `<canvas ref>` | the component's |
| boot | your own `createGame2D/3D` call | the component's |
| gives you | every boot option — `editor`, asset preloading with a progress bar, `showBootFailure` | scene, behaviors, and the common options |
| HUD | `HudLayer` nodes, or DOM in `index.html` | `children`, with `useGame()` inside |

**A scaffolded game keeps its own `App.tsx`.** It is ten lines and it can pass
anything `createGame3D` takes:

```tsx
export function App() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  useEffect(() => {
    const canvas = canvasRef.current;
    // StrictMode runs this twice against the SAME element — boot once.
    if (!canvas || (canvas as { _incanto?: true })._incanto) return;
    (canvas as { _incanto?: true })._incanto = true;
    void (async () => {
      const game = await createGame3D({ canvas, scene: sceneJson, behaviors });
      (window as unknown as { game: typeof game }).game = game;
      document.querySelector('#loading')?.remove();
    })();
  }, []);
  return <canvas ref={canvasRef} id="game" />;
}
```

`index.html` holds `<div id="root"></div>` styled `display: contents`, so every
CSS rule that used to target the canvas as a body child still applies.

Reach for `IncantoCanvas` instead when the game is a COMPONENT of a site you
already have — a page with a header, a route in a Next app, a card in a grid.

## React: the component

```tsx
import { IncantoCanvas, useNodeProp, useSignal } from 'incanto/react';
import { PlayerControl } from './behaviors';
import sceneJson from './game.scene.json';

function Hud() {
  const score = useNodeProp<string>('UI/Score', 'text'); // re-renders only on change
  useSignal('Coin', 'triggerEnter', () => confetti());   // auto-disconnects
  return <div style={{ pointerEvents: 'auto' }}>{score}</div>;
}

export function GamePage() {
  return (
    <div style={{ width: '100%', height: '100vh' }}>
      <IncantoCanvas scene={sceneJson} behaviors={{ PlayerControl }}>
        <Hud /> {/* children = DOM overlay over the canvas, game context inside */}
      </IncantoCanvas>
    </div>
  );
}
```

- The component boots `createGame2D/3D` in an effect (lazy-imported by the
  scene's `dimension` — a 2D game never bundles the 3D stack), and `dispose()`s
  everything on unmount. **StrictMode-safe**: double-invoked boots are disposed.
- All createGame options pass through: `physics`, `touch`, `debug`, `seed`,
  `pixelRatio`, `pointer` (3D), `keyboard: 'window' | 'canvas' | false`,
  `fallback` (loading UI), `onReady(game)`.
- Pass a STABLE `scene` reference — a new object identity re-boots the game.
- Hooks need the `<IncantoCanvas>` context: put HUD components in `children`.
  The overlay wrapper is `pointer-events: none`; re-enable per element.

## DOM HUD vs UILayer — pick one per piece of UI

| | DOM overlay (React/HTML) | `UILayer` nodes (in-canvas) |
|---|---|---|
| Styling | full CSS, fonts, a11y | engine Label/ColorRect only |
| State | `useNodeProp`/`useSignal` | behaviors mutate props |
| Serialized in scene JSON | no | yes (agents can edit it) |
| Works in headless capture | no | yes (`describeCapture` sees it) |

Rule of thumb: score/menus/dialogs that look like WEB UI → DOM overlay;
anything the game itself must own (and runScript must verify) → UILayer.

Pin a DOM element to a world position each frame (subscribe `engine.updated`),
then `transform: translate(x, y)`:

```ts
game.renderer.screenFromWorld(wx, wy)         // 2D
game.renderer.screenFromWorld(wx, wy, wz)     // 3D — takes THREE arguments
```

Going the other way, for taps and clicks:

```ts
game.renderer.worldFromScreen(sx, sy)             // 2D → { x, y }
game.renderer.worldFromScreen(sx, sy, planeY?)    // 3D → { x, y, z } on a
                                                  //   horizontal plane (default y=0)
game.renderer.rayFromScreen(sx, sy)               // 3D → { origin, dir } for
                                                  //   physics.castRay on uneven ground
```

**A pixel in 3D is a ray, not a point**, which is why the 3D form needs a
surface: `worldFromScreen` lands it on the ground plane, and `rayFromScreen`
hands you the ray when the ground is terrain or a stack of crates. Both return
null when the ray misses (looking at the sky). `pick(sx, sy)` gives you the NODE
under a pixel — a different question, and the only one 3D could answer before
0.66.

**Inside a Behavior there is no renderer**, so the same four questions are on
the engine, installed by whichever renderer is running and cleared when it is
disposed:

```ts
this.engine.pointerWorld()                 // where the cursor is → number[] | null
this.engine.toWorld(sx, sy)                // any screen point → [x, y] | [x, y, z]
this.engine.toScreen(node.position)        // → { x, y, behind } | null
this.engine.screenRay(sx, sy)              // 3D only → { origin, dir } for castRay
this.engine.pickAt(sx, sy)                 // which node (cached per frame)
```

The answer is the scene's own shape — `[x, y]` in 2D, `[x, y, z]` on the ground
in 3D — so it goes straight back into a `position`. All are null with no
renderer; a `runScript` `click` step (and `incanto-play`'s `at`) installs
geometric `toWorld`/`toScreen`/`screenRay` for the run, so a game that AIMS is
testable headless and not only one that clicks.

**On uneven ground, cast the ray** — `toWorld` lands on a plane, which is the
wrong answer under a hill or a crate:

```ts
const at = this.engine.input.pointerPosition();
const ray = at && this.engine.screenRay?.(at.x, at.y);
const hit = ray && this.engine.physics?.castRay(ray.origin, ray.dir, 100);
if (hit) place(hit.point);          // WHERE it landed, not how far
```

Perf note: `useNodeProp` deep-compares per frame — subscribe to LEAF values
(`'UI/Score', 'text'`), not big objects like a whole `animations` map.

Perf HUDs: `game.stats()` returns `{ fps, frameMs, nodes, running, triangles,
drawCalls, geometries, textures }` at any time — poll it on a `setInterval`
(2×/s is plenty) into React state; never subscribe it per frame.

## Next.js / SSR

Every incanto entry imports in Node — no `dynamic()` tricks needed. The game
itself boots inside `useEffect`, so server rendering emits the container +
fallback only. In the App Router mark the page `'use client'`.

## No React? Vanilla embed

```ts
const game = await createGame2D({ canvas, scene: sceneJson, behaviors: { … } });
// later, on teardown (SPA route change):
game.dispose(); // one call: renderer, physics, loop, scene, listeners
```

The canvas follows its CSS size every frame — size the CONTAINER with normal
CSS/flex/grid and give the canvas `width:100%; height:100%; display:block`.
Use the scene `viewport` header (see incanto-building-2d-games) so world
coordinates stay in design pixels no matter the container.

## Bridging game ↔ web state

- Web → game: call behavior methods (`game.engine.scene.root.getNode('Player')
  .behavior`), or `engine.input.pressAction('jump')` (same pipeline as keys).
- Game → web: `useNodeProp` (polling, change-detected) or `useSignal`
  (event-driven). For non-React, `engine.updated.connect(read)` +
  `node.on('signal', cb)` are the same primitives.
