---
title: "Assets & asset types"
description: "The asset system is the backbone of a Zero world. Almost everything you work with — a component, a material, a scene, a tool, a mesh, a texture, a shader, a bundle — is an asset. Understanding what…"
section: "Core"
slug: "core-asset-system"
canonical: "https://origozero.ai/docs/core-asset-system"
updated: "2026-09-07T03:15:14.209306336+00:00"
tags: ["documentation", "guide"]
---

# Assets & asset types

## "Everything is an asset" — what that actually means

It's not a slogan. It means every kind of content shares one system, instead of each being its own special case:

- it has a stable **identity** — a guid, plus a readable name like `@builtin::materials.gold`;
- it lives in the world's shared, persistent filesystem (so it's there for everyone, and it survives — see the engine and worlds guides);
- you find it, read it, create it, and validate it through **one** surface, `asset.*`;
- and it is an **instance of a type** that gives it both its shape and its abilities.

Learn the asset system once and you know how to work with *all* content — materials and scenes and tools included — not a dozen different APIs.

## An asset is a reference

You work with an asset through a **reference** (a handle), not by poking at its files:

```lua
local ref = asset.resolve("@builtin::materials.gold")
ref.identity   -- "@builtin::materials.gold"
ref.guid       -- the stable id
ref.type       -- "material"
ref.path       -- where it lives in the filesystem
```

A reference carries **methods**. Every reference has the standard readers — `ref:getText()`, `ref:getBytes()`, `ref:exists()`, `ref:inspect()`, `ref:deps()` — plus `ref.meta` (the metadata table, a field not a call) — and on top of those, **methods its type gives it**. A material reference can read and change its shader properties:

```lua
ref:getProperties()                 -- every property + value
ref:setProperty("roughness", 0.2)   -- change one live
```

A reference of a different type exposes whatever *that* type defines. (Where those type-specific methods come from is the authoring section at the end.)

**`asset.resolve` always returns the same reference for a given asset — not a copy.** Every holder of an asset gets the one shared handle, which is what lets a reference carry **live, shared state**. A material shows it: a property set through one resolved reference is immediately visible through any other reference to that material —

```lua
asset.resolve("@builtin::materials.gold"):setProperty("roughness", 0.2)
asset.resolve("@builtin::materials.gold"):getProperty("roughness")   -- 0.2 — same reference, same live state
```

That live state is **transient**: a `setProperty` updates the live material and what every holder sees, but it does not rewrite the asset's file — persisting a live change back to disk is a separate, explicit step, and unsaved live state resets when you switch between edit and play. The **durable** state is the asset's files. A type holds this live state on the reference's `runtime` table from inside its `behavior.luau` (the material type builds its property cache there and pushes each change straight to the live GPU material); as a user you reach it through the type's methods, not by touching `runtime` directly.

References also point **by identity, not by copy** — a Model component stores a *reference* to its material, so content re-threads to the same assets when pulled into another world.

### Durable state vs play mode

An asset's durable state is its files under `/zero/source/`, written in edit mode and shared live with everyone in the world (see the engine and worlds guides for why source always persists).

**In play mode, a source write is shadow-copied.** An asset you write or create while playing goes to the same authored path it goes to in edit — `/zero/source/<folder>/<name>.<type>/` — and the play write lock takes the bytes onto the **play shadow**: live in the running session, the disk source untouched, listed by `vfs.playShadowPaths()`, and discarded on a guarded play-exit unless they are kept. So the asset keeps the identity, the path and the `require` spelling it has in edit, and the session decides whether it stays.

**The scratch and local trees are outside the lock.** A create filed under `/zero/source/tmp/` or `/zero/source/local/` — the two trees held back from world saves and multiplayer sync — lands where the call filed it in either mode and answers `durable = true` with play still running: the lock speaks for the world's content, and neither tree is in it. Scratch is working space that dies with the session; `local` is this machine's own content, kept here and never sent to a world.

**Ask the call where its bytes went.** `asset.create` returns the durability as a second value, and it is present whatever the answer is:

