How to implement a Stackr MicroRollup: Building a Simon Game

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.

Table of Contents

  1. Introduction to Stackr

    1. Why use a Micro-Rollup?
  2. Setting Up the Project

    1. Prerequisites

    2. Setting Up your first Micro-Rollup

    3. Project Structure

  3. Defining the State

  4. Creating the State Machine

  5. Implementing the Transitions

  6. Setting Up the Micro-Rollup

  7. Initializing the MRU

  8. Building the API Server

  9. Interacting with the MRU

    1. Create a deterministic wallet

    2. Create a game with the deterministic wallet

    3. Submit user moves

  10. Conclusion

Introduction to Stackr

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.

Why use a Micro-Rollup?

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.

Setting Up the Project

Prerequisites

  • 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
    

Setting Up your first Micro-Rollup

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

Project Structure

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 MRU

The 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

Defining the State

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.

Creating the State Machine

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 };

Implementing Transitions

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.

Setting Up the Micro-Rollup

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 };

Initializing the 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);

Building the API Server

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.

Interacting with the MRU

Let's see how we can communicate with the MRU in order to play the game through the API we have created.

Create a deterministic wallet

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);
}

Create a game with the deterministic wallet

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);
        }
    });

Submit user moves

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

Conclusion

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.

Subscribe to 0xrouss
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.