Area Design

Modern area building on Merentha, by example.

This tome walks through how a real area is put together, using Cabeiri (/domains/Cabeiri) as the reference. Cabeiri is the current newbie area, so its files are small, clean, and use the patterns we want new coders to copy.

Every example is based on a real file in the live domain, updated where the original predates current practice. Read the annotations, then go read the neighbouring files in the same zone. Where they differ from this tome, follow the tome.

What an area is

An area (also called a domain) is a single directory under /domains/. Inside it is split into zones, each a self-contained section of the world (a town, a cave, a graveyard). Zones connect to each other through their room exits, so a player can walk seamlessly from one to the next.

The whole area shares one header file that defines its path and its tuning numbers. Change the header, and every room, monster, and item in the area retunes at once. This is the single most important habit in modern area design: never hardcode a number you might want to tune area-wide.

Directory structure

While you build, your area lives in your own realm: /realms/<yourname>/. When it is finished and reviewed, it migrates to its permanent home under /domains/. The internal layout is identical in both places, and the move is nearly free: because every path in the area is built from a single base define in the header, migrating is mostly a matter of changing that one line and relocating the directory. This is exactly why the header matters so much (see the next section).

Here is the Cabeiri tree, shown at its finished location under /domains/:

domains/Cabeiri/          (while building: realms/<yourname>/Cabeiri/)
    cabeiri.h          <- shared header: base path + tuning defines
    Cabeiri.md         <- area overview / TODO notes
    Notes.md           <- catalogue of weapons, armour, monsters, drops
    town/              <- a zone
        room/          <- the rooms
        npc/           <- the monsters
        obj/           <- the items (weapons, armour, food, etc.)
    goblin/            <- another zone
        room/
        npc/
        obj/
    kobold/
    cemetery/
    mountain/
    faerie/
    hades/
    isle/

The rules of the layout:

  • One directory per area. While building it sits under your realm (/realms/<yourname>/); once migrated it sits under /domains/. Everything below the area root is identical in both locations, so nothing inside the area changes when it moves.
  • One subdirectory per zone. Zones are how you keep a large area readable. Cabeiri has eight.
  • Inside each zone, split by object type: room/, npc/, obj/. Rooms go in room/, living monsters in npc/, and everything else (weapons, armour, food, containers) in obj/.
  • cabeiri.h lives at the area root, one level above the zones, so every file reaches it with the same relative include.
  • Cabeiri.md / Notes.md are documentation for you and the next coder. Notes.md in particular is a running catalogue of what the area contains, which makes balancing and auditing far easier. Keep it current.

Rooms refer to each other by full path built from the area’s base path constant, so a goblin-cave room can send an exit up into the mountain zone. Zones are only a naming convention; nothing stops exits from crossing between them, and good areas connect freely.

The shared header

Every file in the area begins with the same two includes:

#include <std.h>
#include "../../cabeiri.h"

<std.h> is the global mudlib header (it gives you ROOM, MONSTER, WEAPON, ARMOUR, and friends). The second is the area header, reached by a relative path: from goblin/obj/sword.c the file is two directories up, hence ../../. Every zone sits at the same depth, so the include is identical everywhere.

Here is the core of cabeiri.h.

The include guard. These two lines and the #endif at the bottom stop the header being processed twice when a file includes it more than once. Every area header needs one.

#ifndef __CABEIRI_H__
#define __CABEIRI_H__

The area’s root path. Every cross-reference to another file is written CABEIRI + "goblin/room/r2.c", never a hardcoded string. While you build, this points at your realm (/realms/<yourname>/Cabeiri/); migrating the area to /domains/ means changing this one line.

#define CABEIRI                 "/domains/Cabeiri/"

Difficulty tiers. Monsters call set_level(CABEIRI_LEVEL_BUFF) instead of set_level(4). Want the whole area a level harder? Bump these three.

#define CABEIRI_LEVEL_BASE      2
#define CABEIRI_LEVEL_BUFF      4
#define CABEIRI_LEVEL_RARE      6

The real file also defines armour-class (CABEIRI_AC_*) and weapon-class (CABEIRI_WC_*) tiers here the same way; you will see a few of them in the weapon and armour examples. Then the guard closes.

#endif // __CABEIRI_H__

The habit: when you write a monster’s level, a weapon’s damage, or a piece of armour’s AC, ask “would I ever want to retune this for the whole area at once?” If yes, it belongs in the header as a #define.

ROOM

