World 101

World 101

The World is a standard smart contract that can be deployed by anyone. Creating a new World is akin to creating a new community computer or installing a new Operating System: it’s a brand new space for state and logic to be deployed by anyone on-chain — although you will probably be the first one to create resources in your new World!

When building with the MUD World framework, the first decision you need to make is whether your project requires a new World, or if you can build on an existing one. Here are some examples of situations you might find yourself in, and recommendations for which route to follow:

  • I am building a standalone proof of concept: start from a fresh World.
  • I am building a project on a new chain that has no World yet: start from a fresh World.
  • I am building features on top of an existing project, like a marketplace for an on-chain game or an aggregator for two AMMs deployed on the same world: build on the World with the application you would like to extend.
  • I want features that can only be installed by the root user / DAO of a World, and no World out there includes them: start from a fresh World.
  • I want to add new features to an application I have built before: build on the World where you initially deployed your application.

World Concepts

Resources and namespaces

A World contains resources. Currently, there exists three types of resources. More of them can be added by the root user of the World (if there is one), and future versions of MUD might include new default resources.

  1. Namespace: a namespace is like a folder in a file system. They are used to group resources together for the purpose of making access-control less verbose. Currently, nested namespaces are not available in World framework. The filesystem is thus flat.
  2. Table: a Store table. Used to store and retrieve data.
  3. System: a piece of logic, stored as EVM bytecode. Systems have no state, and instead read and write to Tables.

Each resource is contained within a namespace. You can think of the resources within a World as a filesystem:

root
|-- mudswap <- Namespace
    | Balance <- Table
    | Pool <- Table
    | Transfer <- System
|-- Tetris <- Namespace
    | Board <- Table
    | Move <- System
    | Drop <- System
    | Score <- Table
    | Win <- System

The organization of resources within namespaces is used for two different features of MUD:

  1. Access control: resources in a namespace have “write” access to the other resources within their namespace. Currently, having write access only matters for systems interacting with tables: it means these systems can create and edit records within those tables.
  2. Synchronization of state: MUD clients can decide which namespaces they synchronize. Synchronization means different things depending on the resource type:
    • Synchronizing a Table means downloading and keeping track of all changes to records found within the Table. As an example, synchronizing a BalanceTable would mean keeping track of the balances of all addresses within that table.
    • Synchronizing a System means downloading its EVM bytecode from the chain, and in a future version of MUD, being able to execute these systems optimistically client side. As an example, this would allow clients to immediately predict the likely outcome of an on-chain action without relying on external nodes or services like Tenderly to simulate the outcome.

A note on managing namespaces and resources: In most basic cases, you don’t need to worry about namespaces and access control while building your application with World (regardless of whether you are deploying a new World or building on an existing one). If your project was generated from the MUD templates using pnpm create mud/yarn create mud/mud create, it will use the tablegen tool from the MUD CLI to generate libraries for tables, and the deploy tool to deploy the resources into the World. Namespace access will be done for you: systems will be able to write to all your tables out-of-the-box. You just need to decide which namespace you will build your application in!

Systems

Systems are stateless pieces of logic executed on the World, represented as a resource within a namespace. They are written in Solidity and compile to the EVM like regular smart contracts. You can think of them as SQL functions acting your SQL database (Store in this case). Systems read and store their state on the World's Store. These storage access are abstracted via the libraries generated with tablegen. You can learn more about tablegen in the Store doc.

Reading and writing to the state in a system:

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import { System } from "@latticexyz/world/src/System.sol";
import { FooBarTable } from "../codegen/tables/FooBarTable.sol";
 
contract ExampleSystem is System {
  function createRecord(uint256 foo, string memory bar) public returns () {
    string memory barValue = FooBarTable.get(foo); // Reading from the state
    FooBarTable.set(foo, bar); // Writing string "bar" at the key foo
  }
}

Systems MUST use the _msgSender() function exposed in the System interface to fetch the msg.sender. Using msg.sender directly is insecure.

There exists two types of systems:

  1. Regular systems: Regular systems are CALLed from the World, which isolates their storage from the World storage. They read and write to the Store by going through the World, which does access control checks (eg: Can this system write to this table>). Regular systems can register namespaced function selectors on the World. eg: World.myNamespace_ExampleSystem_createRecord().
  2. Root systems: Root systems are DELEGATECALLed from the World, which lets them borrow the World storage. They read and write to the Store directly. There are NO access control on Root systems. Root systems can register root function selectors, allowing their functions to be called on the World directly, eg: World.createRecord().

Systems are not root by default, only systems registered in the ROOT namespace (the empty string namespace: "") are treated as root systems.

Both ways of accessing the Store -- through the World or directly in storage -- are abstracted via the code-generated table libraries. Systems transparently switch between these two modes depending on whether they are CALLed or DELEGATECALLed.

