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.
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.
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;
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:
For example, our 4x4 map would convert into the following 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.
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
Once you're ready create a phaser template.
pnpm create mud@latest tutorial --template phaser
cd tutorial
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]
]
}
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: [],
},
},
});
The contract
First let's remove the normal files.
rm packages/contracts/src/systems/IncrementSystem.sol packages/contracts/test/CounterTest.t.sol
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();
}
}
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;
}
}
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);
}
}
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);
}
})
})
};
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
};
}
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,
};
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;
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);
};
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;
};
Run the game
Install the ethers dependency.
cd packages/client/
pnpm install ethers
cd ../..
Run the game.
pnpm dev
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;
[...]
}
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");
}
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 costs92,687
gas. - Moving a
1000x1000
map cost103,440
gas.
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.