The filesystem (VFS)
The two trees
/zero/source/…— the authored world: your assets, scenes, components, code, plus.world_settingsand.world_entrypoint.luau. Durable and shared (the engine + worlds guides)./zero/runtime/…— live engine state: the loaded scene layers and their entities, lighting, generated meshes, render surfaces, logs. Transient — it reflects the running world, not saved content.
Two directories under source belong to the machine rather than to the world, and are always listed even when empty:
/zero/source/tmp/…— scratch. Writable and hot-reloading like anything else, excluded from every save, and gone when the process ends./zero/source/local/…— the runtime write path: where a running session puts data it produces and wants back later. It never syncs, and it survives restarts, kept per world and per user on that device.
Reading runtime state
Entities are folders under /zero/runtime/layers/<layer>/entities/. Read them like files:
ls /zero/runtime/layers/main/entities/ # every entity in the layer
cat /zero/runtime/layers/main/entities/lantern # summary: name, id, transform
cat /zero/runtime/layers/main/entities/lantern/position # {"x":..,"y":..,"z":..}
ls /zero/runtime/layers/main/entities/lantern/components # what's attached
This is the fastest way to answer "what's in the scene right now," "where is this entity," "what components does it have" — and it composes with the shell: grep, find, pipes, and .sh scripts all work over /zero.
Reading source
/zero/source is your authored content as files. Asset folders carry a type suffix (Foo.component/, Bar.material/); cat one for its summary, ls it for its contents, and edit files inside to change them — components, materials, modules, and the like hot-reload.
ls /zero/source # libs, scenes, .world_settings, …
cat /zero/source/Spinner.component # asset summary
Writing source
A path whose last segment carries an asset-type suffix names the ASSET, on every
write surface. Hand Foo.module to vfs.write or to the write_file tool and
the body lands in the entry file that type declares — init.luau for a
.module or .component, mat.yaml for a .material, shader.wgsl for a
.computeShader — with the files the type declares required written beside it.
Naming the entry file yourself reaches the same file.
vfs.write("/zero/source/Spinner.module", "return {}\n") -- → Spinner.module/init.luau
vfs.write("/zero/source/Spinner.module/init.luau", "return {}\n") -- the same file
So both spellings leave one folder that asset.validate passes, and neither
leaves a file wearing an asset's name. Two kinds of type declare no one entry
file inside a folder, and a write to their root path goes where it says: a
container (Plant.bundle), whose inner assets carry their own types, and a type
whose asset is itself a file — .rig, .animation, .avatar.
Removing source while play runs
While play is running, a write to authored /source lands on the play shadow —
live in the session, disk source untouched — and a REMOVAL is answered by what
stands behind the path:
- Content this play session created removes. A folder counts as created by
the session when everything it holds was, whichever layer the bytes ended up
in — the importer's
Shot.texture/from acapturetaken in play, a recording written into a folder that did not exist a minute ago, an asset the session authored. Nothing durable stands behind it, so nothing is lost. - Content that predates play is refused, and the refusal says so: the shadow
carries changed bytes and holds no copy of a path that was taken away, so a
guarded play-exit would have nothing to restore it from. Leave play
(
engine.mode = "edit") or pause it (engine.paused = true) and the removal lands.
Every surface answers this identically — vfs.remove and the shell's rm read
one gate — so neither reports a removal that did not happen. vfs.remove
RAISES the refusal rather than returning it: the play lock is a property of the
running session rather than of the path, and a value returned beside it is a
value a caller reads as an outcome and skips past. vfs.mkdir and vfs.move
answer the same verdict the same way. A failure that IS a property of the path
— it is protected, its route is read-only, it is a plain directory and
recursive was not passed — still comes back as (false, message), and so
does a missing path on a route the lock leaves open (/source/tmp/ scratch,
the runtime routes). The lock answers ahead of whether the path is there, so a
locked /source path raises whether or not it holds anything.
Mutation applies on the next frame
The filesystem isn't read-only — writes to runtime state take effect, but asynchronously, applied on the next frame. Writing an entity's position file moves it; removing its folder despawns it. The Luau API does the same things synchronously (visible the same frame), which is why it's what you reach for in code — but the filesystem is a genuine write surface too, useful from the shell and scripts.
entity(id).position = { 0, 5, 0 } -- API: applies this frame
echo '{"x":0,"y":5,"z":0}' > /zero/runtime/layers/main/entities/lantern/position # VFS write: applies next frame
rm -rf /zero/runtime/layers/main/entities/lantern # despawns it (next frame)
One practical consequence of the async path: right after a VFS write, a same-call API read can still see the old value — it settles on the next frame. Read it back a frame later, or mutate through the API when you need same-frame certainty.
Knowing when a write has landed
The queue reports where it has got to, so waiting for a write's effects is a poll on a number rather than a guessed number of frames.
vfs.mutationSeq()is the drain's clock — the count of VFS mutations the engine has applied. Read it, write, then poll for a larger value.asset.reloadSeq(ref)is the per-asset reading: how many content-change reloads that asset has been through. Writes inside one asset are collected for a short settle window and reload it once, on a later frame, so this is what tells you the reload actually ran.asset.reloadPending(ref)answers the other half — true while a write still owes the asset a reload. It is recorded synchronously with the write, so it already reads true on the line after one, and goes false only once every change written so far has reached its subscribers. That is the wait to make before binding a consumer to an asset you just wrote, so a late reload cannot restart it.
vfs.write(asset.source(graph) .. "/graph.json", encoded)
repeat task.wait() until not asset.reloadPending(graph)
entity(owner).component.add("Generator", { graph = graph })
vfs.watch(path, callback) is the other side of this: it fires with the write itself, in the VM that registered it, rather than when the engine's own side-effects for that write have run.
The shape of /zero
/zero/
source/ authored world — assets, scenes, code, .world_settings, .world_entrypoint.luau (durable, shared)
local/ the runtime write path — never synced, kept per world + per user on this machine
tmp/ scratch — never synced, gone with the process
runtime/ live state — layers/<layer>/entities/, lighting, generated_*, render_surfaces, logs, events/ (component-event publishers + subscriptions)
docs/ API reference + guides
Where a running session keeps what it makes
A session that produces data has two durable places, and they differ in how far the data travels:
runtime_data | /zero/source/local/ | |
|---|---|---|
| shape | JSON leaves, path-keyed, size-bounded | files and asset folders, any bytes |
| survives a restart | yes | yes |
| follows the user to another machine | yes — it replicates | no — this machine only |
| scope | per world; per user for runtime_data.player | per world and per user |
Settings, progress, and small state go in runtime_data, so a new device picks them up. Bulk the device can produce again or does not need elsewhere — caches, recordings, generated assets, anything too large for a JSON store — goes in /zero/source/local/, where it costs the backend nothing because it never reaches it.
Content under local/ is ordinary source: an asset folder written there registers, and asset.list finds it. It is also the only durable write a shipped runtime build has, since that profile never leaves play and a play-mode write anywhere else under /source/ lands on the play shadow.
Nothing outside local/ may reference into it. A world asset that depends on something there resolves for exactly one machine and dangles for every collaborator and player, so the dependency is refused where it would be recorded — dropped from the .refs and replaced with an error-severity dep.local_content problem, which refuses the publish. The other direction is free: local content names world content as much as it likes. To share something local, promote it with the localContent toolbox and reference the copy that lands under /source/.
The model to carry: the engine describes itself as a filesystem you can read — use it to inspect and discover (alongside lsp.* and the registries, the discovering guide), and use the API to change things.