Building a Decentralized Tic Tac Toe Game with Stackr Labs


Hello, fellow developers! Today, I’m thrilled to guide you through creating a decentralized Tic Tac Toe game using Stackr Labs and Micro Rollups (MRUs). This guide will take you through each step, from setting up your project to deploying your game on the blockchain. Let’s get started!
What is Stackr Labs?
Stackr Labs gives your dapp a turbo boost! With Micro-Rollups (MRUs), helps developers build fast, scalable apps. Think of it like preparing food at home for a trip — most of the work happens off-chain, ensuring a smoother experience on the blockchain!
Table of Contents
Prerequisites
Setting Up Your Project
Creating the State Machine
Implementing the Game Logic
Frontend Integration
Testing Your Game
Conclusion
Prerequisites
Before we dive in, make sure you have the following tools installed:
Node.js: Download and install from Node.js
Git: Download and install from Git
Bun: Installation guide can be found here
Stackr CLI: Install globally using:
bun install -g @stackr/cli
Create a New Directory:
mkdir tic-tac-toe
cd tic-tac-toe
Initialize a New Stackr Project:
bunx @stackr/cli@latest init
Setting Up Your Project
You can use the following .env values:
# PRIVATE_KEY is the hex-encoded private key of the Ethereum address that will act as the rollup operator.
PRIVATE_KEY= --YOUR DEVELOPMENT PRIVATE KEY--
# L1_RPC is the RPC endpoint of the L1 chain. Can be found in the network config docs above.
L1_RPC=https://rpc2.sepolia.org/
# VULCAN_RPC is the RPC endpoint of the Vulcan network. Can be found in the network config docs above.
VULCAN_RPC=https://sepolia.vulcan.stf.xyz/
# REGISTRY_CONTRACT is the address of the Stackr registry contract on the L1 chain. Can be found in the network config docs above.
REGISTRY_CONTRACT=0x985bAfb3cAf7F4BD13a21290d1736b93D07A1760
# DATABASE_URI is the connection URI of the SQL database.
DATABASE_URI=./db.sqlite
# NODE_ENV is the current environment (production or development). Relevant for express.js server.
NODE_ENV=development
Creating the State Machine
In this section, we’ll define the game state using Stackr’s state management.
src/stackr/state.ts
import { BytesLike, solidityPackedKeccak256 } from "ethers";
import { State } from "@stackr/sdk/machine";
// Define the structure of a Tic Tac Toe game
interface Game {
gameId: string;
owner: string; // Player X's address
playerO: string; // Player O's address
board: string[]; // 9 cells represented as a string array
currentPlayer: "X" | "O";
winner: "X" | "O" | "Draw" | null;
startedAt: number;
endedAt: number | null;
}// Define the overall application state
export interface AppState {
games: Record<string, Game>;
}// Initial state of the application
export const initialState: AppState = {
games: {},
};// TicTacToeState class extends the State class from @stackr/sdk
export class TicTacToeState extends State<AppState> {
constructor(state: AppState) {
super(state);
} // Generate a root hash of the current state
// This is used for state verification and integrity checks
getRootHash(): BytesLike {
return solidityPackedKeccak256(
["string"],
[JSON.stringify(this.state.games)]
);
}
}
Explanation:
We define an interface
Game
to represent the state of the Tic Tac Toe game.The
initialState
sets up the initial conditions, including an empty board and the starting player.A
State
instance is created to manage the game state.
Implementing the Game Logic
Next, we’ll add the logic to handle player moves and check for wins.
src/stackr/machine.ts
import { StateMachine } from "@stackr/sdk/machine";
import { TicTacToeState, initialState } from "./state";
import { transitions } from "./transitions";
// Define the State Machine for Tic Tac Toe
const machine = new StateMachine({
id: "tic-tac-toe", // Unique identifier for this state machine
stateClass: TicTacToeState, // The state class used by this machine
initialState, // Initial state
on: transitions, // Transitions that can be applied to the state
});export { machine };
Explanation:
StateMachine
: Initializes the state machine with an ID, state class, initial state, and available transitions.
Implementing Transitions
src/stackr/transitions.ts
Define the possible actions/transitions in the game.
// src/stackr/transitions.ts
import { Transitions, SolidityType } from "@stackr/sdk/machine";
import { TicTacToeState } from "./state";
import { hashMessage } from "ethers";const winningCombinations: number[][] = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];// Define the createGame transition
const createGame = TicTacToeState.STF({
schema: {
owner: SolidityType.ADDRESS,
playerO: SolidityType.ADDRESS,
startedAt: SolidityType.UINT,
},
handler: ({ state, inputs, msgSender, block, emit }) => {
// Ensure that msgSender matches inputs.owner
if (msgSender.toLowerCase() !== inputs.owner.toLowerCase()) {
emit({
name: "InvalidSigner",
value: `${msgSender} is not authorized to create a game.`,
});
return state;
} // Generate a unique game ID
const gameId = hashMessage(
`${msgSender}::${block.timestamp}::${Object.keys(state.games).length}`
); // Initialize the new game
state.games[gameId] = {
gameId,
owner: inputs.owner,
playerO: inputs.playerO,
board: Array(9).fill(""),
currentPlayer: "X",
winner: null,
startedAt: inputs.startedAt,
endedAt: null,
}; // Emit an event for game creation
emit({
name: "GameCreated",
value: gameId,
}); return state;
},
});// Define the makeMove transition
const makeMove = TicTacToeState.STF({
schema: {
gameId: SolidityType.STRING,
index: SolidityType.UINT,
player: SolidityType.STRING, // "X" or "O"
},
handler: ({ state, inputs, msgSender, block, emit }) => {
const { gameId, index, player } = inputs;
const game = state.games[gameId]; if (!game) {
emit({
name: "GameNotFound",
value: gameId,
});
return state;
} // Validate player
if (player !== game.currentPlayer) {
emit({
name: "InvalidPlayer",
value: `${player} attempted to move.`,
});
return state;
} // Validate move
if (game.board[index] !== "") {
emit({
name: "InvalidMove",
value: `${index} is already occupied.`,
});
return state;
} // Make the move
game.board[index] = player; // Check for a winner
let winner: "X" | "O" | "Draw" | null = null;
for (const combination of winningCombinations) {
const [a, b, c] = combination;
if (
game.board[a] &&
game.board[a] === game.board[b] &&
game.board[a] === game.board[c]
) {
winner = game.board[a] as "X" | "O";
break;
}
} // Check for draw
if (!winner && game.board.every((cell) => cell !== "")) {
winner = "Draw";
} if (winner) {
game.winner = winner;
game.endedAt = block.timestamp;
emit({
name: "GameEnded",
value: `${gameId}::${winner}`,
});
} else {
// Switch player
game.currentPlayer = player === "X" ? "O" : "X";
emit({
name: "MoveMade",
value: `${gameId}::${player}::${index}`,
});
} return state;
},
});// Define the resetGame transition
const resetGame = TicTacToeState.STF({
schema: {
gameId: SolidityType.STRING,
},
handler: ({ state, inputs, msgSender, block, emit }) => {
const { gameId } = inputs;
const game = state.games[gameId]; if (!game) {
emit({
name: "GameNotFound",
value: gameId,
});
return state;
} // Only the game owner can reset the game
if (msgSender.toLowerCase() !== game.owner.toLowerCase()) {
emit({
name: "UnauthorizedReset",
value: `${msgSender} is not authorized to reset the game.`,
});
return state;
} // Reset the game
state.games[gameId] = {
...game,
board: Array(9).fill(""),
currentPlayer: "X",
winner: null,
endedAt: null,
}; emit({
name: "GameReset",
value: gameId,
}); return state;
},
});// Export the transitions
export const transitions: Transitions<TicTacToeState> = {
createGame,
makeMove,
resetGame,
};
Explanation:
createGame
Transition: Initializes a new Tic Tac Toe game with two players.makeMove
Transition: Processes a player's move, updates the board, checks for a winner or draw, and switches the current player.resetGame
Transition: Resets an existing game to its initial state.
Stackr Labs Integration
src/stackr/mru.ts
Initialize and configure the Stackr Micro-Rollup (MRU) instance.
import { MicroRollup } from "@stackr/sdk";
import { stackrConfig } from "../../stackr.config";
import { machine } from "./machine";
// Create a new MRU instance
const mru = await MicroRollup({
config: stackrConfig, // Configuration for the MRU
stateMachines: [machine], // State machines used by the MRU
});export { mru };
Explanation:
MicroRollup
: Represents the Stackr MRU instance.stateMachines
: Array of state machines the MRU will manage.
Main Application Logic
src/index.ts
Set up the Express server, define API endpoints, and integrate with Stackr.
import { ActionConfirmationStatus, MicroRollup } from "@stackr/sdk";
import { machine } from "./stackr/machine";
import { Playground } from "@stackr/sdk/plugins";
import express, { Request, Response } from "express";
import { mru } from "./stackr/mru";
import path from "path";
import dotenv from "dotenv";
import { verifyTypedData } from "ethers";
dotenv.config();/**
* Main function to set up and run the Stackr micro rollup server
*/
const main = async () => {
// Initialize the MRU instance
await mru.init(); // Initialize the Playground plugin for debugging and testing
Playground.init(mru); // Set up Express server
const app = express();
const PORT = process.env.PORT || 3012; app.use(express.json()); // Serve static files from the "public" directory
app.use(express.static(path.join(__dirname, "public"))); // Enable CORS for all routes
app.use((_req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept"
);
next();
}); // Serve the HTML file for the Tic Tac Toe game
app.get("/", (req: Request, res: Response) => {
res.sendFile(path.join(__dirname, "public", "tic_tac_toe.html"));
}); /**
* GET /info - Retrieve information about the MicroRollup instance
* Returns domain information and schema map for action reducers
*/
app.get("/info", (req: Request, res: Response) => {
const schemas = mru.getStfSchemaMap();
const { name, version, chainId, verifyingContract, salt } =
mru.config.domain;
res.send({
signingInstructions: "signTypedData(domain, schema.types, inputs)",
domain: {
name,
version,
chainId,
verifyingContract,
salt,
},
schemas,
});
}); /**
* POST /createGame - Submit a createGame action
*/
app.post("/createGame", async (req: Request, res: Response) => {
const reducerName = "createGame";
const actionReducer = mru.getStfSchemaMap()[reducerName]; if (!actionReducer) {
res.status(400).send({ message: "NO_REDUCER_FOR_ACTION" });
return;
} try {
const { msgSender, signature, inputs } = req.body; // Log received data for debugging
console.log(`Received createGame action from: ${msgSender}`);
console.log(`Inputs:`, inputs);
console.log(`Signature: ${signature}`); const schemaTypes = {
createGame: [
{ name: "owner", type: "address" },
{ name: "playerO", type: "address" },
{ name: "startedAt", type: "uint256" },
],
Action: [
{ name: "name", type: "string" },
{ name: "inputs", type: "createGame" },
],
}; const createGameAction = {
name: "createGame",
inputs: inputs,
}; const actionParams = {
name: reducerName,
inputs,
signature,
msgSender,
}; // Submit the action to the MicroRollup instance
const ack = await mru.submitAction(actionParams); // Wait for the action to be confirmed (C1 status)
const { errors, logs } = await ack.waitFor(ActionConfirmationStatus.C1); if (errors?.length) {
console.error("Action errors:", errors);
throw new Error(errors[0].message);
} res.status(201).send({ logs });
} catch (e: any) {
console.error("Error processing action:", e.message);
res.status(400).send({ error: e.message });
} return;
}); /**
* POST /makeMove - Submit a makeMove action
*/
app.post("/makeMove", async (req: Request, res: Response) => {
const reducerName = "makeMove";
const actionReducer = mru.getStfSchemaMap()[reducerName]; if (!actionReducer) {
res.status(400).send({ message: "NO_REDUCER_FOR_ACTION" });
return;
} try {
const { msgSender, signature, inputs } = req.body; // Log received data for debugging
console.log(`Received makeMove action from: ${msgSender}`);
console.log(`Inputs:`, inputs);
console.log(`Signature: ${signature}`); const actionParams = {
name: reducerName,
inputs,
signature,
msgSender,
}; // Submit the action to the MicroRollup instance
const ack = await mru.submitAction(actionParams); // Wait for the action to be confirmed (C1 status)
const { errors, logs } = await ack.waitFor(ActionConfirmationStatus.C1); if (errors?.length) {
console.error("Action errors:", errors);
throw new Error(errors[0].message);
} res.status(201).send({ logs });
} catch (e: any) {
console.error("Error processing action:", e.message);
res.status(400).send({ error: e.message });
} return;
}); /**
* POST /resetGame - Submit a resetGame action
*/
app.post("/resetGame", async (req: Request, res: Response) => {
const reducerName = "resetGame";
const actionReducer = mru.getStfSchemaMap()[reducerName]; if (!actionReducer) {
res.status(400).send({ message: "NO_REDUCER_FOR_ACTION" });
return;
} try {
const { msgSender, signature, inputs } = req.body; // Log received data for debugging
console.log(`Received resetGame action from: ${msgSender}`);
console.log(`Inputs:`, inputs);
console.log(`Signature: ${signature}`); const actionParams = {
name: reducerName,
inputs,
signature,
msgSender,
}; // Submit the action to the MicroRollup instance
const ack = await mru.submitAction(actionParams); // Wait for the action to be confirmed (C1 status)
const { errors, logs } = await ack.waitFor(ActionConfirmationStatus.C1); if (errors?.length) {
console.error("Action errors:", errors);
throw new Error(errors[0].message);
} res.status(201).send({ logs });
} catch (e: any) {
console.error("Error processing action:", e.message);
res.status(400).send({ error: e.message });
} return;
}); /**
* GET /games - Retrieve all games from the state machine
*/
app.get("/games", async (req: Request, res: Response) => {
const { games } = machine.state;
res.json(games);
}); /**
* GET /games/:gameId - Retrieve a specific game by ID
*/
app.get("/games/:gameId", async (req: Request, res: Response) => {
const { gameId } = req.params;
const { games } = machine.state; const game = games[gameId]; if (!game) {
res.status(404).send({ message: "GAME_NOT_FOUND" });
return;
} res.json(game);
}); // Start the server
app.listen(PORT, () => {
console.log(`Server running on <http://localhost>:${PORT}`);
});
};// Run the main function
main().catch((error) => {
console.error("Error starting the server:", error);
});
Explanation:
/info
Endpoint: Provides information about the MRU's domain and schemas./:reducerName
Endpoint: Accepts actions to perform state transitions (e.g.,createGame
,makeMove
,resetGame
)./games
&/games/:gameId
Endpoints: Retrieve all games or a specific game respectively.CORS: Enabled for all routes to allow cross-origin requests.
Static Files: Serves the frontend from the
public
directory.
Frontend Implementation
A. Create the Frontend Directory
Create a public
directory inside your project root to store frontend files.
mkdir src/public
touch src/public/tic-tac-toe.html
B. src/public/tic-tac-toe.html
Create the HTML and JavaScript frontend for the Tic Tac Toe game.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tic Tac Toe - Stackr Labs</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
margin-top: 50px;
}
#board {
display: grid;
grid-template-columns: repeat(3, 100px);
grid-gap: 5px;
justify-content: center;
margin: 20px auto;
}
.cell {
width: 100px;
height: 100px;
border: 2px solid #333;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
cursor: pointer;
}
#message {
margin-top: 20px;
font-size: 24px;
}
#controls {
margin-top: 30px;
}
button {
padding: 10px 20px;
font-size: 16px;
margin: 0 10px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Tic Tac Toe</h1><div id="gameControls">
<button id="createGame">Create New Game</button>
</div><div id="board"></div><div id="message"></div><div id="controls">
<button id="resetGame">Reset Game</button>
</div><script src="<https://cdn.jsdelivr.net/npm/ethers@5.7.0/dist/ethers.umd.min.js>"></script>
<script>
const provider = new ethers.providers.Web3Provider(window.ethereum);
let signer;
let gameId = null;
let gameState = null; const messageElement = document.getElementById("message"); // Properly defined // Initialize the app
async function init() {
try {
// Request account access if needed
await provider.send("eth_requestAccounts", []);
signer = provider.getSigner();
console.log("Wallet connected:", await signer.getAddress()); // Event listeners
document.getElementById("createGame").addEventListener("click", createGame);
document.getElementById("resetGame").addEventListener("click", resetGame);
} catch (error) {
console.error("Error connecting wallet:", error);
messageElement.textContent = "Please connect your wallet.";
}
} // Function to create a new game
async function createGame() {
try {
const msgSender = await signer.getAddress(); // Use the connected wallet's address // Define domain and schema types for signing data
const domain = {
name: "Stackr MVP v0",
version: "1",
chainId: 11155111, // Sepolia Testnet
verifyingContract: "0x0000000000000000000000000000000000000000",
salt: "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}; const schemaTypes = {
CreateGame: [
{ name: "owner", type: "address" },
{ name: "playerO", type: "address" },
{ name: "startedAt", type: "uint256" },
],
Action: [
{ name: "name", type: "string" },
{ name: "inputs", type: "CreateGame" },
],
}; const playerO = prompt("Enter Player O's Ethereum Address:");
if (!playerO || !ethers.utils.isAddress(playerO)) {
messageElement.textContent = "Invalid Player O address.";
return;
} const createGameInputs = {
owner: msgSender,
playerO: playerO,
startedAt: Math.floor(Date.now() / 1000),
}; const createGameAction = {
name: "createGame",
inputs: createGameInputs,
}; // Sign the data using the connected wallet's signer
const signature = await signer._signTypedData(
domain,
schemaTypes,
createGameAction
); // Submit the createGame action to the backend
const response = await fetch("/createGame", { // Ensure backend has '/createGame' route
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
msgSender,
inputs: createGameInputs,
signature,
}),
}); if (!response.ok) {
const errorData = await response.json();
console.error("Backend Error:", errorData);
messageElement.textContent = `Error: ${errorData.error || "Unknown error."}`;
return;
} const result = await response.json();
console.log("Game created with logs:", result.logs); // Extract gameId from logs
const gameCreatedLog = result.logs.find(log => log.name === "GameCreated");
if (gameCreatedLog) {
gameId = gameCreatedLog.value;
messageElement.textContent = `Game created! Game ID: ${gameId}`;
fetchGameState();
} else {
messageElement.textContent = "Failed to create game.";
}
} catch (error) {
console.error("Error creating game:", error);
messageElement.textContent = "Error creating game.";
}
} // Function to make a move
async function makeMove(index) {
if (!gameId) {
messageElement.textContent = "Please create a game first.";
return;
} try {
const msgSender = await signer.getAddress(); // Define domain and schema types for signing data
const domain = {
name: "Stackr MVP v0",
version: "1",
chainId: 11155111, // Sepolia Testnet
verifyingContract: "0x0000000000000000000000000000000000000000",
salt: "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
}; const schemaTypes = {
MakeMove: [
{ name: "gameId", type: "string" },
{ name: "index", type: "uint256" },
{ name: "player", type: "string" }, // "X" or "O"
],
Action: [
{ name: "name", type: "string" },
{ name: "inputs", type: "MakeMove" },
],
}; const player = gameState.currentPlayer; const makeMoveInputs = {
gameId,
index,
player,
}; const makeMoveAction = {
name: "makeMove",
inputs: makeMoveInputs,
}; // Sign the data using the connected wallet's signer
const signature = await signer._signTypedData(
domain,
schemaTypes,
makeMoveAction
); // Submit the makeMove action to the backend
const response = await fetch("/makeMove", { // Ensure backend has '/makeMove' route
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
msgSender,
inputs: makeMoveInputs,
signature,
}),
}); if (!response.ok) {
const errorData = await response.json();
console.error("Backend Error:", errorData);
messageElement.textContent = `Error: ${errorData.error || "Unknown error."}`;
return;
} const result = await response.json();
console.log("Move made with logs:", result.logs); // Handle logs to update the UI
const moveMadeLog = result.logs.find(log => log.name === "MoveMade");
const gameEndedLog = result.logs.find(log => log.name === "GameEnded"); if (moveMadeLog) {
messageElement.textContent = `Move made by ${makeMoveInputs.player}.`;
fetchGameState();
} else if (gameEndedLog) {
const [endedGameId, winner] = gameEndedLog.value.split("::");
messageElement.textContent = winner === "Draw" ? "Game ended in a draw!" : `Player ${winner} wins!`;
fetchGameState();
} else {
messageElement.textContent = "Failed to make move.";
}
} catch (error) {
console.error("Error making move:", error);
messageElement.textContent = "Error making move.";
}
} // Function to reset the game
async function resetGame() {
if (!gameId) {
messageElement.textContent = "No game to reset.";
return;
} try {
const msgSender = await signer.getAddress(); // Define domain and schema types for signing data
const domain = {
name: "Stackr MVP v0",
version: "1",
chainId: 11155111, // Sepolia Testnet
verifyingContract: "0x0000000000000000000000000000000000000000",
salt: "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
};
const schemaTypes = {
ResetGame: [
{ name: "gameId", type: "string" },
],
Action: [
{ name: "name", type: "string" },
{ name: "inputs", type: "ResetGame" },
],
}; const resetGameInputs = {
gameId,
}; const resetGameAction = {
name: "resetGame",
inputs: resetGameInputs,
}; // Sign the data using the connected wallet's signer
const signature = await signer._signTypedData(
domain,
schemaTypes,
resetGameAction
); // Submit the resetGame action to the backend
const response = await fetch("/resetGame", { // Ensure backend has '/resetGame' route
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
msgSender,
inputs: resetGameInputs,
signature,
}),
}); if (!response.ok) {
const errorData = await response.json();
console.error("Backend Error:", errorData);
messageElement.textContent = `Error: ${errorData.error || "Unknown error."}`;
return;
} const result = await response.json();
console.log("Game reset with logs:", result.logs); const gameResetLog = result.logs.find(log => log.name === "GameReset"); if (gameResetLog) {
messageElement.textContent = "Game has been reset!";
fetchGameState();
} else {
messageElement.textContent = "Failed to reset game.";
}
} catch (error) {
console.error("Error resetting game:", error);
messageElement.textContent = "Error resetting game.";
}
} // Function to fetch the current game state
async function fetchGameState() {
try {
const response = await fetch(`/games/${gameId}`);
if (!response.ok) {
const errorData = await response.json();
console.error("Backend Error:", errorData);
messageElement.textContent = `Error: ${errorData.message || "Failed to fetch game state."}`;
return;
}
const data = await response.json();
gameState = data;
updateBoard();
if (gameState.winner) {
messageElement.textContent = gameState.winner === "Draw" ? "Game ended in a draw!" : `Player ${gameState.winner} wins!`;
} else {
messageElement.textContent = `Player ${gameState.currentPlayer}'s turn.`;
}
} catch (error) {
console.error("Error fetching game state:", error);
messageElement.textContent = "Error fetching game state.";
}
} // Function to update the board UI
function updateBoard() {
const boardElement = document.getElementById("board");
boardElement.innerHTML = "";
gameState.board.forEach((cell, index) => {
const cellDiv = document.createElement("div");
cellDiv.classList.add("cell");
cellDiv.textContent = cell;
if (cell === "") {
cellDiv.addEventListener("click", () => makeMove(index));
}
boardElement.appendChild(cellDiv);
});
} // Initialize the app on page load
window.onload = init;
</script></body>
</html>
Explanation:
Connect to MetaMask: Requests the user to connect their wallet.
Deterministic Wallet: Generates a deterministic wallet based on the user’s signature to avoid signing every transaction.
Create Game: Allows the user to create a new game by specifying Player O’s Ethereum address.
Make Move: Enables players to make moves on the board by clicking on empty cells.
Reset Game: Resets the game to its initial state.
Fetch Game State: Retrieves the current state of the game to update the UI accordingly.
Testing Your Game
After setting up the backend and frontend, you can start your development server:
bun start
Open your browser and navigate to http://localhost:3012
to see your Tic Tac Toe game in action!
Conclusion
Congratulations! 🎉 You’ve successfully built a decentralized Tic Tac Toe game using Stackr Labs and Micro Rollups. This project showcases how to manage game state on the blockchain while providing a simple and engaging user interface.
Feel free to extend the game further, add features like multiplayer support, or improve the UI. Happy coding! If you have any questions or need further assistance, please feel free to contact me. You can connect with me on X / Twitter or LinkedIn.
Subscribe to my newsletter
Read articles from 0xKaushik directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
