Multi-Step Minting UI in Phaser
deps

"The blockchain says it's processing. How long do I wait? Is it broken? Did I lose my item?"
Every player will ask this question if you implement a one-step mint button. Blockchain transactions are not instantaneous. On Sui, minting typically requires 5-30 seconds but can occasionally take minutes. Without adequate UI feedback, players may panic-click, close the game, or assume the worst.
This article, Part 4 of the "Building an NFT Marketplace for Your Game" series, focuses on constructing the UI layer to make blockchain wait times feel manageable: a multi-step minting popup in Phaser. Learn how to build a better user experience for your NFT game.
The Six States of Minting
A minting operation is not a singular action; it's a process with multiple states the player progresses through:
| Step | What the Player Sees | What's Happening |
|---|---|---|
login_required | "Please log in" | No active session detected |
wallet_input | Wallet address form | Player enters Sui wallet |
confirm | Item preview + "Mint" | Player reviews before committing |
minting | Animated spinner | Edge Function called, polling for status |
success | Checkmark + redeem code | NFT minted on Sui blockchain |
error | Error message + retry | Something went wrong |
Each state has a distinct purpose, ensuring the player always understands their position in the process and their next action when minting NFTs.
Architecture Overview
The minting UI comprises three layers:
| Component | Responsibility | File |
|---|---|---|
NFTMintPopup | Multi-step popup UI, renders each state | ui/nft-mint-popup.ts |
ForTemService | API calls to Edge Function, status polling | core/fortem/fortem-service.ts |
InventoryScene | Orchestrates the flow between components | scenes/inventory.ts |
The popup does not directly call the API. The scene serves as the mediator, creating the popup, managing the mint logic, and updating the popup's state based on the results for NFT minting.
Step 1: Define the Type System
Begin by defining types for the minting lifecycle:
// core/fortem/types.ts
export enum MintingStatus {
IDLE = 'idle',
PENDING = 'pending',
PROCESSING = 'processing',
MINTED = 'minted',
FAILED = 'failed',
}
export interface NFTMetadata {
nftId: string;
redeemCode: string;
objectId?: string;
mintedAt: number;
status: MintingStatus;
}
export interface MintItemRequest {
itemId: string;
itemType: string;
itemName: string;
rarity: string;
description?: string;
recipientAddress?: string;
attributes?: Array<{ name: string; value: string }>;
imageUrl?: string;
}
export interface MintItemResponse {
success: boolean;
data?: {
itemId: number;
nftNumber: number;
redeemCode: string;
status: string;
};
error?: string;
}
export interface MintResult {
success: boolean;
nftMetadata?: NFTMetadata;
error?: string;
}Step 2: Build the Base Panel
The popup extends a Panel class, a reusable container providing background, title area, and a close button:
// ui/panel.ts (simplified)
export class Panel extends Phaser.GameObjects.Container {
private contentContainer: Phaser.GameObjects.Container;
constructor(scene: Phaser.Scene, config: PanelConfig) {
super(scene, config.x, config.y);
// Dark panel background with rounded corners
const bg = scene.add.graphics();
bg.fillStyle(0x2d2d44, 1);
bg.fillRoundedRect(
-config.width / 2, -config.height / 2,
config.width, config.height, 16
);
this.add(bg);
// Content container offset below header
this.contentContainer = scene.add.container(0, 25);
this.add(this.contentContainer);
scene.add.existing(this);
}
addContent(child: Phaser.GameObjects.GameObject): void {
this.contentContainer.add(child);
}
show(): void {
this.setAlpha(0);
this.scene.tweens.add({
targets: this, alpha: 1, duration: 200,
});
}
close(): void {
this.scene.tweens.add({
targets: this, alpha: 0, duration: 150,
onComplete: () => this.destroy(),
});
}
}Step 3: The Multi-Step Popup
The core of the minting UI is presented below. The popup manages its own state and re-renders when the step changes:
// ui/nft-mint-popup.ts
export type MintPopupStep =
| 'wallet_input'
| 'confirm'
| 'minting'
| 'success'
| 'error'
| 'login_required';
export interface NFTMintPopupConfig {
x: number;
y: number;
item: InventoryItem;
walletAddress?: string | null;
isLoggedIn: boolean;
onMint: (walletAddress: string) => void;
onSaveWallet: (walletAddress: string) => Promise<boolean>;
onLogin: () => void;
onClose: () => void;
}
export class NFTMintPopup extends Panel {
private item: InventoryItem;
private currentStep: MintPopupStep;
private walletAddress: string | null;
private contentElements: Phaser.GameObjects.GameObject[] = [];
private spinnerAngle: number = 0;
private spinnerTimer?: Phaser.Time.TimerEvent;
constructor(scene: Phaser.Scene, config: NFTMintPopupConfig) {
super(scene, { x: config.x, y: config.y, width: 380, height: 420 });
this.item = config.item;
this.walletAddress = config.walletAddress ?? null;
// Determine initial step based on current state
if (!config.isLoggedIn) {
this.currentStep = 'login_required';
} else if (!this.walletAddress) {
this.currentStep = 'wallet_input';
} else {
this.currentStep = 'confirm';
}
this.renderCurrentStep();
}
private clearContent(): void {
this.contentElements.forEach(el => el.destroy());
this.contentElements = [];
if (this.spinnerTimer) {
this.spinnerTimer.destroy();
this.spinnerTimer = undefined;
}
}
private renderCurrentStep(): void {
this.clearContent();
switch (this.currentStep) {
case 'login_required': this.renderLoginRequired(); break;
case 'wallet_input': this.renderWalletInput(); break;
case 'confirm': this.renderConfirm(); break;
case 'minting': this.renderMinting(); break;
case 'success': this.renderSuccess(); break;
case 'error': this.renderError(); break;
}
}
// Public methods for external state control
setStep(step: MintPopupStep): void {
this.currentStep = step;
this.renderCurrentStep();
}
setMintResult(result: NFTMetadata): void {
this.mintResult = result;
this.currentStep = 'success';
this.renderCurrentStep();
}
setError(message: string): void {
this.errorMessage = message;
this.currentStep = 'error';
this.renderCurrentStep();
}
}Key design decision: The popup exposes setStep(), setMintResult(), and setError() as public methods. The scene invokes these methods to control state transitions, ensuring the popup itself never directly initiates API calls for your Phaser NFT game.
Step 4: Wallet Input with Browser Prompt
Phaser lacks native text input capabilities. A pragmatic solution involves using window.prompt():
private renderWalletInput(): void {
const title = this.scene.add.text(0, 95, 'Connect Wallet', {
fontSize: '22px', fontFamily: 'Righteous, Arial', color: '#ffffff',
}).setOrigin(0.5);
this.addContent(title);
this.contentElements.push(title);
// Input field (visual only - click triggers browser prompt)
const inputWidth = 280;
const inputY = 190;
const inputBg = this.scene.add.graphics();
inputBg.fillStyle(0x1a1a2e, 1);
inputBg.fillRoundedRect(-inputWidth / 2, inputY - 24, inputWidth, 48, 10);
inputBg.lineStyle(2, 0x4a90d9, 0.6);
inputBg.strokeRoundedRect(-inputWidth / 2, inputY - 24, inputWidth, 48, 10);
this.addContent(inputBg);
this.contentElements.push(inputBg);
const inputText = this.scene.add.text(-inputWidth / 2 + 15, inputY, '0x...', {
fontSize: '13px', color: '#555555',
}).setOrigin(0, 0.5);
this.addContent(inputText);
this.contentElements.push(inputText);
// Clickable area triggers browser prompt
const inputArea = this.scene.add.rectangle(0, inputY, inputWidth, 48, 0x000000, 0);
inputArea.setInteractive({ useHandCursor: true });
inputArea.on('pointerdown', () => {
const address = prompt('Enter your **Sui wallet address**');
if (address) {
this.walletInputText = address.trim();
inputText.setText(this.truncateAddress(this.walletInputText));
inputText.setColor(this.walletInputText ? '#ffffff' : '#555555');
}
});
this.addContent(inputArea);
this.contentElements.push(inputArea);
// Continue button - validates and saves wallet
const saveBtn = new Button(this.scene, {
x: 75, y: 290, width: 130, height: 48,
text: 'CONTINUE', color: 0x2ecc71,
onClick: async () => {
if (!this.walletInputText?.startsWith('0x')) {
this.showToast('Please enter a valid Sui address');
return;
}
const success = await this.config.onSaveWallet(this.walletInputText);
if (success) {
this.walletAddress = this.walletInputText;
this.currentStep = 'confirm';
this.renderCurrentStep();
}
},
});
this.addContent(saveBtn);
this.contentElements.push(saveBtn);
}
private truncateAddress(address: string): string {
if (address.length < 15) return address;
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`;
}While
window.prompt()may lack elegance, it offers broad compatibility and circumvents the complexity of implementing custom text input within Phaser's canvas. Players only enter their Sui wallet address once, as it is subsequently saved to their profile for future minting operations.
Step 5: Animated Spinner for Minting State
The minting state often induces player anxiety. A fluid, animated spinner effectively communicates ongoing activity:
private renderMinting(): void {
const title = this.scene.add.text(0, 80, 'MINTING IN PROGRESS', {
fontSize: '22px', fontFamily: 'Righteous, Arial', color: '#ffffff',
}).setOrigin(0.5);
this.addContent(title);
this.contentElements.push(title);
// Animated arc spinner
const spinner = this.scene.add.graphics();
this.drawSpinner(spinner);
this.addContent(spinner);
this.contentElements.push(spinner);
// Rotate spinner every 50ms
this.spinnerTimer = this.scene.time.addEvent({
delay: 50,
callback: () => {
this.spinnerAngle += 10;
spinner.clear();
this.drawSpinner(spinner);
},
loop: true,
});
const status = this.scene.add.text(0, 230, 'Status: PROCESSING', {
fontSize: '14px', color: '#4a90d9',
}).setOrigin(0.5);
this.addContent(status);
this.contentElements.push(status);
}
private drawSpinner(graphics: Phaser.GameObjects.Graphics): void {
const cx = 0, cy = 140, radius = 25;
// Background circle (faded)
graphics.lineStyle(4, 0x3d3d54, 0.3);
graphics.strokeCircle(cx, cy, radius);
// Animated arc (bright)
graphics.lineStyle(4, 0x4a90d9, 1);
const start = Phaser.Math.DegToRad(this.spinnerAngle);
const end = Phaser.Math.DegToRad(this.spinnerAngle + 90);
graphics.beginPath();
graphics.arc(cx, cy, radius, start, end);
graphics.strokePath();
}Why opt for a graphics-based spinner over a sprite animation? A graphics-based spinner eliminates asset dependencies, renders at any resolution, and utilizes a 90-degree arc length to provide a clear indication of rotation without relying on external image files in your NFT game.
Step 6: The ForTem Service (Client-Side)
The client-side service encapsulates calls to the Supabase Edge Function:
// core/fortem/fortem-service.ts
import { getSupabase } from '@core/supabase/client';
const POLL_INTERVAL = 3000; // 3 seconds
const MAX_POLL_ATTEMPTS = 60; // 3 minutes max
export class ForTemService {
private static instance: ForTemService;
static getInstance(): ForTemService {
if (!ForTemService.instance) {
ForTemService.instance = new ForTemService();
}
return ForTemService.instance;
}
async mintItem(request: MintItemRequest): Promise<MintResult> {
const supabase = getSupabase();
if (!supabase) return { success: false, error: 'Supabase not configured' };
const { data: { session } } = await supabase.auth.getSession();
if (!session) return { success: false, error: 'Please login to mint NFTs' };
const { data, error } = await supabase.functions.invoke('fortem-mint', {
body: { action: 'mint', ...request },
});
if (error) return { success: false, error: error.message ?? 'Unknown error' };
if (!data?.success) return { success: false, error: data?.error ?? 'Unknown error' };
return {
success: true,
nftMetadata: {
nftId: String(data.data.itemId),
redeemCode: data.data.redeemCode,
mintedAt: Date.now(),
status: MintingStatus.PROCESSING,
},
};
}
async waitForMintCompletion(
redeemCode: string,
onStatusChange?: (status: MintingStatus) => void
): Promise<MintResult> {
let attempts = 0;
while (attempts < MAX_POLL_ATTEMPTS) {
const response = await this.getMintingStatus(redeemCode);
if (!response.success) {
return { success: false, error: response.error };
}
const status = this.mapStatus(response.data?.status ?? '');
onStatusChange?.(status);
if (status === MintingStatus.MINTED) {
return {
success: true,
nftMetadata: {
nftId: String(response.data?.id ?? ''),
redeemCode,
objectId: response.data?.objectId,
mintedAt: Date.now(),
status: MintingStatus.MINTED,
},
};
}
if (status === MintingStatus.FAILED) {
return { success: false, error: 'Minting failed on blockchain' };
}
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
attempts++;
}
return { success: false, error: 'Minting timeout' };
}
private mapStatus(status: string): MintingStatus {
switch (status.toUpperCase()) {
case 'PROCESSING': return MintingStatus.PROCESSING;
case 'MINTED':
case 'REDEEMED':
case 'OFFER_PENDING':
case 'KIOSK_LISTED':
return MintingStatus.MINTED;
default: return MintingStatus.PENDING;
}
}
}Polling Strategy
| Parameter | Value | Why |
|---|---|---|
POLL_INTERVAL | 3 seconds | Fast enough for responsive UX, slow enough to avoid rate limits |
MAX_POLL_ATTEMPTS | 60 | 3 minutes total before timeout |
| Status callback | Optional | Lets the popup update its display per-poll |
Why polling instead of websockets? ForTem does not offer real-time status updates. Polling every 3 seconds is simple, reliable, and well within the API rate limits (1,000 requests/day for items). Learn more about efficient NFT game development.
Step 7: Orchestrating the Flow
The inventory scene connects all elements. It is where the mint button is created and where the complete minting lifecycle is managed:
// scenes/inventory.ts (relevant sections)
private showMintPopup(item: InventoryItem): void {
const { x: centerX, y: centerY } = this.getCenter();
const { width, height } = this.getSize();
// Dark overlay behind popup
this.overlay = this.add.graphics();
this.overlay.fillStyle(0x000000, 0.7);
this.overlay.fillRect(0, 0, width, height);
this.overlay.setInteractive(
new Phaser.Geom.Rectangle(0, 0, width, height),
Phaser.Geom.Rectangle.Contains
);
// Create multi-step popup
this.mintPopup = new NFTMintPopup(this, {
x: centerX,
y: centerY,
item,
walletAddress: authService.getWalletAddress(),
isLoggedIn: authService.isAuthenticated(),
onMint: (walletAddress) => this.handleMint(item, walletAddress),
onSaveWallet: async (address) => {
const result = await authService.saveWalletAddress(address);
return result.error === null;
},
onLogin: () => {
this.closeMintPopup();
this.scene.start('ProfileScene');
},
onClose: () => this.closeMintPopup(),
});
this.mintPopup.show();
}The handleMint Function
This function lies at the heart of the minting process. It coordinates calls to the Edge Function, manages status polling, and updates the popup:
private async handleMint(item: InventoryItem, walletAddress: string): Promise<void> {
const itemDef = ITEM_DEFINITIONS[item.itemType];
// Build image URL for NFT
let imageUrl: string | undefined;
if (item.skinData?.textureKey) {
const skinTypeMap: Record<string, string> = {
'paddles': 'paddle',
'balls': 'ball',
};
const skinType = skinTypeMap[item.skinData.textureKey];
if (skinType && item.rarity !== 'common') {
imageUrl = `/assets/nft/${skinType}-${item.rarity}.png`;
}
}
// Resolve to absolute URL for external services
if (imageUrl && !imageUrl.startsWith('http')) {
imageUrl = `${window.location.origin}${imageUrl}`;
}
// 1. Call Edge Function to start minting
const result = await fortemService.mintItem({
itemId: item.id,
itemType: item.itemType,
itemName: itemDef.name,
rarity: item.rarity,
description: itemDef.description,
recipientAddress: walletAddress,
attributes: [
{ name: 'Rarity', value: item.rarity },
{ name: 'Game', value: 'Puzzle Pocket' },
{ name: 'Type', value: itemDef.category },
],
imageUrl,
});
// 2. Handle immediate failure
if (!result.success || !result.nftMetadata) {
this.mintPopup?.setError(result.error ?? 'Minting failed');
return;
}
// 3. Poll for blockchain confirmation
const finalResult = await fortemService.waitForMintCompletion(
result.nftMetadata.redeemCode,
(status) => {
// Update popup with each status change
this.mintPopup?.updateMintingStatus(status);
}
);
// 4. Show final result
if (finalResult.success && finalResult.nftMetadata) {
this.mintPopup?.setMintResult(finalResult.nftMetadata);
} else {
this.mintPopup?.setError(finalResult.error ?? 'Minting failed');
}
}The workflow follows a linear progression: initiate minting, manage immediate errors, poll for completion, and present the final outcome. This avoids branching and intricate state management when minting NFTs.
Step 8: Creating the Mint Button
The mint button is displayed on eligible inventory items:
private createMintButton(x: number, y: number, item: InventoryItem): Phaser.GameObjects.Container {
const container = this.add.container(x, y);
const width = 70, height = 40;
// Purple background for NFT actions
const bg = this.add.graphics();
bg.fillStyle(0x9b59b6, 1);
bg.fillRoundedRect(-width / 2, -height / 2, width, height, 8);
container.add(bg);
const text = this.add.text(0, -6, 'MINT', {
fontSize: '12px', fontFamily: 'Righteous, Arial', color: '#ffffff',
}).setOrigin(0.5);
container.add(text);
const nftText = this.add.text(0, 8, 'NFT', {
fontSize: '10px', fontFamily: 'Righteous, Arial', color: '#ddbbff',
}).setOrigin(0.5);
container.add(nftText);
// Press animation
container.setInteractive(
new Phaser.Geom.Rectangle(-width / 2, -height / 2, width, height),
Phaser.Geom.Rectangle.Contains
);
container.on('pointerdown', () => {
this.tweens.add({
targets: container,
scaleX: 0.95, scaleY: 0.95,
duration: 50, yoyo: true,
onComplete: () => this.showMintPopup(item),
});
});
return container;
}Error Handling Patterns
Every external call carries the potential for failure. Each failure point should be addressed explicitly:
| Failure Point | User Sees | Recovery |
|---|---|---|
| No session | "Please login" | Login button |
| Invalid wallet address | Toast: "Invalid address" | Re-enter wallet |
| Edge Function error | Error screen | Retry button |
| ForTem API failure | Error screen | Retry button |
| Polling timeout (3 min) | Error: "Minting timeout" | Check ForTem marketplace |
| Network error | Error: "Network error" | Retry button |
The error screen includes a Retry button, allowing the player to return to the confirmation step without re-entering their wallet address when minting NFTs.
Making Wait Times Feel Acceptable
Blockchain wait times are inevitable. The following techniques can mitigate player frustration:
1. Set expectations early. The confirmation screen explicitly states: "This item will be minted as an NFT on the Sui blockchain via ForTem." This informs players about the blockchain involvement before they commit.
2. Show continuous feedback. The spinner animation updates at 50ms intervals (20fps). A static "Loading..." message can feel unresponsive after a few seconds, while a smooth animation signals ongoing system activity.
3. Display the current status. A "Status: PROCESSING" message reassures the player that the system is functioning. Update this text to reflect status changes during the polling process.
4. Provide an escape hatch. The close button remains functional even during minting. The minting process will complete in the background, and the player can check the ForTem marketplace later.
Deployment Checklist
- All six popup states render correctly
- Wallet input validates Sui address format (starts with
0x) - Spinner animates smoothly during minting
- Polling stops after success, failure, or timeout
- Error state shows meaningful messages
- Retry returns to confirm, not wallet input
- Close button works in every state
- Popup overlay blocks interaction with inventory behind it
What Comes Next
We have now established the complete minting pipeline, encompassing both the server-side Edge Function (Article 3) and the client-side UI (this article). Players can select items, enter wallet addresses, mint NFTs, and view the corresponding results.
Article 5 will detail the NFT Redemption Flow:
- Constructing the redeem popup UI (using a similar multi-step approach)
- Verifying that the NFT has been burned (
REDEEMEDstatus) - Granting in-game items based on redeemed NFTs
- Managing the complete marketplace cycle: mint, trade, redeem
The ultimate objective is to close the loop, enabling items to flow seamlessly between the game and the blockchain.
Try It Yourself
Before progressing to the next article:
- Build the Panel base class with show/close animations
- Implement the six popup states one at a time
- Test with the testnet Edge Function from Article 3
- Try the full flow: wallet input, confirm, mint, poll, success
Begin with the successful path and then progressively add error handling.
See you in Article 5.
This is Part 4 of the "Building an NFT Marketplace for Your Game" series. Next up: Building the NFT redemption flow.