Skip to main content
This tutorial will guide you through building a high-frequency on-chain game on your Move appchain. BlockForge is a crafting engine that demonstrates Invisible UX: allowing players to interact with the blockchain seamlessly without constant wallet popups. By the end of this tutorial, you will have instructed your AI agent to:
  • Generate and verify a Move smart contract for the game logic.
  • Deploy the contract to your live appchain.
  • Scaffold and connect a React frontend for players to interact with the game.
  • Verify the on-chain functionality.

Your Project Structure

The following steps will instruct your AI agent to create these directories inside your my-initia-project folder:
Project Structure
my-initia-project/
├── blockforge/           # Move smart contract project
└── blockforge-frontend/  # React frontend application
Prerequisite: Ensure you have a Move-compatible appchain running locally. If you haven’t launched one yet, complete the Step-by-Step Guide first.

Readiness Check

Before you start, verify that your local infrastructure is healthy. Prompt:
Using the `initia-appchain-dev` skill, please verify that my appchain, executor bot, and relayer are running and that my Gas Station account has a balance.

Step 1: Create and Unit Test the Smart Contract

Instead of writing the code yourself, instruct your AI agent to do it for you using the initia-appchain-dev skill. Your AI agent will generate the contract and automatically run unit tests to ensure the logic is sound. Example Prompt:
Using the `initia-appchain-dev` skill, please create a Move module project for our BlockForge game in a new directory named `blockforge`. Name the module `items` in the `blockforge` package. The game has the following rules:
- Players can mint basic items called shards.
- Players can craft relics by burning 2 shards.
- Players should have an inventory to store their items.
- I need a way to view a player's inventory.
Please also create and run unit tests to verify these rules.
Your AI agent will generate the blockforge project, including the items.move module and a test script, and confirm that everything passes.
If you prefer to create the Move module and project manually, follow these steps in your blockforge directory. This is what your AI agent would generate for you.First, create a new Move package named blockforge:
Create Move Package
mkdir -p blockforge
cd blockforge
minitiad move new blockforge
minitiad move new blockforge creates a package named blockforge in the current working directory. Creating and entering the blockforge/ directory first keeps the generated package in the expected place.
Now, update the Move.toml file to use the Initia stdlib dependency directly. Replace the content of blockforge/Move.toml with the following:
Move.toml
[package]
name = "blockforge"
version = "0.0.1"

[dependencies]
InitiaStdlib = { git = "https://github.com/initia-labs/movevm.git", subdir = "precompile/modules/initia_stdlib", rev = "main" }

[addresses]
blockforge = "_" # Supply via --named-addresses during build/test. Replace with the deployer hex before publishing if needed.
std = "0x1"
Next, create blockforge/sources/items.move with the following content.
sources/items.move
module blockforge::items {
    use std::error;
    use std::signer;

    const E_INSUFFICIENT_SHARDS: u64 = 1;
    const E_INVALID_INVENTORY_STATE: u64 = 2;

    struct Inventory has key {
        shards: u64,
        relics: u64,
    }

    struct InventoryView has copy, drop, store {
        shards: u64,
        relics: u64,
    }

    fun ensure_inventory(account: &signer) {
        let addr = signer::address_of(account);
        if (!exists<Inventory>(addr)) {
            move_to(account, Inventory { shards: 0, relics: 0 });
        };
    }

    public entry fun mint_shard(account: &signer) acquires Inventory {
        ensure_inventory(account);

        let inventory = borrow_global_mut<Inventory>(signer::address_of(account));
        inventory.shards = inventory.shards + 1;
    }

    public entry fun craft_relic(account: &signer) acquires Inventory {
        ensure_inventory(account);

        let inventory = borrow_global_mut<Inventory>(signer::address_of(account));
        assert!(inventory.shards >= 2, error::invalid_argument(E_INSUFFICIENT_SHARDS));

        inventory.shards = inventory.shards - 2;
        inventory.relics = inventory.relics + 1;
    }

    #[view]
    public fun inventory_of(addr: address): InventoryView acquires Inventory {
        if (!exists<Inventory>(addr)) {
            return InventoryView { shards: 0, relics: 0 }
        };

        let inventory = borrow_global<Inventory>(addr);
        InventoryView {
            shards: inventory.shards,
            relics: inventory.relics,
        }
    }

    #[view]
    public fun shard_count(addr: address): u64 acquires Inventory {
        inventory_of(addr).shards
    }

