Skip to content
Guide · Godot shop

Build an in-game shop in Godot

Build a Godot in-game shop in GDScript. Load offers from a server, show real prices beside coin prices, sell for coins and real money, keep the balance in sync.

Updated

This guide builds a working Godot in-game shop: a screen that lists coin bundles and items, shows what each costs in real money, sells for coins and for real money, and keeps the balance on screen right. It uses PowerUp's Godot SDK, but the rules it follows (prices from the server, real prices beside coin prices, badges and countdowns only when they're true) are worth keeping whatever you build on. The virtual currency page explains why they matter.

What you need

  • Godot 4. The SDK is plain GDScript, built and tested on Godot 4.7.
  • The PowerUp addon in addons/powerup/, switched on under Project Settings → Plugins. That registers the PowerUp autoload.
  • A sandbox key and your API URL, both in the console under Integrations → SDK. The SDK isn't public yet: studios get it with early access.
  • A registered player. Getting started covers setting up the SDK and registering one.

Build with the sandbox key: no real money moves. Every call below returns a Dictionary with ok, and a failure carries a code to branch on and, when the server answered, its details.

The scene

One Control with a few children, each marked as a unique name so the script can reach it with %:

  • Balance, a Label for the coin balance
  • OfferGrid and ItemGrid, two GridContainers: products sold for money, and items sold for coins
  • LimitNotice and Status, two Labels
  • PaySheet, a panel with PayButton and a hidden ContinueHere button, for web checkout

Step 1: load the wallet and the storefront

The storefront is everything on sale to this player right now, already filtered by platform, game version and sale window, with the age and spending rules applied. fetch_home() loads the lobby in one call and fires wallet_changed, so the balance fills in on its own.

extends Control

var _usd_per_coin := 0.0

func _ready() -> void:
    PowerUp.wallet_changed.connect(_on_wallet_changed)
    %PayButton.pressed.connect(_on_pay_pressed)
    await refresh()

func refresh() -> void:
    await PowerUp.fetch_home()                     # fires wallet_changed
    var store: Dictionary = await PowerUp.fetch_storefront()
    if not store.ok:
        %Status.text = "The shop is unavailable right now."
        return
    _render_offers(store.storefront)
    await _render_items()
    await PowerUp.badges_seen("store")             # the shop was opened: clear its lobby badge

store.storefront.offers lists every live product: coin bundles, packs, items, passes and subscriptions. Each offer carries its key, name, price, the coins and items it grants, its end time if it has one, and whether this player can buy it (purchasable, with notPurchasableReason when not).

Step 2, the quick way: PowerUpStorefront

If you only want to see a store, put a PowerUpStorefront node in a test scene and call load_offers(). It fetches the storefront, builds a button per offer and shows the monthly spending notice for younger players.

@onready var storefront: PowerUpStorefront = %Storefront

func _ready() -> void:
    storefront.offer_purchased.connect(func(_result: Dictionary) -> void: %Status.text = "Thanks!")
    storefront.offer_failed.connect(func(reason: String) -> void: %Status.text = reason)
    await storefront.load_offers()

Its buy button uses the sandbox purchase, which only works with sandbox keys. For real money, build the grid yourself, which takes a few more lines.

Step 3: your own grid with PowerUpOfferButton

PowerUpOfferButton is a Button that draws one offer through the disclosure guards. Give it the offer and connect offer_pressed:

func _render_offers(storefront: Dictionary) -> void:
    for child in %OfferGrid.get_children():
        child.queue_free()
    for offer: Dictionary in storefront.get("offers", []):
        var button := PowerUpOfferButton.new()
        button.configure(offer)
        button.offer_pressed.connect(_on_offer_pressed)
        %OfferGrid.add_child(button)
    var limit := PowerUpDisclosure.spend_limit_notice(storefront)
    %LimitNotice.text = limit
    %LimitNotice.visible = limit != ""

