In this guide, we'll walk through the process of implementing a Stackr Micro-Rollup using the popular memory game Simon as our example. Stackr is a powerful framework for building scalable and efficient blockchain applications. We'll cover the key components of a Stackr Micro-Rollup and how they work together to create a functional decentralized application.
Stackr is a framework that allows developers to build scalable blockchain applications using a rollup architecture. Micro-rollups (MRU) are scaling solutions that process executions offchain to facilitate complex logic and then submit proofs to the main chain, significantly improving throughput and reducing costs.
Micro-rollups enhance blockchain scalability by executing complex logic offchain while ensuring onchain verification via an external layer called Vulcan. They are self-contained, stateful environments that can be hosted flexibly and operate efficiently without the burden of generating proofs. Acting as decentralized backends, MRUs combine web2's speed and user experience with blockchain's security and transparency, enabling developers to build scalable, secure apps with less complexity.
Git: You can download it here
Bun: Installation guide
Stackr CLI: The Stackr CLI helps you to create, register, and deploy your MRUs.
Install it globally using:
bun i -g @stackr/cli
Run it without installing:
bunx @stackr/cli
Create a new project using the following command:
bunx @stackr/cli@latest init
Choose the Empty template from the list and follow the instructions:
_ _ _ _
___| |_ __ _ ___| | ___ __ ___| (_)
/ __| __/ _` |/ __| |/ / '__| _____ / __| | |
\__ \ || (_| | (__| <| | |_____| | (__| | |
|___/\__\__,_|\___|_|\_\_| \___|_|_|
? Pick a template (Use arrow keys)
Counter (Recommended)
Chess
Token Transfer
Bridge
❯ Empty
Enter the project name you want and select the SQLite database.
? Database Driver (Use arrow keys)
❯ SQLite (Recommended)
PostgreSQL
MySQL
MariaDB
After the CLI generates all the project you will have to follow the instructions it gives you:
✔ ⚙️ Set up MRU
✔ 🧹 Set up git
✔ ⬇️ Installed packages
Get started by running the following commands:
1. cd simon-game/
2. cp .env.example .env & modify values
3. Register your rollup using npx @stackr/cli@latest register
You can use the following .env values:
PRIVATE_KEY= --YOUR DEVELOPMENT PRIVATE KEY--
VULCAN_RPC=https://sepolia.vulcan.stf.xyz/
L1_RPC=https://rpc2.sepolia.org/
REGISTRY_CONTRACT=0x985bAfb3cAf7F4BD13a21290d1736b93D07A1760
NODE_ENV=development
DATABASE_URI=./db.sqlite
Stackr has a default and recommended projects structure:
├── src
│ ├── stackr
│ │ ├── machine.ts
│ │ ├── mru.ts
│ │ ├── state.ts
│ │ └── transitions.ts
│ └── index.ts
│── .env
└── stackr.config.ts
The root directory contains the configuration files of your project.
.env:
contains the necessary MRU requited environment variables
stackr.config.ts
: exports the StackrConfig defining the MRU configuration, you can learn more about it here
The src directory contains source files of your project.
index.ts
: is the main entrypoint file containing the code to run the MRU and the Express API server to communicate with the MRUThe src/stackr
directory contains the source code that defines the MRU with the Stackr SDK.
machine.ts
: contains all the state machine configuration
state.ts
: defines the custom state
transitions.ts
: defines the state transition functions
mru.ts
: exports the MicroRollup instance, the project starter doesn’t create it because is not necessary, but it will help us separate the logic
The first step in building a Stackr rollup is to define your application's state. In our Simon game example, we'll create a SimonState
class that extends the State
class from the Stackr SDK in the state.ts
file.
import { State } from "@stackr/sdk/machine";
import { BytesLike, solidityPackedKeccak256 } from "ethers";
// Define the structure of a Simon game
interface Game {
gameId: string;
owner: string; // Connected wallet address
user: string; // Deterministic generated wallet address
startedAt: number;
endedAt: number;
roundCount: number;
userSequence: string;
gameSequence: string;
}
// Define the overall application state
export interface AppState {
games: Record<string, Game>;
}
// SimonState class extends the State class from @stackr/sdk
export class SimonState 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)]
);
}
}
This state definition includes a games
object that stores all active Simon games, with each game containing properties like the game ID, owner, user, timestamps, and game sequences.
We store owner
and user
because in the frontend we are going to create a deterministic wallet with the connected wallet signature, doing this we are going to be able to play the Simon game without having to sign every transaction.
Next, we'll create a state machine that uses our SimonState
class in the machine.ts
file. The state machine defines the initial state and links it to the transition functions.
import { StateMachine } from "@stackr/sdk/machine";
import genesisState from "../../genesis-state.json";
import { SimonState } from "./state";
import { transitions } from "./transitions";
// Define the State Machine for the Simon game
const machine = new StateMachine({
id: "simon", // Unique identifier for this state machine
stateClass: SimonState, // The state class used by this machine
initialState: genesisState.state, // Initial state loaded from a JSON file
on: transitions, // Transitions that can be applied to the state
});
export { machine };
Transitions are the heart of your Stackr rollup. They define how the state changes in response to user actions. In our Simon game, we'll implement two main transitions: createGame
and userMoves
in our transitions.ts
file.
import { Transitions, SolidityType } from "@stackr/sdk/machine";
import { SimonState } from "./state";
import { hashMessage } from "ethers";
const colors: string[] = ["red", "blue", "yellow", "green"];
// Define the createGame transition
const createGame = SimonState.STF({
schema: {
timestamp: SolidityType.UINT,
owner: SolidityType.ADDRESS,
},
handler: ({ state, inputs, msgSender, block, emit }) => {
// Generate a unique game ID
const gameId = hashMessage(
`${msgSender}::${block.timestamp}::${
Object.keys(state.games).length
}`
);
// Create a new game in the state
state.games[gameId] = {
gameId: gameId,
owner: inputs.owner,
user: msgSender,
startedAt: block.timestamp,
endedAt: 0,
roundCount: 1,
userSequence: "",
gameSequence: generateNewMove(),
};
// Emit an event for game creation
emit({
name: "Game Created",
value: gameId,
});
return state;
},
});
// Helper function to generate a new move in the game sequence
function generateNewMove(moves: string = "") {
const randomIndex = Math.floor(Math.random() * colors.length);
const newMove = colors[randomIndex];
if (moves.length > 0) {
return moves.concat(`,${newMove}`);
}
return moves.concat(`${newMove}`);
}
// Define the userMoves transition
const userMoves = SimonState.STF({
schema: {
gameId: SolidityType.STRING,
moves: SolidityType.STRING,
},
handler: ({ state, inputs, msgSender, block, emit }) => {
const { gameId, moves } = inputs;
const game = state.games[gameId];
// Check if the user is valid
if (game.user != msgSender) {
emit({
name: "Invalid User",
value: `${gameId},${game.user},${msgSender}`,
});
return state;
}
game.userSequence = moves;
// Check if the user's moves match the game sequence
if (game.gameSequence == moves) {
// If correct, generate a new move and increment the round
game.gameSequence = generateNewMove(game.gameSequence);
game.roundCount += 1;
emit({
name: "New Move",
value: `${gameId},${game.roundCount}`,
});
} else {
// If incorrect, end the game
game.endedAt = block.timestamp;
emit({
name: "Game Ended",
value: `${gameId},${game.roundCount}`,
});
}
return state;
},
});
// Export the transitions
export const transitions: Transitions<SimonState> = {
createGame,
userMoves,
};
These transitions handle creating new games and processing user moves, updating the state accordingly.
The MRU is the core component that ties everything together. It's responsible for managing the state machine and processing actions.
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 };
We need to initialize our MRU in the index.ts
to start it, also we will initialize the playground for easier debugging and testing, give it a try you can play Dino there ;)
import { ActionConfirmationStatus, MicroRollup } from "@stackr/sdk";
import { machine } from "./stackr/machine.ts";
import { Playground } from "@stackr/sdk/plugins";
import express, { Request, Response } from "express";
import { mru } from "./stackr/mru.ts";
import path from "path";
/**
* 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);
We need a way to communite with the MRU without having it in the same application, we are going to use RESTful API Express server to send all the user interactions and receive all the state.
import { ActionConfirmationStatus, MicroRollup } from "@stackr/sdk";
import { machine } from "./stackr/machine.ts";
import { Playground } from "@stackr/sdk/plugins";
import express, { Request, Response } from "express";
import { mru } from "./stackr/mru.ts";
import path from "path";
/**
* 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();
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 Simon game
app.get("/", (req: Request, res: Response) => {
res.sendFile(path.join(__dirname, "public", "simon.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: "signTypedDate(domain, schema.types, inputs)",
domain: {
name,
version,
chainId,
verifyingContract,
salt,
},
schemas,
});
});
/**
* POST /:reducerName - Submit an action to a specific reducer
* Processes the action and returns the result or any errors
*/
app.post("/:reducerName", async (req: Request, res: Response) => {
const { reducerName } = req.params;
const actionReducer = mru.getStfSchemaMap()[reducerName];
if (!actionReducer) {
res.status(400).send({ message: "NO_REDUCER_FOR_ACTION" });
return;
}
try {
const { msgSender, signature, inputs } = req.body;
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) {
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(3012, () => {
console.log("Server running on port 3012");
});
};
// Run the main function
main();
This server provides endpoints for submitting actions, retrieving game information, and querying the current state of the rollup.
We also modified the main endpoint to serve a basic HTML file that is going to be the frontend of the Simon game, in the next section we are going to see how we can communicate between the frontend and the MRU.
Let's see how we can communicate with the MRU in order to play the game through the API we have created.
We prompt the user to sign a message to generate a new wallet with that signature, we are going to create a game and send the user moves with this new wallet.
// Function to create a deterministic wallet
async function createDeterministicWallet() {
await connectMetaMask();
const signer = provider.getSigner();
const msgSender = await signer.getAddress();
const message = `Simon Game Wallet: ${msgSender}`;
const signature = await signer.signMessage(message);
const walletSeed = ethers.utils.keccak256(
ethers.utils.toUtf8Bytes(signature)
);
deterministicWallet = new ethers.Wallet(walletSeed);
console.log("Deterministic wallet address:", deterministicWallet.address);
}
We use the deterministic wallet to sign the transaction with the domain, schema types and the unique inputs like the timestamp and the owner address.
// Event listener for "Create New Game" button click
document
.getElementById("createGameButton")
.addEventListener("click", async () => {
try {
await createDeterministicWallet();
const msgSender = deterministicWallet.address;
// Define domain and schema types for signing data
const domain = {
name: "Stackr MVP v0",
version: "1",
chainId: 11155111,
verifyingContract: "0x0000000000000000000000000000000000000000",
salt: "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
};
const schemaTypes = {
CreateGame: [
{ name: "timestamp", type: "uint256" },
{ name: "owner", type: "address" },
],
Action: [
{ name: "name", type: "string" },
{ name: "inputs", type: "CreateGame" },
],
};
const createGameInputs = {
timestamp: Math.floor(Date.now() / 1000),
owner: await provider.getSigner().getAddress(),
};
const createGameAction = {
name: "createGame",
inputs: createGameInputs,
};
// Sign the data using the deterministic wallet
const signature = await deterministicWallet._signTypedData(
domain,
schemaTypes,
createGameAction
);
// Send request to create a new game
const response = await fetch("http://localhost:3012/createGame", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
msgSender,
inputs: createGameInputs,
signature,
}),
});
const result = await response.json();
console.log("Game created with logs:", result.logs);
// Store game ID and start the game
gameId = result.logs[0].value;
roundNumber = 1;
startGame(gameId);
} catch (error) {
console.error("Error creating game:", error);
}
});
When the user play all the moves for that round we call this function to submit the round to the MRU using the API, if the logs return a New Move
we continue playing but if it returns a Game Ended
we stop the game and communicate it to the user.
// Function to submit user's moves and proceed to the next round
async function submitUserMoves() {
try {
const msgSender = deterministicWallet.address;
// Define schema types for signing user's moves
const schemaTypes = {
UserMoves: [
{ name: "gameId", type: "string" },
{ name: "moves", type: "string" },
],
Action: [
{ name: "name", type: "string" },
{ name: "inputs", type: "UserMoves" },
],
};
const userMoveInputs = {
gameId,
moves: userSequence,
};
const userMoveAction = {
name: "userMoves",
inputs: userMoveInputs,
};
const domain = {
name: "Stackr MVP v0",
version: "1",
chainId: 11155111,
verifyingContract: "0x0000000000000000000000000000000000000000",
salt: "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
};
// Sign user's moves using the deterministic wallet
const signature = await deterministicWallet._signTypedData(
domain,
schemaTypes,
userMoveAction
);
// Send request to submit user's moves
const response = await fetch("http://localhost:3012/userMoves", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
msgSender,
inputs: userMoveInputs,
signature,
}),
});
const result = await response.json();
console.log("Move submitted with logs:", result.logs);
// Handle game progression based on MRU response
if (result.logs[0].name === "New Move") {
userSequence = "";
roundNumber++;
displayGameSequence();
} else if (result.logs[0].name === "Game Ended") {
alert(
`Game Over! You reached round ${
result.logs[0].value.split(",")[1]
}`
);
document.getElementById("gameBoard").style.display = "none";
}
} catch (error) {
console.error("Error submitting moves:", error);
}
}
You can find all the source code from the frontend in the public/simon.html
file in the repository.You can play this Simon game by running bun start
and access the URL localhost:3012 to play your first Simon in an MRU
Implementing a Stackr rollup involves several key components working together:
Defining the application state
Creating a state machine
Implementing state transition functions
Setting up the Micro-Rollup (MRU)
Building an API server to interact with the rollup
Crafting the methods to communicate with the API
By following this structure, you can create scalable and efficient blockchain applications that leverage the power of rollups. The Simon game example demonstrates how to implement game logic, manage state, and process user actions within the Stackr framework.
Thank you for reading. 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.