MUD started as a framework for creating 100% on-chain games, but now it is much more than that. In addition to creating interactive games with smart contract mechanics, MUD is a tool capable of creating autonomous worlds. What is an autonomous world? This goes beyond finance and games; in these worlds, you can create simulations where artificial intelligence is first-class citizens. If this sounds pretty crazy, it’s because it is.
To learn more about MUD, let’s start by creating a game where characters collect coins. In this guide, we will do everything from scratch. We’ll create the contracts, the project structure, and the animations.
Create a New MUD Project
We’ll be using Node v20 (>=18 should be ok), pnpm, and Foundry. If you don’t have them installed, here are the commands.
Dependency installation
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 the dependencies are installed, you can create a new MUD project.
pnpm create mud@latest tutorial
cd tutorial
During the installation process, select a Phaser project.
1. The State Schema
This is the most important file in MUD; it defines the data structure of the state of your contracts. In MUD, you don't declare state variables like mapping
, uint
, bool
, etc., nor do you use enum
. Instead, you define them in the file below.
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: "int32",
y: "int32",
},
key: ["player"]
},
CoinPosition: {
schema: {
x: "int32",
y: "int32",
exists: "bool",
},
key: ["x", "y"]
},
PlayerCoins: {
schema: {
player: "address",
amount: "uint32",
},
key: ["player"]
}
}
});
2. Logic in Solidity
The logic for functions in MUD is the same as in a regular Solidity project. Declare your view
, payable
, pure
functions with modifiers and everything else just as you’re used to.
So, delete the default logic and create the game logic that validates when a player moves and collects coins.
Delete packages/contracts/src/systems/IncrementSystem.sol
that comes by default. You only need to do this if you’re using the vanilla
, react-ecs
, or phaser
templates provided by MUD.
rm packages/contracts/src/systems/IncrementSystem.sol packages/contracts/test/CounterTest.t.sol
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, CoinPosition, PlayerCoins } 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 generateCoins() public {
CoinPosition.set(1, 1, true);
CoinPosition.set(2, 2, true);
CoinPosition.set(2, 3, true);
}
function spawn(int32 x, int32 y) public {
address player = _msgSender();
PlayerPosition.set(player, x, y);
}
function move(Direction direction) public {
address player = _msgSender();
PlayerPositionData memory playerPosition = PlayerPosition.get(player);
int32 x = playerPosition.x;
int32 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;
require(x>= -31 && x<= 31 && y>= -31 && y<= 31, "Invalid position");
PlayerPosition.set(player, x, y);
if(CoinPosition.getExists(x, y))
{
CoinPosition.set(x, y, false);
PlayerCoins.set(player, PlayerCoins.getAmount(player)+1);
}
}
}
Actually, not all functions operate the same way as in a vanilla Solidity project. If you try, you’ll notice that the constructor doesn’t work as expected. This is because System
s are designed to operate without a specific state. This means that a single System
can operate with multiple World
s, which are responsible for managing the state. This is why we put all the initialization logic in the post-deploy contract.
In our case, we place certain coins on the map.
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";
contract PostDeploy is Script {
function run(address worldAddress) external {
StoreSwitch.setStoreAddress(worldAddress);
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
IWorld(worldAddress).app__generateCoins();
vm.stopBroadcast();
}
}
3. Client Interaction
The client contains all the off-chain logic. This is where we define what is displayed on the screen and also what happens when the user clicks or presses a key.
packages/client/src/layers/phaser/systems/myGameSystem.ts
import { Has, defineEnterSystem, defineSystem, defineExitSystem, getComponentValueStrict } from "@latticexyz/recs";
import { PhaserLayer } from "../createPhaserLayer";
import {
pixelCoordToTileCoord,
tileCoordToPixelCoord
} from "@latticexyz/phaserx";
import { TILE_WIDTH, TILE_HEIGHT, Animations, Directions } from "../constants";
function decodeHexString(hexString: string): [number, number] {
const cleanHex = hexString.slice(2);
const firstHalf = cleanHex.slice(0, cleanHex.length / 2);
const secondHalf = cleanHex.slice(cleanHex.length / 2);
return [parseInt(firstHalf, 16), parseInt(secondHalf, 16)];
}
export const createMyGameSystem = (layer: PhaserLayer) => {
const {
world,
networkLayer: {
components: {
PlayerPosition,
CoinPosition
},
systemCalls: {
spawn,
move,
generateCoins
}
},
scenes: {
Main: {
objectPool,
input
}
}
} = layer;
input.pointerdown$.subscribe((event) => {
const x = event.pointer.worldX;
const y = event.pointer.worldY;
const playerPosition = pixelCoordToTileCoord({ x, y }, TILE_WIDTH, TILE_HEIGHT);
if(playerPosition.x == 0 && playerPosition.y == 0)
return;
spawn(playerPosition.x, playerPosition.y)
});
input.onKeyPress((keys) => keys.has("W"), () => {
move(Directions.UP);
});
input.onKeyPress((keys) => keys.has("S"), () => {
move(Directions.DOWN);
});
input.onKeyPress((keys) => keys.has("A"), () => {
move(Directions.LEFT);
});
input.onKeyPress((keys) => keys.has("D"), () => {
move(Directions.RIGHT);
});
input.onKeyPress((keys) => keys.has("I"), () => {
generateCoins();
});
defineEnterSystem(world, [Has(PlayerPosition)], ({entity}) => {
const playerObj = objectPool.get(entity, "Sprite");
playerObj.setComponent({
id: 'animation',
once: (sprite) => {
sprite.play(Animations.Player);
}
})
});
defineEnterSystem(world, [Has(CoinPosition)], ({entity}) => {
const coinObj = objectPool.get(entity, "Sprite");
coinObj.setComponent({
id: 'animation',
once: (sprite) => {
sprite.play(Animations.Coin);
}
})
});
defineSystem(world, [Has(PlayerPosition)], ({ entity }) => {
const playerPosition = getComponentValueStrict(PlayerPosition, entity);
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);
}
})
})
defineSystem(world, [Has(CoinPosition)], ({ entity }) => {
const [coinX, coinY] = decodeHexString(entity);
const coinExists = getComponentValueStrict(CoinPosition, entity).exists;
const pixelPosition = tileCoordToPixelCoord({x: coinX, y: coinY}, TILE_WIDTH, TILE_HEIGHT);
const coinObj = objectPool.get(entity, "Sprite");
if(coinExists) {
coinObj.setComponent({
id: "position",
once: (sprite) => {
sprite.setPosition(pixelPosition.x, pixelPosition.y);
}
})
}else
{
objectPool.remove(entity);
}
})
};
4. Add Images and Animations
If you want to add an animated object, place it in its own folder under packages/art/sprites/
. Ensure that the animation frames are named sequentially, such as 1.png
, 2.png
, 3.png
, etc.
In our case, we will add 2 images for our character and one for the coins.
Next, run the following commands to automate the packaging of your files into sprite sheets.
cd packages/art
yarn
yarn generate-multiatlas-sprites
In the Phaser file, you can configure the speed and name of the animations. Here, you can also define attributes that you can use in your system.
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 { 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: {
},
animations: [
{
key: Animations.Player,
assetKey: Assets.MainAtlas,
startFrame: 1,
endFrame: 2,
frameRate: 3,
repeat: -1,
prefix: "sprites/player/",
suffix: ".png",
},
{
key: Animations.Coin,
assetKey: Assets.MainAtlas,
startFrame: 1,
endFrame: 1,
frameRate: 12,
repeat: -1,
prefix: "sprites/coin/",
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,
};
5. Bind everything together
Perhaps in the future, MUD will automate a couple of functions that you need to connect manually. This allows the client package to connect to the contracts.
packages/client/src/layers/phaser/constants.ts
export enum Scenes {
Main = "Main",
}
export enum Maps {
Main = "Main",
}
export enum Animations {
Player = "Player",
Coin = "Coin",
}
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/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, CoinPosition }: 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) => {
const tx = await worldContract.write.app__move([direction]);
await waitForTransaction(tx);
return getComponentValue(PlayerPosition, singletonEntity);
}
const generateCoins = async (direction: number) => {
const tx = await worldContract.write.app__generateCoins();
await waitForTransaction(tx);
return getComponentValue(PlayerPosition, singletonEntity);
}
return {
spawn, move, generateCoins
};
}
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);
};
6. Run the Game
MUD has a hot reload system. This means that when you make a change in the game, it is detected, and the game reloads, applying the changes. This applies to both the client and the contracts.
pnpm dev
Move with WASD, touch the coins to collect them. The game is multiplayer online by default!
Thanks for reading this guide!
Follow Filosofía Código on dev.to and in Youtube for everything related to Blockchain development.