    #[view]
    public fun relic_count(addr: address): u64 acquires Inventory {
        inventory_of(addr).relics
    }
}
Add the following unit tests inside the same module blockforge::items { ... } block, immediately before the final closing } of items.move, so you can verify minting, crafting, and the insufficient-shards failure path:
Unit Tests
    #[test(account = @blockforge)]
    fun test_mint_shard_creates_inventory(account: &signer) acquires Inventory {
        mint_shard(account);

        let inventory = inventory_of(signer::address_of(account));
        assert!(inventory.shards == 1, E_INVALID_INVENTORY_STATE);
        assert!(inventory.relics == 0, E_INVALID_INVENTORY_STATE);
    }

    #[test(account = @blockforge)]
    fun test_craft_relic_burns_two_shards(account: &signer) acquires Inventory {
        mint_shard(account);
        mint_shard(account);
        craft_relic(account);

        let inventory = inventory_of(signer::address_of(account));
        assert!(inventory.shards == 0, E_INVALID_INVENTORY_STATE);
        assert!(inventory.relics == 1, E_INVALID_INVENTORY_STATE);
    }

    #[test(account = @blockforge)]
    #[expected_failure(abort_code = 0x10001, location = blockforge::items)]
    fun test_craft_relic_requires_two_shards(account: &signer) acquires Inventory {
        mint_shard(account);
        craft_relic(account);
    }
Once the files are created, verify everything is correct by building and testing the project. Replace <YOUR_HEX_ADDRESS_WITH_0X_PREFIX> with your deployment address hex (0x...).
Build Move Package
minitiad move build --language-version=2.1 --named-addresses blockforge=<YOUR_HEX_ADDRESS_WITH_0X_PREFIX>
minitiad move test --language-version=2.1 --named-addresses blockforge=<YOUR_HEX_ADDRESS_WITH_0X_PREFIX>
If the build succeeds and the tests pass, BlockForge is ready to deploy.

Step 2: Deploy to your Appchain

Now that the logic is verified, build and publish the contract to your live appchain using the Gas Station account. Prompt:
Using the `initia-appchain-dev` skill, please build and publish the blockforge Move module located in the `blockforge` directory to my appchain using my Gas Station account, then return the deployed module address.
First, get your Gas Station address and convert it to hex:
Get Gas Station Hex Address
GAS_STATION_BECH32=$(minitiad keys show gas-station -a --keyring-backend test)
minitiad keys parse "$GAS_STATION_BECH32"
Then, build and publish the compiled module to your appchain. Substitute <YOUR_HEX_ADDRESS_WITH_0X_PREFIX> with 0x + the bytes value from the previous command.
Publish Move Module
cd blockforge

# 1. Update Move.toml so [addresses].blockforge matches your deployer hex address.
# 2. Build the module with the correct hex named address and version.
minitiad move build --language-version=2.1 --named-addresses blockforge=<YOUR_HEX_ADDRESS_WITH_0X_PREFIX>

# 3. Deploy the package.
minitiad move deploy --build \
  --language-version=2.1 \
  --named-addresses blockforge=<YOUR_HEX_ADDRESS_WITH_0X_PREFIX> \
  --from gas-station \
  --keyring-backend test \
  --chain-id <YOUR_APPCHAIN_ID> \
  --gas auto --gas-adjustment 1.4 --yes
Move modules do not have a separate instantiate transaction. For this tutorial, the first gameplay call (for example mint_shard) initializes per-player inventory state on demand.
If you are redeploying from the same account, Initia enforces backward compatibility for the existing module. Preserve public function signatures and public struct abilities, or rename the module before republishing. If you see BACKWARD_INCOMPATIBLE_MODULE_UPDATE, this account already has a non-compatible prior version of the module. Use a fresh funded deployer account on the same chain, update blockforge named-address values to its hex, and deploy with --from <FRESH_ACCOUNT>.

Step 3: Smoke Test the Deployed Contract On-Chain

Before frontend work, smoke test the deployed module directly on chain. This keeps contract/module debugging separate from UI integration. Example Prompt:
Using the `initia-appchain-dev` skill, I want to smoke test our live BlockForge game. Using my Gas Station account on my appchain, please:
1. Mint 3 shards.
2. Check my inventory.
3. Craft a relic.
4. Check my inventory again.
Here are the equivalent minitiad commands to interact with the module.
Mint 3 Shards
for i in {1..3}; do
  minitiad tx move execute <YOUR_MODULE_ADDRESS> items mint_shard \
    --from gas-station \
    --keyring-backend test \
    --chain-id <YOUR_APPCHAIN_ID> \
    --gas auto --gas-adjustment 1.4 --yes \
    --args '[]' --type-args '[]'
  sleep 2
