The MUD client libraries

The MUD client libraries

In this code walkthrough you learn how the MUD client libraries function. On all client templates, these files are located in packages/client/src/mud (opens in a new tab).

setup.ts

This file (opens in a new tab) sets up all the definitions required for a MUD client.

import { createClientComponents } from "./createClientComponents";
import { createSystemCalls } from "./createSystemCalls";
import { setupNetwork } from "./setupNetwork";

Import the code that does various types of setup.

export type SetupResult = Awaited<ReturnType<typeof setup>>;

The type definition for the return type of setup. The result is an Awaited (opens in a new tab), which means it may be the result of an input/output operation that is not immediately available. The Awaited result uis of type ReturnType<typeof setup>, which means that TypeScript will see the type that the setup function returns and use that.

export async function setup() {

This is the setup function that is called from the client code.

const network = await setupNetwork();
const components = createClientComponents(network);
const systemCalls = createSystemCalls(network, components);

Get the network information, the components, and the system calls from the imported code.

  return {
    network,
    components,
    systemCalls,
  };
}

Return all of this information. This structure's syntax is shorthand for:

{
  "network": network,
  "components": components,
  "systemCalls": systemCalls
}

getNetworkConfig.ts

This file (opens in a new tab) contains the network specific configuration for the client. The one you see here is to get on the anvil test network by default

import { getBurnerPrivateKey } from "@latticexyz/common";

Normally the template application just creates a temporary wallet (called a burner wallet) and uses a faucet (opens in a new tab) to get ETH for it.

import worldsJson from "contracts/worlds.json";

Import the addresses of the World, possibly on multiple chains, from packages/contracts/worlds.json (opens in a new tab). When the contracts package deploys a new World, it updates this data.

import { supportedChains } from "./supportedChains";

The supported chains (opens in a new tab). By default, there are only two chains here:

  • mudFoundry, the chain running on anvil that pnpm dev starts by default.
  • latticeTestnet, our public test network.
const worlds = worldsJson as Partial<Record<string, { address: string; blockNumber?: number }>>;

Process the list of deployed worlds.