```lua
local clip, d = asset.create("soundClip", "hit", { pcm = buf, sampleRate = 48000, channels = 1 })
-- edit mode:  d.durable == true
-- play mode:  d.durable == false, d.warning names the state,
--             d.playShadow names the routes that keep it, d.shadowed the paths
```

One call answers once for every file it wrote. It is the same answer the `write_file`, `edit_file` and `capture` tools attach to their results — `vfs.durability(paths)` gives it directly at any other write site. To keep a shadowed edit, `vfs.promotePlayShadow(path)` between `engine.paused = true` and `engine.paused = false` promotes that one path; `vfs.write(path, body, { durable = true })` writes canonical source with play still running.

**A create the world reproduces on every load goes to `/zero/runtime/` instead.** That is a create made from a script component's callback or from a scene entrypoint — code that rebuilds the asset each load, so a second copy in the saved manifest would only go stale. It files under `/zero/runtime/assets/<identity>.<type>/`, outside the play write lock, so it answers `durable = true` in either mode. See the **components** guide for the component lifecycle and which hooks run in each mode.

## An asset type defines a kind of asset

Every asset is an instance of a **type**. `material`, `component`, `scene`, `tool`, `shader`, `mesh`, `bundle`, … are all types:

```lua
asset.list("assetType")   -- every type this world knows
asset.categories()        -- the same names, as the strings asset.* takes
```

A type is itself an asset — the system is self-hosting (`material` is an instance of `assetType`, which is an instance of itself). A type definition is a `<typename>.assetType/` folder that declares three things:

- **Shape** — `type.yaml`: the files a valid instance must contain. This is what `asset.validate` checks.
- **Behaviour** — `behavior.luau`: the methods its references get, and how it reacts when its instances change.
- **A starting point** — `template/`: files copied into a new instance.

Each asset records which type it was authored against, as a pinned reference (a guid, not just a name), so an asset and its exact type stay linked even across worlds:

```lua
local t = asset.typeRef(ref)        -- the type's pinned ref, or nil if the asset doesn't pin one
if t then asset.resolve(t) end      -- the type definition itself
```

(Not every asset pins a type — many built-ins return nil here — so guard the result.)

## Behaviour is what makes the system powerful

Behaviour is where the asset system stops being storage and becomes a way to build. A type does two things for *every* one of its instances:

- **It gives references methods.** A material reference has `:getProperties()` / `:setProperty()` because the `material` type defines them — so every material gets them, for free.
- **It reacts when an instance's files change.** A type can define an **`onChange` callback** that the engine calls whenever any file inside one of its instances is written. The built-in `dynamicAsset` type is the worked example: a `dynamicAsset` holds a text `prompt.json`, and its `onChange` notices when that prompt is edited and **regenerates the 3D model automatically**, version-controlling the old one — no explicit call. The callback lives in the type, so every `dynamicAsset` instance reacts the same way. (Its exact signature is in the authoring section below.)

This is the lever to understand: **you build new behaviour by authoring a type.** A thing that regenerates itself when its config changes, a kind of asset with its own query methods, a content format the engine validates for you — each is a type you write, not an engine feature you wait for.

## Putting an asset into the scene: `instantiate`

A type that can become part of the scene defines `instantiate`, and every such type is called and answers the same way. `ref:canInstantiate()` is the capability query — true exactly when the type defines it — so a consumer offers a scene path by capability rather than by a list of type names.

```lua
local root, idMap = ref:instantiate(target?, opts?)
```

**In.** `target` is an owning entity ref: the instance lands under (or, for a hierarchy type like a bundle, onto) that owner. With no target the type spawns a fresh root. The base opts mean the same thing for every type — `position`, `rotation`, `scale`, `name`, `temporary`. `rotation` takes three numbers as pitch/yaw/roll in degrees, or four as a quaternion. A type may honour more opts of its own; its `instantiate` docs say which.

**Out.** Two values, the same two for every type:

