Saving#
A store is a table of values that outlives the server. On one server, everything you put in one is written to a SQLite file in the world folder, so it is still there after a restart, after a crash, and after you stop the game and come back tomorrow. Under a fleet the stores belong to the game and the Moud catalog keeps them, see Under a fleet. Stores are server only: a client script cannot open one, because a player's machine is not where the save lives.
You open a store by name. The name is what the data is filed under, so every script in the place can
call store("players") and read and write the same rows.
local players = store("players")
players:set(id, { coins = 10, level = 2 })
local data = players:get(id) -- nil when there is nothing
players:remove(id)
players:keys("guild:", 100) -- keys starting with a prefix
-- read and write in one step, so two changes at once never lose each other
local new = players:update(id, function(old)
old = old or { coins = 0 }
old.coins += 5
return old
end)- A key is text or a number. For a player's save use
player.id, which stays the same when they change their name. set(key, value)writes the value under that key, replacing whatever was there.get(key)reads it back, and returnsnilwhen nothing has ever been written under that key. The usual shape islocal data = players:get(id) or { coins = 0 }.remove(key)deletes the row.keys(prefix, limit)lists the keys that start with a prefix, which is how you walk a group of saves such as every key beginning withguild:.update(key, fn)reads the value, hands it to your function, and writes back what the function returns, all in one step. It returns the new value.
Values are anything a table can hold: numbers, text, booleans and nested tables, up to 32 deep. A table is saved either as a list, when its keys are 1 to n, or as a table keyed by text; a number key in a keyed table comes back as text. A Vector3, an instance or a function anywhere in it is an error.
Use update whenever the new value depends on the old one. A get, then some arithmetic, then a
set is two separate steps, and a second change landing between them is lost. update does the
read and the write together, so both changes count.
Sessions#
A session is a lock on one key, held by this server, for as long as a player is on it. While the session is held, another server asking for the same key is told no, so two servers never write over each other's copy of the same save.
game.players.joined:connect(function(player)
local session = players:session(player.id) -- errors when another server holds it
session.data.coins = (session.data.coins or 0) + 1
session:save()
end)
-- on leave
session:release() -- saves and lets go-
players:session(key)takes the lock and loads the value. It raises an error when another server is already holding that key, so wrap it inpcallif the player should be told rather than dropped. -
session.datais the loaded value as a plain table. Change it like any table. A key that holds something other than a table, such as a number written withset, cannot be opened as a session. -
session:save()writesdataback without letting the lock go. Call it at checkpoints, such as after a round. -
session:release()saves and lets go, which is what you do when the player leaves. -
Opening a session on a key this server already has open is an error. Release the first one.
A session renews itself while it lives. A server that crashes loses its hold after a minute, so a save is never stuck locked by a server that is gone. When the place stops or hot reloads, every session still open is saved and released for you.
Ordered stores#
An ordered store keeps one number per key and sorts them for you, so a leaderboard is a read rather
than a pass over every save. Server only, like store.
local scores = orderedStore("scores")
scores:set("meek", 12)
scores:increment("meek", 5) -- 17, and hands it back
scores:increment("meek") -- adds 1 when you leave the amount out
scores:get("meek") -- nil when there is nothing
scores:remove("meek")
for place, row in scores:getSorted(false, 10) do
print(place, row.key, row.value)
endincrement is the call to use while a game is running: it reads, adds and writes in one step, and
returns the number it wrote, so you never have to fetch a score before changing it.
getSorted hands back a list of { key = ..., value = ... }:
| Argument | |
|---|---|
ascending |
false is highest first, true is lowest first |
limit |
how many rows; 100 when left out, 1000 at most |
min, max |
only values in this range |
Because it is a list, the index you loop over is the position on the board: row 1 is the top score
when you pass false.
Two keys with the same value come back in key order. Only finite numbers go in: 1 / 0 and a NaN are
an error.
An ordered store and a plain store never share a name. store("scores") and
orderedStore("scores") are two different stores, and writing one does not change the other.
A top ten on the screen#
The board is one gui the server writes, so every player reads the same ten rows without being sent anything.
-- server
local scores = orderedStore("scores")
local screen = game.world:add("StarterGui"):add("ScreenGui", { name = "board" })
local panel = screen:add("Frame", {
position = udim2(1, -12, 0, 12), anchorX = 1,
size = udim2(0, 190, 0, 230),
backgroundColor = color(0, 0, 0), backgroundTransparency = 0.4,
})
panel:add("UIListLayout", { padding = udim(0, 2) })
panel:add("UIPadding", { paddingTop = udim(0, 6), paddingLeft = udim(0, 8) })
local rows = {}
for place = 1, 10 do
rows[place] = panel:add("TextLabel", {
layoutOrder = place,
size = udim2(1, 0, 0, 20),
backgroundTransparency = 1,
textColor = color(1, 1, 1),
textXAlignment = "left",
textSize = 14,
text = "",
})
end
task.every(5, function()
local top = scores:getSorted(false, 10)
for place, label in rows do
local row = top[place]
label.text = if row then place .. ". " .. row.key .. " " .. row.value else ""
end
end)
-- somewhere in the game, when a round is won
scores:increment(winner.name, 1)- The gui goes in a
StarterGui, so every player who joins gets a copy of it. See Containers and Interface. - The ten labels are made once and kept in
rows. The refresh only writes theirtext, which is cheaper than building the list again every five seconds. task.every(5, ...)runs the refresh every five seconds. The server writes the labels and the change reaches every player the way any other property change does, with no message of your own.- A row that does not exist yet, because fewer than ten people have scored, gets an empty string, so the panel keeps its shape.
Leaderboards#
An ordered store is a good list of scores, but most games want a little more from a leaderboard:
who was best today and this week as well as ever, what rank a player holds when they are 4000th,
and the rows around them. leaderboard(name) is an ordered store that does those for you. Server
only, like store.
local wins = leaderboard("wins")
wins:increment(player.id, 1) -- adds, and hands back the all time number
wins:set(player.id, 10) -- replaces
wins:get(player.id, "week") -- nil when there is nothing
wins:remove(player.id)
for _, row in wins:top(10, "day") do
print(row.rank, row.key, row.value)
end
local rank, value = wins:rank(player.id) -- nil, nil when there is nothing
local near = wins:around(player.id, 2) -- two rows above, the player, two belowEvery write lands in three windows at once: all time, today and this week. Reads take the window as
their last argument, "all" when it is left out:
| Window | |
|---|---|
"all" |
every score ever written |
"day" |
since 00:00 UTC today |
"week" |
since 00:00 UTC on Monday |
"yesterday", "lastWeek" |
the window before, to show who won it |
A window resets by itself: at midnight UTC the day board is empty again, and the scores of the day
before are still there as "yesterday". There is nothing to clear and no job to run.
top(count, window) lists up to 100 rows, best first, each { rank, key, value }. rank(key, window) answers the key's place, 1 for the top, and its score. around(key, count, window) lists
count rows on each side of the key and the key itself, which is the "you are here" part of a board
with thousands of players. Two equal scores are ordered by key, the same as in getSorted.
Times and best scores#
A board sorts highest first. A race wants the lowest time on top, and it wants a player's best time, not their latest one:
local laps = leaderboard("laps", { ascending = true })
finishLine.touched:connect(function(part)
local player = playerFromPart(part)
if not player then return end
local time = os.clock() - started[player.id]
local best = laps:submit(player.id, time)
print(player.name, "ran", time, "best ever", best)
end)submit keeps the better of the old score and the new one in each window, lower for an ascending
board and higher for the others, so a slow lap today does not undo a fast one last month, and the
day board still shows today's best.
Who it is for#
Use player.id as the key for a player. It stays the same when they change their name, and the
game's page on the website shows every leaderboard of the game with each player's name and head.
Any other text works as a key too, such as a guild name, and shows on the page as it is.
A board name is 1 to 60 letters. A leaderboard and a store or an ordered store of the same name are
separate, and store() refuses names starting with moud:, which is where leaderboards keep their
rows.
A board on a wall#
The scores update on their own, so a board in the world only has to read them now and then. This
one is a SurfaceGui on the front of a part called board in the lobby:
-- server
local wins = leaderboard("wins")
local wall = game.world:find("board")
local gui = game.world:add("SurfaceGui", { adornee = wall, face = "front", pixelsPerMetre = 50 })
local list = gui:add("Frame", { size = udim2(1, 0, 1, 0), backgroundColor = color(0.08, 0.08, 0.1) })
list:add("UIListLayout", { padding = udim(0, 4) })
local labels = {}
for place = 1, 10 do
labels[place] = list:add("TextLabel", {
layoutOrder = place,
size = udim2(1, 0, 0.1, -4),
backgroundTransparency = 1,
textColor = color(1, 1, 1),
textScaled = true,
text = "",
})
end
local names = {}
game.players.joined:connect(function(player) names[player.id] = player.name end)
task.every(10, function()
local top = wins:top(10, "week")
for place, label in labels do
local row = top[place]
label.text = if row then `{row.rank}. {names[row.key] or row.key} {row.value}` else ""
end
end)- The
SurfaceGuidraws on the front face of itsadornee, and everyone who looks at the wall sees the same labels, because the server writes them. See Interface. top(10, "week")reads the week window, so the wall empties every Monday and fills up again.- The board only knows ids.
namesremembers the names of players who joined this server, and a player it has never seen shows as their id until they join. - Ten seconds between reads keeps the board to six store calls a minute, whatever the number of players.
Under a fleet the boards are kept by the catalog and ranked across every server of the game, like the other stores. Each read is one store call and each write counts two, since it writes three windows, see Quotas.
Under a fleet#
When a fleet runs the game, there is no single server to keep a file. A player may be in the lobby on one server, then in the arena on another, and both need the same coins. So under a fleet every server of the game, on every machine, reads and writes the same stores, kept by the Moud catalog.
The calls are the same, and a script written for one server keeps working. What changes is that other servers write to the same stores at the same time, and that each call travels to the catalog and back.
update(key, fn) reads the value, runs your function, and writes the result only if no other server
wrote that key in between. When one did, it reads again and runs your function again, up to 8 times,
then raises an error. That means your function can run more than once for one update. Let it work
out the new value from old and nothing else, and do anything else, such as telling the player, after
update returns. increment on an ordered store works the same way.
A session is held by one server at a time. When the lobby sends a player to the arena, the lobby still
holds that player's session until the player has left it and the session is released. If the arena
asks for the session first, it is told no. So open the session on arrival inside pcall, and try
again after a short wait, the way the example below does.
Each call waits for the catalog before the script goes on, a few milliseconds when the catalog is close. Keep store calls out of code that runs every frame. Read a save once when the player joins, keep it in a table, and write it back at checkpoints and when they leave.
Under a fleet, a value is at most 256 KB once written out, a store name 1 to 100 letters and a key 1 to 256. Each server also has a budget of store calls a minute that grows with its players, see Quotas. Going over it raises an error a script can catch.
This is how the arena opens the session of a player the lobby just sent:
-- server, in the place a teleport brings players to
local players = store("players")
local sessions = {}
game.players.arrived:connect(function(player)
task.spawn(function()
for tries = 1, 20 do
local ok, session = pcall(function() return players:session(player.id) end)
if ok then
sessions[player.id] = session
return
end
task.wait(0.5)
end
player:kick("Your save is still being written, join again in a moment.")
end)
end)arrivedfires for each player a teleport brought, and the work goes insidetask.spawnbecause it waits.- Each try asks for the session inside
pcall, so the error the arena gets while the lobby still holds it does not stop the script. - Twenty tries half a second apart give the lobby ten seconds to save and let go. If it still has not, the player is told to come back, rather than playing on with no save.
game.persist#
game.persist is a plain table the engine hands back after a hot reload. Saving a file destroys
everything the place created and runs it again, and game.persist is what survives that. It does not
survive the server stopping.
Use it for things that only matter while the server is up, such as the round number or how many times someone has joined this session. Anything that has to be there tomorrow goes in a store. For data several scripts share, that should expire on its own, see Memory stores.
