Log inGet started

Runtime data

runtime_data is a world's persistent, replicated data layer — where you keep state that must survive restarts and reach every player: progression, saved builds, unlocks, per-player records, any…

It's the durable counterpart to live multiplayer. The multiplayer relay (the multiplayer guide) carries small, fast-changing, throwaway state — positions, inputs, the current frame's action — that's meaningless a second later. runtime_data is for the state you want to still be there tomorrow. Reach for the relay when it's transient; reach for runtime_data when it's a save.

It is generic and opaque: it never interprets a key or a value. A value is any JSON-serializable Luau value — a number, a string, a table — and what you store, in what shape, is entirely yours.

Two scopes

Everything lives in one of two stores, both shared across everyone in the world:

runtime_data.world           -- one shared world store
runtime_data.player(userId)  -- a player's store, keyed by id
  • runtime_data.world is the world's own store — global progression, unlocked areas, a shared economy. Anyone can read it; anyone can write it.
  • runtime_data.player(userId) is one player's store. Reads work for any player (their data is replicated to you, so you can read another player's stats), but writes only succeed for your own id. Trying to write another player's store is a loud error, and the rule is enforced for real — a client can't forge a write for someone else.

Both stores expose the identical accessor surface (below), so code that works against one works against the other.

Hierarchical, path-keyed

Keys are slash-paths, and every path is an independent leaf. A deep write touches only that leaf — the rest of the document is untouched, only that one path replicates, and concurrent writes to sibling paths never clobber each other:

local me = runtime_data.player(runtime_data.localId())

me.set("inventory/weapons/primary/ammo", 30)   -- writes ONLY that leaf
me.get("inventory/weapons/primary/ammo")        -- reads ONLY that leaf
me.keys("inventory/")                            -- every path under inventory
me.getTree("inventory")                          -- reconstruct the nested table
me.merge("settings", { volume = 0.8 })           -- shallow-merge into a sub-object

You choose the granularity by how deeply you key. One fat blob at "inventory" is a single value; individually-addressable leaves under "inventory/..." let two systems write different slots without stepping on each other. Your call, per path.

Reads are synchronous, writes return a promise

Reads come straight from the local replica — no round-trip, no waiting:

local hp = runtime_data.world.get("boss/hp", 100)   -- second arg is the default when absent
if runtime_data.world.has("boss/defeated") then ... end
for _, path in ipairs(runtime_data.world.keys("boss/")) do ... end
local all = runtime_data.world.list("boss/")         -- { [path] = value }

Writes return a promise id. Ignore it for fire-and-forget, or task.await(...) to confirm the write landed ("ok" / "ERR:<reason>"):

runtime_data.world.set("boss/defeated", true)        -- fire and forget
local status = task.await(runtime_data.world.set("boss/hp", 0))

runtime_data.world.set("boss/hp", nil)               -- nil value deletes
runtime_data.world.delete("boss/hp")                 -- same effect, explicit

Tied to the player object

Per-player data isn't a separate thing you look up — it's already on the player. Every world.connectedUsers record exposes .data, which is runtime_data.player(thatUser.identity):

for _, user in pairs(world.connectedUsers) do
  print(user.displayName, user.data.get("score", 0)) -- read anyone's score
end

So "show the scoreboard" is a read across everyone's .data. To write your own player's store, take the accessor for your id directly:

local me = runtime_data.player(runtime_data.localId())
me.set("profile/name", "Ada")                        -- writes my own store
me.set("score", 0)

(The multiplayer guide covers connectedUsers and the players registry.)

Reacting to changes

Subscribe to a store to run code when it changes — a value updates for you the moment any player writes it:

local stop = runtime_data.world.onChange(function(path, value)
  -- value is the new decoded value, or nil on delete
  if path == "boss/defeated" then playVictoryStinger() end
end)
-- later:
stop()   -- the returned function unsubscribes

-- Watch every player's store at once:
runtime_data.onPlayerChange(function(userId, path, value)
  refreshScoreboardRow(userId)
end)

Finding out who has data

runtime_data.players()   -- every player id that currently has any per-player data
runtime_data.localId()   -- the local player's id, or nil for an anonymous session

localId() is what gates your writes: runtime_data.player(runtime_data.localId()) is the one store you're allowed to write, and a player accessor's :isLocal() tells you whether it's yours.

The accessor surface, at a glance

Both runtime_data.world and every runtime_data.player(id) share these:

MethodEffect
get(path, default?)Read a value; default when the path is absent.
has(path)Does the path exist?
keys(prefix?)Every path (optionally under prefix).
list(prefix?){ [path] = value } for every path (optionally under prefix).
getTree(prefix)Reconstruct the nested table under prefix.
set(path, value)Write a leaf (nil deletes); returns a promise id.
merge(path, partial)Shallow-merge a table into the value at path.
delete(path)Delete a leaf; returns a promise id.
onChange(cb)Subscribe to this store's changes; returns an unsubscribe fn.

A player accessor additionally carries .id and :isLocal().

Beyond this

To confirm the live surface, iterate the table — for k in pairs(runtime_data) do print(k) end and the same on runtime_data.world — or read the module directly at /zero/source/libs/@builtin/modules/runtime_data.module. The concept to hold onto: runtime_data is the durable, shared memory of a world; the relay is its short-term memory. Put a value here when losing it would lose progress.

  • documentation
  • guide