- **`root`** — the composed root, as an `EntityRef`. Composition is **synchronous**: the root and everything the type built under it are live the moment the call returns, so you can parent to it, read its components, and hand it on in the same statement. There is no frame to wait for and no callback.
- **`idMap`** — the `originalId → runtimeId` map naming what the composition spawned, `{}` for a type that spawns no addressable children. **Never nil.** A component that re-composes the asset on every load (`Asset`) keeps this map and passes it back in, which is how a cross-entity reference into the composition — `SkinnedModel.skeletonRoot` pointing at a bone — survives a reload.

```lua
local root = asset.resolve("Oak", "bundle"):instantiate(nil, { position = { 4, 0, 2 } })
root.rename("OldOak")                       -- live already; nothing to wait on
```

Both halves are implemented once, in `modules/scene_instantiable`: `root` / `place` stand or adopt the root and apply the base opts, and `result` returns. A type composes, then returns through `result`. The `AssetRef` dispatcher runs every `instantiate` through the same check on the way out, so the two values you get back never depend on which asset you were holding — a type that returns something else fails at its own call instead of handing you a nil root.

## Finding and using assets

Find content the way you'd explore any codebase, plus the asset API:

```lua
asset.list("material")                                          -- by type
asset.list("/zero/source/props")                                -- by VFS subtree
asset.inspect("@builtin::materials.gold")                       -- full summary of one
local ref = asset.resolve("@builtin::materials.gold")           -- a handle to use it
```

`asset.list` is a query: every filter narrows the same enumeration, so they compose. `path` picks the subtree, `type` keeps one asset type within it, `scope` keeps one scope, `fields` keeps only assets whose `.metadata` matches:

```lua
asset.list({ path = "/zero/source/props", type = "mesh" })      -- meshes under props
asset.list({ type = "bundle", fields = { tags = "playerAvatar" } })  -- tagged bundles
asset.list({ path = "/zero/source/props", type = "mesh", fields = { tags = "broken" } })
```

A selector takes one value or a list, matching any entry, and `all` / `any` / `none` group whole filters:

```lua
asset.list({ type = { "mesh", "material" } })                   -- either type
asset.list({ path = { "/zero/source/props", "/zero/source/vehicles" } })

asset.list({
    any = {                                                     -- either clause
        { path = "/zero/source/props", type = "mesh" },
        { fields = { tags = "hero" } },
    },
    none = { type = "testSuite" },                              -- minus these
})
```

Matches come back ordered by identity, so a repeat call returns the same sequence; `order` names another field and `limit` / `offset` page it:

```lua
asset.list({ type = "mesh", order = "name", limit = 20, offset = 40 })
```

The result is the array of matches with query methods on it — `:first()`, `:last()`, `:random()`, `:count()`, `:isEmpty()`, `:each()`, `:map()`, `:filter()`, `:sort()`:

```lua
local hero = asset.list({ type = "mesh", fields = { tags = "hero" } }):first()
asset.list("material"):each(function(m) print(m.identity) end)
```

Anything the query cannot mean — an unknown key, a value of the wrong shape, `scope` or `limit` inside a group, or both `type` and its older spelling `category` — raises and says why. A static `type` or `path` also records the enumeration in the calling file's content dependencies when it saves, so the set travels with published content.

### Naming an asset in content

Four calls reach an asset by the name you give them — `asset.resolve`, `asset.tryResolve`, `asset.ref`, `asset.source` — and all four read that name the same way:

```lua
asset.resolve("@builtin::materials.gold")   -- a literal name: recorded as this file's dependency
asset.ref(someName, "material")             -- a computed name: a dynamic resolve
```

A **literal** name is written into the file's content dependencies when it saves, so the asset travels with the content and still resolves once someone installs it in another world. A **computed** name — a variable, a table field, a concatenation — cannot be written down, so nothing pins the asset it happens to reach. That is a **dynamic resolve**, and where it sits decides what happens: it is the point of a tool (and free there), and it is refused on the **gameplay path** — a component or a scene entrypoint — because content that publishes with an unpinned reference breaks the moment it moves. Which of the four calls you reached for makes no difference; `require` and `asset.list` follow the same rule for their own targets.

