Dice Game (Turn-Based Multiplayer)
- Path:
/templates/dice - Highlights: A blackjack-style dice game showcasing NFT-based player accounts, lobby systems, turn-based gameplay, and deterministic random number generation using EffectStream's L2.
The dice template demonstrates how to build a competitive two-player dice game with lobby management, turn-based rounds, and NFT-based player identification. It's an excellent example for games requiring account systems, matchmaking, sequential turn order, and deterministic randomness, all processed through an EffectStream L2 contract on an EVM chain.

Core Concept: Blackjack Dice with NFT Accounts
The goal of this template is to demonstrate a competitive multiplayer dice game where players use NFT accounts to create or join lobbies, compete to reach a target score of 21 without going over, and track their win/loss records across multiple rounds.
- NFT Account System: Each player mints an NFT that serves as their in-game identity, tracked in the database for persistent stats.
- Lobby System: Players create lobbies with configurable round counts, round lengths, time per player, and visibility settings.
- Turn-Based Gameplay: Players alternate turns, rolling two dice and deciding whether to roll again or pass.
- Blackjack Scoring: The goal is to reach exactly 21 (2 points) or get closer to 21 than your opponent (1 point) without going over.
- Deterministic Randomness: Dice rolls use Prando (deterministic PRNG) seeded with block data, ensuring all nodes reach identical results.
- Player Statistics: Track wins, losses, and ties for each NFT account across all completed matches.
This template serves as a foundation for:
- Turn-based strategy games
- Dice-based board games
- Card games with sequential turns
- Any game requiring persistent player accounts and statistics
Quick Start
# Install dependencies
deno install --allow-scripts
./patch.sh
# Build EVM contracts
deno task build:evm
# Start the EffectStream Node (automatically builds and serves frontend)
deno task dev
The game will be available at:
- Frontend: http://localhost:8080
- API: http://localhost:9999
- Explorer: http://localhost:10590
- Blockchain: http://localhost:8545
Using Test Accounts
For development, import Hardhat's test accounts into MetaMask:
- Open MetaMask → Click account icon → Import Account
- Select Private Key and paste one of these:
# Account #0 (10,000 ETH)0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80# Account #1 (10,000 ETH) - for testing multiplayer0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
- Make sure MetaMask is connected to Localhost 8545 (Chain ID: 31337)
⚠️ Never use these private keys on a real network - they're publicly known and only for local development.
Docker Setup
You can run the entire stack in a single Docker container:
Building the Docker Image
# For macOS
DOCKER_DEFAULT_PLATFORM=linux/amd64 docker build -t dice -f Dockerfile .
# For Linux
docker build -t dice -f Dockerfile .
Running the Container
# For macOS
DOCKER_DEFAULT_PLATFORM=linux/amd64 docker run -p 8080:8080 -p 8545:8545 -p 9999:9999 dice
# For Linux
docker run -p 8080:8080 -p 8545:8545 -p 9999:9999 dice
The container exposes:
- Port 8080: Frontend
- Port 8545: Local EVM node (Hardhat)
- Port 9999: EffectStream backend API
The Components in Action
When you run deno task dev, the Process Orchestrator sets up:
- Hardhat EVM Node: Local blockchain on port 8545
- Development Services: PGlite database, log collector, TUI, and Explorer
- EffectStream Node: Backend service on port 9999
- Frontend: React + MUI game interface on port 8080
Game Rules
How to Play
- Mint an NFT Account: Each player mints an NFT that represents their in-game identity
- Create or Join a Lobby: The first player creates a lobby, the second player joins
- Take Turns Rolling Dice: Players alternate turns, rolling two six-sided dice
- Make Strategic Decisions: After each roll, choose to:
- Roll Again: Add another dice roll to your total (initial rolls continue until reaching 16+)
- Pass: Lock in your current score and end your turn
- Avoid Going Over 21: If your total exceeds 21, you score 0 points for that round
- Scoring:
- Exactly 21: 2 points
- Closer to 21 without going over: 1 point
- Tie or over 21: 0 points
- Win the Match: The player with the most points after all rounds wins
Initial Roll Mechanic
When a player's turn begins, they automatically roll dice until their score reaches 16 or higher. This ensures every turn starts with a meaningful decision point.
On-Chain Logic
The template uses a EffectStream L2 Contract deployed on the local EVM chain at 0x5FbDB2315678afecb367f032d93F642f64180aa3. Players submit formatted input strings, and EffectStream processes them to update game state.
// The EffectStreamL2Contract acts as an input mailbox
contract EffectStreamL2 {
event EffectStreamGameInteraction(address indexed user, bytes input, uint256 indexed nonce);
function submitInput(bytes calldata input) external payable {
emit EffectStreamGameInteraction(msg.sender, input, nonce);
}
}
Additionally, the template deploys an ERC721 NFT contract for player accounts:
// Simple NFT for player accounts
contract DiceNFT is ERC721 {
uint256 private _tokenIdCounter;
function mint() external {
uint256 tokenId = _tokenIdCounter++;
_mint(msg.sender, tokenId);
}
}
EffectStream monitors the EffectStreamGameInteraction event for player actions and the ERC721 Transfer event for NFT mints.
The State Machine (state-machine.ts)
The State Machine implements all game logic using generator functions with yield* for structured effects.
Grammar Definition
Input grammar is defined using TypeBox schemas in packages/shared/data-types/src/grammar.ts:
export const grammar = {
// NFT mint event - uses built-in ERC721 grammar for Transfer events
nftMint: builtinGrammars.evmErc721,
// Create a new lobby: c|creatorNftId|numOfRounds|roundLength|playTimePerPlayer|isHidden?|isPractice?
createdLobby: [
["creatorNftId", NftID],
["numOfRounds", Type.Number({ minimum: 1, maximum: 1000 })],
["roundLength", Type.Number({ minimum: 1, maximum: 10000 })],
["playTimePerPlayer", Type.Number({ minimum: 1, maximum: 10000 })],
["isHidden", Type.Optional(Type.Boolean())],
["isPractice", Type.Optional(Type.Boolean())],
],
// Join an existing lobby: j|nftId|lobbyID
joinedLobby: [
["nftId", NftID],
["lobbyID", LobbyID],
],
// Close a lobby: cs|lobbyID
closedLobby: [
["lobbyID", LobbyID],
],
// Submit moves: s|nftId|lobbyID|matchWithinLobby|roundWithinMatch|rollAgain
submittedMoves: [
["nftId", NftID],
["lobbyID", LobbyID],
["matchWithinLobby", Type.Number({ minimum: 0 })],
["roundWithinMatch", Type.Number({ minimum: 0 })],
["rollAgain", Type.Boolean()],
],
} as const satisfies GrammarDefinition;
State Transitions
nftMint
Tracks NFT ownership when players mint their in-game accounts.
stm.addStateTransition("nftMint", function* (data) {
const { blockTimestamp, parsedInput } = data;
yield* World.resolve(upsertNftOwnership, {
nft_id: Number(parsedInput.tokenId),
wallet_address: parsedInput.to,
created_at: new Date(Number(blockTimestamp) * 1000),
});
});
createdLobby
Creates a new game lobby with the creator automatically added as the first player.
stm.addStateTransition("createdLobby", function* (data) {
const { blockHeight, blockTimestamp, parsedInput, randomGenerator } = data;
// Generate unique lobby ID
const lobbyId = randomGenerator.nextString(12);
// Create the lobby
yield* World.resolve(insertLobby, {
lobby_id: lobbyId,
max_players: 2,
num_of_rounds: parsedInput.numOfRounds,
round_length: parsedInput.roundLength,
play_time_per_player: parsedInput.playTimePerPlayer,
current_match: null,
current_round: null,
current_turn: null,
current_proper_round: null,
created_at: new Date(Number(blockTimestamp) * 1000),
creation_block_height: blockHeight,
hidden: parsedInput.isHidden ?? false,
practice: parsedInput.isPractice ?? false,
lobby_creator: parsedInput.creatorNftId,
lobby_state: "open",
});
// Add creator as first player
yield* World.resolve(insertLobbyPlayer, {
lobby_id: lobbyId,
nft_id: parsedInput.creatorNftId,
points: 0,
score: 0,
turn: null,
});
// Initialize global stats for creator
yield* World.resolve(upsertGlobalUserState, {
nft_id: parsedInput.creatorNftId,
});
});
joinedLobby
Allows a second player to join an open lobby, starting the match by assigning turn order and creating the first match and round.
stm.addStateTransition("joinedLobby", function* (data) {
const { blockHeight, parsedInput, randomGenerator } = data;
const lobby = yield* World.resolve(getLobbyById, { lobby_id: parsedInput.lobbyID });
if (!lobby || lobby.length === 0 || lobby[0].lobby_state !== "open") {
return;
}
const lobbyData = lobby[0];
// Prevent creator from joining their own lobby
if (lobbyData.lobby_creator === parsedInput.nftId) {
return;
}
const existingPlayers = yield* World.resolve(getLobbyPlayers, { lobby_id: parsedInput.lobbyID });
// Only allow join if not already in lobby
if (existingPlayers.some(p => p.nft_id === parsedInput.nftId)) {
return;
}
// Add second player
yield* World.resolve(insertLobbyPlayer, {
lobby_id: parsedInput.lobbyID,
nft_id: parsedInput.nftId,
points: 0,
score: 0,
turn: null,
});
// Initialize global stats for joiner
yield* World.resolve(upsertGlobalUserState, {
nft_id: parsedInput.nftId,
});
// Get all players and randomly assign turns
const allPlayers = yield* World.resolve(getLobbyPlayers, { lobby_id: parsedInput.lobbyID });
const firstPlayerIndex = randomGenerator.next() < 0.5 ? 0 : 1;
allPlayers[firstPlayerIndex].turn = 0;
allPlayers[1 - firstPlayerIndex].turn = 1;
// Update player turns
for (const player of allPlayers) {
yield* World.resolve(updateLobbyPlayerTurn, {
lobby_id: parsedInput.lobbyID,
nft_id: player.nft_id,
turn: player.turn,
});
}
// Update lobby to active state
yield* World.resolve(updateLobbyState, {
lobby_id: parsedInput.lobbyID,
lobby_state: "active",
current_match: 0,
current_round: 0,
current_turn: 0,
current_proper_round: 1,
});
// Create first match
yield* World.resolve(insertLobbyMatch, {
lobby_id: parsedInput.lobbyID,
match_within_lobby: 0,
starting_block_height: blockHeight,
});
// Create first round
yield* World.resolve(insertMatchRound, {
lobby_id: parsedInput.lobbyID,
match_within_lobby: 0,
round_within_match: 0,
starting_block_height: blockHeight,
execution_block_height: null,
});
});
submittedMoves
Handles player actions during their turn. Players can choose to roll again or pass.
stm.addStateTransition("submittedMoves", function* (data) {
const { blockHeight, parsedInput, randomGenerator } = data;
const lobby = yield* World.resolve(getLobbyById, { lobby_id: parsedInput.lobbyID });
if (!lobby || lobby.length === 0) {
return;
}
const lobbyData = lobby[0];
// Validate it's the player's turn
const players = yield* World.resolve(getLobbyPlayers, { lobby_id: parsedInput.lobbyID });
const currentPlayer = players.find(p => p.turn === lobbyData.current_turn);
if (!currentPlayer || currentPlayer.nft_id !== parsedInput.nftId) {
return;
}
// Process the move
let newScore = currentPlayer.score;
if (parsedInput.rollAgain) {
// Roll two dice
const die1 = Math.floor(randomGenerator.next() * 6) + 1;
const die2 = Math.floor(randomGenerator.next() * 6) + 1;
newScore += die1 + die2;
// Update player score
yield* World.resolve(updateLobbyPlayerScore, {
lobby_id: parsedInput.lobbyID,
nft_id: parsedInput.nftId,
score: newScore,
});
// Check if player went over 21 or chose to auto-pass at 16+
const shouldAutoPass = newScore >= 16 && newScore <= 21;
if (newScore > 21 || shouldAutoPass) {
// End turn automatically
yield* switchToNextTurn(parsedInput.lobbyID, lobbyData, players, blockHeight);
}
} else {
// Player chose to pass
yield* switchToNextTurn(parsedInput.lobbyID, lobbyData, players, blockHeight);
}
});
closedLobby
Allows the lobby creator to close an open lobby before it starts.
stm.addStateTransition("closedLobby", function* (data) {
const { parsedInput } = data;
const lobby = yield* World.resolve(getLobbyById, { lobby_id: parsedInput.lobbyID });
if (!lobby || lobby.length === 0 || lobby[0].lobby_state !== "open") {
return;
}
yield* World.resolve(updateLobbyState, {
lobby_id: parsedInput.lobbyID,
lobby_state: "closed",
current_match: null,
current_round: null,
current_turn: null,
current_proper_round: null,
});
});
Database Schema
The database has six main tables defined in packages/client/database/src/migrations/database.sql:
lobbies
Stores lobby metadata and current game state.
CREATE TYPE lobby_status AS ENUM ('open', 'active', 'finished', 'closed');
CREATE TABLE lobbies (
lobby_id TEXT PRIMARY KEY,
max_players INTEGER NOT NULL,
num_of_rounds INTEGER NOT NULL,
round_length INTEGER NOT NULL,
play_time_per_player INTEGER NOT NULL,
current_match INTEGER,
current_round INTEGER,
current_turn INTEGER,
current_proper_round INTEGER,
created_at TIMESTAMP NOT NULL,
creation_block_height INTEGER NOT NULL,
hidden BOOLEAN NOT NULL DEFAULT false,
practice BOOLEAN NOT NULL DEFAULT false,
lobby_creator INTEGER NOT NULL,
lobby_state lobby_status NOT NULL
);
lobby_match
Tracks individual matches within a lobby.
CREATE TABLE lobby_match(
id SERIAL PRIMARY KEY,
lobby_id TEXT NOT NULL references lobbies(lobby_id),
match_within_lobby INTEGER NOT NULL,
starting_block_height INTEGER NOT NULL
);
match_round
Tracks individual rounds within each match.
CREATE TABLE match_round(
id SERIAL PRIMARY KEY,
lobby_id TEXT NOT NULL references lobbies(lobby_id),
match_within_lobby INTEGER NOT NULL,
round_within_match INTEGER NOT NULL,
starting_block_height INTEGER NOT NULL,
execution_block_Height INTEGER
);
round_move
Records each dice roll and decision made during a round.
CREATE TABLE round_move (
id SERIAL PRIMARY KEY,
lobby_id TEXT NOT NULL references lobbies(lobby_id),
match_within_lobby INTEGER NOT NULL,
round_within_match INTEGER NOT NULL,
move_within_round INTEGER NOT NULL,
nft_id INTEGER NOT NULL,
roll_again BOOLEAN NOT NULL
);
lobby_player
Tracks players in each lobby and their current scores.
CREATE TABLE lobby_player (
id SERIAL PRIMARY KEY,
lobby_id TEXT NOT NULL references lobbies(lobby_id),
nft_id INTEGER NOT NULL,
points INTEGER NOT NULL DEFAULT 0,
score INTEGER NOT NULL DEFAULT 0,
turn INTEGER
);
global_user_state
Tracks win/loss statistics for each NFT account.
CREATE TABLE global_user_state (
nft_id INTEGER NOT NULL PRIMARY KEY,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
ties INTEGER NOT NULL DEFAULT 0
);
nft_ownership
Maps NFT IDs to wallet addresses (added in migration 001-add-nft-ownership.sql).
CREATE TABLE IF NOT EXISTS nft_ownership (
nft_id INTEGER NOT NULL PRIMARY KEY,
wallet_address TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_nft_ownership_wallet ON nft_ownership(wallet_address);
Type-Safe Queries
The template uses pgtyped to generate TypeScript types from SQL queries. To regenerate types after modifying SQL files:
cd packages/client/database
deno task pgtyped:update
Important: Always run deno task pgtyped:update after modifying SQL queries, as the type definitions need to be regenerated.
API Endpoints
The backend server runs on port 9999 and provides REST endpoints defined in packages/client/node/src/api.ts.
GET /lobby/:lobbyId
Fetches details for a specific lobby including player data.
Example Request:
curl "http://localhost:9999/lobby/abc123xyz456"
Example Response:
{
"lobby_id": "abc123xyz456",
"max_players": 2,
"num_of_rounds": 5,
"round_length": 60,
"play_time_per_player": 30,
"current_match": 0,
"current_round": 0,
"current_turn": 1,
"current_proper_round": 1,
"created_at": "2025-01-15T10:30:00.000Z",
"creation_block_height": 42,
"hidden": false,
"practice": false,
"lobby_creator": 1,
"lobby_state": "active",
"players": [
{
"nftId": 1,
"turn": 0,
"points": 2,
"score": 19
},
{
"nftId": 2,
"turn": 1,
"points": 1,
"score": 21
}
]
}
GET /open_lobbies
Lists all open lobbies available to join.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 0 | Page number (0-based) |
count | number | 10 | Items per page |
Example Request:
curl "http://localhost:9999/open_lobbies?page=0&count=10"
GET /lobbies/active
Lists all currently active matches.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 0 | Page number (0-based) |
count | number | 10 | Items per page |
GET /user_lobbies
Fetches lobbies for a specific wallet address.
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
wallet | string | Yes | Wallet address |
page | number | No | Page number (0-based) |
count | number | No | Items per page |
Example Request:
curl "http://localhost:9999/user_lobbies?wallet=0xf39fd6e51aad88f6f4ce6ab8827279cffb92266&page=0&count=10"
GET /user_lobbies_by_nft
Fetches lobbies where a specific NFT is a player.
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
nft_id | number | Yes | NFT ID |
page | number | No | Page number (0-based) |
count | number | No | Items per page |
GET /nfts
Retrieves all NFTs owned by a wallet address.
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
wallet | string | Yes | Wallet address |
Example Response:
{
"nfts": [1, 3, 7]
}
GET /user_stats
Fetches player statistics for a wallet.
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
wallet | string | Yes | Wallet address |
Example Response:
{
"nft_id": 1,
"wins": 5,
"losses": 2,
"ties": 1
}
Frontend Architecture
The frontend uses React with Material-UI (MUI) for the game interface and lobby management, with wallet integration via @paimaexample/wallets.
Key Components
- OpenLobbies.tsx: Displays available lobbies to join
- MyGames.tsx: Shows the player's game history
- DiceGame.tsx: Main game interface with dice rolling
- Dice.tsx: React wrapper for react-dice-complete library
Wallet Middleware
The middleware layer (paimaMiddleware.src.js) bridges React to the EffectStream wallet API:
import { EffectStreamConfig, sendTransaction, walletLogin, WalletMode } from "@paimaexample/wallets";
import { hardhat } from "viem/chains";
const effectstreamConfig = new EffectStreamConfig(
"dice",
"mainEvmRPC",
"0x5FbDB2315678afecb367f032d93F642f64180aa3",
hardhat,
undefined,
undefined,
false,
);
const endpoints = {
async userWalletLogin({ mode, preferBatchedMode }) {
const result = await walletLogin({
mode,
chain: effectstreamConfig.effectstreamL2Chain,
});
if (!result.success) {
return { success: false };
}
wallet = result.result;
return {
success: true,
result: {
walletAddress: wallet.walletAddress.address,
...wallet,
},
};
},
async createLobby(creatorNftId, numOfRounds, roundLength, timePerPlayer, isHidden = false, isPractice = false) {
const params = ["createdLobby", creatorNftId, numOfRounds, roundLength, timePerPlayer, isHidden, isPractice];
const result = await sendTransaction(wallet, params, effectstreamConfig);
if (!result.success) {
return { success: false, errorMessage: "Failed to create lobby" };
}
// Poll for the lobby with retries
for (let attempt = 0; attempt < 15; attempt++) {
await new Promise(resolve => setTimeout(resolve, 1000));
const response = await fetch(
`http://localhost:9999/user_lobbies?wallet=${walletAddr}&page=0&count=1`
);
if (response.ok) {
const lobbies = await response.json();
if (lobbies && lobbies.length > 0) {
return {
success: true,
lobbyID: lobbies[0].lobby_id,
lobbyStatus: lobbies[0].lobby_state,
};
}
}
}
return {
success: false,
errorMessage: "Lobby created but could not retrieve lobby ID after 15 attempts",
};
},
async joinLobby(nftId, lobbyId) {
return await sendTransaction(
wallet,
["joinedLobby", nftId, lobbyId],
effectstreamConfig
);
},
async submitMoves(nftId, lobbyId, matchWithinLobby, roundWithinMatch, rollAgain) {
return await sendTransaction(
wallet,
["submittedMoves", nftId, lobbyId, matchWithinLobby, roundWithinMatch, rollAgain],
effectstreamConfig
);
},
};
export default endpoints;
export { WalletMode };
Building the Frontend
The frontend uses esbuild for bundling. When you run deno task dev, the frontend is automatically built and served. To manually rebuild:
cd packages/frontend
node esbuild.js # Generates paimaMiddleware.js
Architecture Highlights
Deterministic Random Number Generation
The dice game uses Prando for deterministic random number generation, ensuring all nodes compute identical results:
// In state transitions, randomGenerator is seeded with block data
const die1 = Math.floor(randomGenerator.next() * 6) + 1;
const die2 = Math.floor(randomGenerator.next() * 6) + 1;
This guarantees that:
- All nodes agree on dice roll results
- Game outcomes are provably fair
- State transitions are reproducible
NFT-Based Player Accounts
Unlike traditional wallet-based systems, this template uses NFTs as player identities:
// When an NFT is minted, it's tracked in the database
stm.addStateTransition("nftMint", function* (data) {
yield* World.resolve(upsertNftOwnership, {
nft_id: Number(parsedInput.tokenId),
wallet_address: parsedInput.to,
created_at: new Date(Number(blockTimestamp) * 1000),
});
});
Benefits:
- Players can have multiple game accounts (multiple NFTs)
- Accounts are transferable (trade/sell your game stats)
- Persistent identity across wallet changes
Polling for Lobby Creation
The frontend uses a polling mechanism to wait for lobby indexing before redirecting:
// Poll for the lobby with retries (15 attempts, 1 second apart)
for (let attempt = 0; attempt < 15; attempt++) {
await new Promise(resolve => setTimeout(resolve, 1000));
const response = await fetch(
`http://localhost:9999/user_lobbies?wallet=${walletAddr}&page=0&count=1`
);
if (response.ok) {
const lobbies = await response.json();
if (lobbies && lobbies.length > 0) {
return {
success: true,
lobbyID: lobbies[0].lobby_id,
lobbyStatus: lobbies[0].lobby_state,
};
}
}
}
This ensures the lobby is indexed before the frontend tries to display it.
Generator Function Pattern
State transitions use generator functions with yield* for structured effects:
// State machine uses generators
stm.addStateTransition("createdLobby", function* (data) {
// 1. Extract data from the input
const { blockHeight, parsedInput, randomGenerator } = data;
// 2. Apply SQL updates
yield* World.resolve(insertLobby, {
lobby_id: randomGenerator.nextString(12),
// ... other parameters
});
});
This pattern ensures:
- Deterministic execution: EffectStream manages all side effects
- Testability: State transitions are pure functions
- Type safety: pgtyped generates types for all queries
Separation of Concerns
- Grammar (packages/shared/data-types): TypeBox validation
- Game Logic (packages/shared/game-logic): Pure, deterministic game rules
- State Machine (packages/client/node/src/state-machine): Generator-based orchestration
- Database (packages/client/database): Type-safe queries with pgtyped
- API (packages/client/node/src/api.ts): REST endpoints
- Frontend (packages/frontend): React + MUI game interface
Use Cases and Extensions
This template can be extended for various game types:
Yahtzee or Similar Dice Games
- Replace scoring rules with category-based scoring (straights, full house, etc.)
- Add dice holding mechanics (keep some dice, reroll others)
- Implement scorecard tracking
Craps or Casino Games
- Extend dice rolling to support betting mechanics
- Add house edge calculations
- Implement multiple players per table
Board Games with Dice
- Add grid-based movement using dice rolls
- Implement positional game logic
- Create item collection or resource gathering
RPG Stat Rolling
- Use for character creation systems
- Add stat allocation mechanics
- Implement reroll tokens or modifiers
Troubleshooting
Port Conflicts
lsof -i :8545 # Check EVM node port
lsof -i :9999 # Check API port
lsof -i :8080 # Check frontend port
Frontend Bundle Outdated
cd packages/frontend
node esbuild.js # Regenerate bundle
Database Query Types Out of Sync
After modifying SQL files:
cd packages/client/database
deno task pgtyped:update
Lobby Not Creating
Check that:
- MetaMask is connected to Localhost 8545
- You have test ETH in your account
- The EffectStream node is running (
deno task dev) - Check browser console for errors
- Verify you've minted an NFT account first
NFT Account Not Found
Make sure to:
- Mint an NFT using the "Mint NFT" button in the UI
- Wait for the transaction to be confirmed
- Refresh the page to see your NFT ID
MetaMask Issues
If the UI fails to load:
- Try restarting MetaMask
- Clear browser cache and hard refresh (Cmd/Ctrl + Shift + R)
- Verify you're connected to the correct network (Localhost 8545, Chain ID 31337)
Game Not Starting
If you create a lobby but it doesn't start:
- Wait for a second player to join
- The game shows "Waiting for opponent..." until both players are present
- Check the "Open Lobbies" page to verify the lobby is visible