← Back to Devlog
November 1, 2026 • Monetization & Native Integration

Bridging StoreKit 2 into Godot 4 over a Loopback Socket

The Silent Purchase Button: Maze Point sells one universal non-consumable product. On iPhone and iPad it removes ads; on Apple TV, where the game never shows ads, the same product unlocks the full game through a BUY FULL GAME button. On Android that flow runs through Google Play Billing, but on iOS and tvOS our store code still assumed the InAppStore singleton that Godot 3 shipped with its iOS export. Godot 4 does not include it. On device, Engine.has_singleton("InAppStore") returned false, our fallback path reset the button, and no purchase sheet ever appeared. App Review flagged both the iOS and tvOS builds under Guideline 2.1(b) (App Completeness) because the purchase controls did not respond. Our headless tests had missed it because the mocks always provided a singleton.

1. Why a Native Bridge Instead of a Plugin Port

We looked at porting an iOS GDExtension plugin, but the plugin options we found targeted the older StoreKit 1 API, and none supported tvOS cleanly. StoreKit 2 is a Swift-first, async/await API, and its most useful feature for us is that every transaction arrives as a VerificationResult: Apple has already checked the JWS signature on device, and we only have to decide what to do with .verified versus .unverified.

So we put the store logic where StoreKit 2 is most at home: a small Swift class inside the Xcode host app that Godot's export generates. It starts at launch, owns all StoreKit calls, and talks to GDScript through a socket bound only to the loopback interface. GDScript stays platform-agnostic; store_manager.gd just sends small JSON commands (purchase, restore, confirm_transaction) and reacts to events.

The core of the native side is a long-lived listener on Transaction.updates, which receives purchases completed on another device, Ask to Buy approvals, refunds and revocations, and transactions left unfinished from a previous launch. Purchases made through the in-app sheet do not come through this stream: Product.purchase() returns its result directly, and the bridge passes that result through the same verification handler.

Task {
    for await result in Transaction.updates {
        switch result {
        case .verified(let tx):
            await handleVerified(tx)
        case .unverified(let tx, let error):
            await send(event: "purchase_fail",
                       product: tx.productID,
                       error: error.localizedDescription)
        }
    }
}

Messages are newline-delimited JSON. Each side buffers incoming bytes and splits on \n, so a message split across two reads, or two messages arriving in one read, are both handled correctly.

2. Making the Simulator Tell the Truth

A purchase flow that only works against the App Store sandbox is slow to iterate on and hard to test. We added a .storekit configuration file that declares the product and attached it to the Xcode schemes for both iOS and tvOS. With that in place, the simulator presents a real StoreKit sheet, lets us approve, cancel, or fail a purchase, and lets us issue refunds from Xcode's transaction manager.

3. UI State While the Sheet Is Open

Once the button actually did something, we had to manage what happens around it. Tapping the purchase button now puts it into a disabled PROCESSING... state with changed text, not just a color change, so the state is clear to everyone. If the bridge does not answer within a few seconds, a timeout returns the button to normal with an error message rather than leaving the player stuck.

tvOS needed one more step. The purchase sheet takes focus away from the Godot view, and with a Siri Remote there is no pointer to recover it with. The store is not a global autoload. It is a node that GameStoreController.instantiate_store_manager creates at startup, and it reports results to the game through callbacks. Each result handler first updates the store buttons and the visible screen, then asks the game's menu focus manager to re-sync focus against the controls that are active now:

func _on_premium_purchase_completed(product_id: String) -> void:
	StoreControllerScript.handle_purchase_completed(...)  # hides buy and restore
	_refresh_ui_layout()
	if _is_menu_navigation_active():
		_sync_menu_focus(true)  # focus the first visible control

func _on_premium_purchase_failed(product_id: String, error_msg: String) -> void:
	StoreControllerScript.handle_purchase_failed(...)
	if _is_menu_navigation_active():
		_sync_menu_focus(false)  # keep the current index if still valid

