Hungrit Docs

Shops and currency#

A counter with a menu, a balance that survives restarts, and goods that end up in somebody's hand.

The shape of a shop#

  1. A menu, shown on click.
  2. A button that reports back with emit.
  3. The script checks the balance, charges, and hands the thing over.

Pressing a button on your HUD counts as interacting with your object, which is what makes handing goods straight into a hand allowed.

A working shop#

Luau
--!strictlocal self = require("self")local world = require("world")local players = require("players") local STOCK = {    { key = "rope",    label = "Rope",    price = 10 },    { key = "lantern", label = "Lantern", price = 25 },    { key = "hammer",  label = "Hammer",  price = 40 },} local GRIP = { pos = { x = 0.04, y = -0.02, z = -0.12 },               rot = { x = 0, y = 90, z = 0 } } -- ── the balance ────────────────────────────────────────────────────────── local function coins_of(id: string): number    return tonumber(self.store:get("coins." .. id) or "0") or 0end local function set_coins(id: string, n: number)    self.store:set("coins." .. id, tostring(n))end -- ── the menu ───────────────────────────────────────────────────────────── local function page_for(id: string)    local rows = {        { "text", "General store", size = 22, bold = true },        { "text", "Coins: {coins}" },        { "separator" },    }     for _, item in ipairs(STOCK) do        table.insert(rows, { "row", {            { "text", item.label, w = "2fr" },            { "text", tostring(item.price), w = "1fr", align = "end" },            { "button", "Buy",              emit = "buy",              data = { key = item.key },              enabled = { ">=", "coins", item.price },              w = "1fr" },        }, height = 38 })    end     table.insert(rows, { "button", "Close", close = true, style = "ghost" })    return rowsend local function open_for(p)    p:show_hud({        id = "shop",        anchor = "center",        modal = true,        size = { 360, 0 },        vars = { coins = coins_of(p.id) },        theme = { bg = "#141a24", accent = "#6d5dfc", rounding = 12, pad = 14 },        pages = { main = page_for(p.id) },    })    p:show_cursor()end self:on_touch(open_for) -- ── the purchase ───────────────────────────────────────────────────────── local function find(key: string)    for _, item in ipairs(STOCK) do        if item.key == key then return item end    end    return nilend self:on_hud(function(p, event, data)    if event ~= "buy" then return end     local item = find(tostring(data.key))    if not item then return end     local balance = coins_of(p.id)    if balance < item.price then        return    end     local ok, why = world:spawn({        model = item.key,        hold = p.id,        at = "right_hand",        grip = GRIP,        pos = self:position(),        life = 0,    })     if not ok then        print("could not hand over: " .. tostring(why))        world:spawn({ model = item.key, pos = self:position(), life = 0 })    end     set_coins(p.id, balance - item.price)    p:update_hud("shop", { coins = balance - item.price })end)

The parts worth understanding#

Charge after the goods, not before#

The spawn can be refused. The script's owner may not hold what handing something over needs in this world. Handing over first and charging second means a refusal never takes somebody's money.

The fallback drops the item on the counter, which is a reasonable outcome and matches what happens when hands are full.

pos and grip are different#

pos is where the object is born in the world; grip is where it sits on the bone. Putting world coordinates in grip puts the purchase metres from the hand that bought it.

life = 0#

Everything a script spawns is temporary. The default is thirty seconds, which is fine for a bullet hole, useless for a purchase. life = 0 means as long as the region runs.

Nothing a script spawns survives a restart. If purchases must be permanent, the goods have to be objects that already exist in the world, handed over with self:hold, or the balance has to be what persists, with the goods re-issued.

enabled greys the button out#

Luau
enabled = { ">=", "coins", item.price }

Evaluated on the player's machine, so it updates the instant coins changes, and it means the check in on_hud is a safety net rather than the only guard.

Keep the server-side check anyway. Never trust that a button was disabled.

update_hud, not show_hud#

Rebuilding would throw away which page they were on and flash the panel.

Where the balance lives#

self.store on the shop object works while the shop is the only thing that matters. It has real limits:

  • 256 keys, so 256 customers.
  • It is the shop's memory. Another shop cannot read it.

For a currency shared across a world, put the balance in one object and have shops ask it over events:

Luau
-- in the bankevents:open("acme.bank", function(data, from)    if data.op == "spend" then        -- ...    endend) -- in a shopevents:send("acme.bank", { op = "spend", who = p.id, amount = 25 })

Prefix public channel names, because they are global to the region.

Earning coins#

Luau
--!strictlocal self = require("self")local events = require("events") self:on_touch(function(p)    events:emit("shop:credit", { who = p.id, amount = 5 })    self:set({ transparency = 1, collide = false })    self:after(20, function()        self:set({ transparency = 0, collide = true })    end)end)

And in the shop:

Luau
events:listen("shop:credit", function(e)    local id = tostring(e.who)    set_coins(id, coins_of(id) + (tonumber(e.amount) or 0))end)

See also#

Updated 11 October 2026