1. 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.
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: { 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",
},
},
},
});
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
.
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/Tables.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 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;
}
}
As you may be able to tell already, 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.
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.
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import { System } from "@latticexyz/world/src/System.sol";
import { Movable, Player, Position } from "../codegen/Tables.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 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);
}
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;
}
}
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
.
import { Has, HasValue, getComponentValue, runQuery } from "@latticexyz/recs";
import { uuid, awaitStreamValue } from "@latticexyz/utils";
import { MonsterCatchResult } from "../monsterCatchResult";
import { ClientComponents } from "./createClientComponents";
import { SetupNetworkResult } from "./setupNetwork";
export type SystemCalls = ReturnType<typeof createSystemCalls>;
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);
};
const throwBall = async () => {
// TODO
return null as any;
};
const fleeEncounter = async () => {
// TODO
return null as any;
};
return {
moveTo,
moveBy,
spawn,
throwBall,
fleeEncounter,
};
}
The code we just implemented uses a worldSend
helper to route the call through the world and into MapSystem.sol
for access control checks, account delegation, and other helpful features.
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] : []} />;
};
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. Let’s go ahead and do this in createClientComponents.ts
.
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.ts
methods to apply an optimistic update before we send the transaction and remove the optimistic update once the transaction completes.
Copy the code below and replace the existing createSystemCalls.ts
method.
import { Has, HasValue, getComponentValue, runQuery } from "@latticexyz/recs";
import { uuid, awaitStreamValue } from "@latticexyz/utils";
import { MonsterCatchResult } from "../monsterCatchResult";
import { ClientComponents } from "./createClientComponents";
import { SetupNetworkResult } from "./setupNetwork";
export type SystemCalls = ReturnType<typeof createSystemCalls>;
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 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);
}
};
const throwBall = async () => {
// TODO
return null as any;
};
const fleeEncounter = async () => {
// TODO
return null as any;
};
return {
moveTo,
moveBy,
spawn,
throwBall,
fleeEncounter,
};
}
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.