Big Handmade Maps 100% On-Chain. Is it Possible?

Ahmed Castro - Sep 4 - - Dev Community

Dark Forest has shown that procedurally generated maps can be both engaging to the players and cost-effective in terms of on-chain gas usage. However, in their procgen article, Nalin and Gubsheep (AW developers) expressed that creating handmade maps on-chain presents significant challenges. Inspired by this, I decided to take on the challenge of finding a scalable way to store large handmade maps on-chain.

In this tutorial, we will go into both the theory and practice of creating fully on-chain handcrafted maps. By creating a game where the map has obstacles (mountains) stored in a Merkle Tree optimized for data compression. The player will have to submit Merkle Inclusion Proofs in order to advance.

merkle tree game demo

Prefer to see the complete code? Head to Github to find all the code mentioned in this guide.

Starting with the Theory

Naive Implementation: Avoid this đź™…

Let's begin with a simple map where 0 represents grass and 1 represents mountains. The player can walk only on grass.

Map Bidimensional

Our first intuition might be to declare a two-dimensional mapping and populate it like this:

mapping(uint x => mapping(uint y => terrainType)) map;
map[1][0] = 1;
map[2][2] = 1;
map[2][3] = 1;
map[0][3] = 1;
Enter fullscreen mode Exit fullscreen mode

However, this approach is impractical for large maps due to the high gas costs and block size limitations. To store larger maps on-chain efficiently, we need a more scalable approach: Merkle Trees.

Merkleizing a Map

In this tutorial, we’ll transform a two-dimensional map into a Merkle tree. Players will prove their position by submitting a Merkle inclusion proof for the type of terrain they are on.

Why Merkleize a Map?

Merkleizing a map allows for proofs in logarithmic time, as opposed to linear. However, before Merkleizing, we must convert the map into a one-dimensional array using the following formula:

LEAF_INDEX=yĂ—MAP_WIDTH+x \text{LEAF{\textunderscore}INDEX} = y \times \text{MAP{\textunderscore}WIDTH} + x

For example, our 4x4 map would convert into the following array:

Bidimensional Map in unidimensional array

Merkleizing a one-dimensional array is more straightforward than doing so for a two-dimensional map. The process involves hashing adjacent elements (positions 0 and 1, 2 and 3, etc.) until we reach the Merkle root.

Merkleized map

When the game launches, the only data posted on-chain is the Merkle root. As players move, they submit Merkle proofs to verify their moves. This approach spreads the cost of storing the map across all players rather than placing the entire burden on the deployer.

In practice: Creating a big map on MUD

Supporting Material: How to Create and Autonomous World Game

Let's start by creating a new MUD project.

If you haven't installed MUD expand this and install the dependencies.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install 20
curl -L https://foundry.paradigm.xyz | bash export PATH=$PATH:~/.foundry/bin
sudo npm install -g pnpm
Enter fullscreen mode Exit fullscreen mode

Once you're ready create a phaser template.

pnpm create mud@latest tutorial --template phaser
cd tutorial
Enter fullscreen mode Exit fullscreen mode

The data

For this demo we will test a 32x32 map that we will define on the following public file.

packages/client/public/assets/map.json

