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.
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 nvminstall20curl-L https://foundry.paradigm.xyz | bashexportPATH=$PATH:~/.foundry/binsudonpminstall-g pnpm
Once the dependencies are installed, you can create a new MUD project.
pnpm create mud@latest tutorialcdtutorial
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 likemapping
,uint
,bool
, etc., nor do you useenum
. Instead, you define them in the file below.
packages/contracts/mud.config.ts
import{defineWorld}from"@latticexyz/world";exportdefaultdefineWorld({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 yourview
,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.
Deletepackages/contracts/src/systems/IncrementSystem.sol
that comes by default. You only need to do this if you’re using thevanilla
,react-ecs
, orphaser
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: MITpragmasolidity>=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";contractMyGameSystemisSystem{functiongenerateCoins()public{CoinPosition.set(1,1,true);CoinPosition.set(2,2,true);CoinPosition.set(2,3,true);}functionspawn(int32x,int32y)public{addressplayer=_msgSender();PlayerPosition.set(player,x,y);}functionmove(Directiondirection)public{addressplayer=_msgSender();PlayerPositionDatamemoryplayerPosition=PlayerPosition.get(player);int32x=playerPosition.x;int32y=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 becauseSystem
s are designed to operate without a specific state. This means that a singleSystem
can operate with multipleWorld
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: MITpragmasolidity>=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";contractPostDeployisScript{functionrun(addressworldAddress)external{StoreSwitch.setStoreAddress(worldAddress);uint256deployerPrivateKey=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";functiondecodeHexString(hexString:string):[number,number]{constcleanHex=hexString.slice(2);constfirstHalf=cleanHex.slice(0,cleanHex.length/2);constsecondHalf=cleanHex.slice(cleanHex.length/2);return[parseInt(firstHalf,16),parseInt(secondHalf,16)];}exportconstcreateMyGameSystem=(layer:PhaserLayer)=>{const{world,networkLayer:{components:{PlayerPosition,CoinPosition},systemCalls:{spawn,move,generateCoins}},scenes:{Main:{objectPool,input}}}=layer;input.pointerdown$.subscribe((event)=>{constx=event.pointer.worldX;consty=event.pointer.worldY;constplayerPosition=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})=>{constplayerObj=objectPool.get(entity,"Sprite");playerObj.setComponent({id:'animation',once:(sprite)=>{sprite.play(Animations.Player);}})});defineEnterSystem(world,[Has(CoinPosition)],({entity})=>{constcoinObj=objectPool.get(entity,"Sprite");coinObj.setComponent({id:'animation',once:(sprite)=>{sprite.play(Animations.Coin);}})});defineSystem(world,[Has(PlayerPosition)],({entity})=>{constplayerPosition=getComponentValueStrict(PlayerPosition,entity);constpixelPosition=tileCoordToPixelCoord(playerPosition,TILE_WIDTH,TILE_HEIGHT);constplayerObj=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);constcoinExists=getComponentValueStrict(CoinPosition,entity).exists;constpixelPosition=tileCoordToPixelCoord({x:coinX,y:coinY},TILE_WIDTH,TILE_HEIGHT);constcoinObj=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 underpackages/art/sprites/
. Ensure that the animation frames are named sequentially, such as1.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.
cdpackages/artyarnyarn 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
importPhaserfrom"phaser";import{defineSceneConfig,AssetType,defineScaleConfig,defineMapConfig,defineCameraConfig,}from"@latticexyz/phaserx";importworldTilesetfrom"../../../public/assets/tilesets/world.png";import{TileAnimations,Tileset}from"../../artTypes/world";import{Assets,Maps,Scenes,TILE_HEIGHT,TILE_WIDTH,Animations}from"./constants";constANIMATION_INTERVAL=200;constmainMap=defineMapConfig({chunkSize:TILE_WIDTH*64,// tile size * tile amounttileWidth:TILE_WIDTH,tileHeight:TILE_HEIGHT,backgroundTile:[Tileset.Grass],animationInterval:ANIMATION_INTERVAL,tileAnimations:TileAnimations,layers:{layers:{Background:{tilesets:["Default"]},Foreground:{tilesets:["Default"]},},defaultLayer:"Background",},});exportconstphaserConfig={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 cachingpath:`/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
exportenumScenes{Main="Main",}exportenumMaps{Main="Main",}exportenumAnimations{Player="Player",Coin="Coin",}exportenumDirections{UP=0,DOWN=1,LEFT=2,RIGHT=3,}exportenumAssets{MainAtlas="MainAtlas",Tileset="Tileset",}exportconstTILE_HEIGHT=32;exportconstTILE_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";exporttypeSystemCalls=ReturnType<typeofcreateSystemCalls>;exportfunctioncreateSystemCalls({worldContract,waitForTransaction}:SetupNetworkResult,{PlayerPosition,CoinPosition}:ClientComponents,){constspawn=async(x:number,y:number)=>{consttx=awaitworldContract.write.app__spawn([x,y]);awaitwaitForTransaction(tx);returngetComponentValue(PlayerPosition,singletonEntity);};constmove=async(direction:number)=>{consttx=awaitworldContract.write.app__move([direction]);awaitwaitForTransaction(tx);returngetComponentValue(PlayerPosition,singletonEntity);}constgenerateCoins=async(direction:number)=>{consttx=awaitworldContract.write.app__generateCoins();awaitwaitForTransaction(tx);returngetComponentValue(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";exportconstregisterSystems=(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 inYoutube for everything related to Blockchain development.
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse