Build a Multi-Token Game Economy with ERC-1155

Want to understand how Enjin gaming assets, Gods Unchained cards, or OpenSea shared collections work? Build your own multi-token contract.
In 90-120 minutes, you'll deploy a contract managing unlimited token types: fungible currencies, unique items, and everything in between. One contract powering an entire game economy. You'll see why developers call ERC-1155 the multi-tool of token standards and how batch operations can save 50-80% on gas costs.
Note: This guide builds on concepts from the ERC-721 guide. If you haven't completed it, you'll still be able to follow along, but some concepts will be more familiar if you've worked with NFTs before.
What You'll Learn
This guide goes beyond basic token creation. You'll understand the architecture powering blockchain gaming:
- Why one contract can replace dozens - ERC-20 needs one contract per token. ERC-721 needs one per collection. ERC-1155 manages unlimited token types in a single deployment.
- How batch operations slash gas costs - Minting 10 different items in one transaction instead of 10 separate ones. The savings add up fast.
- What "semi-fungible" actually means - Concert tickets for the same event but different seats. Game items that start identical but become unique through use.
- The
{id}metadata pattern - How one base URI serves metadata for thousands of token types without storing thousands of strings on-chain.
What You Need
| Requirement | Why It Matters | Get It Here |
|---|---|---|
| Node.js & npm | Runs the Hardhat development environment and manages code dependencies | nodejs.org |
| MetaMask wallet | Holds test funds and signs the deployment transaction that creates your contract | metamask.io |
| Sepolia test ETH | Pays for gas fees (~$0 in real value, but required for test network) | Google Cloud Faucet |
| Alchemy RPC URL | Your connection point to Ethereum. Like an API key for blockchain access. | alchemy.com (free tier) |
| Pinata account | Hosts your token images and metadata on IPFS, the decentralized storage layer" | pinata.cloud (free tier) |
Understanding What You're Building
ERC-1155 solves a problem that becomes obvious once you think about game economies.
Imagine building a blockchain game. You need:
- Gold coins - Players earn and spend millions of these. Fungible, like ERC-20.
- Health potions - Common items, maybe 10,000 exist. Semi-fungible.
- Legendary Sword of Fire - Only one exists. Non-fungible, like ERC-721.
- Event tickets - 500 for the same tournament, but each has a seat number.
With previous standards, you'd deploy separate contracts: one ERC-20 for gold, one ERC-721 for the sword, another ERC-721 for potions … Managing dozens of contracts, each with deployment costs, each requiring separate approvals for marketplaces.
ERC-1155 consolidates everything into one contract. Each token type gets an ID:
| Token ID | Name | Supply | Type |
|---|---|---|---|
| 1 | Gold Coin | 1,000,000 | Fungible |
| 2 | Health Potion | 10,000 | Semi-fungible |
| 3 | Legendary Sword | 1 | Non-fungible |
| 4 | Tournament Ticket | 500 | Semi-fungible |
The key insight: fungibility is determined by supply, not by the standard. Token ID 3 with supply of 1 behaves like an NFT. Token ID 1 with supply of 1,000,000 behaves like a currency. Same contract, same interface, different configurations.
Time to build.
Project Setup With Hardhat
We'll create a new Hardhat project for your multi-token contract.
The setup process is identical to the ERC-721 guide. Please see that guide for detailed explanations of each step.
1. Initialize Your Project
npx hardhat --init
Select:
- Version: Hardhat 3 Beta
- Project location:
my-game-items - Project type: TypeScript + Viem
- Dependencies: Confirm with
Y
cd my-game-items
2. Install OpenZeppelin Contracts
npm install --save-dev @openzeppelin/contracts
3. Create Your Contract File
touch contracts/GameItems.sol
Smart Contract Development
We'll build your multi-token contract step by step, inheriting from OpenZeppelin's ERC1155 and Ownable. Unlike ERC-721 which needed URIStorage for per-token metadata, ERC-1155 handles this differently with a base URI pattern.
1. Initial Imports and Contract Structure
Open contracts/GameItems.sol and add the foundation:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract GameItems is ERC1155, Ownable {
constructor() ERC1155("") Ownable(msg.sender) {}
}
What's different from ERC-721:
ERC1155: The multi-token standard. One contract, unlimited token types. Each token ID can have any supply.- No
URIStorageextension: ERC-1155 uses a base URI with{id}substitution instead of storing URIs per token. - Constructor takes a URI string (we'll set it properly later).
2. State Variables and Token Configuration
Add the state variables that define your token economy. In contracts/GameItems.sol:
contract GameItems is ERC1155, Ownable {
// Track total supply per token ID
mapping(uint256 => uint256) private _totalSupply;
// Optional: max supply per token ID (0 = unlimited)
mapping(uint256 => uint256) public maxSupply;
// Optional: price per token ID (0 = free or owner-only)
mapping(uint256 => uint256) public mintPrice;
// Track which token IDs have been configured
mapping(uint256 => bool) public tokenExists;
// Base URI for metadata
string private _baseURI;
/**
* @dev Constructor sets the base metadata URI
* @param baseURI_ Base URI with `{id}` placeholder (e.g., "ipfs://Qm.../")
*/
constructor(string memory baseURI_) ERC1155(baseURI_) Ownable(msg.sender) {
_baseURI = baseURI_;
}
}
Key differences from ERC-721:
_totalSupplymapping: Tracks supply PER token ID. Token ID 1 might have 1,000,000 minted, token ID 2 might have just 5.maxSupplymapping: Optional cap PER token ID. Token ID 3 (legendary sword) has max 1. Token ID 1 (gold) might be unlimited (0).mintPricemapping: Different prices for different items. Legendary items cost more than common ones.tokenExists: Prevents minting unconfigured tokens. Owner must set up a token type before anyone can mint it.
3. Token Type Configuration
Before tokens can be minted, the owner must configure each token type. Add this to contracts/GameItems.sol:
/**
* @dev Configure a new token type (owner only)
* @param tokenId The ID for this token type
* @param _maxSupply Maximum mintable supply (0 = unlimited)
* @param _mintPrice Price to mint one token (0 = free/owner-only)
*/
function configureToken(
uint256 tokenId,
uint256 _maxSupply,
uint256 _mintPrice
) public onlyOwner {
tokenExists[tokenId] = true;
maxSupply[tokenId] = _maxSupply;
mintPrice[tokenId] = _mintPrice;
}
/**
* @dev Configure multiple token types at once (owner only)
*/
function configureTokenBatch(
uint256[] memory tokenIds,
uint256[] memory _maxSupplies,
uint256[] memory _mintPrices
) public onlyOwner {
require(
tokenIds.length == _maxSupplies.length &&
tokenIds.length == _mintPrices.length,
"Array length mismatch"
);
for (uint256 i = 0; i < tokenIds.length; i++) {
tokenExists[tokenIds[i]] = true;
maxSupply[tokenIds[i]] = _maxSupplies[i];
mintPrice[tokenIds[i]] = _mintPrices[i];
}
}
Why this pattern:
Real game economies don't let anyone create arbitrary token types. The owner (game developer) defines what items exist:
- Token ID 1: Gold coins, unlimited supply, can't be bought directly
- Token ID 2: Health potion, max 10,000, costs 0.001 ETH
- Token ID 3: Legendary sword, max 1, costs 0.1 ETH
This mirrors how actual games work. Developers control the item catalog.
4. Public Mint Function
The core minting logic, with payment and supply validation. Add to contracts/GameItems.sol:
/**
* @dev Mint tokens (public, with payment)
* @param to Recipient address
* @param tokenId Which token type to mint
* @param amount How many to mint
*/
function mint(address to, uint256 tokenId, uint256 amount) public payable {
require(tokenExists[tokenId], "Token type does not exist");
require(
maxSupply[tokenId] == 0 ||
_totalSupply[tokenId] + amount <= maxSupply[tokenId],
"Exceeds max supply"
);
require(msg.value >= mintPrice[tokenId] * amount, "Insufficient payment");
_totalSupply[tokenId] += amount;
_mint(to, tokenId, amount, "");
}
/**
* @dev Batch mint multiple token types at once
* @param to Recipient address
* @param tokenIds Array of token types
* @param amounts Array of amounts per type
*/
function mintBatch(
address to,
uint256[] memory tokenIds,
uint256[] memory amounts
) public payable {
require(tokenIds.length == amounts.length, "Array length mismatch");
uint256 totalCost = 0;
for (uint256 i = 0; i < tokenIds.length; i++) {
require(tokenExists[tokenIds[i]], "Token type does not exist");
require(
maxSupply[tokenIds[i]] == 0 ||
_totalSupply[tokenIds[i]] + amounts[i] <= maxSupply[tokenIds[i]],
"Exceeds max supply"
);
totalCost += mintPrice[tokenIds[i]] * amounts[i];
_totalSupply[tokenIds[i]] += amounts[i];
}
require(msg.value >= totalCost, "Insufficient payment");
_mintBatch(to, tokenIds, amounts, "");
}
The power of batch operations:
mintBatch is where ERC-1155 shines. Instead of:
mint(gold, 100) // Transaction 1: ~50,000 gas
mint(potion, 5) // Transaction 2: ~50,000 gas
mint(sword, 1) // Transaction 3: ~50,000 gas
// Total: ~150,000 gas + 3 transaction fees
You get:
mintBatch([gold, potion, sword], [100, 5, 1]) // One transaction: ~80,000 gas
// Savings: ~50% gas + only 1 transaction fee
For games where players acquire multiple items at once (loot drops, shop purchases), this adds up fast.
5. Owner Mint Functions
Free minting for the game owner: initial distribution, rewards, airdrops. Add to contracts/GameItems.sol:
/**
* @dev Owner mint without payment
*/
function ownerMint(address to, uint256 tokenId, uint256 amount) public onlyOwner {
require(tokenExists[tokenId], "Token type does not exist");
require(
maxSupply[tokenId] == 0 ||
_totalSupply[tokenId] + amount <= maxSupply[tokenId],
"Exceeds max supply"
);
_totalSupply[tokenId] += amount;
_mint(to, tokenId, amount, "");
}
/**
* @dev Owner batch mint without payment
*/
function ownerMintBatch(
address to,
uint256[] memory tokenIds,
uint256[] memory amounts
) public onlyOwner {
require(tokenIds.length == amounts.length, "Array length mismatch");
for (uint256 i = 0; i < tokenIds.length; i++) {
require(tokenExists[tokenIds[i]], "Token type does not exist");
require(
maxSupply[tokenIds[i]] == 0 ||
_totalSupply[tokenIds[i]] + amounts[i] <= maxSupply[tokenIds[i]],
"Exceeds max supply"
);
_totalSupply[tokenIds[i]] += amounts[i];
}
_mintBatch(to, tokenIds, amounts, "");
}
Same pattern as ERC-721's ownerMint, but with batch capability. Useful for:
- Initial token distribution at game launch
- Tournament rewards (batch mint prizes to winners)
- Airdrops to community members
6. Helper Functions
Utility functions for querying and managing the contract. Add to contracts/GameItems.sol:
/**
* @dev Get total minted supply for a token type
*/
function totalSupply(uint256 tokenId) public view returns (uint256) {
return _totalSupply[tokenId];
}
/**
* @dev Update mint price for a token type
*/
function setMintPrice(uint256 tokenId, uint256 newPrice) public onlyOwner {
require(tokenExists[tokenId], "Token type does not exist");
mintPrice[tokenId] = newPrice;
}
/**
* @dev Update base URI for all tokens
*/
function setURI(string memory newURI) public onlyOwner {
_baseURI = newURI;
_setURI(newURI);
}
/**
* @dev Withdraw collected funds
*/
function withdraw() public onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "No funds to withdraw");
payable(owner()).transfer(balance);
}
/**
* @dev Override uri to return our base URI
*/
function uri(uint256 tokenId) public view override returns (string memory) {
return string(abi.encodePacked(_baseURI, toString(tokenId), ".json"));
}
/**
* @dev Convert uint to string (helper for uri)
*/
function toString(uint256 value) internal pure returns (string memory) {
if (value == 0) return "0";
uint256 temp = value;
uint256 digits;
while (temp != 0) {
digits++;
temp /= 10;
}
bytes memory buffer = new bytes(digits);
while (value != 0) {
digits -= 1;
buffer[digits] = bytes1(uint8(48 + uint256(value % 10)));
value /= 10;
}
return string(buffer);
}
The uri() function explained:
ERC-1155's metadata pattern differs from ERC-721:
- ERC-721: Store unique URI per token (
tokenURI[tokenId] = "ipfs://Qm.../1.json") - ERC-1155: One base URI, dynamically construct per-token URI
If _baseURI is "ipfs://QmMeta.../":
uri(1)returns"ipfs://QmMeta.../1.json"uri(42)returns"ipfs://QmMeta.../42.json"uri(1000)returns"ipfs://QmMeta.../1000.json"
This saves gas (no storage writes for URIs) and scales to unlimited token types.
Your multi-token contract is complete. It handles fungible currencies, unique items, and everything in between. Batch operations, configurable supplies, and dynamic pricing make up the same architecture powering blockchain games worth billions.
Writing Tests
Your multi-token contract is more complex than ERC-721. It handles multiple token types with different configurations, batch operations with payment calculations, and supply tracking per token ID. More moving parts means more potential bugs.
We'll test with the same dual approach: Solidity unit tests for individual functions, TypeScript integration tests for complete user flows.
Solidity Unit Tests
1. Setup
Create the test file:
touch contracts/GameItems.t.sol
Add the test contract with setup. Open contracts/GameItems.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {GameItems} from "./GameItems.sol";
import {Test} from "forge-std/Test.sol";
contract GameItemsTest is Test {
GameItems game;
string constant BASE_URI = "ipfs://QmTest.../";
// Token IDs for testing
uint256 constant GOLD = 1;
uint256 constant POTION = 2;
uint256 constant SWORD = 3;
// Test configuration
uint256 constant GOLD_MAX = 0; // Unlimited
uint256 constant GOLD_PRICE = 0; // Free (owner only)
uint256 constant POTION_MAX = 10000;
uint256 constant POTION_PRICE = 0.001 ether;
uint256 constant SWORD_MAX = 1;
uint256 constant SWORD_PRICE = 0.1 ether;
address owner = address(this);
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
// Allow test contract to receive ETH (needed for withdraw tests)
receive() external payable {}
function setUp() public {
// Deploy contract
game = new GameItems(BASE_URI);
// Configure token types
game.configureToken(GOLD, GOLD_MAX, GOLD_PRICE);
game.configureToken(POTION, POTION_MAX, POTION_PRICE);
game.configureToken(SWORD, SWORD_MAX, SWORD_PRICE);
// Fund test users
vm.deal(user1, 10 ether);
vm.deal(user2, 10 ether);
}
}
Setup notes:
- Three token types configured: unlimited free gold, 10k potions at 0.001 ETH, unique sword at 0.1 ETH
- This mirrors a real game economy with currency, consumables, and rare items
2. Test Deployment and Configuration
/**
* @notice Tests initial deployment state
*/
function test_ConstructorState() public view {
assertEq(game.owner(), owner, "Incorrect owner");
assertTrue(game.tokenExists(GOLD), "Gold should exist");
assertTrue(game.tokenExists(POTION), "Potion should exist");
assertTrue(game.tokenExists(SWORD), "Sword should exist");
assertFalse(game.tokenExists(999), "Token 999 should not exist");
}
/**
* @notice Tests token configuration
*/
function test_TokenConfiguration() public view {
assertEq(game.maxSupply(GOLD), GOLD_MAX, "Gold max supply incorrect");
assertEq(game.mintPrice(GOLD), GOLD_PRICE, "Gold price incorrect");
assertEq(game.maxSupply(SWORD), SWORD_MAX, "Sword max supply incorrect");
assertEq(game.mintPrice(SWORD), SWORD_PRICE, "Sword price incorrect");
}
/**
* @notice Tests batch token configuration
*/
function test_ConfigureTokenBatch() public {
uint256[] memory ids = new uint256;
uint256[] memory supplies = new uint256;
uint256[] memory prices = new uint256;
ids[0] = 100;
ids[1] = 101;
supplies[0] = 500;
supplies[1] = 1000;
prices[0] = 0.01 ether;
prices[1] = 0.02 ether;
game.configureTokenBatch(ids, supplies, prices);
assertTrue(game.tokenExists(100), "Token 100 should exist");
assertTrue(game.tokenExists(101), "Token 101 should exist");
assertEq(game.maxSupply(100), 500, "Token 100 max supply incorrect");
}
3. Test Single Minting
/**
* @notice Tests successful single mint with payment
*/
function test_Mint_Success() public {
vm.prank(user1);
game.mint{value: POTION_PRICE * 5}(user1, POTION, 5);
assertEq(game.balanceOf(user1, POTION), 5, "Should have 5 potions");
assertEq(game.totalSupply(POTION), 5, "Total supply should be 5");
assertEq(address(game).balance, POTION_PRICE * 5, "Contract should hold payment");
}
/**
* @notice Tests minting fails for unconfigured token
*/
function test_Mint_UnconfiguredToken() public {
vm.prank(user1);
vm.expectRevert("Token type does not exist");
game.mint{value: 1 ether}(user1, 999, 1);
}
/**
* @notice Tests minting fails with insufficient payment
*/
function test_Mint_InsufficientPayment() public {
vm.prank(user1);
vm.expectRevert("Insufficient payment");
game.mint{value: 0.0001 ether}(user1, POTION, 1);
}
/**
* @notice Tests minting fails when exceeding max supply
*/
function test_Mint_ExceedsMaxSupply() public {
// Mint the only sword
vm.prank(user1);
game.mint{value: SWORD_PRICE}(user1, SWORD, 1);
// Try to mint another
vm.prank(user2);
vm.expectRevert("Exceeds max supply");
game.mint{value: SWORD_PRICE}(user2, SWORD, 1);
}
4. Test Batch Minting
/**
* @notice Tests successful batch mint
*/
function test_MintBatch_Success() public {
uint256[] memory ids = new uint256;
uint256[] memory amounts = new uint256;
ids[0] = POTION;
ids[1] = SWORD;
amounts[0] = 3;
amounts[1] = 1;
uint256 totalCost = (POTION_PRICE * 3) + (SWORD_PRICE * 1);
vm.prank(user1);
game.mintBatch{value: totalCost}(user1, ids, amounts);
assertEq(game.balanceOf(user1, POTION), 3, "Should have 3 potions");
assertEq(game.balanceOf(user1, SWORD), 1, "Should have 1 sword");
assertEq(address(game).balance, totalCost, "Contract should hold total payment");
}
/**
* @notice Tests batch mint with insufficient payment fails
*/
function test_MintBatch_InsufficientPayment() public {
uint256[] memory ids = new uint256;
uint256[] memory amounts = new uint256;
ids[0] = POTION;
ids[1] = SWORD;
amounts[0] = 3;
amounts[1] = 1;
vm.prank(user1);
vm.expectRevert("Insufficient payment");
game.mintBatch{value: 0.01 ether}(user1, ids, amounts); // Too low
}
5. Test Owner Minting
/**
* @notice Tests owner can mint without payment
*/
function test_OwnerMint_Success() public {
game.ownerMint(user1, GOLD, 1000);
assertEq(game.balanceOf(user1, GOLD), 1000, "Should have 1000 gold");
assertEq(game.totalSupply(GOLD), 1000, "Total supply should be 1000");
assertEq(address(game).balance, 0, "No payment should be held");
}
/**
* @notice Tests owner batch mint
*/
function test_OwnerMintBatch_Success() public {
uint256[] memory ids = new uint256;
uint256[] memory amounts = new uint256;
ids[0] = GOLD;
ids[1] = POTION;
ids[2] = SWORD;
amounts[0] = 10000;
amounts[1] = 50;
amounts[2] = 1;
game.ownerMintBatch(user1, ids, amounts);
assertEq(game.balanceOf(user1, GOLD), 10000, "Should have 10000 gold");
assertEq(game.balanceOf(user1, POTION), 50, "Should have 50 potions");
assertEq(game.balanceOf(user1, SWORD), 1, "Should have 1 sword");
}
/**
* @notice Tests non-owner cannot use ownerMint
*/
function test_OwnerMint_NotOwner() public {
vm.prank(user1);
vm.expectRevert();
game.ownerMint(user1, GOLD, 1000);
}
6. Test Withdrawals
/**
* @notice Tests owner can withdraw funds
*/
function test_Withdraw_Success() public {
// Generate some revenue
vm.prank(user1);
game.mint{value: POTION_PRICE * 10}(user1, POTION, 10);
vm.prank(user2);
game.mint{value: SWORD_PRICE}(user2, SWORD, 1);
uint256 contractBalance = address(game).balance;
uint256 ownerBalanceBefore = address(owner).balance;
game.withdraw();
assertEq(address(game).balance, 0, "Contract should be empty");
assertEq(address(owner).balance, ownerBalanceBefore + contractBalance, "Owner should receive funds");
}
/**
* @notice Tests withdraw fails with no funds
*/
function test_Withdraw_NoFunds() public {
vm.expectRevert("No funds to withdraw");
game.withdraw();
}
7. Test URI Generation
/**
* @notice Tests URI is correctly generated
*/
function test_URI() public view {
assertEq(game.uri(1), "ipfs://QmTest.../1.json", "URI for token 1 incorrect");
assertEq(game.uri(42), "ipfs://QmTest.../42.json", "URI for token 42 incorrect");
assertEq(game.uri(1000), "ipfs://QmTest.../1000.json", "URI for token 1000 incorrect");
}
8. Run Solidity Tests
npx hardhat test solidity contracts/GameItems.t.sol
TypeScript End-to-End Tests
1. Setup
Create the TypeScript test file:
touch test/GameItems.ts
Add the test structure. Open test/GameItems.ts:
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { network } from "hardhat";
import { parseEther } from "viem";
describe("GameItems", async function () {
const { viem } = await network.connect();
const publicClient = await viem.getPublicClient();
const BASE_URI = "ipfs://QmTest.../";
// Token IDs
const GOLD = 1n;
const POTION = 2n;
const SWORD = 3n;
// Tests will go here
});
2. Implement Complete Game Economy Test
it("should handle a complete game economy lifecycle", async function () {
// 1. SETUP
const [owner, player1, player2] = await viem.getWalletClients();
console.log("\n=== Game Economy Lifecycle Test ===\n");
// 2. DEPLOYMENT
console.log("1. Deploying game items contract...");
const game = await viem.deployContract("GameItems", [BASE_URI], {
client: { wallet: owner }
});
console.log(\` ✓ Contract deployed at: ${game.address}\n\`);
// 3. CONFIGURE TOKEN TYPES
console.log("2. Configuring token types...");
// Gold: unlimited, free (owner distribution only)
let tx = await game.write.configureToken([GOLD, 0n, 0n]);
await publicClient.waitForTransactionReceipt({ hash: tx });
console.log(" ✓ Gold configured (unlimited, owner-only)");
// Potions: max 10000, 0.001 ETH each
tx = await game.write.configureToken([POTION, 10000n, parseEther("0.001")]);
await publicClient.waitForTransactionReceipt({ hash: tx });
console.log(" ✓ Potions configured (max 10000, 0.001 ETH)");
// Legendary Sword: max 1, 0.1 ETH
tx = await game.write.configureToken([SWORD, 1n, parseEther("0.1")]);
await publicClient.waitForTransactionReceipt({ hash: tx });
console.log(" ✓ Legendary Sword configured (max 1, 0.1 ETH)\n");
// 4. OWNER DISTRIBUTES INITIAL GOLD
console.log("3. Owner distributing initial gold to players...");
tx = await game.write.ownerMint([player1.account.address, GOLD, 1000n]);
await publicClient.waitForTransactionReceipt({ hash: tx });
tx = await game.write.ownerMint([player2.account.address, GOLD, 1000n]);
await publicClient.waitForTransactionReceipt({ hash: tx });
assert.equal(
await game.read.balanceOf([player1.account.address, GOLD]),
1000n,
"Player1 should have 1000 gold"
);
console.log(" ✓ Each player received 1000 gold\n");
// 5. PLAYERS BUY POTIONS
console.log("4. Players purchasing potions...");
tx = await game.write.mint(
[player1.account.address, POTION, 5n],
{ value: parseEther("0.005"), account: player1.account }
);
await publicClient.waitForTransactionReceipt({ hash: tx });
assert.equal(
await game.read.balanceOf([player1.account.address, POTION]),
5n,
"Player1 should have 5 potions"
);
console.log(" ✓ Player1 bought 5 potions for 0.005 ETH\n");
// 6. PLAYER BUYS LEGENDARY SWORD
console.log("5. Player2 purchasing legendary sword...");
tx = await game.write.mint(
[player2.account.address, SWORD, 1n],
{ value: parseEther("0.1"), account: player2.account }
);
await publicClient.waitForTransactionReceipt({ hash: tx });
assert.equal(
await game.read.balanceOf([player2.account.address, SWORD]),
1n,
"Player2 should have the sword"
);
assert.equal(
await game.read.totalSupply([SWORD]),
1n,
"Sword total supply should be 1"
);
console.log(" ✓ Player2 owns the only Legendary Sword!\n");
// 7. VERIFY SWORD IS SOLD OUT
console.log("6. Verifying sword is sold out...");
try {
await game.write.mint(
[player1.account.address, SWORD, 1n],
{ value: parseEther("0.1"), account: player1.account }
);
assert.fail("Should have reverted");
} catch (error) {
console.log(" ✓ Correctly rejected - max supply reached\n");
}
// 8. BATCH MINT (LOOT DROP)
console.log("7. Player1 receives loot drop (batch mint)...");
const lootIds = [GOLD, POTION];
const lootAmounts = [500n, 10n];
tx = await game.write.ownerMintBatch([player1.account.address, lootIds, lootAmounts]);
await publicClient.waitForTransactionReceipt({ hash: tx });
assert.equal(
await game.read.balanceOf([player1.account.address, GOLD]),
1500n, // 1000 initial + 500 loot
"Player1 should have 1500 gold"
);
assert.equal(
await game.read.balanceOf([player1.account.address, POTION]),
15n, // 5 bought + 10 loot
"Player1 should have 15 potions"
);
console.log(" ✓ Loot drop: +500 gold, +10 potions\n");
// 9. TRANSFER ITEMS BETWEEN PLAYERS
console.log("8. Player1 trades potions to Player2...");
tx = await game.write.safeTransferFrom(
[player1.account.address, player2.account.address, POTION, 3n, "0x"],
{ account: player1.account }
);
await publicClient.waitForTransactionReceipt({ hash: tx });
assert.equal(
await game.read.balanceOf([player2.account.address, POTION]),
3n,
"Player2 should have 3 potions"
);
console.log(" ✓ 3 potions transferred to Player2\n");
// 10. WITHDRAW REVENUE
console.log("9. Owner withdrawing collected revenue...");
const contractBalance = await publicClient.getBalance({ address: game.address });
console.log(\` Contract balance: ${contractBalance} wei\`);
tx = await game.write.withdraw();
await publicClient.waitForTransactionReceipt({ hash: tx });
const finalBalance = await publicClient.getBalance({ address: game.address });
assert.equal(finalBalance, 0n, "Contract should be empty");
console.log(" ✓ Funds withdrawn to owner\n");
// 11. FINAL STATE
console.log("=== Final Game State ===");
console.log(\`Player1: ${await game.read.balanceOf([player1.account.address, GOLD])} gold, ${await game.read.balanceOf([player1.account.address, POTION])} potions\`);
console.log(\`Player2: ${await game.read.balanceOf([player2.account.address, GOLD])} gold, ${await game.read.balanceOf([player2.account.address, POTION])} potions, ${await game.read.balanceOf([player2.account.address, SWORD])} sword\`);
console.log(\`\nTotal minted - Gold: ${await game.read.totalSupply([GOLD])}, Potions: ${await game.read.totalSupply([POTION])}, Swords: ${await game.read.totalSupply([SWORD])}\`);
console.log("\n=== All tests passed! ===\n");
});
3. Run TypeScript Tests
npx hardhat test nodejs test/GameItems.ts
ERC-1155 uses a different metadata pattern than ERC-721. Instead of storing a unique URI for each token, it uses a base URI with {id} substitution.
For detailed Pinata setup instructions, see ERC-721: Setting Up Pinata.
The {id} Pattern
ERC-721 stores individual URIs per token:
Token 0 → ipfs://QmA.../0.json (stored on-chain)
Token 1 → ipfs://QmB.../1.json (stored on-chain)
Token 2 → ipfs://QmC.../2.json (stored on-chain)
ERC-1155 constructs URIs dynamically from a base:
Base URI: ipfs://QmMeta.../
Token 1 → ipfs://QmMeta.../1.json (computed)
Token 2 → ipfs://QmMeta.../2.json (computed)
Token 3 → ipfs://QmMeta.../3.json (computed)
Why this matters: No storage writes for URIs. Scales to unlimited token types. Gas savings compound as your game grows.
Create a folder structure:
mkdir -p nft-assets/images
mkdir -p nft-assets/metadata
Metadata JSON structure (same as ERC-721, but with game-specific properties):
1.json (Gold Coin - Fungible):
{
"name": "Gold Coin",
"description": "In-game currency used for trading and purchases",
"image": "ipfs://YOUR_IMAGES_CID/gold.png",
"properties": {
"token_type": "fungible",
"tradeable": true
}
}
2.json (Health Potion - Semi-fungible):
{
"name": "Health Potion",
"description": "Restores 50 HP when consumed",
"image": "ipfs://YOUR_IMAGES_CID/potion.png",
"properties": {
"token_type": "semi-fungible",
"effect": "heal",
"power": 50
}
}
3.json (Legendary Sword - Non-fungible):
{
"name": "Legendary Sword of Fire",
"description": "The only sword of its kind. Burns enemies on contact.",
"image": "ipfs://YOUR_IMAGES_CID/sword.png",
"properties": {
"token_type": "non-fungible",
"damage": 100,
"element": "fire",
"rarity": "legendary"
}
}
Upload Process
- Upload images folder to Pinata → Get Images CID
- Update metadata JSON files with Images CID in the
"image"field - Upload metadata folder to Pinata → Get Metadata CID
- Use Metadata CID as your contract's base URI:
ipfs://YOUR_METADATA_CID/
The contract's uri() function will append the token ID and .json automatically.
Setting Up for Deployment
1. Setting Up Environment Variables
Same process as ERC-721. Set your RPC URL and private key in Hardhat's keystore:
npx hardhat keystore set SEPOLIA_RPC_URL
npx hardhat keystore set SEPOLIA_PRIVATE_KEY
2. Create the Deployment Script
Create the deployment file:
touch ignition/modules/GameItems.ts
Add the deployment configuration. Open ignition/modules/GameItems.ts:
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
export default buildModule("GameItemsModule", (m) => {
// Replace with your actual metadata CID from Pinata
const baseURI = "ipfs://YOUR_METADATA_CID/";
const gameItems = m.contract("GameItems", [baseURI]);
return { gameItems };
});
3. Deploy Your Contract
npx hardhat ignition deploy ignition/modules/GameItems.ts --network sepolia
Save your deployed contract address!
Interacting with Your Contract
Configure Token Types
After deployment, configure your token types. Create scripts/configureTokens.ts:
import { network } from "hardhat";
import { parseEther } from "viem";
async function main() {
const { viem } = await network.connect();
const [owner] = await viem.getWalletClients();
const publicClient = await viem.getPublicClient();
const contractAddress = "YOUR_CONTRACT_ADDRESS";
const game = await viem.getContractAt("GameItems", contractAddress);
console.log("Configuring token types...");
// Configure batch
const ids = [1n, 2n, 3n];
const maxSupplies = [0n, 10000n, 1n]; // 0 = unlimited
const prices = [0n, parseEther("0.001"), parseEther("0.1")];
const tx = await game.write.configureTokenBatch([ids, maxSupplies, prices]);
await publicClient.waitForTransactionReceipt({ hash: tx });
console.log("✓ Token types configured!");
console.log(" Token 1 (Gold): unlimited, owner-only");
console.log(" Token 2 (Potion): max 10000, 0.001 ETH");
console.log(" Token 3 (Sword): max 1, 0.1 ETH");
}
main().catch(console.error);
Run with:
npx hardhat run scripts/configureTokens.ts --network sepolia
Mint Tokens
Create scripts/mint.ts:
import { network } from "hardhat";
import { parseEther } from "viem";
async function main() {
const { viem } = await network.connect();
const [minter] = await viem.getWalletClients();
const publicClient = await viem.getPublicClient();
const contractAddress = "YOUR_CONTRACT_ADDRESS";
const game = await viem.getContractAt("GameItems", contractAddress);
// Mint 5 potions
console.log("Minting 5 potions...");
const tx = await game.write.mint(
[minter.account.address, 2n, 5n],
{ value: parseEther("0.005"), account: minter.account }
);
await publicClient.waitForTransactionReceipt({ hash: tx });
console.log("✓ Minted 5 potions!");
console.log(\`Balance: ${await game.read.balanceOf([minter.account.address, 2n])}\`);
}
main().catch(console.error);
Viewing on Rarible
Rarible supports ERC-1155 collections. To view your tokens:
- Visit testnet.rarible.com
- Connect your MetaMask wallet (Sepolia network)
- Navigate to your profile
- Your tokens should appear with their quantities
ERC-1155 tokens display differently than ERC-721; specifically, you'll see quantities like 'x5' for fungible/semi-fungible tokens.
Conclusion
You've built a complete multi-token game economy. Let's recap what you accomplished:
- Built a production-ready ERC-1155 contract with configurable token types, batch operations, supply caps, and dynamic pricing. This is the same architecture powering Enjin and other blockchain gaming platforms.
- Understood the multi-token paradigm where fungibility is determined by configuration, not by the standard. One contract managing currencies, items, and unique collectibles.
- Implemented batch operations that can save 50-80% on gas costs for complex transactions like loot drops or shop purchases.
- Mastered the
{id}metadata pattern for scalable, gas-efficient metadata serving unlimited token types. - Deployed and configured a live contract on Sepolia, minting tokens that appear on Rarible.
What You Built vs. Production Games
Your contract contains the core mechanics of blockchain gaming:
- Multi-token management: One contract for entire economies
- Batch operations: Gas-efficient mass distributions
- Configurable supplies: Fungible currencies to unique legendaries
- Payment handling: Item shop functionality built-in
What production games add:
- Crafting/burning: Combine items to create new ones
- Staking mechanics: Lock items for rewards
- Royalties (ERC-2981): Earn from secondary sales
- Access control: Multiple admin roles, not just owner
Explore advanced features: Add crafting (burn X potions to create Y), implement staking for passive rewards, or integrate ERC-2981 royalties.
Build a game frontend: Connect your contract to a web interface where players can view inventory, trade items, and interact with the economy.
Study production implementations: Read Enjin's contracts, examine Gods Unchained's architecture, understand how Loot (for Adventurers) structured their drops.
You've moved from understanding individual token types to managing entire economies. That's the foundation of blockchain gaming.