Each button writes its own label: the name, the price (or "Price unavailable"), the coins with their real-money value beside them, a value multiplier only when the server verified it, "Ends in 2h 5m" only when the offer has an end time, and a subscription's renewal terms and how to cancel. When the offer can't be bought, the button says why ("Owned", "Monthly spending limit reached") and disables itself. The countdown runs on the server's clock, so a player who moves their device clock can't stretch it. The button shows the US dollar price, which is what web and desktop players pay. On phones, draw your own card with the store's local price (next step).

PowerUp Console · sandbox
The PowerUp console's store card preview: the Pile of Coins bundle at $0.99 for 120 coins, with its renewal, cancel, countdown and badge rows and the publish checks for the offer.
The console previews each offer's card, with the publish checks for that offer below it.

Step 4: drawing your own card with the guards

Want your own card design? Build it from PowerUpDisclosure, the static functions the button uses. Each one returns an empty string when the thing may not be shown, so the card can't say something the server didn't back.

func _fill_card(card: Control, offer: Dictionary) -> void:
    card.get_node("%Title").text = str(offer.get("name", ""))

    var price := PowerUpDisclosure.display_price(offer)          # "" when the card has no price
    card.get_node("%Price").text = price if price != "" else "Price unavailable"
    card.get_node("%Coins").text = PowerUpDisclosure.currency_line(offer)   # "120 coins  (≈ $0.99)"

    var badge := PowerUpDisclosure.badge_text(offer)             # "" unless the server verified it
    card.get_node("%Badge").text = badge
    card.get_node("%Badge").visible = badge != ""

    var left := PowerUp.seconds_until(offer.get("endsAt"))       # -1: no end time, so no countdown
    card.get_node("%Countdown").visible = left > 0

    var blocked := PowerUpDisclosure.blocking_reason(offer)      # why it can't be bought, or ""
    card.get_node("%Buy").disabled = blocked != ""
    card.get_node("%Buy").text = blocked if blocked != "" else "Buy for " + price

Three rules sit behind those calls:

  • Real price beside coin price. currency_line() puts the coins' value at your disclosed rate right next to the coin amount, not in a tooltip.
  • Badges only when verified. A "1.2× value" badge needs a baseline bundle that is live and sold on the same platform. If a price change would make the claim untrue, publishing fails until you fix it.
  • Countdowns only with an end time. No endsAt, no timer. PowerUp's publish checks also fail a sale whose end date was pushed back twice or more in 30 days, because that's a timer that resets rather than ends.

On iOS and Android, players should see the store's own local price. Pass what the store returned to PowerUpDisclosure.store_price(offer, store_prices), hand the result to display_price() and blocking_reason() as their second argument, and hide offers the store didn't return with store_has_product(). The iOS and Android purchase guide wires up the store plugins.

Step 5: items for coins

Items sold for coins live in the catalog. An item has a price while it can be bought for coins, and owned once the player has it by any route.

func _render_items() -> void:
    for child in %ItemGrid.get_children():
        child.queue_free()
    var result: Dictionary = await PowerUp.fetch_catalog("all")
    if not result.ok:
        return
    for item: Dictionary in result.catalog.items:
        var price: Variant = item.get("price")
        if item.get("owned", false) or not (price is Dictionary):
            continue                                   # owned already, or not sold for coins now
        var coins := int((price as Dictionary).get("coins", 0))
        var button := Button.new()
        button.text = "%s\n%s" % [item.get("name", ""), _coin_price_text(coins)]
        button.pressed.connect(_buy_item.bind(str(item.key), coins))
        %ItemGrid.add_child(button)

func _coin_price_text(coins: int) -> String:
    var text := PowerUpDisclosure.coins_text(coins)    # "300 coins"
    if _usd_per_coin > 0.0:
        text += "  (≈ $%.2f)" % (coins * _usd_per_coin)
    return text

The dollar figure comes from the rate in the wallet, which you set in the console. Base it on your smallest bundle, the price with no bulk discount. That matches the EU consumer protection authorities' key principles on in-game virtual currencies (21 March 2025), which ask for the real-money price worked out without quantity discounts.