done
Query Inventory
minitiad query move resource <YOUR_WALLET_ADDRESS> <YOUR_MODULE_HEX_ADDRESS>::items::Inventory
Craft Relic
minitiad tx move execute <YOUR_MODULE_ADDRESS> items craft_relic \
  --from gas-station \
  --keyring-backend test \
  --chain-id <YOUR_APPCHAIN_ID> \
  --gas auto --gas-adjustment 1.4 --yes \
  --args '[]' --type-args '[]'
Query Inventory Again
minitiad query move resource <YOUR_WALLET_ADDRESS> <YOUR_MODULE_HEX_ADDRESS>::items::Inventory

Step 4: Create a Frontend

A game needs a user interface. Let’s create one using the initia-appchain-dev skill. 1. Scaffold the Frontend:
Using the `initia-appchain-dev` skill, please scaffold a new Vite + React application named `blockforge-frontend` in my current directory using the `scaffold-frontend` script. Then, create a component named Game.jsx with Mint Shard and Craft Relic buttons and add it to the main App.jsx file.
2. Connect to Appchain:
Using the `initia-appchain-dev` skill, modify the Game.jsx component in the `blockforge-frontend` directory to connect to our blockforge contract on my appchain. Use the `@initia/interwovenkit-react` package for wallet connection and transaction signing.

The Mint Shard button should call the mint_shard function, and the Craft Relic button should call the craft_relic function. Also, please display the player's current inventory of shards and relics.
If you prefer to set up the frontend manually, follow these steps:1. Create the Project and Install Dependencies:Create a new Vite + React app and install the dependencies used by the working BlockForge frontend:
Create Frontend Project
npm create vite@latest blockforge-frontend -- --template react
cd blockforge-frontend
npm install
npm install @initia/initia.js @initia/interwovenkit-react @tanstack/react-query wagmi buffer util
npm install -D vite-plugin-node-polyfills
Then update vite.config.js so browser builds have the required Node polyfills:
vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { nodePolyfills } from 'vite-plugin-node-polyfills'