Rooms inherit ROOM (/std/room). This is goblin/room/r1.c, the entrance to the goblin cave. Like every file in the area, it starts with the global mudlib header and the area header.

#include <std.h>
#include "../../cabeiri.h"

inherit ROOM; makes this a room. ROOM is a macro from <std.h> that points at /std/room.

inherit ROOM;

create() runs when the room loads. Call the parent’s create() first, always: it builds the room’s internals, and skipping it leaves the room half-built.

void create() {
    ::create();

Room flags. "light": 1 lets players see (0 is pitch dark), "indoors": 1 shelters the room from weather and some spells, and "no teleport": 1 blocks teleporting in or out.

    set_properties(([ "light": 1, "indoors": 1, "no teleport": 1, ]));

The one-line name shown in the room title and on exits. Lowercase, no trailing period.

    set_short("the entrance of a cave");

The paragraph players see on look. The text is split across lines as adjacent string literals, which the compiler joins, so there is no + between the pieces and no \n.

    set_long("Light trickles in from outside the cave's entrance, just enough "
        "penetrating the opening to send steep shadows across the rocky "
        "ground. The walls are hewn from a dark and dense stone and rocks of "
        "all sizes litter the cave floor.");

Sensory detail for the listen and smell commands. "default" is what a player gets with no argument.

    set_listen("default", "The wind howls by the entrance of the cave.");
    set_smell("default", "Dampness wafts up from deeper in the cave.");

Details players can look at that are not real objects. The key is the noun the player types (look rocks); the value is the description. Give most nouns from the long description an entry here.

    set_items(([
        "rocks": "Numerous rocks litter the cavern floor. The rocks look like "
            "they have either fallen off of the walls or the ceiling, which "
            "is slightly unsettling considering the massive size of some of them.",
        "cave": "The cave is dark and wide and delves further under the Daroq "
            "mountains.",
        "light": "A few strands of light penetrate the cavern's darkness.",
        "shadows": "Steep shadows are cast from the dim light.",
    ]));

Directions mapped to destination files, built from the CABEIRI base path. An exit can cross into another zone: up leads into the mountain.

The ordering rule that bites everyone: set_exits() must come after ::create(). The parent’s create() builds the exit mapping, so exits set before it runs are silently wiped.

    set_exits(([
        "down": CABEIRI + "goblin/room/r2.c",
        "up": CABEIRI + "mountain/room/m6.c",
    ]));
}

VAULT (rooms with doors)

A VAULT (/std/vault) is a room with doors: exits that can be opened, closed, locked, and unlocked.

Doors come in pairs: each side is its own room, and each declares the door pointing back at the other. This is goblin/room/r17.c, the north side. Its partner, goblin/room/r24.c, is the mirror image: the same door id, pointing back north.

#include <std.h>
#include "../../cabeiri.h"

Inherit VAULT instead of ROOM. Everything from the ROOM example still applies; the door calls are the addition.

inherit VAULT;

The partner room’s path, defined once because reset() below needs to reach across to it.

#define DOOR_ROOM (CABEIRI + "goblin/room/r24.c")

The same opening as any room: the parent’s create() first, then flags and descriptions.

void create() {
    ::create();
    set_properties(([ "light": 1, "indoors": 1, "no teleport": 1, ]));
    set_short("an open room");
    set_long("A number of cloth banners have been hung on the smooth stone "
        "walls here, each one fairly frayed and long faded. Some crude "
        "torches have been mounted on the walls which provide a decent "
        "amount of light to the tunnel. A splintered wooden door is on the "
        "southern wall.");
    set_listen("default", "Some loud stomping echoes in from somewhere.");
    set_smell("default", "Dampness wafts up from deeper in the cave.");

The door item is a function, not a string. (: ... :) is evaluated each time a player looks, so the description always says whether the door is open or closed right now.

    set_items(([
        "door": (: "A" + (query_open("door") ? "n open" : " closed") + " splintered wooden door leads south." :),
        "banners": "The edges of the banners are frayed and whatever design "
            "was on them has faded.",
        "torches": "Some primitive torches are mounted on the walls. Though "
            "they look simple they provide more than enough light.",
        "walls": "The walls are made of a smooth stone.",
    ]));

Only the ordinary open exits go here. The door’s direction (south) is added by set_door() below, so it is not listed twice.

    set_exits(([
        "west" : CABEIRI + "goblin/room/r16.c",
        "east" : CABEIRI + "goblin/room/r18.c",
    ]));

set_door(id, dest, dir, key) declares the door. id is the noun players use (open door), dest is the room on the other side, dir is the exit it adds, and key is the id of the key that unlocks it, or 0 for none. Both rooms of a pair use the same id, so the base class keeps the two sides in sync when a player opens or closes it.

    set_door("door", CABEIRI + "goblin/room/r24.c", "south", 0);

The starting state: closed but unlocked. For a locked door, pass a key id to set_door() and call set_locked("door", 1); a player then needs an object whose set_id() matches that key.

    set_open("door", 0);
    set_locked("door", 0);

A custom message for a successful open. There are matching hooks for close, lock, and unlock.

    set_string("door", "open", "You pull on the door and its hinges creak loudly in the quiet cave.");
}