Step 6: buying with coins

buy_item() takes the price the player saw. If the price changed since the screen loaded, the server refuses instead of charging a different amount. Keep one retry key per tap, so a tap retried after a dropped connection can't charge twice.

var _retry_keys := {}   # item key -> the retry key for this tap

func _buy_item(item_key: String, coins: int) -> void:
    if not _retry_keys.has(item_key):
        _retry_keys[item_key] = PowerUp.new_idempotency_key()
    var result: Dictionary = await PowerUp.buy_item(item_key, coins, _retry_keys[item_key])
    if result.get("code", "") != "transport_error":
        _retry_keys.erase(item_key)                    # the server answered: a new tap gets a new key
    if result.ok:
        %Status.text = "Added to your collection."
        await _render_items()
        return
    var details: Dictionary = result.get("details", {})
    match result.code:
        "insufficient_coins":
            %Status.text = "You need %d more coins." % (int(details.needed) - int(details.balance))
        "price_changed":
            %Status.text = "The price is now %d coins." % int(details.priceCoins)
            await _render_items()
        "wallet_in_debt":
            %Status.text = "A refund took back coins you'd spent. New coins pay that back first."
        "already_owned":
            %Status.text = "You already have this."
        _:
            %Status.text = "Not available right now."

wallet_changed fires after a successful buy, so the balance updates without another request. The wallet_in_debt case is real: when a store refunds a coin purchase after the player spent those coins, the coins are owed back, and spending waits until new coins have repaid them.

Step 7: buying with real money

Each offer says how it's paid for on this device: rail is stripe on web and desktop, and apple or google on phones. Send the tap there.

func _on_offer_pressed(offer: Dictionary) -> void:
    match str(offer.get("rail", "")):
        "stripe":
            await _open_pay_sheet(offer)
        "apple", "google":
            await _buy_in_store(offer)
        _:
            %Status.text = "This can't be bought on this device."

Web and desktop: Stripe Checkout

Browsers only allow a popup during the tap itself. So create the checkout when the sheet opens, and open it in the Pay button's handler with no await before it.

var _checkout: Dictionary = {}
var _offer: Dictionary = {}

func _open_pay_sheet(offer: Dictionary) -> void:
    _offer = offer
    %PaySheet.show()
    %PayButton.disabled = true
    %PayButton.text = "Preparing…"
    var started: Dictionary = await PowerUp.start_checkout(str(offer.key))
    if not started.ok:
        %PaySheet.hide()
        %Status.text = PowerUpDisclosure.blocking_text(started.code)
        return
    _checkout = started.checkout
    %PayButton.text = "Pay " + PowerUpDisclosure.display_price(offer)
    %PayButton.disabled = false

func _on_pay_pressed() -> void:
    if not PowerUp.open_checkout(_checkout):         # false: a popup blocker said no
        %ContinueHere.show()                         # its button: _on_continue_here_pressed()
        return
    await _wait_for_payment(str(_checkout.checkoutId))

# A popup checkout comes back to PowerUp's own page, not to the game, so "Continue in this tab" starts a
# redirect checkout instead. Leaving the page needs no click permission, so the await is fine. At boot,
# PowerUp.checkout_return_id() hands back its id: poll that one.
func _on_continue_here_pressed() -> void:
    var redirect: Dictionary = await PowerUp.start_checkout(str(_offer.key), "redirect")
    if redirect.ok:
        PowerUp.open_checkout(redirect.checkout)

# Poll while the sheet is open: a session lasts 30 minutes and then turns "expired" by itself.
func _wait_for_payment(checkout_id: String) -> void:
    var attempt := 0
    while %PaySheet.visible:
        await get_tree().create_timer(PowerUp.checkout_poll_delay(attempt)).timeout
        attempt += 1
        var status: Dictionary = await PowerUp.checkout_status(checkout_id)
        if not status.ok:
            continue
        match str(status.checkout.status):
            "completed":                             # coins are in: wallet_changed already fired
                %PaySheet.hide()
                await refresh()
                return
            "expired", "failed":
                %Status.text = "The payment didn't go through. Try again?"
                return