{
    "map": [
        [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1],
        [0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
        [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    ]
}
Enter fullscreen mode Exit fullscreen mode

The table

Our table will consist of the players position, where every address can control only one player. Also a Singleton that holds the map merkle root as a commitment so no one can cheat later in the game.

packages/contracts/mud.config.ts

import { defineWorld } from "@latticexyz/world";

export default defineWorld({
  namespace: "app",
  enums: {
    Direction: [
      "Up",
      "Down",
      "Left",
      "Right"
    ]
  },
  tables: {
    PlayerPosition: {
      schema: {
        player: "address",
        x: "uint32",
        y: "uint32",
      },
      key: ["player"]
    },
    Map: {
      schema: {
        merkleRoot: "bytes32",
        size: "uint32"
      },
      key: [],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

The contract

First let's remove the normal files.

rm packages/contracts/src/systems/IncrementSystem.sol packages/contracts/test/CounterTest.t.sol
Enter fullscreen mode Exit fullscreen mode

Now let's commit to a merkle root in our PostDeploy script. Keep in mind that if you change the map data it will result in a new merkle root. I conveniently print the root on the terminal so you can grab it from there.

packages/contracts/script/PostDeploy.s.sol

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;

import { Script } from "forge-std/Script.sol";
import { console } from "forge-std/console.sol";
import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";

import { IWorld } from "../src/codegen/world/IWorld.sol";

import { Map } from "../src/codegen/index.sol";

contract PostDeploy is Script {
  function run(address worldAddress) external {
    // Specify a store so that you can use tables directly in PostDeploy
    StoreSwitch.setStoreAddress(worldAddress);

    // Load the private key from the `PRIVATE_KEY` environment variable (in .env)
    uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");

    // Start broadcasting transactions from the deployer account
    vm.startBroadcast(deployerPrivateKey);

    // Initialize Map
    Map.set(
      0xc99004d76733dbd8a4a6f3f3ecdc08392637d31e4339cce7c2b2aa7220e85fbf,
      32
    );

    vm.stopBroadcast();
  }
}
Enter fullscreen mode Exit fullscreen mode

And define the movement logic on chain. Notice we verify the merkle inclusion proofs on chain to check if the player is walking into grass and not a mountain.

packages/contracts/src/systems/MyGameSystem.sol

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;

import { System } from "@latticexyz/world/src/System.sol";
import { PlayerPosition, PlayerPositionData, Map } from "../codegen/index.sol";
import { Direction } from "../codegen/common.sol";
import { getKeysWithValue } from "@latticexyz/world-modules/src/modules/keyswithvalue/getKeysWithValue.sol";
import { EncodedLengths, EncodedLengthsLib } from "@latticexyz/store/src/EncodedLengths.sol";

contract MyGameSystem is System {
    function spawn(uint32 x, uint32 y) public {
        address player = _msgSender();
        PlayerPosition.set(player, x, y);
    }

    function move(Direction direction, bytes32 positionLeaf, bytes32[] calldata proof) public {
        require(positionLeaf == bytes32(0), "Must move to walkable area"); // 0 is grass, 1 is mountains
        address player = _msgSender();
        PlayerPositionData memory playerPosition = PlayerPosition.get(player);

        uint32 x = playerPosition.x;
        uint32 y = playerPosition.y;

        if(direction == Direction.Up)
            y-=1;
        if(direction == Direction.Down)
            y+=1;
        if(direction == Direction.Left)
            x-=1;
        if(direction == Direction.Right)
            x+=1;

        PlayerPosition.set(player, x, y);

        require(verify(positionLeaf, Map.getMerkleRoot(), proof, getLeafIndex(x, y)), "invalid proof");
    }

    function verify(bytes32 leaf, bytes32 root, bytes32[] calldata proof, uint256 leafIndex) internal pure returns (bool) {
        bytes32 computedHash = leaf;
        for (uint256 i = 0; i < proof.length; i++) {
            if (leafIndex % 2 == 0) {
                computedHash = keccak256(abi.encodePacked(computedHash, proof[i]));
            } else {
                computedHash = keccak256(abi.encodePacked(proof[i], computedHash));
            }
            leafIndex /= 2;
        }
        return computedHash == root;
    }

    function getLeafIndex(uint32 x, uint32 y) public view returns (uint32) {
        // Calculate the leaf index as y * mapWidth + x
        return y * Map.getSize() + x;
    }
}
Enter fullscreen mode Exit fullscreen mode

The client

The map interpreter

Let's modify the default Map System to interpret our map.json file.

packages/client/src/layers/phaser/systems/createMapSystem.ts

import { Tileset } from "../../../artTypes/world";
import { PhaserLayer } from "../createPhaserLayer";

export async function createMapSystem(layer: PhaserLayer) {
  const {
    scenes: {
      Main: {
        maps: {
          Main: { putTileAt },
        },
      },
    },
  } = layer;

  try {
    const response = await fetch('/assets/map.json');
    const data = await response.json();
    const map: number[][] = data.map;

    for (let y = 0; y < map.length; y++) {
      for (let x = 0; x < map[y].length; x++) {
        const coord = { x: x, y: y };
        const tileType = map[y][x];

        if (tileType === 1) {
          putTileAt(coord, Tileset.Mountain, "Foreground");
        } else {
          putTileAt(coord, Tileset.Grass, "Background");
        }
      }
    }
  } catch (error) {
    console.error("Error loading the map:", error);
  }
}
Enter fullscreen mode Exit fullscreen mode

The Client: User interaction and Merkle Proofs generation

packages/client/src/layers/phaser/systems/myGameSystem.ts

import { Has, defineEnterSystem, defineSystem, getComponentValueStrict } from "@latticexyz/recs";
import { PhaserLayer } from "../createPhaserLayer";
import { 
  pixelCoordToTileCoord,
  tileCoordToPixelCoord
} from "@latticexyz/phaserx";
import { TILE_WIDTH, TILE_HEIGHT, Animations, Directions } from "../constants";
import { keccak256, toUtf8Bytes, zeroPadValue } from 'ethers';

// Utils
function hashFunction(data: Uint8Array): string {
  return keccak256(data);
}

function hexStringToBytes(hex: string): Uint8Array {
  if (hex.startsWith('0x')) {
    hex = hex.slice(2);
  }
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < hex.length; i += 2) {
    bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
  }
  return bytes;
}

// Build the Merkle Tree
function buildMerkleTree(leafNodes: (number | string)[]): string {
  // Convert leaf nodes to bytes32 if they are numbers
  const processedLeafNodes = leafNodes.map(node =>
    typeof node === 'number' ? zeroPadValue("0x0"+node, 32) : node
  );

  let level = processedLeafNodes;
  while (level.length > 1) {
    const nextLevel: string[] = [];
    for (let i = 0; i < level.length; i += 2) {
      const left = level[i];
      const right = i + 1 < level.length ? level[i + 1] : left;
      const combined = new Uint8Array([
        ...hexStringToBytes(left),
        ...hexStringToBytes(right)
      ]);
      nextLevel.push(hashFunction(combined));
    }
    level = nextLevel;
  }
  return level[0];
}

// Hash the entire map
function hashMap(map: number[][]): string {
  const flatMap: (number | string)[] = map.flat();
  return buildMerkleTree(flatMap);
}

interface HashPath {
  leafHash: string;
  path: string[];  // Just hashes in the path
}

// Generate the hash path for a specific position
function generateHashPath(map: number[][], x: number, y: number): HashPath {
  const flatMap: (number | string)[] = map.flat();
  const index = y * map[0].length + x;
  const leafHash = typeof flatMap[index] === 'number'
    ? zeroPadValue("0x0"+flatMap[index], 32)
    : flatMap[index];
  const path: string[] = [];
  let level = flatMap.map(value => typeof value === 'number' ? zeroPadValue("0x0"+value, 32) : value);

  let currentIndex = index;

  while (level.length > 1) {
    const nextLevel: string[] = [];
    const levelLength = level.length;

    for (let i = 0; i < levelLength; i += 2) {
      const left = level[i];
      const right = i + 1 < levelLength ? level[i + 1] : left;
      const combined = new Uint8Array([
        ...hexStringToBytes(left),
        ...hexStringToBytes(right)
      ]);
      const parentHash = hashFunction(combined);
      nextLevel.push(parentHash);

      if (i === currentIndex || i + 1 === currentIndex) {
        const siblingIndex = i === currentIndex ? i + 1 : i;
        path.push(level[siblingIndex]); // Store only the hash
        currentIndex = Math.floor(currentIndex / 2); // Move up to the parent index
      }
    }
    level = nextLevel;
  }

  return { leafHash, path };
}

export const createMyGameSystem = (layer: PhaserLayer) => {
  const {
    world,
    networkLayer: {
      components: {
        PlayerPosition,
      },
      systemCalls: {
        spawn,
        move,
      }
    },
    scenes: {
        Main: {
            objectPool,
            input
        }
    }
  } = layer;

  let myPosition = {x: 0, y: 0};
  let map: number[][];

  const loadMap = async () => {
    try {
      const response = await fetch('/assets/map.json');
      const data = await response.json();
      map = data.map;
      console.log("Map loaded");

      const mapHash = hashMap(map);
      console.log('Map Hash (Merkle Root):', mapHash);

    } catch (error) {
      console.error("Error loading the map:", error);
    }
  };

  loadMap();

  input.pointerdown$.subscribe((event) => {
    const x = event.pointer.worldX;
    const y = event.pointer.worldY;
    const playerPosition = pixelCoordToTileCoord({ x, y }, TILE_WIDTH, TILE_HEIGHT);
    console.log(playerPosition)
    if(playerPosition.x == 0 && playerPosition.y == 0)
        return;
    spawn(playerPosition.x, playerPosition.y) 
  });

  input.onKeyPress((keys) => keys.has("W"), () => {
    myPosition.y -= 1;
    console.log(myPosition)

    const path = generateHashPath(map, myPosition.x, myPosition.y);

    console.log('Leaf Hash:', path.leafHash);
    console.log('Leaf Index:', myPosition.y * map[0].length + myPosition.x);

    const proof = path.path.map(hash => `0x${hash.slice(2)}`);
    console.log('Proof:', JSON.stringify(proof));

    move(Directions.UP, path.leafHash, proof);
  });

  input.onKeyPress((keys) => keys.has("S"), () => {
    myPosition.y += 1;
    console.log(myPosition)

    const path = generateHashPath(map, myPosition.x, myPosition.y);

    console.log('Leaf Hash:', path.leafHash);
    console.log('Leaf Index:', myPosition.y * map[0].length + myPosition.x);

    const proof = path.path.map(hash => `0x${hash.slice(2)}`);
    console.log('Proof:', JSON.stringify(proof));

    move(Directions.DOWN, path.leafHash, proof);
  });

  input.onKeyPress((keys) => keys.has("A"), () => {
    myPosition.x -= 1;
    console.log(myPosition)

    const path = generateHashPath(map, myPosition.x, myPosition.y);

    console.log('Leaf Hash:', path.leafHash);
    console.log('Leaf Index:', myPosition.y * map[0].length + myPosition.x);

    const proof = path.path.map(hash => `0x${hash.slice(2)}`);
    console.log('Proof:', JSON.stringify(proof));

    move(Directions.LEFT, path.leafHash, proof);
  });

  input.onKeyPress((keys) => keys.has("D"), () => {
    myPosition.x += 1;
    console.log(myPosition)

    const path = generateHashPath(map, myPosition.x, myPosition.y);

    console.log('Leaf Hash:', path.leafHash);
    console.log('Leaf Index:', myPosition.y * map[0].length + myPosition.x);

    const proof = path.path.map(hash => `0x${hash.slice(2)}`);
    console.log('Proof:', JSON.stringify(proof));

    move(Directions.RIGHT, path.leafHash, proof);
  });

  defineEnterSystem(world, [Has(PlayerPosition)], ({entity}) => {
    const playerObj = objectPool.get(entity, "Sprite");
    playerObj.setComponent({
        id: 'animation',
        once: (sprite) => {
            sprite.play(Animations.Player);
        }
    })
  });

  defineSystem(world, [Has(PlayerPosition)], ({ entity }) => {
    const playerPosition = getComponentValueStrict(PlayerPosition, entity);
    myPosition = playerPosition;
    const pixelPosition = tileCoordToPixelCoord(playerPosition, TILE_WIDTH, TILE_HEIGHT);

    const playerObj = objectPool.get(entity, "Sprite");

    playerObj.setComponent({
      id: "position",
      once: (sprite) => {
        sprite.setPosition(pixelPosition.x, pixelPosition.y);
      }
    })
  })
};
Enter fullscreen mode Exit fullscreen mode

The Animations, System Calls and Registration

packages/client/src/mud/createSystemCalls.ts

import { getComponentValue } from "@latticexyz/recs";
import { ClientComponents } from "./createClientComponents";
import { SetupNetworkResult } from "./setupNetwork";
import { singletonEntity } from "@latticexyz/store-sync/recs";

export type SystemCalls = ReturnType<typeof createSystemCalls>;

export function createSystemCalls(
  { worldContract, waitForTransaction }: SetupNetworkResult,
  { PlayerPosition }: ClientComponents,
) {
  const spawn = async (x: number, y: number) => {
    const tx = await worldContract.write.app__spawn([x, y]);
    await waitForTransaction(tx);
    return getComponentValue(PlayerPosition, singletonEntity);
  };

  const move = async (direction: number, leaf: string, proof: string[]) => {
    const tx = await worldContract.write.app__move([direction, leaf, proof]);
    await waitForTransaction(tx);
    return getComponentValue(PlayerPosition, singletonEntity);
  };

  return {
    spawn, move
  };
}
Enter fullscreen mode Exit fullscreen mode

packages/client/src/layers/phaser/configurePhaser.ts

import Phaser from "phaser";
import {
  defineSceneConfig,
  AssetType,
  defineScaleConfig,
  defineMapConfig,
  defineCameraConfig,
} from "@latticexyz/phaserx";
import worldTileset from "../../../public/assets/tilesets/world.png";
import { TileAnimations, Tileset } from "../../artTypes/world";
import { Sprites, Assets, Maps, Scenes, TILE_HEIGHT, TILE_WIDTH, Animations } from "./constants";

const ANIMATION_INTERVAL = 200;

const mainMap = defineMapConfig({
  chunkSize: TILE_WIDTH * 64, // tile size * tile amount
  tileWidth: TILE_WIDTH,
  tileHeight: TILE_HEIGHT,
  backgroundTile: [Tileset.Grass],
  animationInterval: ANIMATION_INTERVAL,
  tileAnimations: TileAnimations,
  layers: {
    layers: {
      Background: { tilesets: ["Default"] },
      Foreground: { tilesets: ["Default"] },
    },
    defaultLayer: "Background",
  },
});

export const phaserConfig = {
  sceneConfig: {
    [Scenes.Main]: defineSceneConfig({
      assets: {
        [Assets.Tileset]: {
          type: AssetType.Image,
          key: Assets.Tileset,
          path: worldTileset,
        },
        [Assets.MainAtlas]: {
          type: AssetType.MultiAtlas,
          key: Assets.MainAtlas,
          // Add a timestamp to the end of the path to prevent caching
          path: `/assets/atlases/atlas.json?timestamp=${Date.now()}`,
          options: {
            imagePath: "/assets/atlases/",
          },
        },
      },
      maps: {
        [Maps.Main]: mainMap,
      },
      sprites: {
        [Sprites.Player]: {
          assetKey: Assets.MainAtlas,
          frame: "sprites/golem/idle/0.png",
        },
      },
      animations: [
        {
          key: Animations.Player,
          assetKey: Assets.MainAtlas,
          startFrame: 0,
          endFrame: 3,
          frameRate: 6,
          repeat: -1,
          prefix: "sprites/golem/idle/",
          suffix: ".png",
        },
      ],
      tilesets: {
        Default: {
          assetKey: Assets.Tileset,
          tileWidth: TILE_WIDTH,
          tileHeight: TILE_HEIGHT,
        },
      },
    }),
  },
  scale: defineScaleConfig({
    parent: "phaser-game",
    zoom: 1,
    mode: Phaser.Scale.NONE,
  }),
  cameraConfig: defineCameraConfig({
    pinchSpeed: 1,
    wheelSpeed: 1,
    maxZoom: 3,
    minZoom: 1,
  }),
  cullingChunkSize: TILE_HEIGHT * 16,
};
Enter fullscreen mode Exit fullscreen mode

packages/client/src/layers/phaser/constants.ts

export enum Scenes {
  Main = "Main",
}

export enum Maps {
  Main = "Main",
}

export enum Animations {
  Player = "Player",
}
export enum Sprites {
  Player,
}

export enum Directions {
  UP = 0,  
  DOWN = 1,  
  LEFT = 2,  
  RIGHT = 3,  
}

export enum Assets {
  MainAtlas = "MainAtlas",
  Tileset = "Tileset",
}

export const TILE_HEIGHT = 32;
export const TILE_WIDTH = 32;

Enter fullscreen mode Exit fullscreen mode

packages/client/src/layers/phaser/systems/registerSystems.ts

import { PhaserLayer } from "../createPhaserLayer";
import { createCamera } from "./createCamera";
import { createMapSystem } from "./createMapSystem";
import { createMyGameSystem } from "./myGameSystem";

export const registerSystems = (layer: PhaserLayer) => {
  createCamera(layer);
  createMapSystem(layer);
  createMyGameSystem(layer);
};
Enter fullscreen mode Exit fullscreen mode

For simplicity, on this demo we set a fixed camera where the 0,0 position is the top left position.

packages/client/src/layers/phaser/createPhaserLayer.ts

import { createPhaserEngine } from "@latticexyz/phaserx";
import { namespaceWorld } from "@latticexyz/recs";
import { NetworkLayer } from "../network/createNetworkLayer";
import { registerSystems } from "./systems";

export type PhaserLayer = Awaited<ReturnType<typeof createPhaserLayer>>;
type PhaserEngineConfig = Parameters<typeof createPhaserEngine>[0];

export const createPhaserLayer = async (networkLayer: NetworkLayer, phaserConfig: PhaserEngineConfig) => {
  const world = namespaceWorld(networkLayer.world, "phaser");

  const { game, scenes, dispose: disposePhaser } = await createPhaserEngine(phaserConfig);
  world.registerDisposer(disposePhaser);

  const { camera } = scenes.Main;

  camera.phaserCamera.setBounds(0, 0, 500, 500);
  camera.phaserCamera.centerOn(0, 0);

  const components = {};

  const layer = {
    networkLayer,
    world,
    game,
    scenes,
    components,
  };

  registerSystems(layer);

  return layer;
};
Enter fullscreen mode Exit fullscreen mode

Run the game

Install the ethers dependency.

cd packages/client/
pnpm install ethers
cd ../..
Enter fullscreen mode Exit fullscreen mode

Run the game.

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Closing thoughts

Encoding expressive worlds

Instead of a 0 1 data. We could encode data into each position with no gas usage increase. For example we could encode the following structure:

struct MapTile {
  uint8 terrainType;
  uint8 fishApparationRate;
  uint8 wildCatApparationRate;
  bool isExplored;
  string npcDialog;
  [...]
}
Enter fullscreen mode Exit fullscreen mode

Only submit proofs once

To save gas and offchain indexing, players should submit a merkle proof once. All results should be stored so players in the future can query the map that has been already explored on chain.

For simplicity I'm not doing it on this guide but it should look something like this:

mapping(uint x => mapping(uint y => bytes32 terrainData)) mapData;
mapping(uint x => mapping(uint y => bool isExplored)) mapIsExplored;

function move(Direction direction, bytes32 positionLeaf, bytes32[] calldata proof) public {
  [...]
  mapData[x][y] = positionLeaf;
  mapIsExplored[x][y] = true;
}

function move(Direction direction) public {
  [...]
  require(mapIsExplored[x][y], "Tile not explored yet");
}
Enter fullscreen mode Exit fullscreen mode

Bigger maps require Web2 optimizations

Maybe a specialized indexer backend that precalculates the whole map merkle inclusion proofs so players can consult. In terms of gas cost this demo scales quite well do it's logarithmic verification costs. For example I did the following test:

  • Moving in a 32x32 map costs 92,687 gas.
  • Moving a 1000x1000 map cost 103,440 gas.

gas comparation

As you can see, the map size doesn't impact too much on the gas cost. The rest of the logic on the move function is more significant.

For this test I used this map whose merkle root computes to 0x86f3820289c9335418aaa077ba6a1dc6ab512203cc1faecb450bfbfe64021e98.

Thanks for reading this guide!

Follow FilosofĂ­a CĂłdigo on dev.to and in Youtube for everything related to Blockchain development.

. . . . . . . . . . . . . . . . . . . . . . . . .