reset() runs periodically. This one swings a left-open door shut on both sides, using DOOR_ROOM to reach the partner and tell everyone in either room. query_reset_number() > 1 skips the very first reset, right after the room loads.

void reset() {
    ::reset();
    if(query_open("door") && query_reset_number() > 1) {
        message("info", "The door loudly swings closed.", query_living_contents() + DOOR_ROOM->query_living_contents());
        set_open("door", 0);
        DOOR_ROOM->set_open("door", 0);
    }
}

MONSTER

Monsters inherit MONSTER (/std/monster, which itself inherits /std/living). This is goblin/npc/warrior.c, the basic goblin fighter.

#include <std.h>
#include "../../cabeiri.h"

inherit MONSTER;

Locals are declared at the top of the function, then the parent’s create() runs first, just like a room.

void create() {
    object salve;

    ::create();

The nouns players can target: kill goblin, kill warrior. Include every reasonable word, from the general to the specific.

    set_id(({ "goblin", "goblin warrior", "warrior" }));

set_name() is the proper name, set_short() is what appears in a room’s contents, and set_long() is the look description.

    set_name("goblin warrior");
    set_short("a goblin warrior");
    set_long("A scrawny green-skinned goblin with a bad complexion and "
        "crooked teeth. He growls in your general direction.");

Difficulty comes from the area header, never a magic number. Health, damage, and experience all scale from the level.

    set_level(CABEIRI_LEVEL_BUFF);

set_class() gives the monster that class’s combat behaviour and abilities.

    set_race("goblin");
    set_class("fighter");
    set_gender("male");

A 33% chance each round to cast something from the spell list.

    set_spell_chance(33);
    set_spells(({ "whirl" }));

Random flavour actions: how often they fire, then the pool of messages.

    set_emotes(5, ({
        "The goblin scratches his butt.",
        "The goblin stretches his arms to the sky.",
        "The goblin sounds like he's gargling his own spit.",
    }), 0);

The clone guard. create() runs once on the blueprint (the master copy the driver loads) and again on every clone that appears in the world. Gear, loot, and money belong to each clone, so the blueprint stops here.

    if(!clonep()) {
        return;
    }

set_inventory() clones each file into the monster and runs the command after it, so the sword arrives already wielded. The value can be a command, a count (2 for two copies), or ({ count, command }). Entries with a count of 0 are skipped, so random(2) makes the chainmail a coin flip: about half the warriors spawn wearing it.

    set_inventory(([
        CABEIRI + "goblin/obj/sword.c": "wield sword",
        CABEIRI + "goblin/obj/chainmail.c": ({ random(2), "wear chainmail" }),
    ]));

set_inventory() only clones and runs a command. An item that needs setup after cloning, like this salve’s type and number of uses, is still cloned by hand. !random(5) makes it a one-in-five drop.

    if(!random(5)) {
        salve = new(SALVE);
        salve->set_salve_type("newbie");
        salve->set_uses(1 + random(2));
        salve->move(this_object());
    }

Coins that drop into the corpse when it dies.

    add_money("electrum", random(50));
    add_money("copper", random(100));
}

WEAPON

Weapons inherit WEAPON (/std/weapon). This is goblin/obj/sword.c.

#include <std.h>
#include "../../cabeiri.h"

inherit WEAPON;

Identity works the same as for monsters: the nouns players can target, the proper name, the short shown in inventory, and the look description.

