Players and movement
In this section, we will accomplish the following:
- Spawn in each unique wallet address as an entity with the
Player
,Movable
, andPosition
components. - Operate on a player's
Position
component with a system to create movement. - Optimistically render player movement in the client.
Create the components as tables
To create tables in MUD we are going to edit the mud.config.ts
file.
You can define tables, their types, their schemas, and other types of information here.
MUD then autogenerates all of the files needed to make sure your app knows these tables exist.
We're going to start by defining three new tables:
Player: 'bool'
- determine which entities are players (e.g. distinct wallet addresses).Movable: 'bool'
- determine whether or not an entity can move.Position: { valueSchema: { x: 'uint32', y: 'uint32' } }
- determine which position an entity is located on a 2D grid.
The syntax is as follows:
import { mudConfig } from "@latticexyz/world/register";
export default mudConfig({
enums: {
// TODO
},
tables: {
Movable: "bool",
Player: "bool",
Position: {
dataStruct: false,
valueSchema: {
x: "uint32",
y: "uint32",
},
},
},
});
Explanation
Movable: "bool",
Player: "bool",
When we do not specify a key schema for a table, MUD uses the default, a bytes32
value.
The key in these cases is the entity ID for the entities being described.
As in most cases in the Ethereum ecosystem, there is no distinction between zero (or in the case of boolean values, false
) and nothing.
So by default entites are neither Movable
nor a Player
.
Position: {
dataStruct: false,
valueSchema: {
x: "uint32",
y: "uint32",
},
The Position
of an entity includes both an x
coordinate and a y
coordinate.
When a table has multiple fields, we use valueSchema
inside a structure to describe them instead of just using a string with the Solidity field type.
If after modifying the file you get an error on the pnpm dev
process, restart it.
Create the map system and its methods
In MUD, a system can have an arbitrary number of methods inside of it.
Since we will be moving players around on a 2D map, we start the codebase off by creating a system that will encompass all of the methods related to the map: MapSystem.sol
in packages/contracts/src/systems
.
spawn
method
Before we add in the functionality of users moving we need to make sure each user is being properly identified as a player with the position and movable table. The former gives us a means of operating on it to create movement, and the latter allows us to grant the entity permission to use the move system.
To solve for these problems we can add the spawn
method, which will assign the Player
, Position
, and Movable
tables we created earlier, inside of MapSystem.sol
.
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import { System } from "@latticexyz/world/src/System.sol";
import { Movable, Player, Position } from "../codegen/index.sol";
import { addressToEntityKey } from "../addressToEntityKey.sol";
contract MapSystem is System {
function spawn(uint32 x, uint32 y) public {
bytes32 player = addressToEntityKey(address(_msgSender()));
require(!Player.get(player), "already spawned");
Player.set(player, true);
Position.set(player, x, y);
Movable.set(player, true);
}
function distance2(uint32 deltaX, uint32 deltaY) internal pure returns (uint32) {
return deltaX * deltaX + deltaY * deltaY;
}
}
Explanation
import { Movable, Player, Position } from "../codegen/index.sol";
Import the components we use from the automatically generated table list.
import { addressToEntityKey } from "../addressToEntityKey.sol";
This function (opens in a new tab) converts an address, use as the player's, to a bytes32
value that can identify an entity.
function spawn(uint32 x, uint32 y) public {
The spawn
function spawns a new player entity on the map.
bytes32 player = addressToEntityKey(address(_msgSender()));
In certain cases systems are called by a MUD World
using the call opcode.
In that case, msg.sender()
is the World
that called the system, not the actual player.
_msgSender()
takes care of this and gives us the real user identity, the one who called the World
.
require(!Player.get(player), "already spawned");
To get a component value we use <Component name>.get(<entity>)
.
Player.set(player, true);
Position.set(player, x, y);
Movable.set(player, true);
}
To set a component value we use <Component name>.set(<entity>, <value>)
.
As you see, writing systems and their methods in MUD is similar to writing regular smart contracts. The key difference is that their state is defined and stored in tables rather than in the system contract itself.
moveBy
method
Next we’ll add the moveBy
method to MapSystem.sol
.
This will allow us to move users (e.g. the user's wallet address as their entityID) by updating their Position
table.
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import { System } from "@latticexyz/world/src/System.sol";
import { Movable, Player, Position } from "../codegen/index.sol";
import { addressToEntityKey } from "../addressToEntityKey.sol";
contract MapSystem is System {
function spawn(uint32 x, uint32 y) public {
bytes32 player = addressToEntityKey(address(_msgSender()));
require(!Player.get(player), "already spawned");
Player.set(player, true);
Position.set(player, x, y);
Movable.set(player, true);
}
function moveBy(uint32 clientX, uint32 clientY, int32 deltaX, int32 deltaY) public {
bytes32 player = addressToEntityKey(_msgSender());
require(Movable.get(player), "cannot move");
(uint32 fromX, uint32 fromY) = Position.get(player);
require(distance2(deltaX, deltaY) == 1, "can only move to adjacent spaces");
require(clientX == fromX && clientY == fromY, "client confused about location");
uint32 x = uint32(int32(fromX) + deltaX);
uint32 y = uint32(int32(fromY) + deltaY);
Position.set(player, x, y);
}
function distance2(int32 deltaX, int32 deltaY) internal pure returns (uint32) {
return uint32(deltaX * deltaX + deltaY * deltaY);
}
}
Explanation
function moveBy(uint32 clientX, uint32 clientY, int32 deltaX, int32 deltaY) public {
If the client and the onchain code ever get out of sync about a player's position, it's best to forbid user movement until they synchronize again. To be able to detect that situation, we have the client code call the system onchain with what it thinks is the player's position.
(uint32 fromX, uint32 fromY) = Position.get(player);
This is how we get the player's real position (the onchain state is the authoritative version).
require(distance2(deltaX, deltaY) == 1, "can only move to adjacent spaces");
require(clientX == fromX && clientY == fromY, "client confused about location");
If the attempted move is more than one space, or if the client is out of sync about the player's location, do not move.
This method will allow users to interact with a smart contract, auto-generated by MUD, to update their position. However, we are not yet able to visualize this on the client, so let's add that to make it feel more real.
Modify the user interface to call the map system
We’ll fill in the moveBy
and spawn
methods in our client’s createSystemCalls.ts
.
import { Has, HasValue, getComponentValue, runQuery } from "@latticexyz/recs";
import { uuid } from "@latticexyz/utils";
import { ClientComponents } from "./createClientComponents";
import { SetupNetworkResult } from "./setupNetwork";
export type SystemCalls = ReturnType<typeof createSystemCalls>;
export function createSystemCalls(
{ playerEntity, worldContract, waitForTransaction }: SetupNetworkResult,
{ Player, Position }: ClientComponents
) {
const moveBy = async (deltaX: number, deltaY: number) => {
if (!playerEntity) {
throw new Error("no player");
}
const playerPosition = getComponentValue(Position, playerEntity);
if (!playerPosition) {
console.warn("cannot moveBy without a player position, not yet spawned?");
return;
}
const tx = await worldContract.write.moveBy([playerPosition.x, playerPosition.y, deltaX, deltaY]);
await waitForTransaction(tx);
};
const spawn = async (x: number, y: number) => {
if (!playerEntity) {
throw new Error("no player");
}
const canSpawn = getComponentValue(Player, playerEntity)?.value !== true;
if (!canSpawn) {
throw new Error("already spawned");
}
const tx = await worldContract.write.spawn([x, y]);
await waitForTransaction(tx);
};
return {
moveBy,
spawn,
};
}
Explanation
export function createSystemCalls(
{ playerEntity, worldContract, waitForTransaction }: SetupNetworkResult,
From the network setup we get the user identity (playerEntity
), the address of the World
we use, and the function to wait for a transaction to be included.
{ Player, Position }: ClientComponents
The client code that uses the system calls needs the Player
and Position
components, as you'll see below.
) {
const moveBy = async (deltaX: number, deltaY: number) => {
This function moves the player by a specific amount.
if (!playerEntity) {
throw new Error("no player");
}
const playerPosition = getComponentValue(Position, playerEntity);
if (!playerPosition) {
console.warn("cannot moveBy without a player position, not yet spawned?");
return;
}
If there is no player, or no player position, we can't move. Currently there is no way to spawn a player without setting a position, so this check is redundant - but we may introduce a spawn method in the future that is positionless, in which case we'll need it.
const tx = await worldContract.write.moveBy([playerPosition.x, playerPosition.y,
deltaX, deltaY]);
await waitForTransaction(tx);
};
This (worldContract.write.<method>
) is the way we call a method on a system at the root namespace.
const spawn = async (x: number, y: number) => {
...
}
Spawn a new player.
It is very similar to moveBy
above.
return {
moveBy,
spawn,
};
}
Return the functions in a structure so they can be called.
Now we can apply all of these backend changes to the client by updating GameBoard.tsx
to spawn the player when a map tile is clicked, show the player on the map, and move the player with the keyboard.
import { useComponentValue } from "@latticexyz/react";
import { GameMap } from "./GameMap";
import { useMUD } from "./MUDContext";
import { useKeyboardMovement } from "./useKeyboardMovement";
export const GameBoard = () => {
useKeyboardMovement();
const {
components: { Player, Position },
network: { playerEntity },
systemCalls: { spawn },
} = useMUD();
const canSpawn = useComponentValue(Player, playerEntity)?.value !== true;
const playerPosition = useComponentValue(Position, playerEntity);
const player =
playerEntity && playerPosition
? {
x: playerPosition.x,
y: playerPosition.y,
emoji: "🤠",
entity: playerEntity,
}
: null;
return <GameMap width={20} height={20} onTileClick={canSpawn ? spawn : undefined} players={player ? [player] : []} />;
};
You can run this command to update all the files to this point in the game's development.
git reset --hard 561f68112cb104ca106dcf3d79fcaa4ce72d9e66
Now that we have players, movement, and a basic map, let's start making improvements to the map itself.