The Stripe checkout guide covers the rest: checking again when the game regains focus, and picking up a checkout after "Continue in this tab" reloads the page.

iOS and Android: the store sheet

On phones, ask PowerUp for a purchase intent first. That runs the age, consent and spending checks before the player pays. Then open the store sheet and post what the store returned.

func _buy_in_store(offer: Dictionary) -> void:
    var intent: Dictionary = await PowerUp.create_purchase_intent(str(offer.key))
    if not intent.ok:
        %Status.text = PowerUpDisclosure.blocking_text(intent.code)
        return
    # Open the App Store or Google Play sheet for intent.intent.productId with your billing
    # plugin. Give Play Billing intent.intent.obfuscatedAccountId (StoreKit: appAccountToken),
    # then post what the store returned, for example on Android:
    #   await PowerUp.submit_receipt("google", {"purchaseToken": token,
    #       "productId": intent.intent.productId, "intentId": intent.intent.intentId})

Never add coins on the client when the store sheet closes. Wait for submit_receipt(): PowerUp checks the receipt, credits the wallet, and wallet_changed fires. On Android, don't acknowledge or consume the purchase in the game, because PowerUp does it. On iOS, finish the transaction only when the result says finish_transaction.

Step 8: keep the balance in sync

Everything that moves coins fires wallet_changed: loading the lobby, buying an item, a finished checkout, a verified receipt, claiming a free offer or a quest reward. One handler keeps the label right:

func _on_wallet_changed(wallet: Dictionary) -> void:
    var coins: Variant = wallet.get("coins")
    if not (coins is Dictionary):
        return                                         # a game with no coin currency
    var rate: Variant = coins.get("usdRate")
    _usd_per_coin = float(rate) if rate != null else 0.0
    %Balance.text = PowerUpDisclosure.thousands(int(coins.get("balance", 0)))

coins.inPlay is what's held for a match in progress, if your game uses match purses. The sandbox purchase doesn't fire the signal, so refresh after a sandbox buy.

Before you ship

  • Prices come from the storefront and the catalog. Nothing is hard-coded in the build.
  • Every coin amount for sale shows its real-money value beside it.
  • Badges and countdowns go through PowerUpDisclosure or PowerUpOfferButton.
  • Coin buys send the price the player saw, with one retry key per tap.
  • Coins appear only after the server says so.
  • If storefront.purchaseBlocked is age_gate_required, ask the player's birth year at a calm moment and call PowerUp.set_birth_year().

For tests without a network, PowerUp.set_transport() sends every request to a function of yours instead of over HTTP, and the addon's demo/ folder holds recorded answers for the routes a lobby needs. The storefront docs list every field this guide reads.

Questions

Can I build a Godot in-game shop without a server?

You can draw one, but anything sold for real money needs a server to check the payment before coins or items are granted, and a coin balance players can buy into belongs on a server too. With PowerUp that server is ours, so your game needs no backend of its own for the shop.

Should shop prices live in the game or on the server?

On the server. Prices that ship inside a build can't change without an update, and the server has to know the price anyway to charge it. The game draws what the storefront returns and states the price the player saw when it buys.

Why doesn't my value badge show up?

The SDK only draws a value badge the server verified, which needs a live baseline bundle sold on the same platform. On iOS and Android, PowerUpDisclosure.store_badge_text() also hides it when the store's local prices move the ratio more than 2% from the verified one.

Does the PowerUpStorefront node take real payments?

No. Its built-in buy button uses the sandbox purchase, which only works with sandbox keys. It's the fastest way to see your store working. For real money, draw the offers with PowerUpOfferButton and send each tap to the right checkout, as this guide does.

See it running on a real game.

We’ll walk you through the console with buggs.io’s setup.