Skip to content

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
--!strict
local 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 0
end

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

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

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 player may not have granted the world its hold permission. 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 — 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 bank
events:open("acme.bank", function(data, from)
    if data.op == "spend" then
        -- ...
    end
end)

-- in a shop
events:send("acme.bank", { op = "spend", who = p.id, amount = 25 })

Prefix public channel names — they are global to the region.

Earning coins

luau
--!strict
local 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

Hungrit scripting documentation.