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#
- A menu, shown on click.
- A button that reports back with
emit. - 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#
--!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#
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:
-- 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#
--!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:
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