export default defineConfig({
  plugins: [
    react(),
    nodePolyfills({
      globals: {
        Buffer: true,
        process: true,
      },
    }),
  ],
})
2. Gather Runtime Values for Frontend Config:Collect the values you will use for frontend configuration:
Gather Frontend Values
APPCHAIN_ID=$(curl -s http://localhost:26657/status | jq -r '.result.node_info.network')
NATIVE_DENOM=$(minitiad q bank denoms-metadata --output json | jq -r '.metadatas[0].base // empty')
[ -z "$NATIVE_DENOM" ] && NATIVE_DENOM=$(minitiad q bank total --output json | jq -r '.supply[0].denom')
NATIVE_SYMBOL=$(minitiad q bank denoms-metadata --output json | jq -r '.metadatas[0].symbol // empty')
[ -z "$NATIVE_SYMBOL" ] && NATIVE_SYMBOL="$NATIVE_DENOM"

# By default this tutorial deploys from gas-station, so module owner = gas-station.
# If you used the fresh account fallback in Step 2, query that deployer instead.
BLOCKFORGE_MODULE_ADDRESS=$(minitiad keys show gas-station -a --keyring-backend test)

echo "APPCHAIN_ID=$APPCHAIN_ID"
echo "NATIVE_DENOM=$NATIVE_DENOM"
echo "NATIVE_SYMBOL=$NATIVE_SYMBOL"
echo "BLOCKFORGE_MODULE_ADDRESS=$BLOCKFORGE_MODULE_ADDRESS"
3. Create .env from Runtime Values:
Create Frontend Env
cat > .env <<EOF
VITE_APPCHAIN_ID=$APPCHAIN_ID
VITE_INITIA_RPC_URL=http://localhost:26657
VITE_INITIA_REST_URL=http://localhost:1317
VITE_INITIA_INDEXER_URL=http://localhost:8080
VITE_NATIVE_DENOM=$NATIVE_DENOM
VITE_NATIVE_SYMBOL=$NATIVE_SYMBOL
VITE_NATIVE_DECIMALS=6
VITE_BLOCKFORGE_MODULE_ADDRESS=$BLOCKFORGE_MODULE_ADDRESS
EOF
4. Set up Providers in main.jsx:Wrap your application with InterwovenKitProvider to enable wallet connectivity. Ensure the customChain includes fee_tokens, staking, and bech32_prefix.
main.jsx
import { Buffer } from 'buffer'
window.Buffer = Buffer
window.process = { env: { NODE_ENV: 'development' } }

import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { WagmiProvider, createConfig, http } from 'wagmi'
import { mainnet } from 'wagmi/chains'
import {
  InterwovenKitProvider,
  TESTNET,
  injectStyles,
} from '@initia/interwovenkit-react'
import '@initia/interwovenkit-react/styles.css'
import InterwovenKitStyles from '@initia/interwovenkit-react/styles.js'
import App from './App.jsx'

injectStyles(InterwovenKitStyles)

const queryClient = new QueryClient()
const wagmiConfig = createConfig({
  chains: [mainnet],
  transports: {
    [mainnet.id]: http(),
  },
})

const customChain = {
  chain_id: import.meta.env.VITE_APPCHAIN_ID,
  chain_name: 'BlockForge Appchain',
  pretty_name: 'BlockForge',
  bech32_prefix: 'init',
  network_type: 'testnet',
  apis: {
    rpc: [{ address: import.meta.env.VITE_INITIA_RPC_URL }],
    rest: [{ address: import.meta.env.VITE_INITIA_REST_URL }],
    indexer: [{ address: import.meta.env.VITE_INITIA_INDEXER_URL }],
  },
  fees: {
    fee_tokens: [
      {
        denom: import.meta.env.VITE_NATIVE_DENOM,
        fixed_min_gas_price: 0,
        low_gas_price: 0,
        average_gas_price: 0,
        high_gas_price: 0
      },
    ],
  },
  staking: {
    staking_tokens: [{ denom: import.meta.env.VITE_NATIVE_DENOM }],
  },
  metadata: {
    is_l1: false,
    minitia: {
      type: 'minimove',
    },
  },
  native_assets: [
    {
      denom: import.meta.env.VITE_NATIVE_DENOM,
      name: 'Native Token',
      symbol: import.meta.env.VITE_NATIVE_SYMBOL,
      decimals: Number(import.meta.env.VITE_NATIVE_DECIMALS ?? 6),
    }
  ]
}

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <WagmiProvider config={wagmiConfig}>
      <QueryClientProvider client={queryClient}>
        <InterwovenKitProvider
          {...TESTNET}
          defaultChainId={customChain.chain_id}
          customChain={customChain}
          customChains={[customChain]}
        >
          <App />
        </InterwovenKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  </React.StrictMode>,
)
5. Add the Game Component:Then create src/Game.jsx:
Game.jsx
import React, { useEffect, useState } from 'react'
import { AccAddress, RESTClient } from '@initia/initia.js'
import { MsgExecute } from '@initia/initia.proto/initia/move/v1/tx'
import { useInterwovenKit } from '@initia/interwovenkit-react'

const CHAIN_ID = import.meta.env.VITE_APPCHAIN_ID
const REST_URL = import.meta.env.VITE_INITIA_REST_URL
const MODULE_ADDRESS = import.meta.env.VITE_BLOCKFORGE_MODULE_ADDRESS
const MODULE_ADDRESS_HEX = AccAddress.toHex(MODULE_ADDRESS)
const INVENTORY_STRUCT_TAG = `${MODULE_ADDRESS_HEX}::items::Inventory`
const rest = new RESTClient(REST_URL, { chainId: CHAIN_ID })
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

const EMPTY_INVENTORY = { shards: 0, relics: 0 }

export default function Game() {
  const { initiaAddress, openConnect, requestTxSync } = useInterwovenKit()
  const [inventory, setInventory] = useState(EMPTY_INVENTORY)
  const [loading, setLoading] = useState(false)

  const loadInventory = async (walletAddress) => {
    if (!walletAddress) {
      setInventory(EMPTY_INVENTORY)
      return
    }

    setLoading(true)
    try {
      const resource = await rest.move.resource(walletAddress, INVENTORY_STRUCT_TAG)
      setInventory({
        shards: Number(resource.data?.shards ?? 0),
        relics: Number(resource.data?.relics ?? 0),
      })
    } catch (error) {
      const message = String(error?.response?.data?.message || error?.message || '')
      if (message.includes('not found')) {
        setInventory(EMPTY_INVENTORY)
      } else {
        throw error
      }
    } finally {
      setLoading(false)
    }
  }

  useEffect(() => {
    loadInventory(initiaAddress)
  }, [initiaAddress])

  const execute = async (functionName) => {
    if (!initiaAddress) {
      openConnect()
      return
    }

    await requestTxSync({
      chainId: CHAIN_ID,
      messages: [
        {
          typeUrl: '/initia.move.v1.MsgExecute',
          value: MsgExecute.fromPartial({
            sender: initiaAddress,
            moduleAddress: MODULE_ADDRESS,
            moduleName: 'items',
            functionName,
            typeArgs: [],
            args: [],
          }),
        },
      ],
    })

    await sleep(2000)
    await loadInventory(initiaAddress)
  }

  return (
    <div>
      <button onClick={() => execute('mint_shard')}>Mint Shard</button>
      <button onClick={() => execute('craft_relic')}>Craft Relic</button>
      <p>Shards: {loading ? '...' : inventory.shards}</p>
      <p>Relics: {loading ? '...' : inventory.relics}</p>
    </div>
  )
}
Render <Game /> from src/App.jsx.
Pro Tip: Refresh Delay After Transactions: local Move state queries can lag briefly after a successful requestTxSync. Wait about 2 seconds before reloading inventory so the new state is visible.

