---
name: incanto-assets
description: Where game art comes from in Incanto — the FULL built-in catalog shipped in the package (2D sprites with animations, tilesets, item pickups, effects, AND 3D foliage textures: tree leaves, tree bark, terrain/ground splat textures), how to reference each in scene JSON (the catalog `url` contract), the incanto-assets CLI (list/info/copy), external/MCP-provided asset URLs, art-free prototyping with ColorRect2D and data URIs, and the library sprite-animation JSON convention. Use when a game needs sprites, tiles, models, leaf/bark/terrain textures, or any visual asset.
---

# Assets — where the art comes from

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

Resolve art in this order: ① built-ins (zero setup) → ② asset MCP servers /
known URLs → ③ art-free primitives. Never invent asset URLs — `incanto-check`
asks every remote url's server whether it exists, and a 404 is missing art.

**3D characters and props are step ②, always** — the built-in catalog is 2D
sprites, tiles, items, foliage/terrain textures and audio, with no models in it.
Search with your asset MCP (or the editor's 📚 library), then run the URL
through `bunx incanto-model <url>`: for a rigged humanoid it prints a whole
playable character — model, locomotion clips, body, controller, skin and input
— ready to paste. See `incanto-3d-models.md`.

## 1. Built-in assets (in the package)

```bash
bunx incanto-assets list                  # name · kind · GRID · what it is
bunx incanto-assets info medieval-knight  # description + animation names
bunx incanto-assets copy medieval-knight --out public/assets
```

`list --json` prints every entry. Each entry carries a **`url`** — the drop-in
reference you put in scene JSON so the asset LOADS (the contract, see §1b) —
and, where the art has a grid, the numbers a node needs:

```
2dbasic          character  [animated] 111×83           2dbasic sprite sheet image…
minecraft-tiles  tile                  16×16 (25 tiles) Minecraft-themed tiles…
```

`frameWidth`/`frameHeight` are what `AnimatedSprite2D.frameWidth` and
`TileMap2D.tileSize` want; `columns` and `tiles` tell you the highest index a
`legend` may name (25 tiles means 0–24, and asking for 99 draws clamp-streaks —
the engine reports it, but the catalog is where you get the number). **The sizes
live in these fields and nowhere else** — a frame size in the description was a
second copy, and it drifted: `2dbasic` said 192×192 for art whose real grid is
111×83.

### Categories — the whole built-in set

| kind        | examples                                                      | what they're for                              |
|-------------|--------------------------------------------------------------|-----------------------------------------------|
| `character` | `medieval-knight`, `goblin`, `ghost`, `2dbasic`              | animated 2D sprite SHEETS (idle/move/attack…) |
| `tile`      | `floor00`, `wall00`, `minecraft-tiles`                       | 2D tile textures / tilesheets                 |
| `item`      | `coin`, `gem`, `gold`, `hp_potion`, `box`, `trap`, `map`, …  | 2D pickup sprites                             |
| `effect`    | `swoosh`                                                     | 2D effect sprites                             |
| `foliage`   | `leaves_oak/ash/pine/aspen`                                  | **Tree3D** `leafTexture` (leaf-cluster cutout)|
| `foliage`   | `bark_oak/birch/pine_{color,normal,roughness}`              | **Tree3D** trunk bark (sampled by default)    |
| `foliage`   | `ground_grass`, `ground_dirt`, `ground_dirt_normal`        | **Terrain3D** grassland ground textures       |
| `audio`     | `explosion`, `gold-loot`, `slash`, `heal`, `ui-click`, …    | **AudioPlayer** `src` sound files (see below)  |
| `backdrop`  | `sky_gradient`                                               | **`environment.skybox`** — an equirect picture behind the scene, never a light |

### 1b. The `url` contract — referencing each kind

Every catalog entry has a `url` that is directly usable; there are two classes:

- **Foliage (leaves / bark / ground)** — `url` is a hosted image on the agent8 CDN
  (`agent8-games.verse8.io`, the ONLY sanctioned external host) — the exact texture
  Tree3D/Terrain3D sample **zero-setup by default**, so a bare node already works.
  Override only to swap the look; drop a `url` straight into the matching prop:

  ```jsonc
  // Tree3D — a bare node already loads oak leaves from the agent8 CDN; override
  // leafTexture only to swap in a different built-in cutout (e.g. ash)
  { "type": "Tree3D", "props": { "type": "broadleaf",
      "leafTexture": "https://agent8-games.verse8.io/assets/3D/default/textures/vegetation/ash_color.png" } },

  // MeshInstance3D material — built-in ground/bark color as a map (+ a normal map)
  { "type": "MeshInstance3D", "props": { "material": {
      "map":       "https://agent8-games.verse8.io/assets/3D/default/textures/vegetation/ground/grass.jpg",
      "normalMap": "https://agent8-games.verse8.io/assets/3D/default/textures/vegetation/ground/dirt_normal.jpg" } } }
  ```

  `Terrain3D.textureBase` is a directory base (`<base>/<layer>.png`), not a
  single file — leave it at the default agent8 set or point it at your own base.

- **Audio (`kind: "audio"`)** — `url` is `incanto/assets/audio/<file>`, the
  drop-in for `AudioPlayer.src` (e.g. `"src": "incanto/assets/audio/explosion.mp3"`).
  For zero-asset sound, prefer the procedural SFX **presets** instead of a file.
  Full guide: **incanto-audio.md** (presets, AudioPlayer, volume buses).

- **Backdrop (`kind: "backdrop"`)** — an equirectangular picture for
  `environment.skybox`. `url` is `incanto/assets/backdrops/<file>`, which the
  dev server serves as written — but a **built** game has no such route, so
  `incanto-assets copy sky_gradient --out public` and write the copied name:

  ```json
  "environment": { "sky": { "type": "atmosphere" }, "skybox": "sky_gradient.png" }
  ```

  It is a picture behind the scene and NEVER a light source (an 8-bit image as
  light turns every metal into plastic); keep `sky` or `preset` for lighting.
  Why the two roles are separate, and what `.hdr` is for: the "A picture as
  the sky" section of **incanto-building-3d-games.md**.

- **Packaged 2D sprites (character / tile / item / effect)** — `url` is
  `incanto/assets/<file>` (e.g. `incanto/assets/items/coin.png`). This is the
  **bundler-import / copy** reference. In a bundler game (vite, the usual target)
  import it; otherwise `incanto-assets copy` puts the file in your project. These
  props are `$asset` REFS, not URLs — the url lives in the scene `assets{}` entry:

  ```ts
  import coinUrl from 'incanto/assets/items/coin.png';
  // scene assets: { "coin": { "type": "texture", "url": coinUrl } }
  // node:         { "type": "Sprite2D", "props": { "texture": "$coin" } }
  ```

  `copy` does this for you and prints the ready-to-paste JSON — for animated
  sheets it includes the full `animations` map (idle/move/attack…) derived from
  the sheet metadata. `Sprite2D.texture` / `AnimatedSprite2D.sheet` take a
  `"$assetKey"` ref, and the engine hard-fails at LOAD on anything else — a raw
  URL, a bare catalog id, or a `$key` the scene does not declare:

  ```
  [UNKNOWN_ASSET] 'Coin.texture' names asset '$coinn', which the scene does not
                  declare. Declared: [coin, gem]. (at '/Room/Coin')
  ```

  (It genuinely did not, until 0.61: the check lived in the renderer, so only a
  browser ever reached it and a typo'd ref simply drew nothing.)

The scene **composer (incanto-editor)** wires all of this for you: the inspector
shows an asset PICKER on every texture/sheet/map prop — browse the built-ins by
name (with kind + description), and picking one inserts the loadable value
(foliage → the CDN url; 2D sprite → a `$asset` ref + the scene `assets{}` entry).

## 2. External art (asset MCP servers, CDNs, your files)

Any URL works in scene `assets{}` — `{ "type": "spritesheet", "url": "<from
your asset tool>", "frameWidth": …, "frameHeight": … }`. When an asset source
hands you a sheet WITH a library-convention animation JSON (`{frame: {width,
height}, animations: {idle: {start, end, frameRate, repeat}}}`), convert it
mechanically:

```ts
import { spriteFromLibraryMeta } from 'incanto/2d';
const { asset, props } = spriteFromLibraryMeta(animJson, {
  url: sheetUrl, assetKey: 'hero',
});
// asset → scene assets.hero; props → an AnimatedSprite2D's props. Done.
```

The `animations` map it returns (and the one `incanto-assets copy` prints) also
carries ALIASES for the movement states a character controller emits and the
sheet does not name — `"run": "move"`, `"fall": "idle"` — because library sheets
are `idle`/`move`/`attack` and no sheet ships a falling pose. That is what makes
the printed JSON work when you paste it next to
`movementStateChanged → play`; repoint any alias once you have the art. Sheets
with no `idle` (a spinning coin) get none: they are not characters.

For 3D files, ALWAYS inspect before placing: `bunx incanto-model file.glb`
(bounds, animations, rig — see incanto-3d-models).

### The agent8 library, from inside the editor

`bunx incanto-editor --token <v8 access token>` puts a **📚** button on every
field that takes a resource. It browses the same catalog the agent8 workbench
does (2D sprites; 3D characters/monsters/objects/vehicles/weapons/polyhaven/
textures), previews a GLB with the engine and an image as an image, and on pick
writes the URL — or, for a `$assetKey` field, writes the scene `assets{}` entry
AND the ref together. See `incanto-editor` for the token and proxy details.

Everything the library hands you is an ordinary URL on the agent8 CDN, so a
scene built that way stays plain JSON you can also hand-write.


## 3. No art yet? Ship gameplay anyway

- `ColorRect2D` — solid rectangles for paddles/walls/platforms/flashes.
- `Particles2D` presets — fire/explosion/magic with zero files.
- `AudioPlayer` SFX **presets** — coin/jump/hurt/explosion/… with zero files
  (the audio analog; see incanto-audio.md).
- 1×1 data-URI + `tint` + `scale` on a Sprite2D for anything else;
  canvas-generated `data:` URLs also work as asset urls.

Swap in real art later by changing ONLY the `assets{}` entry — node props
stay untouched.


## Art you named and never copied

`bunx incanto-check` warns when a scene asset points at a local file the project
does not contain:

```
  ok  src/game.scene.json
      warn: $hero → assets/hero.png is not in the project (looked in public/,
      ./, and beside the scene). It will draw nothing.
```

That used to be a browser-only failure — visible in `assetErrors()`, which needs
a running game and someone to look at it — and it is the single most common way
a scene draws nothing. Remote (`https:`) urls are ASKED — a HEAD request with a
four-second deadline, one per url per run — and a 404 is the same warning
(`$avatar → https://…/base-model.glb is not found on its server (HTTP 404)`);
a host that cannot be reached is a `note:` and never a failure, so the check
works offline. Inline (`data:`) urls are left alone. This is the half of "never
invent asset URLs" a server can answer: a model at a made-up CDN path passed
every instrument except the browser before it.

## A spritesheet grid that does not fit says so

`frameWidth`/`frameHeight` are the two numbers nothing could check for you, and
getting them wrong does not fail — it draws the wrong art. A size that does not
divide the sheet slices every row a little further off centre; an animation
naming a frame past the end of the grid reads whatever is at the wrong end of
the image.

Both are reported now, once, the first time the sprite draws:

```
[incanto] spritesheet 'hero' does not fit its frame size: 1344 px wide is not a
whole number of 100 px frames (672, 448, 336, 224, 192, 112 would divide it).
Every row after the first is cut off centre.

[incanto] spritesheet 'hero' has 35 frames (0–34), and an animation asks for
frame 40. Those frames draw whatever is at the wrong end of the sheet.
```

The suggested sizes are the ones that actually divide your image, so the fix is
usually in the message. **All three nodes that cut a grid out of an image check
it** — `AnimatedSprite2D`, `AnimatedSprite3D` and `TileMap2D` — because they do
the same floor-division and it fails the same silent way. An
`AnimatedSprite3D` with a sheet and no `frameWidth`/`frameHeight` also says so
rather than drawing a mob-shaped hole. `bunx incanto-assets info <name>` prints the real frame
size for a built-in, and the editor's 📚 picker fills it in from the catalog's
metadata — or leaves it BLANK when the catalog does not carry it, because an
invented frame size is exactly this bug.

## Textures are shared automatically (3D)

A hundred `Sprite3D`s pointing at one atlas cost **one** fetch, one decode and
one GPU upload. The renderer's asset store keys textures by URL plus their
sampler settings, so you never need to hoist a texture yourself or worry that
spawning enemies re-downloads their sheet.

Two consequences worth knowing:

- **Do not mutate a texture you got from a node.** `Sprite3D` and `Terrain3D`
  hold the shared master. The two nodes with per-node UV state
  (`AnimatedSprite3D`'s frame window, `MeshInstance3D`'s `material.repeat`) get
  a private clone automatically — you do not have to ask for it.
- **`pixelArt: true` also turns mipmaps off**, which is what makes pixel art stay
  crisp at distance instead of blurring into mud.

A texture that 404s shows up in `game.assetErrors()` alongside models, by the
URL you wrote — so "why is my sprite invisible" is answerable without opening the
network tab. **That includes vegetation**: a `Tree3D` leaf or bark URL that fails
used to leave a grove of bare branches and say nothing anywhere, because that
node keeps its own texture cache. In **2D** the same question is `renderer.assets.errors()`
(`$ref`, url and reason per failed entry), and the scene EDITOR reads it: a
failed asset is red in the explorer with the url in its tooltip and the
consequence in its inspector.

**And a texture INSIDE a model.** A `.gltf` + `.bin` + a missing `textures/`
folder — the ordinary shape of a Blender "glTF Separate" export or a Sketchfab
download — used to be the one silent case: GLTFLoader swallows a sub-resource
failure, so the model resolved as a SUCCESS (`status: 'ready'`, `error: null`,
empty `assetErrors()`, `stats().errors` 0) and the character simply rendered
flat white. A half-loaded model was indistinguishable from a whole one. Now the
missing texture is in `assetErrors()` by its URL, like every other one.
