How to create an Autonomous World Game

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.

MUD Example Game

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.

Depenency 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
Enter fullscreen mode Exit fullscreen mode

Once the dependencies are installed, you can create a new MUD project.

pnpm create mud@latest tutorial
cd tutorial
Enter fullscreen mode Exit fullscreen mode

During the installation process, select a Phaser project.

slect phaser with mud

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"] } }
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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); } }
}
Enter fullscreen mode Exit fullscreen mode

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 Systems are designed to operate without a specific state. This means that a single System can operate with multiple Worlds, 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(); }
}
Enter fullscreen mode Exit fullscreen mode

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); } })
};
Enter fullscreen mode Exit fullscreen mode

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.

packages/art/player/1.png
Player 1 demo

packages/art/player/2.png
Player 2 demo

packages/art/coin/1.png
Coin demo

Next, run the following commands to automate the packaging of your files into sprite sheets.

cd packages/art
yarn
yarn generate-multiatlas-sprites
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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.


Discover more from Coursity

Subscribe to get the latest posts sent to your email.

Leave a Comment

Your email address will not be published. Required fields are marked *

Discover more from Coursity

Subscribe now to keep reading and get access to the full archive.

Continue reading

Scroll to Top