Step 5: Wallet Funding and UI Verification

Ask your AI agent to fund your browser wallet, then verify frontend behavior manually in the browser:
  1. Start the frontend:
Start Vite Dev Server
cd blockforge-frontend
npm run dev
Check the browser console if you encounter issues.
  1. Open your browser wallet and copy your address (init1...).
  2. Give this prompt to your AI agent, replacing <YOUR_WALLET_ADDRESS> with the address you just copied:
Using the `initia-appchain-dev` skill, please fund my wallet address <YOUR_WALLET_ADDRESS> with 1 INIT on L1 and 100 of my appchain's native tokens on L2.
  1. Connect your wallet in blockforge-frontend.
  2. Test the gameplay flow by minting shards, crafting a relic, and confirming the displayed inventory state updates correctly after each action.
If you get stuck, see the Debugging Workflow guide.

⚡ Power-Up: Auto-signing

To make your BlockForge game natively integrated with the Initia stack, you can enable Auto-signing to create a frictionless experience where players don’t see wallet popups for every game action. Auto-signing works by deriving a unique, application-specific Ghost Wallet from a one-time signature. Your primary wallet then grants this Ghost Wallet permission (Authz) to execute specific functions and use its balance for fees (Feegrant), allowing for seamless, session-based gameplay.

Step 6: Update the Frontend

You can enable session-based signing by modifying your provider configuration and adding a toggle in your UI. Example Prompt:
Using the `initia-appchain-dev` skill, please modify my InterwovenKit configuration in `main.jsx` to enable `enableAutoSign`. Then, update `Game.jsx` to include a button that toggles auto-signing on and off for the player using the `autoSign` methods from `useInterwovenKit`.
First, enable the feature in your provider:
main.jsx
<InterwovenKitProvider
  {...TESTNET}
  // Enables auto-signing for MoveVM transactions
  enableAutoSign={true}
>
  <App />
</InterwovenKitProvider>
Then, use the useInterwovenKit hook in your component to manage the session:
Game.jsx
const { initiaAddress, autoSign, requestTxSync } = useInterwovenKit()
const CHAIN_ID = '<YOUR_APPCHAIN_ID>'

// Check if auto-sign is active for this chain
const isAutoSignEnabled = autoSign?.isEnabledByChain?.[CHAIN_ID]

const toggleAutoSign = async () => {
  if (isAutoSignEnabled) {
    try {
      await autoSign?.disable(CHAIN_ID)
    } catch (error) {
      const message = String(error?.response?.data?.message || error?.message || '')
      if (message.includes('authorization not found')) {
        await autoSign?.enable(CHAIN_ID, {
          permissions: ["/initia.move.v1.MsgExecute"]
        })
        await autoSign?.disable(CHAIN_ID)
      } else {
        throw error
      }
    }
  } else {
    // Permissions are required for the session key to sign specific message types
    await autoSign?.enable(CHAIN_ID, { 
      permissions: ["/initia.move.v1.MsgExecute"] 
    })
  }
}

const handleAction = async () => {
  await requestTxSync({
    chainId: CHAIN_ID,
    autoSign: isAutoSignEnabled, // Enables headless signing flow
    feeDenom: isAutoSignEnabled ? '<YOUR_NATIVE_DENOM>' : undefined, // MANDATORY for invisible UX
    messages: [
      {
        typeUrl: "/initia.move.v1.MsgExecute",
        value: {
          sender: initiaAddress,
          moduleAddress: "<YOUR_MODULE_ADDRESS>",
          moduleName: "items",
          functionName: "mint_shard",
          typeArgs: [], // MANDATORY: Even if empty
          args: [],     // MANDATORY: Even if empty
        },
      }
    ]
  })
}

