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 players and their movement in the client.
1.1 Create the components as tables
To create tables in MUD we are going to navigate to 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 movePosition: { schema: { 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({
tables: {
Movable: "bool",
Player: "bool",
Position: {
dataStruct: false,
schema: {
x: "uint32",
y: "uint32",
},
},
},
});
1.2 Create the 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 started the codebase off by creating a system that will encompass all of the methods related to the map: MapSystem.sol
in src/systems
.
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import { System } from "@latticexyz/world/src/System.sol";
contract MapSystem is System {
function distance(uint32 fromX, uint32 fromY, uint32 toX, uint32 toY) internal pure returns (uint32) {
uint32 deltaX = fromX > toX ? fromX - toX : toX - fromX;
uint32 deltaY = fromY > toY ? fromY - toY : toY - fromY;
return deltaX + deltaY;
}
}
Spawn method
Before we add in the functionality of players moving we need to make sure each user is being properly identified as a player with a position and movable component.
To solve this problem we can add the spawn method—a way for us to associate tables with a user as they spawn into the world. Let's assign the Player
, Position
, and Movable
tables we created earlier.
import { Movable, Player, Position } from "../codegen/Tables.sol";
import { addressToEntityKey } from "../addressToEntityKey.sol";
contract MapSystem is System {
function distance(uint32 fromX, uint32 fromY, uint32 toX, uint32 toY) internal pure returns (uint32) {
uint32 deltaX = fromX > toX ? fromX - toX : toX - fromX;
uint32 deltaY = fromY > toY ? fromY - toY : toY - fromY;
return deltaX + deltaY;
// add this method
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);
}
}
As you may be able to tell already, writing systems and their methods 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. Insert an excerpt about how this is a key part of MUD, and elaborate on the benefits this brings to the developer.
Move method
Next we’ll add the move 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.
// add this to MapSystem.sol
function move(uint32 x, uint32 y) public {
bytes32 player = addressToEntityKey(_msgSender());
require(Movable.get(player), "cannot move");
(uint32 fromX, uint32 fromY) = Position.get(player);
require(distance(fromX, fromY, x, y) == 1, "can only move to adjacent spaces");
Position.set(player, x, y);
}
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.
We’ll fill in the moveTo
and moveBy
and spawn
methods in our client’s createSystemCalls.ts
. These use a worldSend
helper to route the call through the world and into MapSystem.sol
for access control checks, account delegation, and other helpful features.
export function createSystemCalls(
{ playerEntity, worldSend, txReduced$ }: SetupNetworkResult,
{ Player, Position }: ClientComponents
) {
const moveTo = async (x: number, y: number) => {
if (!playerEntity) {
throw new Error("no player");
}
const tx = await worldSend("move", [x, y]);
await awaitStreamValue(txReduced$, (txHash) => txHash === tx.hash);
};
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;
}
await moveTo(playerPosition.x + deltaX, playerPosition.y + deltaY);
};
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 worldSend("spawn", [x, y]);
await awaitStreamValue(txReduced$, (txHash) => txHash === tx.hash);
};
Now we can update our 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] : []} />;
};
1.3 Add optimistic rendering
You may notice that your movement on the game board is laggy. While this is the default behavior of even web2 games (e.g. lag between user actions and client-side rendering), this problem is worsened by the need to wait on transaction confirmations on a blockchain.
A commonly used pattern in game development is the addition of optimistic rendering—client-side code that assumes a successful user action and renders it in the client before the server agrees, or, in this case, before the transaction is confirmed.
This pattern has a trade-off, especially on the blockchain: it can potentially create a worse user experience when transactions fail, but it creates a much smoother experience when the optimistic assumption proves to be true.
MUD provides an easy way to add optimistic rendering. First we need to override our Position
component on the client to add optimistic updates.
import { overridableComponent } from "@latticexyz/recs";
import { SetupNetworkResult } from "./setupNetwork";
export type ClientComponents = ReturnType<typeof createClientComponents>;
export function createClientComponents({ components }: SetupNetworkResult) {
return {
...components,
Player: overridableComponent(components.Player),
Position: overridableComponent(components.Position),
};
}
Now we can update our createSystemCalls
methods to apply an optimistic update before we send the transaction and remove the optimistic update once the transaction completes.
const moveTo = async (x: number, y: number) => {
if (!playerEntity) {
throw new Error("no player");
}
const positionId = uuid();
Position.addOverride(positionId, {
entity: playerEntity,
value: { x, y },
});
try {
const tx = await worldSend("move", [x, y]);
await awaitStreamValue(txReduced$, (txHash) => txHash === tx.hash);
} finally {
Position.removeOverride(positionId);
}
};
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;
}
await moveTo(playerPosition.x + deltaX, playerPosition.y + deltaY);
};
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 positionId = uuid();
Position.addOverride(positionId, {
entity: playerEntity,
value: { x, y },
});
const playerId = uuid();
Player.addOverride(playerId, {
entity: playerEntity,
value: { value: true },
});
try {
const tx = await worldSend("spawn", [x, y]);
await awaitStreamValue(txReduced$, (txHash) => txHash === tx.hash);
} finally {
Position.removeOverride(positionId);
Player.removeOverride(playerId);
}
};
Try moving the player around with the keyboard now. It should feel much snappier!
Now that we have players, movement, and a basic map, let's start making improvements to the map itself.