NFT Redemption Flow: From Blockchain Back to Game

deps

deps

NFT Redemption Flow: From Blockchain Back to Game

Your player minted a rare Epic paddle as an NFT three weeks ago. Someone on the ForTem marketplace bought it for 5 SUI. The buyer now has a redeem code: PP-A7F-KXMQT-92BL. They type it into your game. What happens next?

Three hidden engineering problems immediately surface:

  1. NFT Redemption Verification: How do you verify the NFT was actually burned on-chain? A malicious user could submit a redeem code for an NFT that still exists on the blockchain, attempting to claim in-game benefits while retaining the tradeable asset.
  2. Original Minter vs. Marketplace Buyer: Is this the original minter reclaiming their item, or a marketplace buyer receiving something new? These are fundamentally different operations with different inventory implications.
  3. Granting the Correct Item: How do you grant the correct item with the right type, rarity, and skin variant? The blockchain stores string attributes. Your game uses TypeScript enums. Bridging these two worlds without crashes requires defensive mapping.

This is Part 5 of the "Building an NFT Marketplace for Your Game" series. We built server-side minting in Article 3 and its client-side UI in Article 4. Now we build the reverse: a burned NFT becomes a playable game item again. Learn how to implement NFT redemption in your game.

Why Redemption Is Harder Than Minting

Minting is a one-way push. You take a game item, package it as an NFT, and send it to the Sui blockchain. The flow is linear: validate, mint, poll, done. Redemption is the opposite, but it is not simply minting in reverse. Learn about NFT minting vs NFT redemption.

Redemption requires verification plus conditional branching. Before granting anything, you must confirm the NFT was burned. Then you must determine which of two fundamentally different scenarios applies. Only then can you modify the player's inventory. Discover the complexities of NFT game development.

The NFT status lifecycle indicates when redemption is valid:

PENDINGPROCESSINGMINTED → (OFFER_PENDING | KIOSK_LISTED) → REDEEMED

Only the REDEEMED status means the NFT has been burned and the item can be claimed. Every other status means the NFT still exists on-chain, and granting items for unburned NFTs would let players duplicate value. Ensure secure NFT integration in your game.

Here is a side-by-side comparison of the two directions:

AspectMinting (Articles 3-4)Redemption (This Article)
DirectionGame → BlockchainBlockchain → Game
VerificationWallet address onlyNFT burn status on-chain
Item handlingMark existing item as NFTRestore or create new item
ScenariosSingle pathTwo distinct paths
Failure modesAPI error, timeoutInvalid code, not burned, already redeemed

The single-path nature of minting made it straightforward. Redemption's branching logic is where the real complexity lies. Explore NFT marketplace development.

Architecture Overview

The redemption flow uses the same Mediator pattern established in Article 4: the popup never calls APIs directly. The scene orchestrates everything, keeping components decoupled and testable.

ComponentResponsibilityFile
RedeemPopup5-state popup UI, code input, feedbackui/redeem-popup.ts
ForTemServiceAPI call to Edge Function with action='redeem'core/fortem/fortem-service.ts
ProfileSceneOrchestrates flow, decides scenario, updates inventoryscenes/auth/profile-scene.ts
EconomyManagerFinds, restores, or creates inventory itemscore/economy/economy-manager.ts

One important difference from minting: redemption lives in ProfileScene, not InventoryScene. Redeem codes originate from the ForTem marketplace, outside the game entirely. A player receives a code via email, the ForTem website, or a friend's message. The profile screen, where players manage their account and external integrations, is the natural entry point for external codes. Enhance your game with NFT redeem codes.

Step 1: Define the Type System

Following the types-first approach from Article 4, we start by defining the data shapes that flow through the system. Here are the response types for the Edge Function call and the processed result the client works with:

// core/fortem/types.ts
 
export interface RedeemItemResponse {
  success: boolean;
  data?: {
    id: number;
    name: string;
    description: string;
    attributes: Array<{ name: string; value: string }>;
    status: string;
    itemImage: string;
    nftNumber: number;
  };
  error?: string;
}
 
