Appearance
Saving data
Memory that survives a restart lives in a store. There are three, and the difference is who can see it:
self.store— this object's own drawer. Nothing else reads it.world.store— the whole region's drawer, shared by every script in it. Where a scoreboard or an economy goes.players.store— one person's, and it follows them to whichever server they play on next. Where an inventory goes.
Both hold text; json is how a table becomes text.
Starting with the object's own:
luau
self.store:set("visits", "1")
local v = self.store:get("visits") -- "1", or nil
self.store:set("visits", nil) -- remove itWhat survives what
| a local variable | self.store | world.store | a property | |
|---|---|---|---|---|
| an event ending | ✅ | ✅ | ✅ | ✅ |
| the script being re-saved | ❌ | ✅ | ✅ | ✅ |
| the region restarting | ❌ | ✅ | ✅ | ✅ |
| the world moving machine | ❌ | ✅ | ✅ | ✅ |
| who can read it | your script | your script | every script here | everyone |
Use a local variable for anything you can rebuild. Use the store for what you cannot. Use a property when the value is part of what the object is, and other systems should see it.
Everything is text
luau
local n = tonumber(self.store:get("coins") or "0") or 0
n += 10
self.store:set("coins", tostring(n))That double or handles both cases: nothing saved yet, and something saved that is not a number. It is worth writing every time.
For true and false, pick two words and stay with them:
luau
self.store:set("open", open and "yes" or "no")
local open = self.store:get("open") == "yes"For anything with more shape than a single number or flag, do not hand-roll it — json keeps the types for you, and the next section is about exactly that.
Always check the refusal
luau
local ok, why = self.store:set("log", huge_string)
if not ok then
print("could not save: " .. why) -- "store full", "value too large"
endA full store still reads, overwrites and deletes — only a brand new key is refused, and nothing of yours is erased behind your back.
Saving a table
Use json. It turns a table into text and back, and it is the only approach here that cannot corrupt a value quietly.
luau
local json = require("json")
local state = {
owner = id,
colour = "#55e38b",
uses = 3,
notes = { "first line\nsecond line", "another" },
}
local text = json.encode(state)
if text then self.store:set("state", text) end
local back = json.decode(self.store:get("state") or "{}")
print(back.uses) -- 3, a number — not "3"Numbers come back as numbers, true comes back as true, and a value containing a newline is still one value.
Do not join values with a separator. The old advice was table.concat(items, "\n") plus "pick a separator that cannot appear in the values" — and the day a value contains it, the data breaks on read, long after the write that caused it, with nothing reporting a problem.
Both functions refuse rather than guess, and tell you why:
luau
local text, why = json.encode(state)
if not text then
print("could not save: " .. why) -- "cannot encode a table that contains itself"
endMemory that belongs to one person
An inventory, a level, a quest somebody is halfway through. It is not the object's and not the world's — it is theirs, and it has to be there when they come back, even if they come back to a different server.
luau
local players, json = require("players"), require("json")
players:on_enter(function(p)
local raw, why = players.store:get(p.id, "save")
if why then return end -- see the warning below
local save = raw and json.decode(raw) or { gold = 0, items = {} }
-- …hand them their stuff…
end)Writing works the same as every other store, and update is still the right way to change a running total:
luau
players.store:update(p.id, "gold", function(current)
return tostring((tonumber(current) or 0) + 10)
end)Key it by p.id, never by name — names can be changed, that id cannot.
Why not just use the world's store
You could keep inv.<player id> in world.store, and for a small game it works. Two things eventually break it:
- the key ceiling becomes a player ceiling — 4096 keys is 4096 people, ever;
- it belongs to the world, not the person. The day one experience runs as several instances at once, each has its own world save, and somebody's inventory depends on which one they landed in.
players.store has neither problem: it lives on the central server, keyed by the person and the experience.
Check why before deciding somebody is new. nil, nil means nothing was saved under that key. nil, <reason> means the drawer was not reachable — usually because that person is not in this world. Treating the second as "new player" and writing a fresh save is how a real one gets overwritten.
How much fits
| keys | value size | total per player |
|---|---|---|
| 128 | 64 KB | 256 KB |
Per person, per experience — a save per subject rather than a key per item. 256 KB is on the order of 2500 inventory lines.
Memory the whole world shares
self.store is one object's drawer. Nothing else can read it — which is what makes it useless for a scoreboard, a shop's till or a quest everybody is on.
world.store is the region's drawer: every script here reads and writes the same one.
luau
local world = require("world")
world.store:set("shop.bread", "3")
print(world.store:get("shop.bread")) -- "3", from any object in the worldIt survives a restart, and it survives the world being moved to another machine — it travels inside the world's own save.
Changing a value that depends on the old one
Use update. Never get then set.
luau
-- ✅ one step, nothing can slip in between
world.store:update("gold", function(current)
return tostring((tonumber(current) or 0) + 10)
end)luau
-- ❌ two steps: another script's write can land between them and be lost
local n = tonumber(world.store:get("gold")) or 0
world.store:set("gold", tostring(n + 10))Your function gets the current value (nil if the key was never written) and returns the new one — or nil to delete it.
Who may write
Reading is public: everyone in the region can already see the scoreboard. Writing needs the world's data permission. Your own world's scripts have it, because you own the world; a visitor's script gets nil, "permission" unless you grant it in the world's settings.
How much fits
| object | the world | personal HUD | |
|---|---|---|---|
| keys | 256 | 4096 | 64 |
| value size | 64 KB | 64 KB | 8 KB |
| total | 1 MB | 8 MB | 32 KB |
It is a store, not a database: room for a leaderboard, a price list, a row per regular. Not a log of everything that ever happened.
Time that passes while nobody is there
Save a timestamp, not a countdown:
luau
--!strict
local self = require("self")
local world = require("world")
local GROW = 3600 -- an hour
self:on_touch(function()
local planted = tonumber(self.store:get("planted") or "0") or 0
if planted == 0 then
self.store:set("planted", tostring(world:clock()))
self:set({ color = "#5b7c3a" })
return
end
if world:clock() - planted >= GROW then
self:set({ color = "#d8a13a" })
self.store:set("planted", "0")
end
end)world:clock() keeps running while the world is off, so a world closed on Friday and opened on Monday comes back with the weekend on its clock. That is the only version of time a visitor recognises: the crop grew.
A countdown only moves while somebody is ticking it. A timestamp is true whether anyone was there or not.
Catching up on waking
luau
world:on_wake(function(elapsed)
local stages = math.floor(elapsed / 60)
advance(stages)
end)Arithmetic, never a loop. elapsed can be a month — over two and a half million. for i = 1, elapsed do will exhaust your instruction budget and the script will be killed.
Writing less often
Every set is work. A counter that changes on every shot does not need saving on every shot:
luau
local dirty = false
local uses = tonumber(self.store:get("uses") or "0") or 0
self:on_touch(function()
uses += 1
dirty = true
end)
self:on_timer(30, function()
if not dirty then return end
dirty = false
self.store:set("uses", tostring(uses))
end)You lose at most thirty seconds of counting if the region stops. For a use counter that is a fair trade; for somebody's coins it is not. Choose per value.
Worn items: the last moment to save
A worn item's script is torn down on every world switch. on_detach runs before that happens, and it is the last place a write will land:
luau
self:on_detach(function()
self.store:set("charge", tostring(charge))
end)What the store is not
- Not shared. Another script cannot read yours. To share, tell them with events or put it in a tag.
- Not a database. No queries, no sorting, no listing keys.
- Not for what spawned objects hold. Everything
world:spawnmakes is gone after a restart, so its memory has nowhere to go.