Re-syncing is more reliable than remembering the old focus target. After a successful purchase, the button the player pressed is hidden, so focus has to move to a control that is still visible and valid, and the focus manager already knows which controls those are.

4. Hardening the Bridge

The first version worked, and our review pass then asked a better question: what else on the device could talk to this socket, and what happens if things arrive in the wrong order? We made four changes.

Mutual challenge-response authentication

Binding to loopback keeps the socket off the network, but we still wanted each side to prove it is the real other half before any store event moves. The client and server each generate a fresh random nonce and exchange them. Each side then computes an HMAC-SHA256 proof over both nonces using a shared secret. The proofs are domain-separated with a role label, so a server proof can never be reflected back as a valid client proof:

// Simplified: each proof binds a role label and both nonces
let expected = hmacSHA256(secret, role: "client",
                          clientNonce, serverNonce)
guard constantTimeCompare(expected, receivedProof) else {
    connection.cancel()
    return
}

Both Swift and GDScript compare proofs in constant time, combining every byte with XOR and checking the result once at the end, so response timing does not reveal how much of a proof matched. We pinned identical test vectors in both languages so that a mismatch in encoding or hashing fails a unit test instead of failing silently on device.

Finish only after the game has persisted

StoreKit keeps a transaction in Transaction.unfinished until you call finish(). That is a safety net, and the original bridge discarded it by finishing as soon as it relayed the event. If the game crashed before saving, the purchase was acknowledged to Apple but never recorded locally. Now the bridge keeps verified transactions in a pending map keyed by transaction ID, and calls finish() only when GDScript replies with confirm_transaction after its save succeeds:

func _on_ios_purchase_success(product_id: String, tx_id: String) -> void:
	var saved := _on_purchase_verified(product_id)
	if saved and not tx_id.is_empty():
		_send_sk_payload({"action": "confirm_transaction", "transaction_id": tx_id})
	# If the save failed, StoreKit redelivers the transaction next launch.

Current entitlement beats transaction history

A refunded transaction arrives with a revocationDate. Revoking on that signal alone is wrong if the player refunded once and then bought again, because the old refund can arrive after the newer purchase. Before relaying a revocation, the bridge asks Transaction.currentEntitlement(for:). If an active, verified entitlement exists, the historical refund is finished and ignored. On iOS and tvOS 18.4 and later, Apple deprecated that method in favor of currentEntitlements(for:). After each successful handshake, the bridge also sends every current entitlement to the game, which is how a fresh install or a new device restores the purchase without the player doing anything.

Bounded, polite connection handling

Reads have a maximum buffer size, and a connection that exceeds it without sending a newline is dropped. Only a small, fixed number of unauthenticated connections may wait at once, and the oldest is evicted first. When a new client authenticates, any stale authenticated connection is closed. On the GDScript side, polling the socket is throttled instead of running every frame, and the client reconnects when the app resumes, so a missing bridge (for example, in the editor) costs almost nothing. The Swift server runs on the main actor, which removes a whole class of races between the transaction listener and socket callbacks.

5. Testing Across the Language Boundary

The headless Godot suite cannot run StoreKit, so we test each half against a contract. A mock bridge in test_store_manager.gd sends scripted messages and checks how the store reacts to purchase, restore, revocation, and timeout paths. It also checks the cases that matter most: a failed save must never send confirm_transaction, and a queued command must time out cleanly when no bridge is present. After the hardening pass, the suite passed with 1,399 assertions. On the native side, we used the simulator with the .storekit file for buy, cancel, refund, and reinstall on both iOS and tvOS, including D-pad-only navigation on Apple TV.

Takeaways

  • Do not trust that an engine singleton exists because a mock says it does. Check native availability on a real target early.
  • Let StoreKit 2 handle verification, and keep its finish() safety net until your own persistence has succeeded.
  • Decide entitlement from the current entitlement, not from individual transaction events, which can arrive out of order.
  • A loopback socket is a small protocol. Give it authentication, framing, size limits, and test vectors shared between both languages.
  • On TV platforms, focus is part of the purchase flow. Re-sync it to the currently visible controls after any system sheet.