Building a buy and sell shop in Godot 4

A Godot 4 shop buy/sell UI grounded in real vendor code: stock counts, sell price as a fraction of buy, affordability checks, and moving gold and items as one.

The player clicks Buy on a health potion. Four things have to line up before anything changes. The potion has to be in stock, the player has to have enough gold, the gold has to leave the wallet, and the potion has to land in the bag. Miss any one of those and you want the whole click to do nothing, not half of it. The failure that ruins a shop is the one where the gold is gone and the item never arrived. The reverse is just as bad: the player sells a sword, pockets the gold, and still has the sword. So the real job of a Godot 4 shop buy/sell UI is less about the pixels and more about making one button press either fully happen or fully not.

I'll walk through how the Vendor Lite addon models this, because the buy and sell paths are small enough to read end to end. Then I'll show you the exact line where the Lite version is honest about not being fully atomic. That line is the thing that will bite you, so it gets its own note further down.

Two nodes: a wallet and a vendor

There are only two moving parts. A WalletLite holds a single currency as a plain integer. A VendorLite holds the stock and does the buying and selling. Neither one owns the player's bag. The vendor talks to whatever inventory you hand it, as long as that inventory answers add_item, remove_item, and has_item. That duck typing is deliberate. It means the same vendor works against the Inventory Lite bag, against your own custom container, or against the tiny demo bag that ships with the addon, and the vendor never knows which of those it's holding.

The wallet is about as plain as it gets. The one method that matters for a shop is subtract, and the thing to notice is that it returns a bool.

# WalletLite.gd
class_name WalletLite
extends Node

signal balance_changed(new_total: int)

@export var currency_id: String = "gold"
@export var starting_balance: int = 0

var _balance: int = 0

func has_amount(amount: int) -> bool:
    return _balance >= amount

func add(amount: int) -> void:
    if amount <= 0:
        return
    _balance += amount
    balance_changed.emit(_balance)

func subtract(amount: int) -> bool:
    if amount <= 0 or _balance < amount:
        return false
    _balance -= amount
    balance_changed.emit(_balance)
    return true

subtract refuses a non-positive amount and refuses to go negative, and it tells you which happened only by returning false. That bool is what lets the vendor treat 'charge the player' as a step that can fail rather than an assumption. add, by contrast, returns nothing and silently ignores amounts of zero or less. Keep that asymmetry in mind. It shows up in the buy path in a way that's easy to get wrong if you write your own.

Stock is a flat array of dictionaries

The vendor's inventory of things to sell is not a Resource in the Lite tier. It's just an Array you set in the inspector or in code, one Dictionary per offer.

vendor.stock = [
    {"item": apple,  "price": 5,   "stock": 20, "sell_price": 2},
    {"item": potion, "price": 25,  "stock": 5,  "sell_price": 10},
    {"item": sword,  "price": 150, "stock": 1,  "sell_price": 75},
    {"item": ring,   "price": 300, "stock": -1, "sell_price": 120},  # -1 means unlimited
]

price is what the player pays to buy. sell_price is what the vendor pays when the player sells the same item back, and in almost every RPG that number is a fraction of the buy price. The apple costs 5 and sells for 2. The ring costs 300 and buys back at 120. There's no automatic ratio in Lite. You type the sell number per entry. That's tedious with a big catalog, and the Pro tier derives it from a single buyback ratio, but for a handful of items the explicit number is clearer and lets you overprice or underprice individual things on purpose. A stock of -1 means unlimited, which is what you want for a ring the vendor should always carry.

Everything hinges on finding the right entry for an item, and the addon matches on the item's id string rather than on object identity.

func _find_entry(item: Resource) -> Dictionary:
    if item == null:
        return {}
    for e in stock:
        if not (e is Dictionary):
            continue
        var entry_item: Resource = e.get("item")
        if entry_item == null:
            continue
        if String(entry_item.id) == String(item.id):
            return e
    return {}

Matching on String(item.id) instead of on the Resource reference is the small decision that makes the rest of the system tolerant. Think about what carries the same id but is a different object: a loaded save, a Resource you duplicated, an item instanced fresh at runtime. None of those are the same object as the one sitting in your stock array, yet they all carry the same id, so they resolve to the same offer. The whole family of addons agrees on this convention, which is why a Vendor can price an item that came out of an Inventory it has never seen. The catch is that your item Resources must have an id property, and it has to be stable. Two different swords with an empty id will collide onto the same stock entry.

The buy path

Here is the whole of buy, and it reads top to bottom as a sequence of gates that each bail out early.

func buy(item: Resource, count: int = 1) -> bool:
    if _wallet == null:
        _reject(REASON_NO_WALLET). Return false
    if _inventory == null:
        _reject(REASON_NO_INVENTORY). Return false
    var entry := _find_entry(item)
    if entry.is_empty():
        _reject(REASON_OUT_OF_STOCK). Return false
    if int(entry.get("stock", -1)) >= 0 and int(entry.stock) < count:
        _reject(REASON_OUT_OF_STOCK). Return false
    var unit_price := int(entry.get("price", 0))
    var total := unit_price * count
    if not _wallet.has_amount(total):
        _reject(REASON_NO_FUNDS). Return false
    # Only call subtract if there's actually something to deduct. WalletLite
    # rejects amount <= 0, which would misfire as REASON_NO_FUNDS for free items.
    if total > 0 and not _wallet.subtract(total):
        _reject(REASON_NO_FUNDS). Return false
    if int(entry.get("stock", -1)) >= 0:
        entry.stock = int(entry.stock) - count
    if _inventory.has_method("add_item"):
        _inventory.add_item(item, count)
    purchase_completed.emit(item, count, total)
    stock_changed.emit()
    return true

