Worlds & ZeroMind
What a world is
Everything under /zero/source belongs to the world: it persists and is shared live with everyone connected (the engine guide covers the persistence model). A world has an identity, a branch, and a set of participants:
world.guid() -- the current world's id (nil if none)
world.branch() -- the branch, e.g. "main"
world.name() -- the handle it answers to
world.title() -- the display name ZeroMind shows
world.participants() -- who's currently connected
world.on("player_join", function(p) end) -- react to joins / leaves
Creating and switching worlds
world.create(name, title) -- create a new world; returns a promise resolving to its guid
world.swap(worldGuid) -- point the engine at another world (all vfs/zm calls retarget)
What players see while the world loads
Every boot of a world shows a loading screen from the moment the engine knows which world it is loading until the world draws its first frame. A world brands it with a loadingScreen asset (its title, a line of text or rotating tips, a background picture, a logo, its colours and how progress is drawn) named in the world's settings:
local screen = asset.create("loadingScreen", "title", { folder = "branding" })
vfs.write(screen.path .. "/screen.yaml", 'title: "Skyward"\ntext: "The islands are waking up"\n')
world.loading_screen = screen -- [world] loading_screen in .world_settings
screen:preview() draws it over the window the way a boot does, so it can be looked at and captured. It is world content, published with the world, and a world that names none shows the engine's own screen. types/loadingScreen is the full reference.
ZeroMind — the shared library
ZeroMind isn't only the backend; it's a shared library of published content that other people and agents have made — whole worlds, plus components, materials, shaders, tools, scenes, and packages. It's the first place to look before building something: a fitting published asset you can bring in beats authoring from scratch.
You search, read, preview, and install shared content with your ZeroMind tools and the shell. zeromind_search finds published worlds and assets. Break what you are building into the systems and parts it needs and search for each, as well as for the whole; for each part, shortlist two or three hits from their summaries, containers and open issues. Read each candidate's implementation without installing it: in the shell, zm ls-tree @<guid> lists its closure, zm grep <pattern> @<guid> and zm show @<guid>:<path> read its files, and zm log @<guid> its history; zeromind_inspect reads its record and thumbnail. Compare them, then zeromind_preview shows the exact tree an install would write (every file and dependency with its destination path and size) without writing anything, and zeromind_install pulls assets and their closures into your /source/ (pass several guids to install them all in one call). Verify the installed one does the job; if it does not, delete what it installed and take the next candidate. Leave feedback on what you use — upvote, comment, review, with engage. Searching surfaces candidates; installing brings the content into your world, where you reference it by its identity. Libraries available in the current world are listed with library.list(), and library content lives under /zero/source/libs/@<namespace>/, referenced as @<namespace>::... (just like @builtin::...).
zeromind_install's at is the destination directory to install into, not a rename: the asset keeps its own name and category suffix, so at = "/source/combat" lands a sea.material at /source/combat/sea.material (a .material folder the registry recognizes). It defaults to /source; dependencies land under /source/deps/<owner_world>/.
An install is one entry in the world and in Source Control: its record, <root>.pull, which every engine materializes the files from; installed files appear as changes only when you edit them. The record is engine bookkeeping, so ls and cat do not show it: zeromind_installs (the installs tool of the zeromind toolbox, world.installs()) lists each install with its version, file count, dependencies and deleted paths, and names an asset installed at two places in also_at.
An install that Source Control lists as thousands of changed files, or that installAsset refuses with "an install an earlier engine landed as one row per file", was written by an earlier engine as one row per file. zeromind.convertInstalls (world.convertInstalls; { dryRun = true } previews) brings it into one record that states the whole published version, keeps the files you changed as rows, and commits nothing; the Source Control panel offers the same as Convert beside the install, and zm status names the install's row count and the tool.
Library content is read-only — it belongs to the world that published it. To change a library asset, fork it: copy it out of /source/libs/... into your own /source/... and edit the copy. (@builtin itself is a library mounted from the engine — which is why everything in it is something you could fork and remake.)
Publishing your work
Source edits are durable the moment you make them (there's no save step), but they aren't a published version until you publish. The zm command in the engine bash does that with the verbs you know from git:
zm add . # stage your changes
zm commit -m "describe what changed" # a navigable checkpoint in the world's history
zm push # publish a new playable version for players and importers
A commit message is the one word after -m, so quote a message of several words (zm commit -m "add the town and cast"); a message written without -m is refused with that form. The same verbs exist as the zm Luau toolbox (zm.add / zm.commit / zm.push), where the message is zm.commit's first argument. A commit stays inside the editor's history; a push is what players and other importers actually see. Browse the full set with tools.list("zm") or man zm.
Staging records each scene as its scene.json holds it. Entities you spawned or changed through the ECS are pending scene changes until you write them into the scene: accept them with the sceneAuthoring toolbox (changes, then acceptChanges), or in edit mode save the active scene with layers.active:save(), and then zm add. A staging call that leaves pending edits out posts a notice naming them and the calls that keep or drop them. The core/development guide has the full flow.
Committing your own work when others are authoring too
The working tree is the world's and everyone sees it. The staging area — what zm add puts a path into and what zm commit freezes — is one caller's account of which of its own paths the next commit records, and callers that name none share a single default area. So when more than one of you is authoring a world at once, name your own:
zm --stage fauna add demo/fauna.module # stage into your own area
zm --stage fauna commit -m "fauna: the swallow colony"
zm --stage fauna status # what your area holds
zm push --stage fauna # publish, gated on your area's content
A commit through a named area carries the paths staged into that name and leaves everyone else's staged where they are, so each of you commits your own work under your own message. Through world.* the same name is an option: world.add(path, { stage = "fauna" }), and likewise on world.add_all / world.unstage / world.discard / world.commit / world.vcsStatus / world.push / world.publishBlockers. Pick a name for your slice of the work and use it on every staging verb, the push included.
A push publishes the branch, which every author shares, and the pre-push gate that reads whether the world's assets meet their type's content requirements decides per asset whose push a shortfall refuses. An asset the named area holds or committed refuses that area's push, naming the asset and the file its type asks for. An asset another area holds publishes carrying its shortfall and is named back to you with the area that holds it, so a slice of your own that is complete publishes while somebody else's is still being written. An asset no area holds is nobody's in particular and refuses every caller, so name your area on the push too, or the gate answers about the content nobody claimed rather than about yours. What an area holds is read from the staged rows a status call reports and from the world's history, where every commit records the area it came through, so every engine on the world reads the same answer and it outlasts the session that made the commit. An area holds what it committed until a commit through another area lands inside the asset: an area you named holds it for that whole time, and the area a call acts on when it names none is that caller's own and holds it while the caller's session is open. A commit through the one shared area, which calls whose caller the transport did not name act on, holds nothing and takes nothing over. A description a type asks for (a README, an about.yaml text still starting with [[describe]]) is answered for only by the push that edits, stages or commits its asset, so a world's older assets do not stand in the way of a push that leaves them as they were. A type's about.yaml is the one file whose absence is not: a type without one refuses every push of the world that holds it.
Staging is advisory: what another staging area holds and what another caller is writing never refuse or narrow a stage, a commit, a merge or a push. A path is staged in one area at a time, so a call that stages a path another area holds (zm add <path> / world.add, zm add . / world.add_all, a commit re-staging a source, zm restore --staged / world.unstage of a path only another area holds) moves it out of that area, whichever account opened it and whether or not the session holding it is still open. Nothing is discarded: the working tree keeps the bytes, and the other area's caller can stage them again. Every take is in the call's report as released, one { path, stage, account, same_account, session, live } row per path and area.
Contention is between accounts. Every agent and engine of one account (the desktop editor, a proxy-launched engine, several agents in one engine) shares that account's work: a take from an area of your own account is one of your agents handing work over, and it is silent. A take from another account's area warns, naming the account, the area and the session holding it; when that session is open the warning says that account is working on the paths now, and when it has ended it records the take. A bulk stage takes every dirty path whoever wrote it, and a path a caller of another account is writing while its session is open is staged with a warning naming that caller and account, and listed in the report's written.
The working tree records which caller wrote each dirty path, and zm status labels each with its writer: you, the other caller's key such as client:<session> (client:ide-slot-<n>@<engine seat> for an engine's dock agent, since every engine numbers its dock slots the same way), after the account's name when another account wrote it, session ended once that caller's session is over, or unattributed. A row keyed by a bare client:ide-slot-<n>, written before dock agents' keys named their engine's seat, resolves to no agent on any engine. A session ends when its MCP client disconnects or closes it, when the proxy that served it restarts, and when the engine it wrote through leaves the world. zm status also lists, as information, the paths other areas hold under Held by another staging area: the account holding each (your account for your own), the area, its session, and whether that session is working now. zm add . && zm commit -m "describe the change" && zm push publishes the world as the working tree holds it, and the editor's Source Control panel's Stage All (world.add_all()) does the same and says how many changes it staged, how many it took from other areas and from another account, and which it left out for an unresolved conflict.
zm --stage fauna add .
# added: /zero/source/demo/fauna.module/init.luau
# added: /zero/source/demo/flora.luau
# [warn] world.add_all: took 1 path(s) from staging area 'flora' of account 3f2c…, session client:session-beta (account 3f2c… is working on them now); they are staged here: /zero/source/demo/flora.luau
Staging a source by name (zm add <path>, world.add), and the commit re-staging a source whose dependency table moved, also stages beside it each asset it references whose current version no commit holds, since the reference needs that version to resolve. Such an asset is staged with your path, taken out of any other staging area that held it, and named as carried, so a commit carries nothing you were not told about. world.add returns the same account as { staged, carried, released, written }.
zm --stage fauna add /zero/source/demo/swallow.luau
# added: /zero/source/demo/swallow.luau
# [warn] world.add: carried /zero/source/demo/flight.module: the staged paths reference a version of it that no commit holds, so it is staged with them
Working alone, name nothing — the default area is the whole story, no other area holds anything, and zm add / zm commit are exactly the two lines above.
Finding the rest
world.* and library.* are authored modules under modules/api/engine/ — read them for the full surface. What persists (and why there's no "save") is in the engine guide. Searching and installing shared content is done through your ZeroMind tools.