return (
  <div>
    <button onClick={toggleAutoSign}>
      {isAutoSignEnabled ? 'Disable Auto-Sign' : 'Enable Auto-Sign'}
    </button>
    <button onClick={handleAction}>
      Mint With Current Auto-Sign Mode
    </button>
  </div>
)
Pro Tip: You can check the exact expiration of the current session using autoSign.expiredAtByChain[chainId].
If you want the complete finished frontend after applying the manual steps above, use the consolidated reference below.
If you want a single copyable end-state after completing the manual steps above, use this consolidated reference. It combines:
  • runtime config from .env,
  • InterwovenKit provider wiring in main.jsx,
  • wallet connect/display in App.jsx, and
  • live Move inventory, actions, and auto-sign toggle flow in Game.jsx.
.env
.env
VITE_BLOCKFORGE_MODULE_ADDRESS=<YOUR_MODULE_OWNER_BECH32>
src/main.jsx
src/main.jsx
import { Buffer } from 'buffer'
window.Buffer = Buffer
window.process = { env: { NODE_ENV: 'development' } }

import React from 'react'
import ReactDOM from 'react-dom/client'
import "@initia/interwovenkit-react/styles.css";
import { injectStyles, InterwovenKitProvider, TESTNET } from "@initia/interwovenkit-react";
import InterwovenKitStyles from "@initia/interwovenkit-react/styles.js";
import { WagmiProvider, createConfig, http } from "wagmi";
import { mainnet } from "wagmi/chains";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from './App.jsx'
import './index.css'

injectStyles(InterwovenKitStyles);

const queryClient = new QueryClient();
const wagmiConfig = createConfig({
  chains: [mainnet],
  transports: { [mainnet.id]: http() },
});

const customChain = {
  chain_id: '<YOUR_APPCHAIN_ID>',
  chain_name: 'blockforge',
  pretty_name: 'BlockForge',
  network_type: 'testnet',
  bech32_prefix: 'init',
  logo_URIs: {
    png: 'https://raw.githubusercontent.com/initia-labs/initia-registry/main/testnets/initia/images/initia.png',
    svg: 'https://raw.githubusercontent.com/initia-labs/initia-registry/main/testnets/initia/images/initia.svg',
  },
  apis: {
    rpc: [{ address: 'http://localhost:26657' }],
    rest: [{ address: 'http://localhost:1317' }],
    indexer: [{ address: 'http://localhost:8080' }],
    'json-rpc': [{ address: 'http://localhost:8545' }],
  },
  fees: {
    fee_tokens: [{
      denom: '<YOUR_NATIVE_DENOM>',
      fixed_min_gas_price: 0,
      low_gas_price: 0,
      average_gas_price: 0,
      high_gas_price: 0
    }],
  },
  staking: {
    staking_tokens: [{ denom: '<YOUR_NATIVE_DENOM>' }]
  },
  metadata: {
    is_l1: false,
    minitia: {
      type: 'minimove',
    },
  },
  native_assets: [
    {
      denom: '<YOUR_NATIVE_DENOM>',
      name: 'Native Token',
      symbol: '<YOUR_TOKEN_SYMBOL>',
      decimals: Number(import.meta.env.VITE_NATIVE_DECIMALS ?? 6)
    }
  ]
}

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <WagmiProvider config={wagmiConfig}>
      <QueryClientProvider client={queryClient}>
        <InterwovenKitProvider
          {...TESTNET}
          defaultChainId={customChain.chain_id}
          enableAutoSign={true}
          customChain={customChain}
          customChains={[customChain]}
        >
          <App />
        </InterwovenKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  </React.StrictMode>,
)
src/App.jsx
src/App.jsx
import React from 'react'
import { useInterwovenKit } from "@initia/interwovenkit-react";
import Game from './Game.jsx';

