Guides
Writing MUD Modules

Writing MUD Modules

On this page you learn how to write a MUD module. Modules are onchain installation scripts that create resources and their associated configuration when called by a World. The main difference between writing modules and writing MUD tables and Systems directly is that you have to use the registration functions directly, you cannot rely on the configuration file.

The sample module

The sample module lets games specify when a specific match began, and how long it is supposed to take. This is implemented in a single System. We also need a single table whose key is a game ID, and whose schema includes a start time and a length.

Create the table

You could hand-craft the table, but that is a lot of unnecessary (and error-prone) work. The easiest way to create tables is to create a mud.config.ts file and use mud tablegen to create the Solidity code.

  1. Create an application from a template, and delete the parts that are not needed.

    pnpm create mud@latest module --template vanilla
    cd module
    rm -r packages/client
    cd packages/contracts
    rm test/*.sol script/*.sol src/systems/*.sol
  2. Create this mud.config.ts file:

    mud.config.ts
    import { defineWorld } from "@latticexyz/world";
     
    export default defineWorld({
      namespace: "timer",
      tables: {
        Timer: {
          schema: {
            id: "uint256",
            startTime: "uint256",
            lengthSeconds: "uint256",
          },
          key: ["id"],
        },
      },
    });
  3. Run the table generation.

    pnpm mud tablegen

You can now see the table's Solidity code under src/codegen/tables.

Create the System

We need to provide this functionality:

  • createTimer is the function that creates a timer, with an id, a start time (the present) and a time length. If a timer already exists with that ID, don't modify it (that would enable some abuses).
  • timePassed is a view function that tells the caller how much time passed since the timer was created.
  • timeLeft is a view function that tells the caller how much time is left. It reverts if it has already been lengthSeconds since startTime.

Create this file in src/systems/TimerSystem.sol:

TimeSystem.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;
 
import { System } from "@latticexyz/world/src/System.sol";
import { Timer } from "../codegen/index.sol";
 
contract TimerSystem is System {
  function createTimer(uint256 id, uint256 lengthSeconds) external {
    // First, verify there isn't already a value here
    require(Timer.getStartTime(id) == 0, "Can't modify an existing timer");
    Timer.set(id, block.timestamp, lengthSeconds);
  }
 
  function timePassed(uint256 id) external view returns (uint256) {
    uint256 startTime = Timer.getStartTime(id);
    return block.timestamp - startTime;
  }
 
  function timeLeft(uint256 id) external view returns (uint256) {
    uint256 endTime = Timer.getStartTime(id) + Timer.getLengthSeconds(id);
    require(endTime >= block.timestamp, "Timer already done");
    return endTime - block.timestamp;
  }
}

Create the module itself

The module has two parts, a constructor and a registration function. The constructor deploys the System and stores its address for future registrations.

The registration function has these jobs:

  • Create the timer namespace
  • Register the table
  • Register the System
  • Transfer ownership of the timer namespace to whoever called the module registration function.

Create this file in src/TimerModule.sol

TimerModule.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;
 
import { Module } from "@latticexyz/world/src/Module.sol";
import { ResourceId, WorldResourceIdLib, WorldResourceIdInstance } from "@latticexyz/world/src/WorldResourceId.sol";
import { RESOURCE_SYSTEM } from "@latticexyz/world/src/worldResourceTypes.sol";
 
import { IWorld } from "./codegen/world/IWorld.sol";
 
// The System
import { TimerSystem } from "./systems/TimerSystem.sol";
 
// The table
import { Timer } from "./codegen/index.sol";
 
contract TimerModule is Module {
  using WorldResourceIdInstance for ResourceId;
 
  // The System that is deployed once when the module itself is deployed.
  TimerSystem private immutable timerSystem = new TimerSystem();
 
  // The resource IDs
  ResourceId private immutable namespaceResource = WorldResourceIdLib.encodeNamespace(bytes14("timer"));
  ResourceId private immutable timerSystemResource = WorldResourceIdLib.encode(RESOURCE_SYSTEM, "timer", "TimerSystem");
 
  function installRoot(bytes memory) public pure override {
    revert Module_RootInstallNotSupported();
  }
 
  function install(bytes memory) public {
    IWorld world = IWorld(_world());
 
    // Register the namespace.
    // Note that if it is already registered `registerNamespace`
    // reverts, so we cannot be installed twice.
    world.registerNamespace(namespaceResource);
 
    // Register the table
    Timer.register();
 
    // Register the System
    world.registerSystem(timerSystemResource, timerSystem, true);
 
    // Register the functions that can be called
    world.registerFunctionSelector(timerSystemResource, "createTimer(uint256,uint256)");
    world.registerFunctionSelector(timerSystemResource, "timePassed(uint256)");
    world.registerFunctionSelector(timerSystemResource, "timeLeft(uint256)");
 
    // Transfer namespace ownership
    world.transferOwnership(namespaceResource, _msgSender());
  }
}
Explanation
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;
 
import { Module } from "@latticexyz/world/src/Module.sol";
import { ResourceId, WorldResourceIdLib, WorldResourceIdInstance } from "@latticexyz/world/src/WorldResourceId.sol";
import { RESOURCE_SYSTEM } from "@latticexyz/world/src/worldResourceTypes.sol";

We need to know how to be a Module and how to generate resource IDs for registration.

import { IWorld } from "./codegen/world/IWorld.sol";
 
// The System
import { TimerSystem } from "./systems/TimerSystem.sol";
 
// The table
import { Timer } from "./codegen/index.sol";

Import previously created definitions.

contract TimerModule is Module {
  using WorldResourceIdInstance for ResourceId;
 
  // The System that is deployed once when the module itself is deployed.
  TimerSystem private immutable timerSystem = new TimerSystem();

In Solidity immutable (opens in a new tab) variables are values set when the contract is first deployed. As Systems are stateless, it is best to deploy them only once, and just register them to any World that needs them.

  // The resource IDs
  ResourceId private immutable namespaceResource = WorldResourceIdLib.encodeNamespace(bytes14("timer"));
  ResourceId private immutable timerSystemResource =
    WorldResourceIdLib.encode(RESOURCE_SYSTEM, "timer", "TimerSystem");

We need these identifiers to register the namespace and Systems. There is no point wasting gas on recalculating these values either, so it's best to do it once.

function installRoot(bytes memory) public pure override {
  revert Module_RootInstallNotSupported();
}

We do not need root privileges, so it's best if we don't have them (opens in a new tab).

  function install(bytes memory) public {
    IWorld world = IWorld(_world());
 
    // Register the namespace.
    // Note that if it is already registered `registerNamespace`
    // reverts, so we cannot be installed twice.
    world.registerNamespace(namespaceResource);

Register the namespace.

     // Register the table
    Timer.register();
 

The table generated from mud.config.ts comes with its own register() method to register it to the current World. Note that if the namespace was not a constant the way it is here we wouldn't be able to use .register() because it uses a resource ID that includes the namespace.

    // Register the System
    world.registerSystem(timerSystemResource, timerSystem, true);

For Systems, which are written directly in Solidity by the developer, we call registerSystem. The third parameter is whether public access is enabled or not.

    // Register the functions that can be called
    world.registerFunctionSelector(timerSystemResource, "createTimer(uint256,uint256)");
    world.registerFunctionSelector(timerSystemResource, "timePassed(uint256)");
    world.registerFunctionSelector(timerSystemResource, "timeLeft(uint256)");

We should also register every function that needs to be accessible through the World. This is not necessary, you can use call (opens in a new tab), but the registration makes it a lot easier to use our Systems.

 
    // Transfer namespace ownership
    world.transferOwnership(namespaceResource, _msgSender());
  }
}

Finally, transfer ownership of the namespace to whoever called us. Otherwise, the namespace cannot be modified, because it will be owned by the module contract.

Verify the module works

  1. In a separate directory, deploy and run a World.

    pnpm create mud@latest world --template react
    cd world
    pnpm dev
  2. Make a note of the World's address.

  3. Return to the module's directory, build the module and deploy it to the same blockchain.

    source .env
    forge create TimerModule --private-key $PRIVATE_KEY --rpc-url http://127.0.0.1:8545
  4. Set these environment variables:

    VariableValue
    WORLD_ADDRESSAddress of the World from the first step
    MODULE_ADDRESSAddress of the module you just deployed (the deployed to address)
  5. Install the module in the World:

    cast send $WORLD_ADDRESS "installModule(address,bytes)" $MODULE_ADDRESS 0x --private-key $PRIVATE_KEY --rpc-url http://127.0.0.1:8545
  6. Create a ten-minute timer.

    cast send --private-key $PRIVATE_KEY --rpc-url http://127.0.0.1:8545 $WORLD_ADDRESS "timer__createTimer(uint,uint)" 0x1234 600
  7. Try to run the same command again, see that you can't modify an existing timer.

  8. See how much time passed.

    cast call --rpc-url http://127.0.0.1:8545 $WORLD_ADDRESS "timer__timePassed(uint256)" 0x1234 | cast to-dec
  9. See how much time is left.

    cast call --rpc-url http://127.0.0.1:8545 $WORLD_ADDRESS "timer__timeLeft(uint256)" 0x1234 | cast to-dec