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:
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:
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 —
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, assets are shadow-copied. A write to an asset while playing lands on a runtime copy under /zero/runtime/, never on the source — so play-time changes can't clobber your source content, and they're discarded when you leave play. This includes assets you create while playing: asset.create in play writes the new asset to the runtime copy, while the same call in edit writes to source. So if creation should happen in only one mode, guard it on engine.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:
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 whatasset.validatechecks. - 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:
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 thematerialtype defines them — so every material gets them, for free. - It reacts when an instance's files change. A type can define an
onChangecallback that the engine calls whenever any file inside one of its instances is written. The built-indynamicAssettype is the worked example: adynamicAssetholds a textprompt.json, and itsonChangenotices 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 everydynamicAssetinstance 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.
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 anEntityRef. 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— theoriginalId → runtimeIdmap 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.skeletonRootpointing at a bone — survives a reload.
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:
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:
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:
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:
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():
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:
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:
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:
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:
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:
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:
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:
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:
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
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:
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:
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:
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:
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.
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.