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.
- 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.
- Table: a Store table. Used to store and retrieve data.
- 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:
- 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.
- 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.
- Synchronizing a Table means downloading and keeping track of all changes to records found within the Table. As an example, synchronizing a
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:
- Regular systems: Regular systems are
CALL
ed 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()
. - Root systems: Root systems are
DELEGATECALL
ed 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 CALL
ed or DELEGATECALL
ed.
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.
- 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
- 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
.
- 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
- 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, DogData } 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, DogData({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.
We can run pnpm mud worldgen
in the contract folder to recreate the systems.
> pnpm mud worldgen
Generated system interface: src/codegen/world/IIncrementSystem.sol
Generated system interface: src/codegen/world/IMySystem.sol
Generated system interface: src/codegen/world/IWorld.sol
After this step, the filesystem of the World is like this:
root
| Counter <- Table
| increment <- System
| Dog <- Table
| mysystem <- System
- 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 } 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).