void create() {
    ::create();
    set_id(({"sword", "rusty sword"}));
    set_name("sword");
    set_short("a rusty short sword");
    set_long("A short sword made of corroded, bent and rusted metal. The edge "
        "is both dull and slightly sharp, as if it had been used for cutting "
        "rocks or wood.");

The weapon class (damage tier), from the area header so all the area’s loot stays on one scale. WC_D is low-end; WC_X is the area’s best.

    set_wc(CABEIRI_WC_D);

The weight. Heavier weapons encumber more.

    set_mass(7);

The sale price, randomised a little (random(16) adds 0 to 15) so the economy is not perfectly predictable.

    set_value(25+random(16));

The weapon skill it trains and its attack style: "blade", "blunt", "knife", "projectile", and others. Two-handed weapons also call set_hands(2), which blocks the off hand (no shield), and set_hit((: ... :)) adds an optional special hit on each swing.

    set_type("blade");

What it is made of. Materials interact with rust, acid, fire, and some abilities.

    set_material(({"metal", "rust"}));

Keeps the sword through reboots for players level 1 and up, which is everyone. See the autoload appendix for the full format.

    set_auto_load(1);
}

ARMOUR

Armour inherits ARMOUR (/std/armour). This is goblin/obj/chainmail.c.

#include <std.h>
#include "../../cabeiri.h"

inherit ARMOUR;

Identity first, exactly as for weapons.

void create() {
    ::create();
    set_name("chainmail");
    set_id( ({ "chainmail", "suit of chainmail", "suit" }) );
    set_short("an old suit of chainmail");
    set_long("An old suit of chainmail that is badly damaged. Some links "
        "have been broken and it is rather rusty. It is most definitely not "
        "the best armour to be found anywhere.");

The armour slot: "body armour", "helmet", "gloves", "boots", "shield", "robe", "bracers", and so on. It decides where the item is worn and which slot it fills.

    set_type("body armour");

Ties the armour to a body size. Wrong-sized armour fits poorly or not at all; most human-scaled gear passes 0 for one size fits all.

    set_size("goblin");

The body parts it protects. A helmet is just ({ "head" }); body armour covers the torso and all four limbs. The AC is spread across them.

    set_limbs(({ "torso", "left arm", "left leg", "right arm", "right leg" }));

The armour class, from the area header so every body armour in the area is consistent and tunable from one place.

    set_ac(CABEIRI_AC_BODYARMOUR);

Weight, materials, price, and autoload work exactly as for weapons.

    set_mass(25);
    set_material(({ "metal", "steel", "rust" }));
    set_value(200+random(31));
    set_auto_load(1);
}

RING

A ring inherits RING (/std/obj/ring), which is itself a thin subclass of ARMOUR, so everything from the ARMOUR example applies. This is goblin/obj/ring.c.

  • inherit RING; gives you the ring slot (worn on a finger) with all the armour machinery underneath.
  • set_ac(random(CABEIRI_AC_RINGS)) randomises the AC from 0 up to the header’s ring cap, so some rings roll worthless and some roll useful. Use a flat set_ac(CABEIRI_AC_RINGS) if you want every copy identical.
  • set_size(0) means one size fits all, which is normal for rings.

Rings are the natural place to hang small stat or resistance bonuses on named loot; the base is deliberately minimal so you add only what a ring needs.

#include <std.h>
#include "../../cabeiri.h"

inherit RING;

void create() {
    ::create();
    set_id(({ "ring", "ring of darkness" }));
    set_name("ring of darkness");
    set_short("a ring of darkness");
    set_long("A featureless ring made from black obsidian.");
    set_ac(random(CABEIRI_AC_RINGS));
    set_mass(1);
    set_material(({ "rock", "obsidian" }));
    set_size(0);
    set_value(140);
    set_auto_load(1);
}

Storage: containers, chests, and bags

“Storage” covers a few distinct things. Pick the right base:

You want Inherit Worn? Movable?
A fixed chest/urn that holds loot STORAGE (/std/storage) no usually no
A carry bag the player wears BACKPACK (/std/obj/backpack) yes (torso) yes
A door on a room VAULT n/a n/a

CONTAINER (/std/container) is the low-level base that both STORAGE and ROOM build on; you rarely inherit it directly.

A fixed loot container (STORAGE)

This is cemetery/obj/urn.c, a sealed urn that holds coins and cannot be carried off.

#include <std.h>

STORAGE gives it put and get behaviour: players can put items in and take items out.

inherit STORAGE;

coins is declared at the top, then the usual identity block.