The order is what makes it feel like a transaction. Nothing changes until every check has passed. First the bindings, then the offer exists, then the offer has enough stock, then the wallet can cover the bill. Only after all four does any state move: gold comes out, stock ticks down, and the item goes into the bag. Return false and the caller knows to leave the UI alone. The reject signal tells the UI why, so you can flash 'not enough gold' without the vendor knowing anything about your HUD.

That guard on total > 0 before calling subtract is worth a sentence, because it looks like a micro-optimization and isn't. A free item has a total of 0. WalletLite.subtract returns false for any amount of 0 or less, by design, since a wallet shouldn't pretend to charge nothing. Without the guard, buying a free item would call subtract(0), get false back, and get rejected as REASON_NO_FUNDS even though the player has plenty. So the guard exists to stop a correct wallet rule from breaking free items. Small trap, and one you'd hit the first time a quest handed out a zero-cost reward through the shop.

The sell path, and why it is more than buy in reverse

Selling looks symmetric and mostly is, but two things differ. The vendor has to confirm the player actually owns what they're selling, and it has to decide whether it will even accept an item that isn't in its stock list.

func sell(item: Resource, count: int = 1) -> bool:
    if _wallet == null:
        _reject(REASON_NO_WALLET). Return false
    if _inventory == null:
        _reject(REASON_NO_INVENTORY). Return false
    if not (_inventory.has_method("has_item") and _inventory.has_item(item, count)):
        _reject(REASON_NOT_OWNED). Return false
    var entry := _find_entry(item)
    var unit_price := 0
    if not entry.is_empty():
        unit_price = int(entry.get("sell_price", entry.get("price", 0)))
    elif not accept_any_sale:
        _reject(REASON_NOT_LISTED). Return false
    # (else: accept_any_sale on an unlisted item, unit_price stays 0)
    var total := unit_price * count
    if _inventory.has_method("remove_item"):
        _inventory.remove_item(item, count)
    if total > 0:
        _wallet.add(total)
    if not entry.is_empty() and int(entry.get("stock", -1)) >= 0:
        entry.stock = int(entry.stock) + count
    sale_completed.emit(item, count, total)
    stock_changed.emit()
    return true

The ownership check is the first real gate: has_item(item, count) has to be true or the vendor rejects with REASON_NOT_OWNED. Then comes the pricing decision. If the item is listed, its sell_price is used, falling back to price if you never set a sell_price on that entry. If the item is not listed, behavior forks on the accept_any_sale flag. With it off, the vendor refuses anything it doesn't stock and rejects with REASON_NOT_LISTED. With it on, the vendor takes the item but pays 0 for it, unless your own code intercepts the sell call and sets a price first. That zero-payout case is the escape hatch for a junk merchant who accepts anything but only pays for what he cares about.

Notice the sell side has the same non-atomic shape as buy, mirrored. remove_item runs, then the wallet is credited, and remove_item's return value is thrown away. In practice sell is safer than buy, because you already confirmed ownership with has_item before removing, so remove_item is very unlikely to come up short. But 'very unlikely' is not 'guaranteed'. If you have an inventory whose has_item and remove_item can disagree (say a container with per-slot locks), you'd want to check what remove_item actually took and only credit that much.

Wiring the shop UI to buttons

The UI side is deliberately dumb, which is the point. A row per stock entry with a Buy button, a row per bag item with a Sell button, and a label bound to the wallet. The vendor emits stock_changed after any successful transaction and the wallet emits balance_changed, so the UI just redraws on those signals rather than tracking state itself.

func _ready() -> void:
    _vendor.bind_wallet(_wallet)
    _vendor.bind_inventory(_bag)
    _vendor.stock_changed.connect(_refresh)
    _wallet.balance_changed.connect(func(_v): _refresh())
    _vendor.transaction_rejected.connect(
        func(reason): _flash("Rejected: %s" % reason))

func _make_buy_row(entry: Dictionary) -> HBoxContainer:
    var row := HBoxContainer.new()
    var label := Label.new()
    var stock_text := "inf" if int(entry.get("stock", -1)) < 0 else str(int(entry.stock))
    label.text = "%s: %dg (stock %s)" % [entry.item.name, int(entry.price), stock_text]
    label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
    row.add_child(label)
    var btn := Button.new()
    btn.text = "Buy"
    btn.pressed.connect(_vendor.buy.bind(entry.item, 1))
    row.add_child(btn)
    return row

The button doesn't decide anything. It calls buy with the item and a count, and the vendor's return value plus its signals carry the outcome. A rejected buy flashes a reason and leaves the UI untouched, because nothing in the vendor changed. A successful buy fires stock_changed and balance_changed, both wired to a full redraw, so the stock count drops and the gold label updates with no line of code that says 'now update the gold label'. Reading sell_price back for the bag side is the same one call: _vendor.get_sell_price(item).

What you have, and where the seam is

That's a working shop. Stock with per-item counts and an unlimited sentinel, buy prices and separate sell prices, an affordability check that treats charging as a step that can fail, ownership verification on the way out, and a reject channel that lets the UI explain itself. Two nodes, two public methods, and a fistful of signals. You can build the whole front end against that surface without touching the vendor's internals.

The seam to know about is the atomicity one from the note above. The Lite buy trusts the bag to have room and doesn't roll back if it doesn't. For most games that's fine, because you cap the bag generously or check capacity in the button handler before calling buy. If you need a hard guarantee, say currencies that must never leak or a bag that's often full, the clean version reads what add_item couldn't fit and refunds it, so the charge and the delivery can't drift apart. That's the model the paid tier ships with, alongside restock timers and a drop-in ShopUI on top. Either way, the core idea travels: decide everything before you move anything, and let the button just ask.

Or use mine

Vendor Lite, free on GitHub · Vendor / Shop, $4.99

All Godot posts · Home