export interface RedeemResult {
  success: boolean;
  item?: {
    name: string;
    itemType: string;
    rarity: string;
  };
  error?: string;
}

And the popup's own types for managing its internal state:

// ui/redeem-popup.ts
 
export type RedeemPopupStep =
  | 'input'
  | 'verifying'
  | 'success'
  | 'error'
  | 'login_required';
 
export interface RedeemedItemInfo {
  name: string;
  itemType: string;
  rarity: string;
}

The minting popup from Article 4 had 6 states. Redemption needs only 5; there is no wallet_input or confirm step. The player enters a code, and we verify it. No wallet address is needed because the NFT has already been burned; there is no on-chain transaction to send. Streamline NFT game UX.

PopupStatesWhy
NFTMintPopup (Art. 4)6: login, wallet, confirm, minting, success, errorNeeds wallet address + player confirmation
RedeemPopup (Art. 5)5: login, input, verifying, success, errorCode input only, no wallet needed

Step 2: The Edge Function — Verifying the Burn

The redeemNFT() function was introduced in Article 3's Edge Function. Let's examine it in full detail now that we understand why every check matters. Authentication reuses the same nonce-based flow from Article 3; each API call gets a fresh access token.

// supabase/functions/fortem-mint/index.ts
 
async function redeemNFT(redeemCode: string): Promise<Response> {
  if (!redeemCode) {
    return new Response(
      JSON.stringify({ success: false, error: 'Missing redeemCode' }),
      { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    );
  }
 
  try {
    const accessToken = await getAccessToken();
 
    const statusResponse = await fetch(
      `${FORTEM_API_URL}/api/v1/developers/collections/${FORTEM_COLLECTION_ID}/items/${redeemCode}`,
      {
        headers: {
          'Authorization': `Bearer ${accessToken}`,
          'Content-Type': 'application/json',
        },
      }
    );
 
    if (!statusResponse.ok) {
      if (statusResponse.status === 404) {
        return new Response(
          JSON.stringify({ success: false, error: 'Item not found' }),
          { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
        );
      }
      return new Response(
        JSON.stringify({ success: false, error: `Failed to verify redeem code: ${statusResponse.status}` }),
        { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      );
    }
 
    const itemData = await statusResponse.json();
    const status = itemData.data?.status;
 
    // The critical check: only REDEEMED items can be claimed
    if (status !== 'REDEEMED') {
      return new Response(
        JSON.stringify({
          success: false,
          error: status === 'MINTED' || status === 'KIOSK_LISTED'
            ? 'Item not yet redeemed on ForTem. Please redeem it on the ForTem marketplace first.'
            : `Item status is ${status}. Only REDEEMED items can be claimed.`,
        }),
        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      );
    }
 
    // Return full item data for the client to grant in-game
    return new Response(
      JSON.stringify({
        success: true,
        data: {
          id: itemData.data?.id,
          name: itemData.data?.name,
          description: itemData.data?.description,
          attributes: itemData.data?.attributes ?? [],
          status: itemData.data?.status,
          itemImage: itemData.data?.itemImage,
          nftNumber: itemData.data?.nftNumber,
        },
      }),
      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    );
  } catch (error) {
    console.error('Redeem verification error:', error);
    return new Response(
      JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }),
      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    );
  }
}

The function returns item attributes (Type, Rarity) in the response. This is what makes the dual-scenario handling possible on the client side. The blockchain becomes the source of truth for item identity, not the game's local inventory. Learn about secure blockchain integration.

Step 3: Building the Redeem Popup

The RedeemPopup follows the same architecture as the minting popup: a Panel subclass that manages its own rendering, with public methods the scene uses to control state transitions.

// ui/redeem-popup.ts
 
export class RedeemPopup extends Panel {
  private currentStep: RedeemPopupStep;
  private contentElements: GameObjects.GameObject[] = [];
  private redeemCodeInput?: string;
  private redeemedItem?: RedeemedItemInfo;
  private errorMessage?: string;
  private spinnerAngle: number = 0;
  private spinnerTimer?: Phaser.Time.TimerEvent;
 
  constructor(scene: Scene, popupConfig: RedeemPopupConfig) {
    const width = popupConfig.width ?? 380;
    const height = popupConfig.height ?? 400;
 
    super(scene, {
      x: popupConfig.x,
      y: popupConfig.y,
      width,
      height,
      title: '',
      showCloseButton: true,
      onClose: popupConfig.onClose,
    });
 
    this.popupConfig = popupConfig;
 
    if (!popupConfig.isLoggedIn) {
      this.currentStep = 'login_required';
    } else {
      this.currentStep = 'input';
    }
 
    this.renderCurrentStep();
  }
 
  private renderCurrentStep(): void {
    this.clearContent();
    switch (this.currentStep) {
      case 'login_required': this.renderLoginRequired(); break;
      case 'input':          this.renderInput();         break;
      case 'verifying':      this.renderVerifying();     break;
      case 'success':        this.renderSuccess();       break;
      case 'error':          this.renderError();         break;
    }
  }
 
  // Public methods for external state control
  setStep(step: RedeemPopupStep): void {
    this.currentStep = step;
    this.renderCurrentStep();
  }
 
  setSuccess(item: RedeemedItemInfo): void {
    this.redeemedItem = item;
    this.currentStep = 'success';
    this.renderCurrentStep();
  }
 
  setError(message: string): void {
    this.errorMessage = message;
    this.currentStep = 'error';
    this.renderCurrentStep();
  }
}

Same pattern as the mint popup: expose setStep(), setSuccess(), and setError() so the scene controls state transitions. The popup renders; the scene decides. Build engaging NFT game interfaces.

Step 4: Redeem Code Input

The input step is where the player types their ForTem redeem code. Here is the rendering logic:

// ui/redeem-popup.ts
 
private renderInput(): void {
  const title = this.scene.add.text(0, 5, 'Redeem NFT', {
    ...FONT_STYLE.HEADING,
    fontSize: '24px',
  }).setOrigin(0.5);
  this.addContent(title);
  this.contentElements.push(title);
 
  const giftIcon = this.scene.add.text(0, 70, '\u{1F381}', {
    fontSize: '48px',
  }).setOrigin(0.5);
  this.addContent(giftIcon);
  this.contentElements.push(giftIcon);
 
  const message = this.scene.add.text(0, 125, 'Enter your ForTem redeem code:', {
    ...FONT_STYLE.SMALL,
    fontSize: '14px',
    color: COLORS_HEX.TEXT_SECONDARY,
    align: 'center',
  }).setOrigin(0.5);
  this.addContent(message);
  this.contentElements.push(message);
 
  // Visual input field
  const inputWidth = 300;
  const inputY = 175;
 
  const inputBg = this.scene.add.graphics();
  inputBg.fillStyle(0x1a1a2e, 1);
  inputBg.fillRoundedRect(-inputWidth / 2, inputY - 26, inputWidth, 52, 10);
  inputBg.lineStyle(2, COLORS.PRIMARY, 0.6);
  inputBg.strokeRoundedRect(-inputWidth / 2, inputY - 26, inputWidth, 52, 10);
  this.addContent(inputBg);
  this.contentElements.push(inputBg);
 
  this.redeemCodeInput = '';
  const inputText = this.scene.add.text(-inputWidth / 2 + 15, inputY, 'PP-XXX-XXXXX-XXXX', {
    ...FONT_STYLE.BODY,
    fontSize: '15px',
    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, 52, 0x000000, 0);
  inputArea.setInteractive({ useHandCursor: true });
  inputArea.on('pointerdown', () => {
    const code = prompt('Enter your ForTem redeem code:');
    if (code) {
      this.redeemCodeInput = code.trim().toUpperCase();
      inputText.setText(this.redeemCodeInput || 'PP-XXX-XXXXX-XXXX');
      inputText.setColor(this.redeemCodeInput ? COLORS_HEX.TEXT : '#555555');
    }
  });
  this.addContent(inputArea);
  this.contentElements.push(inputArea);
 
  // Redeem button with validation
  const redeemBtn = new Button(this.scene, {
    x: 75,
    y: 285,
    width: 130,
    height: 48,
    text: 'REDEEM',
    fontSize: '18px',
    color: COLORS.SUCCESS,
    onClick: () => {
      if (!this.redeemCodeInput || this.redeemCodeInput.length < 5) {
        this.showToast('Please enter a valid redeem code');
        return;
      }
      this.currentStep = 'verifying';
      this.renderCurrentStep();
      this.popupConfig.onRedeem(this.redeemCodeInput);
    },
  });
  this.addContent(redeemBtn);
  this.contentElements.push(redeemBtn);
}

The input automatically uppercases the code. Redeem codes follow the format PP-{TYPE}-{TIMESTAMP}-{RANDOM}, always uppercase. A minimal validation check (length < 5) catches empty submissions without being overly strict about format; the Edge Function handles real validation. Implement user-friendly NFT code redemption.

Reusing window.prompt() from the minting popup (Article 4). Same pragmatic choice: Phaser has no native text input, and players only enter a redeem code once per redemption. A custom input component would add hundreds of lines of complexity for a single-use interaction.

Step 5: The ForTem Service Client

The client-side service method that calls our Edge Function:

// core/fortem/fortem-service.ts
 
async redeemItem(redeemCode: string): Promise<RedeemResult> {
  const supabase = getSupabase();
  if (!supabase) {
    return { success: false, error: 'Supabase not configured' };
  }
 
  try {
    const { data: { session } } = await supabase.auth.getSession();
    if (!session) {
      return { success: false, error: 'Please login to redeem NFTs' };
    }
 
    const { data, error } = await supabase.functions.invoke<RedeemItemResponse>(
      'fortem-mint',
      {
        body: {
          action: 'redeem',
          redeemCode,
        },
      }
    );
 
    if (error) {
      return { success: false, error: error.message ?? 'Unknown error' };
    }
 
    if (!data?.success || !data.data) {
      return { success: false, error: data?.error ?? 'Unknown error' };
    }
 
    // Parse item identity from NFT attributes
    const attributes = data.data.attributes ?? [];
    const typeAttr = attributes.find(a => a.name === 'Type');
    const rarityAttr = attributes.find(a => a.name === 'Rarity');
 
    return {
      success: true,
      item: {
        name: data.data.name,
        itemType: typeAttr?.value ?? 'UNKNOWN',
        rarity: rarityAttr?.value ?? 'Common',
      },
    };
  } catch (err) {
    const message = err instanceof Error ? err.message : 'Network error';
    return { success: false, error: message };
  }
}

Notice the key difference from minting: single call, no polling. The minting flow required polling because the blockchain transaction took time to process. Redemption verification is instantaneous; the NFT was already burned before the player received their code. We just confirm the REDEEMED status and extract item identity from attributes. Optimize your game with efficient NFT APIs.

OperationAPI CallsPattern
Minting (Art. 4)1 mint + N status pollsFire-and-poll
Redemption (Art. 5)1 redeem verificationSingle request-response

Step 6: The Two Redemption Scenarios

This is the heart of the article. Two players can enter the same format of redeem code, but the game must handle their situations completely differently. Master NFT redemption logic for your game.

Scenario 1: Own Item Reclaim. Alice mints her Epic Paddle as an NFT. Nobody buys it on the ForTem marketplace. She decides she wants it back in-game, so she redeems it. The item already exists in her inventory, marked with isNFT: true. We need to "un-NFT" it, clear the NFT metadata, and restore it as a normal game item.

Scenario 2: Marketplace Purchase. Alice mints her Epic Paddle. Bob sees it on ForTem and buys it for 5 SUI. Bob enters the redeem code in his game client. He does NOT have this item in his inventory. We need to create a brand new inventory item with the correct type, rarity, and a randomly assigned skin variant.

Here is the orchestration method that decides between these two paths:

// scenes/auth/profile-scene.ts
 
private async handleRedeem(redeemCode: string): Promise<void> {
  EventBus.emit(GameEvents.NFT_REDEEM_STARTED, { redeemCode });
 
  // Critical: sync cloud data first to find user's own minted items
  if (syncService.isCloudSyncAvailable()) {
    await syncService.syncOnLogin();
  }
 
  // Check if this is user's own minted item
  const existingItem = economyManager.findItemByRedeemCode(redeemCode);
 
  // Only check marketplace redeems for double-redemption
  if (!existingItem && economyManager.isAlreadyRedeemed(redeemCode)) {
    this.redeemPopup?.setError('This code has already been redeemed');
    EventBus.emit(GameEvents.NFT_REDEEM_FAILED, { redeemCode, error: 'Already redeemed' });
    return;
  }
 
  // Verify with ForTem that the NFT was burned
  const result = await fortemService.redeemItem(redeemCode);
 
  if (!result.success || !result.item) {
    this.redeemPopup?.setError(result.error ?? 'Failed to redeem');
    EventBus.emit(GameEvents.NFT_REDEEM_FAILED, { redeemCode, error: result.error });
    return;
  }
 
  if (existingItem) {
    // Scenario 1: Restore user's own minted item
    economyManager.restoreRedeemedItem(redeemCode);
  } else {
    // Scenario 2: Add new item from marketplace purchase
    const itemTypeMap: Record<string, ItemType> = {
      'SKIN': ItemType.PADDLE_SKIN,
      'paddle': ItemType.PADDLE_SKIN,
      'PADDLE_SKIN': ItemType.PADDLE_SKIN,
      'ball': ItemType.BALL_SKIN,
      'BALL_SKIN': ItemType.BALL_SKIN,
      'COLLECTIBLE': ItemType.BADGE,
      'BADGE': ItemType.BADGE,
    };
 
    const rarityMap: Record<string, Rarity> = {
      'Common': Rarity.COMMON,
      'COMMON': Rarity.COMMON,
      'Uncommon': Rarity.UNCOMMON,
      'UNCOMMON': Rarity.UNCOMMON,
      'Rare': Rarity.RARE,
      'RARE': Rarity.RARE,
      'Epic': Rarity.EPIC,
      'EPIC': Rarity.EPIC,
      'Legendary': Rarity.LEGENDARY,
      'LEGENDARY': Rarity.LEGENDARY,
    };
 
    const itemType = itemTypeMap[result.item.itemType] ?? ItemType.BADGE;
    const rarity = rarityMap[result.item.rarity] ?? Rarity.COMMON;
 
    economyManager.addRedeemedItem(itemType, rarity, redeemCode);
  }
 
  // Show success
  this.redeemPopup?.setSuccess({
    name: result.item.name,
    itemType: result.item.itemType,
    rarity: result.item.rarity,
  });
 
  EventBus.emit(GameEvents.NFT_REDEEM_SUCCESS, {
    redeemCode,
    item: result.item,
  });
}

The decision tree, visualized:

# Decision Tree
# Enter redeem code
#   │
#   ▼
# Sync cloud data (critical!)
#   │
#   ▼
# findItemByRedeemCode(code)
#   │
#   ├─ Found ──▶ User's OWN minted item
#   │            Verify REDEEMED on ForTem
#   │            restoreRedeemedItem() → isNFT = false
#   │
#   └─ Not found ──▶ MARKETPLACE purchase
#                    isAlreadyRedeemed()? → Error
#                    Verify REDEEMED on ForTem
#                    addRedeemedItem() → new item + random skin

Two subtleties deserve attention. First, cloud sync must run before the redeem code lookup. Without syncing, a player who minted on Device A and redeems on Device B would incorrectly trigger Scenario 2 instead of Scenario 1, creating a duplicate item. Second, the double-redemption check only applies to marketplace purchases. A player reclaiming their own item should always succeed (the item already exists; we're just clearing its NFT flag). Prevent NFT duplication exploits.

Step 7: The Inventory Methods

Four methods in EconomyManager handle the actual inventory operations. Each method corresponds to a specific point in the decision tree above.

// core/economy/economy-manager.ts
 
// Scenario 1: Find the original minted item
findItemByRedeemCode(redeemCode: string): InventoryItem | null {
  const normalizedCode = redeemCode.toUpperCase();
  return this.data.inventory.find(
    item => item.nftMetadata?.redeemCode?.toUpperCase() === normalizedCode
  ) ?? null;
}
 
// Scenario 1: "Un-NFT" the item
restoreRedeemedItem(redeemCode: string): InventoryItem | null {
  const item = this.findItemByRedeemCode(redeemCode);
  if (!item) return null;
 
  item.isNFT = false;
  delete item.nftId;
  delete item.nftMetadata;
 
  this.save();
  return item;
}
 
// Prevent double-redemption for marketplace purchases
isAlreadyRedeemed(redeemCode: string): boolean {
  const normalizedCode = redeemCode.toUpperCase();
  return this.data.inventory.some(item => {
    const redeemedFrom = item.metadata?.redeemedFrom as string | undefined;
    return redeemedFrom?.toUpperCase() === normalizedCode;
  });
}
 
// Scenario 2: Create a new item from marketplace purchase
addRedeemedItem(
  itemType: ItemType,
  rarity: Rarity,
  redeemCode: string
): InventoryItem {
  const definition = ITEM_DEFINITIONS[itemType];
 
  const newItem: InventoryItem = {
    id: this.generateItemId(),
    itemType,
    rarity,
    quantity: 1,
    acquiredAt: Date.now(),
    isNFT: false, // Redeemed items are not NFTs (the NFT was burned)
    metadata: {
      redeemedFrom: redeemCode,
    },
  };
 
  // Assign random skin frame if applicable
  if (definition.skinVariants) {
    const usePremium =
      definition.skinVariants.premiumFrames &&
      definition.skinVariants.premiumFrames.frames[rarity]?.length > 0 &&
      Math.random() > 0.5;
 
    const source = usePremium
      ? definition.skinVariants.premiumFrames!
      : { textureKey: definition.skinVariants.textureKey, frames: definition.skinVariants.frames };
 
    const frames = source.frames[rarity];
    if (frames && frames.length > 0) {
      const randomFrame = frames[Math.floor(Math.random() * frames.length)]!;
      newItem.skinData = {
        textureKey: source.textureKey,
        frame: randomFrame,
      };
    }
  }
 
  this.data.inventory.push(newItem);
  this.save();
  return newItem;
}

Notice isNFT: false on the new item. This is deliberate. The NFT was burned on the blockchain during redemption. The resulting game item is a regular inventory item; it can be played with, equipped, and even minted again as a new NFT later. The metadata.redeemedFrom field tracks provenance and prevents the same code from being redeemed twice. Secure your game's NFT inventory system.

Design Decision: Why assign a random skin variant to marketplace purchases? The original minter selected a specific visual through gameplay progression. A marketplace buyer is purchasing the item's type and rarity, not its exact appearance. Random assignment makes each redemption feel unique while preserving the economic value that rarity provides. Enhance your game with unique NFT rewards.

The Enum Mapping Problem

Look at those itemTypeMap and rarityMap objects in the orchestration code. They exist because of a fundamental tension: the ForTem API returns string attributes, but your game uses TypeScript enums.

ForTem Attribute ValueGame EnumIssue
"SKIN"ItemType.PADDLE_SKINDifferent naming convention
"paddle"ItemType.PADDLE_SKINLowercase variant
"PADDLE_SKIN"ItemType.PADDLE_SKINExact match
"Epic"Rarity.EPICTitle case vs uppercase
"EPIC"Rarity.EPICExact match

Case variations are a real bug vector. During development, the ForTem API returned "Epic" for rarity. After a backend update, it started returning "EPIC". Without the mapping table, the game would have defaulted every item to Rarity.COMMON, silently downgrading Epic items. Avoid common NFT integration errors.

The defensive defaults (?? ItemType.BADGE and ?? Rarity.COMMON) ensure the game never crashes on an unexpected attribute value. A Common Badge is wrong, but a crash is worse. In production, you would add logging here to catch unexpected values early.

Integration Lesson: When bridging external APIs to internal type systems, always map at the boundary. Never let external strings propagate through your codebase. Define the mapping in one place, add defensive defaults, and log unexpected values. Ensure seamless NFT game integration.

Error Handling Patterns

Every failure point in the redemption flow has a specific, actionable error message. The player should always know what to do next.

Failure PointUser SeesRecovery
Not logged in"Login Required" screenLogin button
Empty/short codeToast: "Please enter a valid redeem code"Re-enter code
Code not found (404)"Item not found"Check code, try again
NFT still MINTED/KIOSK_LISTED"Please redeem on ForTem marketplace first"Go to ForTem
Already redeemed (marketplace)"This code has already been redeemed"N/A
Network/API errorGeneric error screenRetry button

The most important row is the fourth one. A player who enters a valid redeem code for an NFT that hasn't been burned yet needs to know where to go to complete the burn. "Please redeem on ForTem marketplace first" directs them back to the ForTem platform. Compare this with a generic "Invalid code" message, which would leave them confused. Improve NFT game player experience.

The Complete Marketplace Cycle

Let's zoom out. With Article 5 complete, the full NFT marketplace loop is now functional:

# [In-Game Item] → Mint (Art. 3-4) → [NFT on Sui] → Trade on ForTem → Burn/Redeem → Redeem Code (Art. 5) → [In-Game Item]

Each article contributed a specific piece to this cycle:

ArticleWhat It BuiltRole in the Cycle
1Vision and architectureDefined the loop
2Item economy and eligibilityWhich items can enter the loop
3Edge Function (mint + status + redeem)Server-side infrastructure
4Minting UIGame → Blockchain direction
5Redemption flowBlockchain → Game direction

The circle is now complete. An item can leave the game, trade hands on the blockchain, and return to a different player's inventory, all verified, type-safe, and with proper error handling at every step. Build a fully functional NFT game economy.

Deployment Checklist

  • All five popup states render correctly
  • Redeem code input accepts and uppercases PP-XXX-XXXXX-XXXX format
  • Spinner animates during verification
  • Own-minted items are correctly restored (isNFT reset, metadata cleared)
  • Marketplace purchases create new items with correct type and rarity
  • Double-redemption is prevented for marketplace purchases
  • Cloud sync runs before redeem code lookup
  • Error messages are actionable (not generic)
  • Event bus emits all three redeem events (STARTED, SUCCESS, FAILED)
  • Close button works in every state

What Comes Next

The core NFT marketplace loop is now complete. Items can flow out of the game and back in. But there are still production concerns we haven't addressed:

  • Article 6: Marketplace Integration — listing workflows, purchase verification, and real-time inventory updates when trades happen outside the game.
  • Article 7: Production Lessons — security hardening, scaling considerations, and the mistakes I made along the way that you can avoid.

Try It Yourself

  1. Build the RedeemPopup with 5 states; start with input and success.
  2. Wire up the ForTem redeem API call through ForTemService.
  3. Test Scenario 1: Mint an item, then redeem it back and verify isNFT resets.
  4. Test Scenario 2: Use a redeem code from a different account and verify a new item is created.
  5. Verify double-redemption prevention works by submitting the same marketplace code twice.

Start with the happy path and add error handling incrementally. The dual-scenario logic in handleRedeem is the critical piece; get that right first, then polish the UI states.


*This is Part 5 of the "Building an NFT Marketplace for Your Game" series. Next up: Marketplace integration and