Log inGet started

The filesystem (VFS)

Updated 6 September 2026

The two trees

  • /zero/source/… — the authored world: your assets, scenes, components, code, plus .world_settings and .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 a capture taken 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/
shapeJSON leaves, path-keyed, size-boundedfiles and asset folders, any bytes
survives a restartyesyes
follows the user to another machineyes — it replicatesno — this machine only
scopeper world; per user for runtime_data.playerper 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.

  • documentation
  • guide