So when the name is computed, move the lookup into a tool and consume the tool's output. And when the question is only *"is there something under this name?"*, ask the probe built for it:

```lua
if not asset.exists(meshName, "mesh") then          -- computed name, no handle, no dependency
    asset.create("mesh", meshName, geometry)
end
```

`asset.exists` reads the store for the name's files instead of resolving a handle, so it pins nothing and stays available to content that generates its own assets. (It answers `false` for a registered asset whose files live elsewhere — `@builtin::` assets among them; `asset.tryResolve` is the registry-wide question, and being a lookup it follows the rule above.)

Content that **generates** its assets has two ways to hold them without ever looking one up by a computed name:

```lua
local mesh = asset.create("mesh", meshName, geometry)     -- creation hands back the ref; keep it
local mine = asset.list({ type = "mesh", path = "/zero/source/generated" })   -- literal selectors, so it travels
```

`asset.create` returns the same reference `asset.resolve` would, so a generator keeps what it made rather than re-finding it. And `asset.list` is the enumeration that *is* recorded: a literal `type` or `path` writes the whole matched set into the file's dependencies, so filtering the result by name in Luau answers "which of mine is this?" and still publishes. A computed selector there is the same dynamic resolve the lookups are.

`asset.inspect(ref)` returns a **structured record**, not text: `{ identity, name, guid, source, typeName, typeDefinitionPath, scope, origin, description, tags, detail }`. `detail` is type-specific — a component's record carries its declared `fields`, `methods`, `events`, and lifecycle `hooks`; a module's carries its `exports`; a material's carries its `shader` and `properties`; and so on per type. A type with no inspect hook leaves `detail` nil — the envelope alone.

For agent-readable output, use the `assets.describe` tool instead of rendering the record yourself — it resolves the asset (the same way `assets.find` results resolve), calls `asset.inspect`, and renders the record to markdown:

```lua
tools.use("assets", "describe", "@builtin::components.Camera")
```

It pairs with `assets.find` — find an asset, then describe it — and is named `describe` (not `inspect`) so it's never confused with the `asset.inspect` code API.

The folders are right there too — `ls`, `rg`, `cat` over `/zero/source/libs/@builtin/` — and reading a built-in is the best way to see how it's built. Tags and free-form fields live in each asset's `.metadata` and are how content is filtered:

```lua
asset.tags(ref)
asset.add_tag(ref, "playerAvatar")
asset.set_field(ref, "author", "me")
```

## Why an asset will not load, and what the engine is holding

`asset.diagnose(ref)` answers the "this asset is broken and I need to tell an
author what to fix" question in one call, from the engine's own reading:

```lua
local d = asset.diagnose("myTexture")
if not d.usable then print(d.reason, d.detail) end
print(d.primary)        -- the file the type's `primary` list actually resolved to
```

`reason` is one of `asset.unusableReasons()` — `noSuchAsset`, `noPrimaryFile`,
`payloadEmpty`, `decodeFailed`, `importFailed`, `importInFlight` — and `detail`
is the engine's own message: the importer's error text for a failed import, the
decoder's for a payload it refuses, the payload names the type expected for a
missing one. The bytes go through the engine's decoder wherever it has one for
that container, so `usable` is the verdict a load would reach.

Each reason is the reading at the moment of the call, so a payload being removed
is reported at whichever stage the call catches it: `payloadEmpty` while the file
is there with nothing in it, `noPrimaryFile` once it is gone. `diagnose` answers
whether a load would succeed now — a texture already on the device stays resident
under a payload that has since gone, and `asset.gpuResident(ref)` is what says so.

**Read `primary` even when an asset loads.** A type declares its payload as a
list tried in order — a `.texture` is `data.ztex`, then `data.tex`, then
`*.png`, `*.jpg`, … — so an asset that lost its encoded payload keeps loading
from whatever image is left beside it, including the generated `preview.png`
thumbnail. The load succeeds at the thumbnail's size and the wrong pixels
appear. `asset.primaryFile(ref)` and `d.primary` name the file, which is what
makes that visible:

```lua
local p = asset.primaryFile(ref)
print(p.resolved, p.path, table.concat(p.declared, ", "))
```

`asset.observe()` answers the other half — what the engine is holding for
content right now, per resource and in bytes:

```lua
local r = asset.observe()
for _, t in r.textures do print(t.identity or t.key, t.bytes) end
for _, m in r.meshes do print(m.identity or m.guid, m.bytes) end
print(r.totals.textureBytes, r.totals.meshBytes, r.totals.cpuCount)
```

Three pools, named because they are different pools: `textures` and `meshes` are
the device's, `cpu` is the set a live script-component context holds, and an
asset can be in one and not the others. `asset.cpuResident(ref)` and
`asset.gpuResident(ref)` ask about one asset (also as the `ref.cpu_resident` /
`ref.gpu_resident` properties). `totals` carries the aggregates the rows sum to,
so the listing reconciles exactly against `renderer.textureMemory()` and the
`meshes` category of `renderer.gpuMemory()`. `renderer.texture.list()` and
`renderer.mesh.list()` report the same resources from the renderer's side.
`/zero/runtime/residency` serves the same reading to a non-Luau reader.

## Creating an asset of an existing type

```lua
asset.create("material", "Brass", { shader = "@builtin::shaders.pbr", base_color = {0.8, 0.5, 0.2, 1}, roughness = 0.4 })
asset.create("component", "Spinner")
```

`asset.create(typeName, name, opts?)` runs the type's creation logic and writes the new instance, ready to edit. Check its structure against the type with `asset.validate`, which returns `{ ok, problems, typeName, validated }` — `problems` lists any missing/unexpected files, and `ok` stays false until the instance has everything its `type.yaml` requires:

```lua
local v = asset.validate("/zero/source/Brass.material")
-- v.ok, v.problems (each { code, message, path, severity })
```

`asset.validate` reads the type contract — which files a `type.yaml` requires.
Whether the bytes in those files can be used is `asset.diagnose`, above.

## Generating content you don't have

`asset.create` makes a new instance of a type you configure by hand — a material, a component. But when you need **content that doesn't exist yet and can't hand-author** — a 3D model of a character or prop, a sound effect, a texture, an animation, a whole environment — **generate it from a text description.** A `.service` is an asset that does exactly that:

```lua
asset.list("service")    -- mesh_gen, image_gen, audio_gen, anim_gen, pbr_gen, world_gen, …
local mesh = asset.resolve("mesh_gen", "service")
local g = mesh:invoke({ prompt = "a wooden treasure chest" })   -- async; returns a handle
-- track with the services toolbox: tools.use("services","status", g.id) until "completed", then .asset is spawnable
```

Generation runs in the background and lands a finished asset in your world — a mesh you spawn, a material you apply, a sound you play. This is the answer to "I need an X and there isn't one": generate it. The full workflow — discovery, credits/cost, every generator, and where authoring your own service lives — is the **generating-assets-and-content** guide.

## Authoring a new asset type

When no existing type fits what you need, author one — now that you know how the pieces work. Scaffold it:

```lua
asset.create("assetType", "trafficLight")   -- → /zero/source/trafficLight.assetType/
```

You get `type.yaml`, `behavior.luau`, and `template/` to fill in. The type registers the moment it's written, but it can't produce instances until you define how an instance is made.

`behavior.luau` returns a table; each part is optional:

