Hungrit Docs

Saving data#

Memory that survives a restart lives in a store. There are three, and the difference is who can see it:

  • self.store is this object's own drawer. Nothing else reads it.
  • world.store is the whole region's drawer, shared by every script in it. Where a scoreboard or an economy goes.
  • players.store is 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 nilself.store:set("visits", nil)          -- remove it

What survives what#

a local variableself.storeworld.storea property
an event ending✅✅✅✅
the script being re-saved❌✅✅✅
the region restarting❌✅✅✅
the world moving machine❌✅✅✅
who can read ityour scriptyour scriptevery script hereeveryone

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 0n += 10self.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"end

A 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.

Careful

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"end

Memory 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. It sits in the world's own save, next to the scoreboard, and is read by every script in the region.

players.store has neither problem: it lives on the central server, keyed by the person and the world.

Careful

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#

keysvalue sizetotal per player
12864 KB256 KB

Per person, per world, so 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 world

It 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 betweenworld.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 lostlocal n = tonumber(world.store:get("gold")) or 0world.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.

Listing and ranking#

Both drawers list their keys by prefix, and the world's ranks them:

Luau
local world = require("world") for _, key in self.store:keys("item.") do    print("I hold", key)end -- the five best scores, highest firstfor i, row in world.store:top("score.", 5) do    print(i, row.key, row.value)end

Name keys like paths (score.<player id>, item.sword) and a prefix works like a folder.

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#

objectthe worldpersonal HUD
keys256409664
value size64 KB64 KB8 KB
total1 MB8 MB32 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
--!strictlocal 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")    endend)

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)

Careful

Arithmetic, never a loop. elapsed can be a month, which is 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 = falselocal uses = tonumber(self.store:get("uses") or "0") or 0 self:on_touch(function()    uses += 1    dirty = trueend) 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:spawn makes is gone after a restart, so its memory has nowhere to go.

See also#

Updated 11 October 2026