Multi-Step Minting UI in Phaser

deps

deps

Multi-Step Minting UI in Phaser

"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:

StepWhat the Player SeesWhat's Happening
login_required"Please log in"No active session detected
wallet_inputWallet address formPlayer enters Sui wallet
confirmItem preview + "Mint"Player reviews before committing
mintingAnimated spinnerEdge Function called, polling for status
successCheckmark + redeem codeNFT minted on Sui blockchain
errorError message + retrySomething 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:

ComponentResponsibilityFile
NFTMintPopupMulti-step popup UI, renders each stateui/nft-mint-popup.ts
ForTemServiceAPI calls to Edge Function, status pollingcore/fortem/fortem-service.ts
InventorySceneOrchestrates the flow between componentsscenes/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

ParameterValueWhy
POLL_INTERVAL3 secondsFast enough for responsive UX, slow enough to avoid rate limits
MAX_POLL_ATTEMPTS603 minutes total before timeout
Status callbackOptionalLets 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 PointUser SeesRecovery
No session"Please login"Login button
Invalid wallet addressToast: "Invalid address"Re-enter wallet
Edge Function errorError screenRetry button
ForTem API failureError screenRetry button
Polling timeout (3 min)Error: "Minting timeout"Check ForTem marketplace
Network errorError: "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 (REDEEMED status)
  • 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:

  1. Build the Panel base class with show/close animations
  2. Implement the six popup states one at a time
  3. Test with the testnet Edge Function from Article 3
  4. 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.