Assets & asset types
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…
"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.
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.list({ type = "mesh", path = "/zero/source/props" }) -- type inside a subtree
asset.list({ type = "bundle", fields = { tags = "playerAvatar" } }) -- type + metadata
asset.inspect("@builtin::materials.gold") -- full summary of one
local ref = asset.resolve("@builtin::materials.gold") -- a handle to use it
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")
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 })
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.
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.