```lua
local M = {}

-- Methods every reference of this type gets — each is function(self, ...) and
-- becomes ref:method(...). self carries the reference's path/identity/guid/type/name.
M.ref = {
  state = function(self) return vfs.read(self.path .. "/config.yaml") end,
}

-- What "a new instance" is. Returns { [filename] = contents }; asset.create
-- writes those files, and the write is what registers the instance.
function M.onCreate(name, opts)
  return { ["config.yaml"] = "state: red\n" }
end

-- Fires on each write inside an instance. The engine passes:
--   change.path   — the written path
--   change.asset  — this instance's folder
--   change.type   — the typename
--   change.kind   — "edited" (a file changed) or "seeded" (asset just loaded in)
--   change.origin — "local" (this client wrote it) or "remote" (a peer did)
-- It MUST converge: your own writes trigger it again, so bail when nothing
-- meaningful changed, or you loop forever.
function M.onChange(ref, change)
  local path = ref.path
  if change.path ~= path .. "/config.yaml" then return end       -- only the file we react to
  local now = vfs.read(change.path)
  if now == asset.get_field(path, "lastSeen") then return end     -- nothing new → stop (no loop)
  asset.set_field(path, "lastSeen", now)
  -- ...do the work here; the guard above keeps your own writes from re-triggering it
end

return M
```

A type needs **either an `onCreate` or real files in `template/`** before `asset.create("trafficLight", ...)` can make instances — a bare scaffold has neither yet. `type.yaml` lists the files a valid instance must have, so `asset.validate` can check them.

The lifecycle hooks are exactly **`onCreate` and `onChange`** — there's no separate delete or validate hook; structural checks are `type.yaml`'s job, and `ref` is the method surface. `opts` is whatever your `onCreate` reads — there's no engine-imposed options schema, so to see what a type accepts, read its `onCreate` (or its README); the `material` type, for example, reads `shader` plus property names.

Read the two best worked examples in full before authoring your own: **`material.assetType`** (rich `ref` methods + `onCreate`) and **`dynamicAsset.assetType`** (the `onChange` regeneration pattern). `asset.inspect` each, then open its `behavior.luau`.

`type.yaml` also carries an **`indexing:`** block, which decides what search embeds for every instance of your type. It is not optional and there is no useful default: ZeroMind runs exactly what you declare and adds nothing, so anything you leave out is absent from the index — silently, for every instance, forever. Twenty types once shipped without one and nothing they contained could be found.

Two words carry it, the same two the search tool exposes. **`identity`** is what an instance IS: for something you can look at that is the picture (`content:` + `modality: image`, which a text query matches directly), and for everything else the authored text that says what it is. **`capability`** is what it DOES and how it is made — code, settings, or a model-written summary of them. A type with no behaviour has no `capability` entry, and a type with nothing to look at has no image; an empty slot is a statement, and filling it with whatever is lying around is not.

```yaml
indexing:
  data:
    - role: identity
      extractor: verbatim
      source: { file: "README.md" }
    - role: capability
      extractor: derive                       # a model reads it and embeds its DESCRIPTION
      source: { files: ["init.luau"] }
    - role: capability
      extractor: verbatim                     # …and the code itself, because `derive` drops detail
      source: { files: ["init.luau"] }
  facets:
    file: ".metadata"                         # embedded whole, so keys nobody declared still index
```

`derive` takes its instruction from your type's own `README.md` — write that to say what instances of your type are and derivation inherits it. Never write `source: { field: name }`: a filename is one or two words with no usable embedding, and a corpus indexed that way answers "language runtime" with `say_runtime_2.soundClip`. The full schema, every extractor, and the facet sources are in `assetTypes/README.md`.

Give your type a `README.md` at its root (`<name>.assetType/README.md`) — it's the type's authoring reference, and the `guides` tool surfaces it automatically as `types/<name>` (e.g. `guides { path: "types/material" }`), discovered live from the registered type, so authors find it the same way they find every other guide.

## Finding the rest

Every type documents itself — `asset.inspect("@builtin::assetTypes.<typename>")` explains what it is and how to author it (use the full identity; a bare leaf like `"material"` can be ambiguous). `lsp.methods("asset")` lists the full asset API with signatures. The ground truth for any type is its definition under `/zero/source/libs/@builtin/assetTypes/<typename>.assetType/` — ordinary authored content, and the best teacher for everything above.
