Tutorials
1. Players and movement

1. Players and movement

In this section, we will accomplish the following:

  1. Spawn in each unique wallet address as an entity with the Player, Movable, and Position components.
  2. Operate on a player's Position component with a system to create movement.
  3. 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:

  1. Player: 'bool' → determine which entities are players (e.g. distinct wallet addresses)
  2. Movable: 'bool' → determine whether or not an entity can move
  3. Position: { schema: { x: 'uint32', y: 'uint32' } } → determine which position an entity is located on a 2D grid

The syntax is as follows:

mud.config.ts
import { mudConfig } from "@latticexyz/world/register";
 
export default mudConfig({
  enums: {
    // TODO
  },
  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.

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.

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.

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 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.

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.

GameBoard.tsx
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.

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.

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 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.