export async function getNetworkConfig() {

This is the function that does the actual work.

const params = new URLSearchParams(window.location.search);

Read the query sting parameters (opens in a new tab).

const chainId = Number(params.get("chainId") || params.get("chainid") || import.meta.env.VITE_CHAIN_ID || 31337);

Get the chain ID. If there is a chainId (or chainid) parameter in the URL, use that. If not, check if when you the UI was started there was a VITE_CHAIN_ID environment variable. If not even that, default to 31337.

const chainIndex = supportedChains.findIndex((c) => c.id === chainId);
const chain = supportedChains[chainIndex];
if (!chain) {
  throw new Error(`Chain ${chainId} not found`);
}

Find the chain (unless it isn't in the list of supported chains).

const world = worlds[chain.id.toString()];
const worldAddress = params.get("worldAddress") || world?.address;
if (!worldAddress) {
  throw new Error(`No world address found for chain ${chainId}. Did you run \`mud deploy\`?`);
}

Get the address of the World. If you want to use a different address than the one in worlds.json, provide it as worldAddress in the query string.

const initialBlockNumber = params.has("initialBlockNumber")
  ? Number(params.get("initialBlockNumber"))
  : world?.blockNumber ?? 0n;

MUD clients use events to synchronize the database, meaning they need to look as far back as when the World was started. The block number for the World start can be specified either on the URL (as initialBlockNumber) or in the worlds.json file. If neither has it, it starts at the first block, zero.

  return {
    privateKey: getBurnerPrivateKey(),
    chainId,
    chain,
    faucetServiceUrl: params.get("faucet") ?? chain.faucetUrl,
    worldAddress,
    initialBlockNumber,
  };
}

setupNetwork.ts

This file (opens in a new tab) contains the definitions required to connect to a blockchain.

import { createPublicClient, fallback, webSocket, http, createWalletClient, Hex, parseEther, ClientConfig } from "viem";

The MUD client code is built on top of Viem (opens in a new tab). This line imports the functions we need from Viem.

import { createFaucetService } from "@latticexyz/services/faucet";
import { encodeEntity, syncToRecs } from "@latticexyz/store-sync/recs";

Import some functions from the Lattice libraries.

import { getNetworkConfig } from "./getNetworkConfig";

Get the network configuration.

import { world } from "./world";
import { IWorld__factory } from "contracts/types/ethers-contracts/factories/IWorld__factory";
import { createBurnerAccount, createContract, transportObserver, ContractWrite } from "@latticexyz/common";

Import various necessary definitions for MUD.

import { Subject, share } from "rxjs";

Use the RxJS library (opens in a new tab) to create event handlers.

import mudConfig from "contracts/mud.config";

Import packages/contracts/mud.config.ts with the World information.

export type SetupNetworkResult = Awaited<ReturnType<typeof setupNetwork>>;

The type definition for the return type of setup. The result is an Awaited (opens in a new tab), which means it may be the result of an input/output operation that is not immediately available. The Awaited result uis of type ReturnType<typeof setup>, which means that TypeScript will see the type that the setup function returns and use that.

export async function setupNetwork() {
  const networkConfig = await getNetworkConfig();

Get the network configuration.

const clientOptions = {
  chain: networkConfig.chain,
  transport: transportObserver(fallback([webSocket(), http()])),
  pollingInterval: 1000,
} as const satisfies ClientConfig;
 
const publicClient = createPublicClient(clientOptions);

The configuration for the client (URL, etc). This creates a Viem public client (opens in a new tab).

const burnerAccount = createBurnerAccount(networkConfig.privateKey as Hex);
const burnerWalletClient = createWalletClient({
  ...clientOptions,
  account: burnerAccount,
});

Create A temporary wallet and a client (opens in a new tab) for it.

const write$ = new Subject<ContractWrite>();

A Subject (opens in a new tab) is a way to multicast events into multiple listeners.

const worldContract = createContract({
  address: networkConfig.worldAddress as Hex,
  abi: IWorld__factory.abi,
  publicClient,
  walletClient: burnerWalletClient,
  onWrite: (write) => write$.next(write),
});

Create an object for communicating with the deployed World.

const { components, latestBlock$, blockStorageOperations$, waitForTransaction } = await syncToRecs({
  world,
  config: mudConfig,
  address: networkConfig.worldAddress as Hex,
  publicClient,
  startBlock: BigInt(networkConfig.initialBlockNumber),
});

Download the World state to have a local copy.

// Request drip from faucet
if (networkConfig.faucetServiceUrl) {
  const address = burnerAccount.address;
  console.info("[Dev Faucet]: Player address -> ", address);
 
  const faucet = createFaucetService(networkConfig.faucetServiceUrl);
 
  const requestDrip = async () => {
    const balance = await publicClient.getBalance({ address });
    console.info(`[Dev Faucet]: Player balance -> ${balance}`);
    const lowBalance = balance < parseEther("1");
    if (lowBalance) {
      console.info("[Dev Faucet]: Balance is low, dripping funds to player");
      // Double drip
      await faucet.dripDev({ address });
      await faucet.dripDev({ address });
    }
  };
 
  requestDrip();
  // Request a drip every 20 seconds
  setInterval(requestDrip, 20000);
}

If there is a faucet, request (test) ETH if you have less than 1 ETH.

 
  return {
    world,
    components,
    playerEntity: encodeEntity({ address: "address" }, { address: burnerWalletClient.account.address }),
    publicClient,
    walletClient: burnerWalletClient,
    latestBlock$,
    blockStorageOperations$,
    waitForTransaction,
    worldContract,
    write$: write$.asObservable().pipe(share()),
  };
}

Return the network configuration.

createClientComponents.ts

This file (opens in a new tab) creates components for use by the client.

By default it only returns the components from setupNetwork.ts, but you can change that if you need more components.

createSystemCalls.ts

This file (opens in a new tab) creates the system calls that the client can use to ask for changes in the World state (using the System contracts).

import { getComponentValue } from "@latticexyz/recs";
import { ClientComponents } from "./createClientComponents";
import { SetupNetworkResult } from "./setupNetwork";
import { singletonEntity } from "@latticexyz/store-sync/recs";
 
export type SystemCalls = ReturnType<typeof createSystemCalls>;
 
export function createSystemCalls(

This is the function that does the actual work.

  { worldContract, waitForTransaction }: SetupNetworkResult,

This syntax informs TypeScript that:

  { Counter }: ClientComponents

From the second parameter we only care about Counter. This component comes to us through createClientComponent.ts, but before that it originates in syncToRecs (opens in a new tab).

) {
  const increment = async () => {
    const tx = await worldContract.write.increment();
    await waitForTransaction(tx);
    return getComponentValue(Counter, singletonEntity);
  };
 
  return {
    increment,
  };
}

The sole System call here is increment. Because IncrementSystem is in the root namespace, .increment can be called directly on the contract.