Tables

Tables are a type of resource, just like systems, and they are installed on the World at runtime. You can define them in your MUD config and they will automatically be registered by the deployer. The state of each table is represented withtin the storage of the World contract.

Getting started with World

In order to use World, you just need your project to have the right folder structure and have a mud.config.ts file at the root of your contract folder. It is recommended starting from one of the MUD template to get familiar with the structure, but it is also possible to roll out your own folder and file organization.

  1. Start from the minimal template

Run the following command:

pnpm create mud@canary your-project-name

You'll be prompted to pick the type of template to use; select vanilla.

Jump into your new project's directory by running:

cd your-project-name
  1. Let’s look at the MUD config

Open the project in your favorite code editor. The MUD config for the vanilla template looks like this:

import { mudConfig } from "@latticexyz/world/register";
 
export default mudConfig({
  tables: {
    Counter: {
      keySchema: {},
      schema: "uint32",
    },
  },
});

Let’s break it down:

First, notice there is no namespace key: all our resources will be installed in the ROOT namespace, and our systems' functions will be registered as-is on the World.

Secondly, there is one singleton table named “Counter”, with a single column named value with type uint32. To learn more about the format for defining tables, head to the Store documentation.

Lastly, this project has one system at IncrementSystem.sol, but it does not need to be in the config. Any file that ends in *System.sol is considered a system and deployed by default.

The file system on the World looks like this now when deployed:

root
| Counter <- Table
| increment <- System

Because increment is in the same namespace as counter, it can write records on that table using the libraries generated by tablegen.

  1. Adding another table

Let’s add a new table. It’s as simple as extending the config. For reference on how to create new tables and different options available, refer to the Store documentation (opens in a new tab).

import { mudConfig } from "@latticexyz/world/register";
 
export default mudConfig({
  tables: {
    Counter: {
      keySchema: {},
      schema: "uint32",
    },
    Dog: {
      schema: {
        owner: "address",
        name: "string",
        color: "string",
      },
    },
  },
});

We can run pnpm mud tablegen in the contract folder to recreate the libraries.

> pnpm mud tablegen
Generated table: src/codegen/tables/Counter.sol
Generated table: src/codegen/tables/Dog.sol

We now have a library in src/codegen/tables/Dog.sol that can be used to interact with the new table we created!

The file system on the World looks like this at this stage:

root
  | Counter <- Table
  | increment <- System
  | Dog <- Table
  1. Adding another system

Let’s add a system that writes to our new table.

We create a file in src/systems named MySystem.sol

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import { System } from "@latticexyz/world/src/System.sol";
 
contract MySystem is System {
  function doStuff() public returns () {}
}

Now we can import our new table, and write something to it. Let’s write a function that adds a new record to Dog, and takes the color and the name as an argument. It will assign the owner column to the sender of the transaction:

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import { System } from "@latticexyz/world/src/System.sol";
import { Dog } from "../codegen/tables/Dog.sol"; // import table we created
contract MySystem is System {
  function addEntry(string memory name, string memory color) public returns (bytes32) {
    bytes32 key = bytes32(abi.encodePacked(block.number, msg.sender, gasleft())) // creating a random key for the record
    address owner = _msgSender() // IMPORTANT: always refer to the msg.sender using the _msgSender() function
    Dog.set(key, {owner: owner, name: name, color: color}) // creating our record!
    return key;
  }
}

That’s it! MySystem, just like IncrementSystem, will have access to Dog given they are in the same namespace.

After this step, the filesystem of the World is like this:

root
  | Counter <- Table
  | increment <- System
  | Dog <- Table
  | mysystem <- System
  1. Writing a test

Let's write a test to make sure our system actually adds a record to the Dog when addEntry is called.

Create a new file named MySystemTest.t.sol in the test folder.

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
 
import "forge-std/Test.sol";
import { MudTest } from "@latticexyz/store/src/MudTest.sol";
import { IWorld } from "../src/codegen/world/IWorld.sol";
import { Dog, DogId } from "../src/codegen/Tables.sol";
 
contract MySystemTest is MudTest {
  IWorld world;
 
  function setUp() public override {
    super.setUp();
    world = IWorld(worldAddress);
  }
 
  function testAddEntry() public {
    // Add a new entry to the Dog via the system
    // this will call the addEntry function on MySystem
    bytes32 key = world.addEntry("bob", "blue");
    // Expect the value retrieved from the Dog at the corresponding key to match "bob" and "blue"
    string memory name = Dog.getName(key);
    string memory color = Dog.getColor(key);
    assertEq(name, "bob");
    assertEq(color, "blue");
  }
}

Run your test suite with pnpm mud test in the contracts folder of your project (or yarn test, npm test depending on which package manager you are using).