void create() {
    object coins;
    ::create();
    set_name("urn");
    set_id(({ "urn", "ancient urn", }));
    set_short("an ancient urn");
    set_long("A tall ceramic urn glazed in dark earth tones, its surface "
        "cracked with age. The rim is sealed with hardened wax. Too fragile "
        "to move, but something shifts faintly inside when the air stirs.");
    set_material(({ "organic" }));

How much it holds. A chest meant for gear would use a much larger number; 10 keeps this urn to a little loot.

    set_max_encumbrance(10);

Pinning it to the room. set_prevent_put() stops players putting the urn inside another container; set_prevent_get() stops them picking it up at all.

    set_prevent_put("The urn's contents would tip over in such a container!");
    set_prevent_get("The urn is too fragile to pick up!");

Lets it hold a little more than its raw capacity would allow.

    set_property("magic hold", 10);
    reset();

Seeding it with coins: clone /std/obj/coins.c, set the money, and move it inside. This is the standard way to fill a container.

    coins = new("/std/obj/coins.c");
    coins->set_money("platinum", 5 + random(5));
    coins->set_money("gold", 25 + random(25));
    coins->move(this_object());

The lock. set_key() is deliberately an id no key carries, so the urn can only be opened by breaking it or through a quest; a normal chest would use a real key’s id. set_can_close() and set_can_lock() enable those verbs, and set_closed() and set_locked() start it sealed.

    set_key("key does not exist");
    set_can_close(1);
    set_can_lock(1);
    set_closed(1);
    set_locked(1);
}

A wearable carry bag (BACKPACK)

The simplest carry bag just inherits BACKPACK and sets a few values. This is Mjharr/mines/obj/satchel.c.

Prefer inheriting BACKPACK over building a bag by hand. Only add code (stat bonuses, a custom wear handler, colour codes) when the item genuinely needs it.

#include <std.h>

BACKPACK is ARMOUR with storage switched on, so the satchel is worn on the torso and holds items. None of that plumbing is repeated here.

inherit BACKPACK;

Identity, materials, weight, and price, as for any armour. Note the trailing space at the end of the long description.

void create() {
    ::create();
    set_name("satchel");
    set_id(({ "satchel", "leather satchel" }));
    set_short("a leather satchel");
    set_long("This leather satchel can be used to carry a few items, it has a stiff shoulder strap which needs to be broken in. "); // need trailing space
    set_material(({"leather"}));
    set_mass(18);
    set_value(5000);

How much it carries, and the small armour bonus for wearing it.

    set_max_encumbrance(425);
    set_ac(2);

Keeps the satchel through reboots for players level 30 and up. Only the bag itself is saved: the player save looks at top-level inventory, so whatever is inside the bag does not come back with it.

    set_auto_load(30);
}

Appendix: autoload

set_auto_load() decides whether an item survives a reboot in a player’s inventory. The condition is checked against the player carrying the item; if it passes, the item comes back when they log in.

With no set_auto_load() call at all, the item does not persist. That is right for food, quest pickups, and anything that should not be hoarded.

Only items carried directly are saved. Anything inside a bag or other container does not come back with it.

A number is a minimum level. 1 means every player keeps the item; 30 means only players level 30 and up.

set_auto_load(1);

A string passes if it matches any one of the player’s class, subclass, race, guild, kingdom, or gender. The match is exact, including capitals.

set_auto_load("cleric");

A mapping lists conditions that must all pass. The common case is newbie gear that only follows low-level players: this autoloads for levels 1 to 15, so starter gear disappears once a player outgrows it.

set_auto_load(([ "level": 1, "maxlevel": 15 ]));

Every key a mapping can use, all optional:

  • class, subclass, race: a string, or an array to allow any one of several. race also matches a werewolf’s form.
  • guild, kingdom: a name. Capitals do not matter here.
  • gender: a gender, such as "male".
  • level, maxlevel: the lowest and highest player level allowed.
  • alignment: a positive number means at least that good; a negative number means at least that evil.
  • quest: the player has completed this quest.
  • deed: the player has done this deed.

Because every key must pass, you would never use them all at once; this just shows the shape of each. A misspelled key never passes, so a typo means the item never autoloads.

set_auto_load(([
    "class": ({ "mage", "monk", "cleric" }),
    "subclass": "white",
    "race": ({ "centaur", "satyr" }),
    "guild": "The Order of Merentha",
    "kingdom": "Jewel",
    "gender": "male",
    "level": 1,
    "maxlevel": 140,
    "quest": "quest",
    "deed": "deed",
    "alignment": 1500,
]));

An array of mappings passes if any one mapping passes. Here, clerics level 20 and up keep the item, and so does any member of the Order.

set_auto_load(({
    ([ "class": "cleric", "level": 20 ]),
    ([ "guild": "The Order of Merentha" ]),
}));

For Cabeiri’s full per-item breakdown, see the tables in domains/Cabeiri/Notes.md. Keeping an area’s Notes.md current is part of good area design.