function App() {
  const { initiaAddress, openConnect, openWallet } = useInterwovenKit();

  const shortenAddress = (addr) => {
    if (!addr) return "";
    return `${addr.slice(0, 8)}...${addr.slice(-4)}`;
  };

  return (
    <div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
      <header style={{
        width: '100%',
        maxWidth: '1200px',
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
        padding: '2rem'
      }}>
        <h1 style={{ fontSize: '1.25rem', fontWeight: 800, margin: 0 }}>BlockForge</h1>

        {!initiaAddress ? (
          <button onClick={openConnect} className="btn btn-primary">Connect Wallet</button>
        ) : (
          <button onClick={openWallet} className="btn btn-secondary">
            {shortenAddress(initiaAddress)}
          </button>
        )}
      </header>

      <main style={{ flex: 1, width: '100%', maxWidth: '760px', padding: '2rem' }}>
        <div style={{ display: 'grid', gap: '1.5rem' }}>
          <section className="card" style={{ display: 'grid', gap: '0.9rem' }}>
            <p style={{ margin: 0, color: 'var(--fg-muted)', fontSize: '0.8rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
              Local Rollup
            </p>
            <div>
              <h2 style={{ fontSize: '2.2rem', margin: 0 }}>Items Workshop</h2>
              <p style={{ color: 'var(--fg-muted)', margin: '0.75rem 0 0', lineHeight: 1.6 }}>
                Connect a wallet to mint shards and craft relics against the deployed <code>items</code> module.
              </p>
            </div>
          </section>

          <Game />
        </div>
      </main>
    </div>
  )
}

export default App
src/Game.jsx
src/Game.jsx
import React, { useEffect, useState } from 'react'
import { AccAddress, RESTClient } from '@initia/initia.js'
import { MsgExecute } from '@initia/initia.proto/initia/move/v1/tx'
import { useInterwovenKit } from '@initia/interwovenkit-react'

const CHAIN_ID = '<YOUR_APPCHAIN_ID>'
const REST_URL = 'http://localhost:1317'
const MODULE_ADDRESS = import.meta.env.VITE_BLOCKFORGE_MODULE_ADDRESS
const MODULE_ADDRESS_HEX = AccAddress.toHex(MODULE_ADDRESS)
const INVENTORY_STRUCT_TAG = `${MODULE_ADDRESS_HEX}::items::Inventory`
const rest = new RESTClient(REST_URL, { chainId: CHAIN_ID })
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

const EMPTY_INVENTORY = { shards: 0, relics: 0 }

function Game() {
  const { initiaAddress, openConnect, requestTxSync, autoSign } = useInterwovenKit()
  const [inventory, setInventory] = useState(EMPTY_INVENTORY)
  const [isLoadingInventory, setIsLoadingInventory] = useState(false)
  const [pendingAction, setPendingAction] = useState('')
  const [isTogglingAutoSign, setIsTogglingAutoSign] = useState(false)
  const [error, setError] = useState('')
  const [txHash, setTxHash] = useState('')

  const isConnected = Boolean(initiaAddress)
  const isAutoSignEnabled = Boolean(autoSign?.isEnabledByChain?.[CHAIN_ID])

  const loadInventory = async (address) => {
    if (!address) {
      setInventory(EMPTY_INVENTORY)
      return
    }

    setIsLoadingInventory(true)
    try {
      const resource = await rest.move.resource(address, INVENTORY_STRUCT_TAG)
      setInventory({
        shards: Number(resource.data?.shards ?? 0),
        relics: Number(resource.data?.relics ?? 0),
      })
      setError('')
    } catch (fetchError) {
      const message = String(fetchError?.response?.data?.message || fetchError?.message || '')
      const notFound = fetchError?.response?.status === 500 && message.includes('not found')

      if (notFound) {
        setInventory(EMPTY_INVENTORY)
        setError('')
      } else {
        setError(message || 'Failed to load inventory.')
      }
    } finally {
      setIsLoadingInventory(false)
    }
  }

  useEffect(() => {
    if (!initiaAddress) {
      setInventory(EMPTY_INVENTORY)
      setError('')
      setTxHash('')
      return
    }

    loadInventory(initiaAddress)
  }, [initiaAddress])

  const submitAction = async (functionName) => {
    if (!initiaAddress) {
      openConnect()
      return
    }

    setPendingAction(functionName)
    setError('')
    setTxHash('')

    try {
      const result = await requestTxSync({
        chainId: CHAIN_ID,
        autoSign: isAutoSignEnabled,
        feeDenom: isAutoSignEnabled ? '<YOUR_NATIVE_DENOM>' : undefined,
        messages: [
          {
            typeUrl: '/initia.move.v1.MsgExecute',
            value: MsgExecute.fromPartial({
              sender: initiaAddress,
              moduleAddress: MODULE_ADDRESS,
              moduleName: 'items',
              functionName,
              typeArgs: [],
              args: [],
            }),
          },
        ],
      })

      await sleep(2000)
      await loadInventory(initiaAddress)

      const responseHash =
        result?.txhash ||
        result?.tx_response?.txhash ||
        result?.transactionHash ||
        ''
      setTxHash(responseHash)
    } catch (submitError) {
      setError(
        String(
          submitError?.response?.data?.message ||
          submitError?.message ||
          'Transaction failed.',
        ),
      )
    } finally {
      setPendingAction('')
    }
  }

  const toggleAutoSign = async () => {
    if (!initiaAddress) {
      openConnect()
      return
    }

    setIsTogglingAutoSign(true)
    setError('')

    try {
      if (isAutoSignEnabled) {
        try {
          await autoSign?.disable(CHAIN_ID)
        } catch (disableError) {
          const disableMessage = String(
            disableError?.response?.data?.message ||
            disableError?.message ||
            '',
          )

          if (disableMessage.includes('authorization not found')) {
            await autoSign?.enable(CHAIN_ID, {
              permissions: ['/initia.move.v1.MsgExecute'],
            })
            await autoSign?.disable(CHAIN_ID)
          } else {
            throw disableError
          }
        }
      } else {
        await autoSign?.enable(CHAIN_ID, {
          permissions: ['/initia.move.v1.MsgExecute'],
        })
      }
    } catch (toggleError) {
      setError(
        String(
          toggleError?.response?.data?.message ||
          toggleError?.message ||
          'Failed to update auto-sign.',
        ),
      )
    } finally {
      setIsTogglingAutoSign(false)
    }
  }

  return (
    <section className="card" style={{ display: 'grid', gap: '1.25rem' }}>
      <div>
        <p style={{ margin: 0, color: 'var(--fg-muted)', fontSize: '0.8rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
          BlockForge
        </p>
        <h2 style={{ margin: '0.35rem 0 0', fontSize: '1.9rem' }}>Forge items on your appchain</h2>
      </div>

      <div style={{ display: 'grid', gap: '0.9rem', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
        <button
          className="btn btn-primary"
          type="button"
          disabled={pendingAction === 'mint_shard'}
          onClick={() => submitAction('mint_shard')}
        >
          {pendingAction === 'mint_shard' ? 'Minting...' : 'Mint Shard'}
        </button>
        <button
          className="btn btn-secondary"
          type="button"
          disabled={pendingAction === 'craft_relic'}
          onClick={() => submitAction('craft_relic')}
        >
          {pendingAction === 'craft_relic' ? 'Crafting...' : 'Craft Relic'}
        </button>
      </div>

      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
        <div>
          <p style={{ margin: 0, color: 'var(--fg-muted)', fontSize: '0.8rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
            Auto-Sign
          </p>
          <p style={{ margin: '0.35rem 0 0', color: 'var(--fg-muted)' }}>
            {isAutoSignEnabled ? 'Enabled for Move execute messages on this chain.' : 'Disabled. Wallet approval is required for each transaction.'}
          </p>
        </div>

        <button className="btn btn-secondary" type="button" onClick={toggleAutoSign} disabled={isTogglingAutoSign}>
          {isTogglingAutoSign ? 'Updating...' : isAutoSignEnabled ? 'Disable Auto-Sign' : 'Enable Auto-Sign'}
        </button>
      </div>

      <section style={{ display: 'grid', gap: '0.9rem' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
          <div>
            <p style={{ margin: 0, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--fg-muted)' }}>
              Inventory
            </p>
            <p style={{ margin: '0.35rem 0 0', color: 'var(--fg-muted)' }}>
              {isConnected ? initiaAddress : 'Connect a wallet to view your items.'}
            </p>
          </div>

          {!isConnected ? (
            <button className="btn btn-primary" type="button" onClick={openConnect}>
              Connect Wallet
            </button>
          ) : (
            <button className="btn btn-secondary" type="button" onClick={() => loadInventory(initiaAddress)}>
              {isLoadingInventory ? 'Refreshing...' : 'Refresh Inventory'}
            </button>
          )}
        </div>

        <p>Shards: {isLoadingInventory ? '...' : inventory.shards}</p>
        <p>Relics: {isLoadingInventory ? '...' : inventory.relics}</p>

        {txHash ? <p>Latest tx: <code>{txHash}</code></p> : null}
        {error ? <p>{error}</p> : null}
      </section>
    </section>
  )
}

export default Game
This reference assumes:
  • your module owner is stored in .env as bech32,
  • moduleAddress in MsgExecute uses that bech32 value,
  • inventory reads use rest.move.resource with a hex struct tag.

Power-Up Verification

  1. Connect your wallet in blockforge-frontend.
  2. Enable Auto-sign and approve the one-time Auto-sign setup.
  3. Click Mint Shard and confirm the transaction completes without a new wallet signature prompt.
  4. Disable Auto-sign.
  5. Click Mint Shard again and confirm a wallet signature prompt is required.

Next Steps

Now that you’ve mastered a Move application, you’re ready to build your own idea! Ensure your project meets all the technical pillars before submitting. Review Submission Requirements ->