Gall Agent
Status: Implemented (design updated 2026-03-29) Date: 2026-03-26
Overview
%mud-world is the core Gall agent. One instance runs per world host. It manages:
- World state (rooms, mobs, items, areas)
- Player sessions (guests and citizens)
- Game logic (combat, quests, movement, economy)
- Game tick (via Behn timer)
- Web client serving (via Eyre)
- Subscriptions (real-time updates to connected players)
Data Structures
Core Types
:: === IDENTITY ===
::
+$ session-id @uv :: unique session token
+$ room-id @ud :: room identifier
+$ mob-id @ud :: mob template identifier
+$ instance-id @uv :: spawned mob/item instance
+$ item-id @ud :: item template identifier
+$ area-id @tas :: area short name
+$ quest-id @ud :: quest identifier
::
:: === ENUMERATIONS ===
::
+$ direction
$? %north %south %east %west %up %down ==
::
+$ sector
$? %city %forest %mountain %water %underground
%indoor %field %desert %road %swamp %air
==
::
+$ player-class
$? %mage %warrior %thief %ranger %cleric %paladin %psionicist ==
::
+$ player-race
$? %human %elf %dwarf %halfling %orc %troll
%gnome %goblin %sprite %centaur
==
::
+$ damage-type
$? %slash %pierce %bash %fire %cold
%lightning %acid %poison %holy %shadow
==
::
+$ item-type
$? %weapon %armor %potion %scroll %food
%drink %container %key %trash %other
==
::
+$ wear-slot
$? %head %neck %torso %arms %hands %waist
%legs %feet %finger-l %finger-r %wield %shield
%wrist-l %wrist-r %about %hold
==
::
+$ mob-flag
$? %aggressive %sentinel %scavenger %wimpy
%assist %shopkeeper %questmaster %trainer
%healer %banker
==
::
+$ room-flag
$? %dark %no-mob %indoors %no-recall %no-magic
%no-flee %no-pk %safe %pet-shop
==
::
+$ combat-state ?(%idle %fighting %fleeing %dead)
::
+$ char-position ?(%standing %sitting %resting %sleeping %fighting %dead)
::
:: === STAT BLOCK ===
::
+$ stats
$: str=@ud int=@ud wis=@ud
dex=@ud con=@ud luck=@ud
==
::
:: === CHARACTER CREATION CONSTANTS ===
::
:: Base stat: 10 for all races/classes
:: Free points at creation: 12
:: Max any single stat at creation: 20
:: Formula: base(10) + race_mod + class_mod + player_allocation
::
:: Race modifiers:
:: STR INT WIS DEX CON LUCK
:: human: +0 +0 +0 +0 +0 +0 (versatile, no weaknesses)
:: elf: -1 +2 +1 +1 -1 +0 (smart and agile, fragile)
:: dwarf: +1 -1 +0 -1 +2 +1 (tough and lucky, slow)
:: halfling: -1 +0 +1 +2 -1 +1 (nimble, weak)
:: orc: +2 -2 -1 +0 +2 +1 (brute force, dumb)
:: troll: +2 -2 +0 -1 +3 +0 (walking tank, very dumb)
:: gnome: -1 +2 +2 +0 -1 +0 (intellectual, squishy)
:: goblin: -1 +0 -1 +2 +0 +2 (fast and lucky, weak)
:: sprite: -2 +1 +1 +2 -2 +2 (glass cannon caster)
:: centaur: +2 +0 +0 +1 +1 -2 (strong and fast, unlucky)
::
:: Class primary stat bonus (+2):
:: warrior: +2 STR
:: mage: +2 INT
:: cleric: +2 WIS
:: thief: +2 DEX
:: ranger: +1 STR, +1 DEX
:: paladin: +1 STR, +1 WIS
:: psionicist: +1 INT, +1 WIS
::
:: Stat effects (maps to combat formulas):
:: STR: +melee damage (STR/5 per hit), carry capacity
:: INT: +spell damage (INT/4), +max mana (INT * 3 per level)
:: WIS: +heal power (WIS/4), +mana regen, skill practice efficiency
:: DEX: +dodge chance (DEX/3 %), +move cost reduction
:: CON: +max HP (CON * 4 per level), +HP regen, poison/disease resist
:: LUCK: +crit chance (LUCK/10 %), +loot quality, quest reward bonus
::
::
:: === ROOM ===
::
+$ room
$: id=room-id
name=@t
description=@t
area=area-id
sector=sector
exits=(map direction exit-info)
flags=(set room-flag)
coord=(unit coord) :: for mapping (optional — client infers via BFS if absent)
==
::
+$ exit-info
$: target=room-id
door=(unit door) :: ~ = open passage, no door
hidden=? :: hidden exits don't show in exit list until discovered
==
::
+$ door
$: state=door-state
key=(unit item-id) :: item required to unlock (~ = no key needed)
pick-dc=@ud :: difficulty for thief pick lock (0 = unpickable)
description=@t :: "a heavy iron door"
==
::
+$ door-state ?(%open %closed %locked)
::
+$ coord [x=@sd y=@sd z=@sd] :: signed integers for map position
::
:: === AREA ===
::
+$ area
$: id=area-id
name=@t
min-level=@ud
max-level=@ud
rooms=(set room-id)
resets=(list reset-cmd)
reset-interval=@dr :: how often area repopulates
last-reset=@da :: last reset time
==
::
+$ reset-cmd
$% [%mob-load mob-id=@ud room=room-id max=@ud]
[%obj-load item-id=@ud room=room-id]
[%obj-give item-id=@ud mob-inst=instance-id]
[%obj-equip item-id=@ud mob-inst=instance-id slot=wear-slot]
[%door room=room-id dir=direction locked=?]
==
::
:: === MOB (NPC/Monster) ===
::
+$ mob-template
$: id=mob-id
name=@t
short-desc=@t :: "a snarling wolf"
long-desc=@t :: seen in room
look-desc=@t :: on examine
area=area-id
level=@ud
=stats
max-hp=@ud
damage=@ud :: base damage per hit
hit-bonus=@ud :: bonus to hit roll
xp-reward=@ud
gold-min=@ud
gold-max=@ud
=damage-type
flags=(set mob-flag)
loot=(list [item-id @ud]) :: item-id, drop-chance-in-100
==
::
+$ mob-instance
$: id=instance-id
template=mob-id
room=room-id
hp=@ud
max-hp=@ud
=combat-state
target=(unit session-id) :: who it's fighting
spawn-time=@da
==
::
:: === ITEM ===
::
+$ item-template
$: id=item-id
name=@t
short-desc=@t :: "a rusty sword"
long-desc=@t :: seen on ground
look-desc=@t :: on examine
=item-type
=item-binding :: loot protection classification
level=@ud :: minimum level to use
weight=@ud
value=@ud :: gold value for shops
slot=(unit wear-slot) :: where it's worn, if equippable
weapon-stats=(unit weapon-data)
armor-stats=(unit armor-data)
effects=(list effect)
==
::
:: Item binding / loot protection
:: Controls whether others can take this item from your corpse.
:: Full design TBD — this is a placeholder classification that will
:: need fleshing out. Considerations:
:: - Quest rewards and achievement items should probably be protected
:: - Common drops and shop-bought gear might be lootable
:: - Cross-world items need special handling (can you even loot
:: something that "belongs" to another world's item system?)
:: - PvP vs PvE death may have different loot rules
:: - World operators should be able to configure loot policies
:: - Some items might be "soulbound" (can never leave your possession)
:: - Citizen items that live on their ship vs guest items that live
:: on the host — different ownership models, different loot rules?
::
+$ item-binding
$? %none :: anyone can loot from corpse
%protected :: cannot be looted by others
%soulbound :: cannot be dropped, traded, or looted
==
::
+$ weapon-data
$: dice-num=@ud :: number of damage dice
dice-size=@ud :: size of each die
=damage-type
==
::
+$ armor-data
$: defense=@ud :: damage reduction
resistances=(map damage-type @ud) :: % resistance per type
==
::
+$ effect
$: stat=@tas :: which stat/property affected
modifier=@sd :: signed: +5 or -3
==
::
+$ item-instance
$: id=instance-id
template=item-id
location=item-location
overrides=(map @tas @t) :: fields that differ from template
::
:: Instances inherit everything from their template.
:: The overrides map stores ONLY what's different.
::
:: 90% of instances have empty overrides — a "rusty sword" from
:: a mob drop is identical to the template, just with a unique ID.
::
:: Overrides enable:
:: - Enchanted items: {"damage-type": "fire", "name": "Flamebrand"}
:: - Quest rewards: {"binding": "protected", "look-desc": "..."}
:: - GM-created uniques: {"name": "Excalibur", "short-desc": "...",
:: "dice-num": "5", "dice-size": "12", "binding": "soulbound"}
:: - Named items: {"name": "Wanderer's Trusty Blade"}
:: - Degraded items: {"defense": "8"} (was 12, worn down)
::
:: To read an item property:
:: check overrides first → fall back to template
::
:: Override keys match template field names. Values are stored as
:: @t (cords) and parsed by type when read. This keeps the map
:: generic without needing a typed union for every possible override.
::
:: Admin commands for creating special items:
:: > item-override <instance-id> name "Shadowfang"
:: > item-override <instance-id> damage-type "shadow"
:: > item-override <instance-id> binding "soulbound"
::
:: A fully overridden item with no matching template is effectively
:: a one-of-a-kind creation — the template just provides defaults
:: for any field NOT overridden.
::
==
::
+$ item-location
$% [%room room=room-id]
[%player session=session-id]
[%equipped session=session-id slot=wear-slot]
[%mob inst=instance-id]
[%nowhere ~] :: despawned / destroyed
==
::
:: === PLAYER CHARACTER ===
::
+$ character
$: name=@t
=player-race
=player-class
level=@ud
xp=@ud
xp-to-level=@ud
=stats
hp=@ud
max-hp=@ud
mana=@ud
max-mana=@ud
moves=@ud
max-moves=@ud
gold=@ud
quest-points=@ud
room=room-id
=char-position
=combat-state
target=(unit instance-id) :: mob instance in combat
inventory=(list instance-id)
equipment=(map wear-slot instance-id)
skills=(map @tas @ud) :: skill-name → proficiency
cooldowns=(map @tas @ud) :: skill → round number when usable
active-effects=(list active-effect) :: buffs/debuffs currently active
discovered-waypoints=(set room-id) :: waypoints this player has found
last-waypoint-use=@da :: for cooldown tracking
corpse-locations=(list [world=@p room=room-id]) :: where your corpses are
trains=@ud
practices=@ud
kills=@ud
deaths=@ud
played=@dr :: total time played
==
::
:: === SESSION (active connection) ===
::
+$ player-session
$: id=session-id
=player-type
=character
connected-at=@da
last-input=@da
idle-warned=?
==
::
+$ player-type
$% [%guest ip-hash=@uv created=@da]
[%citizen ship=@p]
==
::
:: === COMBAT ===
::
+$ combat-round
$: attacker=session-id
defender=instance-id
round=@ud
==
::
:: === MAIL ===
::
+$ mail-message
$: id=@ud
from=@t :: sender name
from-ship=(unit @p) :: sender @p if citizen, ~ if guest
to=@t :: recipient name
text=@t
sent=@da
read=?
==
::
:: === BULLETIN BOARD ===
::
+$ board
$: room=room-id :: the room this board is in
name=@t :: "Town Square Notice Board"
posts=(list board-post)
max-posts=@ud :: capacity, default 50
==
::
+$ board-post
$: id=@ud
author=@t
author-ship=(unit @p) :: @p if citizen
subject=@t
body=@t
posted=@da
pinned=? :: pinned posts always show at top
==
::
:: === QUEST ===
::
+$ quest-type ?(%kill %fetch %explore)
::
+$ active-quest
$: id=quest-id
player=session-id
=quest-type
target=@t :: mob name, item name, or room name
target-id=@ud :: mob-id, item-id, or room-id
area=area-id
timer=@da :: deadline
completed=?
==
::
:: === SHOP ===
::
+$ shop
$: keeper=@ud :: NPC id that runs this shop
room=room-id
inventory=(list item-id) :: what they sell (templates)
buy-markup=@ud :: percentage above base value
sell-markdown=@ud :: percentage below base value
==
::
:: === ADMIN DELEGATION ===
::
:: Three roles, flat hierarchy. No per-command granularity.
:: Owner = host @p, always has full access, cannot be revoked.
:: Admins cannot act on other admins — only on regular players/guests.
:: Only the owner can delegate or modify world-config.
::
+$ admin-role ?(%admin %builder)
::
:: What each role can do:
::
:: owner: everything
:: admin: moderate players (mute, boot, ban, restore, snoop, transfer)
:: + all builder powers
:: cannot: set-config, delegate, act on other admins
:: builder: create/edit rooms, mobs, items, areas, shops, spawn, purge, reset
:: cannot: moderate players, set-config, delegate
::
:: === WORLD CONFIG ===
::
+$ world-config
$: name=@t
description=@t
owner=@p
max-guests=@ud
guest-level-cap=@ud
guest-purge-days=@ud :: days before inactive guest is purged (default 30)
spawn-room=room-id :: where guests respawn / new players start
recall-room=room-id :: where %recall sends you (usually same as spawn)
tick-interval=@dr :: combat tick rate
regen-interval=@dr :: HP/mana/moves regen rate
allow-pk=?
motd=@t :: message of the day
loot-policy=loot-policy :: corpse looting rules
delegations=(map @p admin-role) :: admin/builder delegation
trusted-worlds=(set @p) :: worlds whose XP we respect
trust-policy=trust-mode :: how we handle external XP
==
::
+$ trust-mode
$? %whitelist-only :: only trust worlds on the list
%trust-all :: trust everyone (risky)
%trust-none :: ignore all external XP
==
Agent State
+$ state-0
$: %0 :: state version (for migrations)
config=world-config
:: world content
rooms=(map room-id room)
areas=(map area-id area)
mob-templates=(map mob-id mob-template)
item-templates=(map item-id item-template)
shops=(map room-id shop)
:: live instances
mob-instances=(map instance-id mob-instance)
item-instances=(map instance-id item-instance)
:: players
sessions=(map session-id player-session)
citizen-index=(map @p session-id) :: lookup session by @p
guest-tokens=(map @uv session-id) :: lookup session by cookie token
:: game state
active-combats=(set session-id) :: sessions currently fighting
active-quests=(map session-id active-quest)
:: communication
mailboxes=(map @t (list mail-message)) :: keyed by recipient name
boards=(map room-id board) :: bulletin boards by room
:: timers
last-tick=@da
last-regen=@da
:: admin
banned-ships=(set @p)
banned-ips=(set @uv) :: hashed IPs
:: counters
next-quest-id=@ud
next-mail-id=@ud
next-post-id=@ud
==
Gall Arms
on-init
Start Behn timer for game tick.
Set default world config.
Load world content (rooms, mobs, items) from initial state or bundled data.
Run all area resets to populate the world.
Bind Eyre endpoint at /mud.
on-poke
All game commands arrive as pokes. Two marks:
Mark: %mud-command
Player game actions. Requires valid session.
+$ mud-command
$% :: movement
[%move dir=direction]
[%look target=(unit @t)] :: look, or look <thing>
[%examine target=@t]
:: combat
[%kill target=@t]
[%flee ~]
[%cast spell=@tas target=(unit @t)]
[%skill skill=@tas target=(unit @t)]
:: inventory
[%get target=@t]
[%drop target=@t]
[%wear target=@t]
[%remove target=@t]
[%wield target=@t]
[%inventory ~]
:: communication
[%say text=@t] :: room only
[%yell text=@t] :: area-wide
[%tell target=@t text=@t] :: direct, both must be online
[%mail target=@t text=@t] :: async DM, stored on host
[%mail-read ~] :: read your inbox
[%mail-delete id=@ud] :: delete a message
[%gossip text=@t] :: world-wide chat
[%newbie text=@t] :: new player help channel
:: bulletin boards
[%board-read target=(unit @ud)] :: list posts, or read specific post
[%board-post subject=@t body=@t] :: post to board in current room
[%board-remove id=@ud] :: remove your post (or any if admin)
:: character
[%score ~]
[%who ~]
[%train stat=@tas]
[%practice skill=@tas]
[%equipment ~] :: show equipped items by slot
:: group
[%group-invite target=@t] :: invite player to your group
[%group-accept ~] :: accept pending invite
[%group-decline ~] :: decline pending invite
[%group-leave ~] :: leave your current group
[%group-kick target=@t] :: kick member (leader only)
[%group-leader target=@t] :: transfer leadership
[%gtell text=@t] :: group chat
:: trading
[%give target=@t item=@t] :: give item to player in room
[%give-gold target=@t amount=@ud] :: give gold to player in room
:: doors
[%open dir=direction] :: open a door
[%close dir=direction] :: close a door
[%unlock dir=direction] :: unlock with key in inventory
[%lock dir=direction] :: lock with key in inventory
:: quest
[%quest ~] :: request auto-quest from quest master
[%quest-complete ~]
[%quest-abandon ~]
:: corpse
[%loot target=@t] :: loot %none items from a corpse
[%retrieve target=@t] :: retrieve your own %protected items
[%corpses ~] :: list where your corpses are
:: travel
[%waypoint target=(unit @ud)] :: list waypoints, or travel to one
[%recall ~] :: teleport to world's recall-room
:: costs 50 moves, can't use in combat
:: citizens: recall sends to world recall-room (NOT home)
:: to go home: use portal or die
:: misc
[%save ~] :: force save (citizens)
[%quit ~] :: disconnect
==
Mark: %mud-admin
Admin actions. Requires host ship or delegated admin.
+$ mud-admin
$% :: player management
[%goto room=room-id]
[%transfer session=session-id room=room-id]
[%force session=session-id cmd=@t]
[%snoop session=session-id]
[%restore session=session-id] :: full heal
[%boot session=session-id reason=@t]
:: punishment
[%mute session=session-id duration=@dr]
[%ban-ship ship=@p reason=@t]
[%ban-ip ip-hash=@uv reason=@t]
[%unban-ship ship=@p]
:: communication
[%announce text=@t] :: broadcast to all connected players
[%immtalk text=@t] :: admin-only private channel
[%board-pin board=room-id post=@ud] :: pin post to top of board
[%board-unpin board=room-id post=@ud]
:: world management
[%purge room=room-id] :: remove all mobs/items from room
[%reset-area area=area-id] :: force area reset
[%spawn-mob mob-id=@ud room=room-id]
[%spawn-item item-id=@ud room=room-id]
[%set-config config=world-config]
:: delegation (owner only)
[%delegate ship=@p role=admin-role]
[%undelegate ship=@p]
[%trust-world ship=@p] :: add to trusted-worlds
[%untrust-world ship=@p] :: remove from trusted-worlds
:: building (OLC)
[%room-create ~]
[%room-edit room=room-id field=@tas value=@t]
[%room-exit room=room-id dir=direction target=room-id]
[%room-delete room=room-id]
[%mob-create template=mob-template]
[%mob-edit mob=mob-id field=@tas value=@t]
[%item-create template=item-template]
[%item-edit item=item-id field=@tas value=@t]
[%item-override inst=instance-id field=@tas value=@t] :: modify a specific instance
[%item-unique template=item-id overrides=(map @tas @t) room=room-id] :: spawn a one-off
[%area-create area=area]
[%area-edit area=area-id field=@tas value=@t]
[%shop-create shop=shop]
==
Mark: %mud-guest-auth
Guest character creation. Arrives from Eyre HTTP handler.
+$ mud-guest-auth
$% [%create name=@t race=player-race class=player-class]
[%resume token=@uv]
[%logout token=@uv]
==
on-watch
Subscription paths that players connect to for real-time updates:
/game/[session-id] — primary game feed for this player
room changes, combat output, system messages
this is the main SSE stream the client reads
/chat/[channel-name] — channel-specific messages
channels: say, tell, gossip, newbie, clan
/online — player connect/disconnect events
used by the OnlineUsers panel
/ticker — game tick pulse (for debug/admin)
The /game/[session-id] path is the critical one. It carries everything a player needs:
- Room descriptions when they move
- Combat round results
- Quest updates
- System messages
- Vitals changes (HP/mana/moves)
- Room content changes (someone enters/leaves, mob spawns)
The client subscribes to this one path and dispatches updates internally by type.
on-peek (Scry Endpoints)
Read-only queries. No side effects. Used by the web client for initial state and by admin tools.
/x/mud-world/status → world-status
/x/mud-world/config → world-config
/x/mud-world/online → (list player-summary)
/x/mud-world/room/[room-id] → room (with contents)
/x/mud-world/room/[room-id]/contents → (list @t) items and mobs in room
/x/mud-world/area/[area-id] → area
/x/mud-world/areas → (list area-summary)
/x/mud-world/character/[session-id] → character (full stats)
/x/mud-world/mob/[mob-id] → mob-template
/x/mud-world/item/[item-id] → item-template
/x/mud-world/who → (list who-entry)
/x/mud-world/quest/[session-id] → (unit active-quest)
/x/mud-world/map/[session-id] → (list room-summary) explored rooms
Scry Response Types
+$ world-status
$: name=@t
owner=@p
online-count=@ud
guest-count=@ud
citizen-count=@ud
uptime=@dr
area-count=@ud
room-count=@ud
==
::
+$ player-summary
$: name=@t
level=@ud
class=player-class
area=@t :: area name (not room — privacy)
=player-type
idle=@dr
==
::
+$ who-entry
$: name=@t
level=@ud
class=player-class
race=player-race
title=(unit @t)
clan=(unit @t)
idle=@dr
is-guest=?
==
::
+$ area-summary
$: id=area-id
name=@t
min-level=@ud
max-level=@ud
room-count=@ud
==
::
+$ room-summary
$: id=room-id
name=@t
sector=sector
exits=(map direction room-id)
coord=(unit coord)
==
on-arvo
Handles Behn timer wakeups. This is the game loop.
Every tick (configurable, default 2 seconds):
1. Process combat rounds for all active combats
2. Check quest timers (expire overdue quests)
3. Increment idle timers, warn/disconnect AFK players
Every regen tick (default 30 seconds):
4. Regenerate HP/mana/moves for all connected players
5. Run mob AI (wander, aggro checks)
6. Check hunger/thirst (if implemented)
Every area reset check (every 60 seconds):
7. Check each area's reset timer
8. Reset areas that are due (respawn mobs/items)
After processing:
9. Re-arm Behn timer for next tick
10. Emit subscription updates for all affected players
on-agent
Handles responses from other agents (cross-ship communication for citizens):
Citizen's ship pokes %mud-world to enter the world
→ on-poke processes the join
→ creates session, subscribes citizen to /game/[session-id]
Citizen's ship sends character data for validation
→ on-agent receives the response
→ validates character, creates local session state
If we poke another world (future: cross-world portals)
→ on-agent handles acknowledgments and errors
on-leave
Player disconnects (subscription dropped):
1. Find session by subscription wire
2. If in combat: mob wins, player "dies" (respawn rules apply)
3. Remove from room's player list
4. Emit "X has left the game" to other players in room
5. For guests: keep character in state (can resume later)
6. For citizens: clean up session, send final character state back to their ship
7. Update online count
Eyre Endpoints
Static File Serving
GET /mud → serve index.html (SolidJS app)
GET /mud/assets/* → serve JS/CSS/images from glob
Served by binding /mud in on-init and handling HTTP requests to serve the built frontend glob.
API Endpoints
These are direct HTTP endpoints, not Eyre channel pokes. The %mud-world agent
binds these paths in on-init via Eyre. Incoming HTTP requests arrive in on-arvo
as %eyre signs. The agent parses the HTTP request, processes the action
internally (same logic as if it received a %mud-guest-auth poke), and returns
an HTTP response with JSON + set-cookie header.
The %mud-guest-auth mark is used for the internal processing — the HTTP handler
converts the JSON body into the mark structure, processes it, and returns a
response. The mark exists so the same logic can also be invoked via Ames poke
if needed in the future.
Session-id flow: Guest creation returns both a token (stored in cookie for
resume) and a session-id (used to subscribe to /game/[session-id]). The
client stores the session-id in memory and uses it immediately to open the SSE
subscription via Eyre channel.
POST /mud/api/guest/create
Request: {"name": "Wanderer", "race": "human", "class": "warrior"}
Response: {"ok": true, "token": "0v1a2b3c...", "session": "0v4d5e6f..."}
Sets cookie: mud-session=0v1a2b3c...
Creates guest character, returns session token
POST /mud/api/guest/resume
Request: {"token": "0v1a2b3c..."}
Response: {"ok": true, "session": "0v4d5e6f...", "character": {...}}
Resumes existing guest session if character still exists
POST /mud/api/guest/logout
Request: {"token": "0v1a2b3c..."}
Response: {"ok": true}
Ends guest session (character persists for later resume)
GET /mud/api/status
Response: {"name": "...", "online": 23, "guests": 15, "citizens": 8, ...}
Public — no auth required. Used by world directory crawlers.
GET /mud/api/who
Response: [{"name": "Aragorn", "level": 45, "class": "warrior", ...}, ...]
Public — online player list for the lobby/login screen
GET /mud/api/areas
Response: [{"id": "dark-forest", "name": "The Dark Forest", "levels": "10-20"}, ...]
Public — area listing for world info
Eyre Channel (Standard Urbit Pattern)
After auth (guest via cookie, citizen via +code), all game communication uses standard Eyre channels:
PUT /~/channel/mud-{timestamp} → poke, subscribe, ack, delete
GET /~/channel/mud-{timestamp} → SSE event stream
This is what the EyreClient class in the web client design doc uses. Same mechanism for guests and citizens.
Subscription Update Format
All updates emitted on /game/[session-id] use the %mud-update mark:
+$ mud-update
$% :: room updates
[%room-enter =room players=(list @t) mobs=(list @t) items=(list @t)]
[%room-desc description=@t]
[%room-exits exits=(map direction room-id)]
[%player-enters name=@t]
[%player-leaves name=@t]
[%mob-enters short-desc=@t]
[%mob-leaves short-desc=@t]
:: combat
[%combat-start target=@t]
[%combat-round lines=(list log-line)]
[%combat-end victory=? xp=@ud gold=@ud loot=(list @t)]
[%combat-flee success=?]
[%you-died ~]
:: vitals
[%vitals hp=@ud max-hp=@ud mana=@ud max-mana=@ud moves=@ud max-moves=@ud]
:: character
[%xp-gain amount=@ud tnl=@ud]
[%level-up new-level=@ud gains=@t]
[%gold-change amount=@sd balance=@ud] :: signed: +50 or -30
[%stat-update =stats]
:: inventory
[%item-get name=@t]
[%item-drop name=@t]
[%item-wear name=@t slot=wear-slot]
[%item-remove name=@t slot=wear-slot]
:: communication
[%chat channel=@t from=@t text=@t]
:: channel values: "say", "yell", "tell", "gossip", "newbie",
:: "announce", "immtalk"
[%overheard ~] :: "You hear talking nearby..."
:: sent to adjacent rooms when someone uses say
:: mail
[%mail-received =mail-message] :: new mail (real-time if online)
[%mail-inbox messages=(list mail-message)] :: full inbox on request/login
[%mail-notification count=@ud] :: "You have 3 unread messages."
:: bulletin board
[%board-contents =board] :: full board on %board-read
[%board-post =board-post] :: single post on %board-read N
[%board-updated room=room-id] :: notify: board in this room changed
:: quest
[%quest-assigned =active-quest]
[%quest-complete qp=@ud gold=@ud bonus=@t]
[%quest-failed reason=@t]
[%quest-timer-warning minutes=@ud]
:: system
[%system-message text=@t]
[%motd text=@t]
[%tick ~] :: heartbeat for client keepalive
:: map
[%map-room =room-summary] :: room data for client map cache
:: who
[%online-join name=@t level=@ud class=player-class is-guest=?]
[%online-leave name=@t]
==
::
+$ log-line
$: type=@tas :: combat, system, loot, xp, etc.
text=@t
==
JSON Encoding
All updates are JSON-encoded for the SSE stream. The client receives them as:
{"room-enter": {
"room": {"id": 42, "name": "The Dark Forest", "sector": "forest",
"exits": {"north": 41, "east": 43, "south": 45},
"coord": {"x": 5, "y": -3, "z": 0}},
"players": ["Aragorn", "Luna"],
"mobs": ["a snarling wolf", "a forest spider"],
"items": ["a rusty sword"]
}}
{"combat-round": {
"lines": [
{"type": "combat", "text": "Your slash hits the wolf for 15 damage!"},
{"type": "combat", "text": "The wolf bites you for 8 damage!"}
]
}}
{"vitals": {"hp": 85, "max-hp": 100, "mana": 50, "max-mana": 50,
"moves": 72, "max-moves": 100}}
{"chat": {"channel": "gossip", "from": "Aragorn",
"text": "anyone want to group for Dark Forest?"}}
Game Tick Implementation
Timer Architecture
One Behn timer, re-armed every tick. The tick handler runs all periodic logic:
:: in on-init:
=/ next-tick (add now.bowl tick-interval.config.state)
:_ this
[%pass /tick %arvo %b %wait next-tick]~
:: in on-arvo, when /tick wire fires:
++ on-arvo
|= [=wire =sign-arvo]
?+ wire (on-arvo:def wire sign-arvo)
[%tick ~]
=/ now now.bowl
:: 1. process combat rounds
=. state (process-combats state now)
:: 2. check quest timers
=. state (check-quest-timers state now)
:: 3. check idle players
=. state (check-idle-players state now)
:: 4. regen (if regen interval elapsed)
=? state (gte (sub now last-regen.state) regen-interval.config.state)
(process-regen state now)
:: 5. mob AI (on regen tick)
=? state (gte (sub now last-regen.state) regen-interval.config.state)
(process-mob-ai state now)
:: 6. area resets
=. state (check-area-resets state now)
:: 7. re-arm timer
=/ next (add now tick-interval.config.state)
:_ this
[%pass /tick %arvo %b %wait next]~
==
Combat Round Processing
For each session in active-combats:
1. Player auto-attacks mob (see Combat Formulas below)
2. Mob auto-attacks player
3. Check mob death:
- If mob HP <= 0: award XP + gold, roll loot, end combat
4. Check player death:
- If player HP <= 0:
- Create corpse (binding rules apply)
- If guest: respawn at world spawn-room
- If citizen AND citizen.ship == our.bowl: respawn at spawn-room (own host)
- If citizen AND citizen.ship != our.bowl: Ames poke to citizen's ship
with %mud-portal-exit (reason: %death), drop subscription
5. Check mob flee (if wimpy flag and HP < 20%):
- Mob flees to random exit
6. Emit combat-round update with all log lines
7. Emit vitals update
Skills & Spells
Resource System
| Resource | Used By | Regeneration |
|---|---|---|
| Mana | Spells (mage, cleric, psionicist, paladin, ranger) | WIS/5 per regen tick (~30 sec) |
| Moves | Physical skills (warrior, thief, ranger), movement | DEX/3 per regen tick |
| Cooldowns | Most skills, measured in combat rounds | Automatic, tick-based |
Warriors and thieves never touch mana — they manage moves and cooldowns. Casters manage mana pools. Hybrids (paladin, ranger) juggle both. Different tactical feel per class.
Skill Learning
- Skills unlock at specific levels (all by level 30)
- Must spend practices to learn:
practice <skill> - Initial proficiency: 50%. Improves with use (1% chance per use, cap 95%)
- Proficiency affects: spell damage/healing multiplier, cooldown reduction at high prof
- 2 practices per level = enough to learn everything by ~level 25, mastery takes longer
Hoon Type
+$ skill-def
$: name=@tas :: skill identifier
display-name=@t :: "Magic Missile"
class=player-class :: which class learns this
level=@ud :: level when available
cost-type=?(%mana %moves) :: resource consumed
cost=@ud :: amount consumed
cooldown=@ud :: rounds before reuse (0 = none)
target-type=?(%self %ally %enemy %group %room)
=skill-effect
==
::
+$ skill-effect
$% [%damage base=@ud stat=?(%str %int %wis) divisor=@ud dtype=damage-type]
[%heal base=@ud stat=?(%str %int %wis) divisor=@ud]
[%buff stat=@tas modifier=@sd duration=@ud]
[%debuff stat=@tas modifier=@sd duration=@ud]
[%special tag=@tas] :: unique effects handled in code
==
::
+$ active-cooldown [skill=@tas expires=@ud] :: round number when available
::
:: Add to character type:
:: cooldowns=(map @tas @ud) :: skill → round when usable
:: proficiencies=(map @tas @ud) :: skill → proficiency 0-95
:: active-effects=(list active-effect)
::
+$ active-effect
$: name=@t
stat=@tas
modifier=@sd
expires=@ud :: round number
==
Class Rosters
All skills unlock by level 30 — guests experience the full class kit before hitting the level cap. Power past 30 comes from stats, gear, and proficiency, not new abilities.
Warrior — Melee damage, durability, no mana
| Skill | Lvl | Cost | CD | Target | Effect |
|---|---|---|---|---|---|
| Bash | 1 | 10 mv | 3 rnd | Enemy | Bonus attack + target loses next attack |
| Kick | 3 | 5 mv | 2 rnd | Enemy | Quick bonus damage (STR/3) |
| Rescue | 8 | 15 mv | 5 rnd | Ally | Pull aggro from mob attacking your ally |
| Berserk | 15 | 20 mv | 10 rnd | Self | +50% damage, -25% defense, 5 rounds |
| Disarm | 20 | 15 mv | 6 rnd | Enemy | Target drops weapon, -30% damage 3 rounds |
| Rally | 25 | 25 mv | 15 rnd | Group | +15% damage for group, 4 rounds |
Identity: Highest HP, most attacks per round, no mana dependency. The reliable engine. Rescue and Rally make them essential in groups. Berserk is the “push the button” moment — big damage, real risk.
Mage — Spell damage, utility, glass cannon
| Skill | Lvl | Cost | CD | Target | Effect |
|---|---|---|---|---|---|
| Magic Missile | 1 | 10 mn | none | Enemy | Reliable damage (INT/3 + 5) |
| Frost Bolt | 5 | 20 mn | 2 rnd | Enemy | Damage + target loses 1 attack next round |
| Shield | 8 | 30 mn | 12 rnd | Self | +30% magic resistance, 10 rounds |
| Identify | 10 | 15 mn | none | Item | Reveals item stats |
| Fireball | 15 | 40 mn | 3 rnd | Enemy | Heavy damage (INT/2 + 15) |
| Lightning Storm | 25 | 60 mn | 6 rnd | Room | Moderate damage to ALL mobs in room |
Identity: Lowest HP, highest burst damage, bypasses armor. Spells use INT, not STR — different gear priorities. Lightning Storm is the only true AOE, making mages uniquely valuable for clearing rooms. Mana management is the game — blow your pool and you’re a wet noodle.
Cleric — Healing, buffs, moderate combat
| Skill | Lvl | Cost | CD | Target | Effect |
|---|---|---|---|---|---|
| Heal | 1 | 15 mn | none | Ally | Heal (WIS/3 + 10) |
| Bless | 3 | 20 mn | 10 rnd | Ally | +10% damage, 8 rounds |
| Cure Poison | 5 | 10 mn | none | Ally | Remove poison effect |
| Smite | 10 | 25 mn | 2 rnd | Enemy | Holy damage (WIS/4 + 12), 2x vs undead |
| Sanctuary | 20 | 50 mn | 15 rnd | Ally | Target takes 25% less damage, 6 rounds |
| Resurrect | 30 | 100 mn | 60 rnd | Ally | Revive dead player at 25% HP in current room (prevents death-to-home) |
Identity: The group anchor. Only class that can prevent death-to-home via Resurrect — this alone makes clerics invaluable. Strong buffs (Bless, Sanctuary) mean even solo clerics are durable. Smite gives them offensive options, especially in undead areas.
Thief — Burst damage, stealth, tricks
| Skill | Lvl | Cost | CD | Target | Effect |
|---|---|---|---|---|---|
| Backstab | 1 | 15 mv | 4 rnd | Enemy | 2x damage, must initiate combat (opener) |
| Sneak | 3 | 5 mv | none | Self | Move without being seen (toggle, breaks on attack) |
| Hide | 3 | 5 mv | none | Self | Become hidden in current room (breaks on action) |
| Peek | 5 | 5 mv | none | Enemy | See mob’s inventory and gold |
| Poison Blade | 10 | 10 mv | 8 rnd | Self | Next 3 attacks add poison DOT (3 rounds each) |
| Evade | 20 | 20 mv | 6 rnd | Self | +50% dodge chance, 3 rounds |
| Mug | 25 | 15 mv | 10 rnd | Enemy | Steal gold during combat |
Identity: Highest single-hit damage (Backstab opener), best scouting (Sneak/Hide/Peek), utility through disruption. Fragile — Evade is their only defense. The class for players who like preparation and timing over sustained slugfests. Mug gives them unique gold income.
Ranger — Hybrid fighter, nature utility, tracking
| Skill | Lvl | Cost | CD | Target | Effect |
|---|---|---|---|---|---|
| Track | 1 | 10 mv | none | — | Shows direction to a named mob in the area |
| Dual Wield | 5 | passive | — | Self | Equip second weapon (offhand at 70% damage) |
| Forage | 5 | 5 mv | none | Self | Find food/herbs in wilderness rooms |
| Entangle | 10 | 20 mn | 5 rnd | Enemy | Root target in place, 3 rounds (can’t flee) |
| Nature’s Touch | 15 | 25 mn | 4 rnd | Ally | Heal (WIS/4 + 8) — weaker than cleric |
| Volley | 20 | 15 mv | 3 rnd | Enemy | Two quick attacks in one round |
| Call Animal | 25 | 40 mn | 30 rnd | Self | Summon combat pet, 10 rounds |
Identity: The self-sufficient explorer. Track makes them the best at navigating unfamiliar areas. Dual Wield gives sustained damage. Forage means they never run out of food. Jack of all trades — can heal a bit, root enemies, summon help. Not the best at anything, but never useless.
Paladin — Tank/healer hybrid, holy warrior
| Skill | Lvl | Cost | CD | Target | Effect |
|---|---|---|---|---|---|
| Lay Hands | 1 | 30 mn | 15 rnd | Ally | Big heal (WIS/2 + 20) |
| Holy Strike | 5 | 15 mn | 2 rnd | Enemy | Melee + holy damage, 2x vs undead |
| Taunt | 8 | 10 mv | 4 rnd | Enemy | Force mob to attack you instead of ally |
| Shield of Faith | 10 | 25 mn | 10 rnd | Group | +15% defense for group, 5 rounds |
| Aura of Protection | 20 | 40 mn | 20 rnd | Group | Small damage reduction for group, 8 rounds |
| Divine Wrath | 25 | 50 mn | 8 rnd | Enemy | Heavy holy damage (STR/4 + WIS/4 + 15) |
Identity: The group protector. Taunt + Shield of Faith + Aura = the best tank for group content. Lay Hands provides emergency healing. Scales with both STR and WIS (Divine Wrath), rewarding hybrid stat builds. Slower and less bursty than warrior, but groups survive longer with a paladin.
Psionicist — Mental attacks, debuffs, disruption
| Skill | Lvl | Cost | CD | Target | Effect |
|---|---|---|---|---|---|
| Mind Blast | 1 | 12 mn | none | Enemy | Psionic damage (INT/3 + 6) |
| Confusion | 5 | 20 mn | 5 rnd | Enemy | Target’s damage reduced 30%, 3 rounds |
| Mind Shield | 8 | 25 mn | 12 rnd | Self | +40% psionic resistance, 8 rounds |
| Psychic Drain | 12 | 30 mn | 4 rnd | Enemy | Damage + restore mana equal to damage dealt |
| Dominate | 20 | 45 mn | 8 rnd | Enemy | Target mob fights for you, 3 rounds |
| Mindstorm | 25 | 55 mn | 6 rnd | Enemy | Heavy damage (INT/2 + 18) |
Identity: The controller. Confusion and Dominate manipulate enemies rather than just hitting them. Psychic Drain solves their own mana problems — a well-played psionicist never runs dry. Mind Shield makes them resistant to their own damage type (PvP consideration). Less raw damage than mage, but more tactical disruption.
Class Comparison
| Class | HP | Mana | Damage | Healing | Group Value | Solo |
|---|---|---|---|---|---|---|
| Warrior | ★★★★★ | — | ★★★★ | — | Tank, Rally buff | ★★★★ |
| Mage | ★ | ★★★★★ | ★★★★★ | — | AOE, burst | ★★★ |
| Cleric | ★★★ | ★★★★ | ★★ | ★★★★★ | Heals, Resurrect | ★★★ |
| Thief | ★★ | — | ★★★★ (burst) | — | Scouting, gold | ★★★★ |
| Ranger | ★★★ | ★★★ | ★★★ | ★★ | Tracking, utility | ★★★★★ |
| Paladin | ★★★★ | ★★★ | ★★★ | ★★★ | Tank, group defense | ★★★ |
| Psionicist | ★★ | ★★★★ | ★★★★ | — | Debuffs, control | ★★★ |
Solo tier: Ranger > Warrior = Thief > Mage = Cleric = Paladin = Psionicist Group tier: Cleric (resurrect) > Paladin (tank/heal) > Warrior (tank/rally) > Mage (AOE) > rest
Every class can solo. No class is useless in groups. Clerics and paladins become more valuable as content gets harder — the natural incentive to group.
Combat Formulas
Design Principle: Every Hit Connects
No miss rolls. No “You miss!” spam. Every attack deals damage — the variance is in how much, not whether. Combat feels impactful and fast. The interesting decisions are about resource management (mana, skill cooldowns, when to flee) not about hoping the RNG likes you.
Base Damage Calculation
:: Player attacking mob:
raw_damage = weapon_base + (STR / 5)
variance = raw_damage * random(-20, +20) / 100 :: ±20% swing
gross = (raw_damage + variance) * proficiency_mult
:: Proficiency multiplier (applies to skills and auto-attack weapon proficiency):
:: proficiency 50% (freshly learned) → 0.75x damage
:: proficiency 75% → 1.0x damage (baseline)
:: proficiency 95% (mastered) → 1.15x damage
proficiency_mult = 0.5 + (proficiency / 200) :: range: 0.75 to 0.975
:: Armor reduction (flat, not percentage):
armor_value = sum of all equipped armor defense values
reduction = armor_value / 3 :: armor absorbs 1/3 of its value
:: Resistance (percentage, per damage type):
resist_pct = defender resistance to this damage type :: 0-75%, hard capped
resist_amt = gross * resist_pct / 100
:: Final damage:
net_damage = max(1, gross - reduction - resist_amt) :: always deal at least 1
Example — Level 10 warrior with a decent sword vs. a forest wolf:
weapon_base = 12 (a 2d6 average sword)
STR = 18 → STR/5 = 3
raw_damage = 15
variance = 15 * (+14%) = +2 (rolled +14 out of ±20 range)
gross = 17
wolf armor = 5 → reduction = 1
wolf resist = 0% to slash
net_damage = max(1, 17 - 1 - 0) = 16
No miss. Wolf takes 16. Clean.
Mob Attacking Player
Same formula, mob’s perspective:
raw_damage = mob.damage + (mob.str / 5)
variance = ±20%
gross = raw + variance
reduction = player_armor / 3
resist = player resistance to mob's damage type
net_damage = max(1, gross - reduction - resist)
Multiple Attacks Per Round
Instead of ROM’s second_attack / third_attack skill rolls, attacks per round scale with level and class:
| Condition | Attacks/Round |
|---|---|
| Base | 1 |
| Level 20+ OR warrior class | 2 |
| Level 50+ AND warrior/ranger/paladin | 3 |
| Haste spell/effect active | +1 bonus attack |
| Dual wielding | +1 with offhand (offhand does 70% damage) |
| Slow effect | -1 attack (minimum 1) |
Each attack uses the same formula independently. More attacks = more total damage, but each one still connects — no filler misses.
Defense: Mitigation, Not Avoidance
Traditional MUDs have parry/dodge/shield block that negate entire attacks. We replace these with mitigation — they reduce damage, they don’t cancel it:
| Defense | Effect | Trigger |
|---|---|---|
| Parry | Reduce incoming damage by 30% | Automatic, requires weapon, skill-dependent chance per round (50-80%) |
| Dodge | Reduce incoming damage by 25% | Automatic, DEX-dependent chance (30-60%) |
| Shield block | Flat damage absorbed (= shield defense value) | Automatic, requires shield, 40-70% chance |
| Armor | Flat reduction on every hit | Always active |
| Resistance | Percentage reduction by damage type | Always active, from gear/race/spells |
Parry and dodge are probabilistic mitigation — when they trigger, they soften the blow rather than negating it. The player always takes some damage, but a well-geared tank takes much less.
Example round — wolf attacks level 10 warrior:
wolf raw_damage = 8
variance = 8 * (-7%) = -1
gross = 7
warrior armor = 15 → reduction = 5
warrior parry = triggers (65% chance) → 7 * 30% = 2 absorbed
warrior dodge = doesn't trigger (40% chance)
warrior resist = 0% to pierce
net_damage = max(1, 7 - 5 - 2) = 1
Wolf barely scratches the warrior. Makes sense — level 10 warrior in armor vs a wolf. At higher levels, tougher mobs will push through defenses more.
Critical Hits
Instead of miss/hit variance, we add upside variance through crits:
crit_chance = 5 + (LUCK / 10) + skill_bonus :: base 5%, scales with luck
if random(1, 100) <= crit_chance:
damage *= 1.5 :: 50% bonus damage
emit "CRITICAL HIT!" message
Crits feel good. They’re the exciting upside that misses were the boring downside of. LUCK stat has a clear purpose. Certain skills/spells can boost crit chance.
Spell Damage
Spells use INT instead of STR, ignore armor, and are reduced only by magical resistance:
raw_damage = spell_base + (INT / 4)
variance = ±15% :: slightly tighter than melee
gross = (raw + variance) * proficiency_mult :: same proficiency formula as melee
:: Spells bypass physical armor entirely
:: Only magical resistance applies:
resist_pct = defender resistance to spell's damage type
resist_amt = gross * resist_pct / 100
net_damage = max(1, gross - resist_amt)
This makes mages the counter to heavily-armored targets. Warriors stack armor → great vs melee, weak vs spells. Mages deal magic damage → ignores armor, resisted by magic resist gear. Natural class tension.
Healing
Healing follows the same “always works” principle:
heal_amount = spell_base + (WIS / 4)
variance = ±10% :: tighter than damage — healing should be reliable
actual_heal = min(heal + variance, max_hp - current_hp) :: can't overheal
Damage Display
Since every hit lands, the combat log is dense and satisfying:
Your slash strikes the forest wolf! [16 damage]
The forest wolf claws at you! [1 damage, parried]
Your slash strikes the forest wolf! [19 damage, CRITICAL!]
The forest wolf is looking pretty hurt.
No “You miss the wolf. The wolf misses you. You miss the wolf.” — every line carries information.
Mob health indicators (instead of showing exact HP):
- “is in perfect health” (100%)
- “has a few scratches” (90%+)
- “has some wounds” (75%+)
- “is bleeding freely” (50%+)
- “is looking pretty hurt” (35%+)
- “is in awful condition” (20%+)
- “is near death!” (5%+)
Summary: Why This System
| ROM (Traditional) | Our System |
|---|---|
| d20 hit roll — can miss entirely | Every attack hits |
| THAC0 (confusing, lower = better) | Flat formula, easy to understand |
| Parry/dodge negate entire attack | Parry/dodge reduce damage |
| Miss spam fills the screen | Every log line is meaningful |
| Damage cap with weird progressive softening | Clean formula, scales naturally |
| Need to understand AC, THAC0, hitroll, damroll | STR = hit harder, armor = take less, resistance = type defense |
Players can intuit the whole system: STR makes you hit harder, INT makes spells stronger, armor absorbs physical hits, resistances reduce typed damage, crits are lucky bonus damage. No hidden tables, no inverse scales, no jargon.
Mob AI Processing
For each mob instance in the world:
If mob has %aggressive flag:
Check if any player in same room
If player level within aggro range: initiate combat
If mob has %scavenger flag:
Check if any items on the floor in same room
Pick up random item
If mob does NOT have %sentinel flag:
Small random chance (5%) to wander to adjacent room
If mob is in combat and HP < wimpy threshold:
Attempt to flee
Area Reset Processing
For each area:
If (now - last-reset) > reset-interval:
For each reset command in area.resets:
Execute: spawn mob, place item, lock door, etc.
Only spawn if current count < max allowed
Update last-reset timestamp
Emit repop message to players in area
Level-Up Gains
Fixed per level, no randomness. Class determines the weighting, stats determine the magnitude.
Per-Level Gains
HP gained = CON * 4 + class_hp_bonus
Mana gained = INT * 3 + class_mana_bonus
Moves gained = DEX * 2 + 10
Trains gained = 1 per level (spend to increase stats)
Pracs gained = 2 per level (spend to learn/improve skills)
Class Bonuses Per Level
| Class | HP Bonus | Mana Bonus | Role |
|---|---|---|---|
| Warrior | +8 | +0 | Highest HP, no mana bonus |
| Paladin | +5 | +3 | Hybrid tank/caster |
| Ranger | +5 | +2 | Hybrid melee/utility |
| Thief | +4 | +1 | Moderate HP, light mana |
| Cleric | +3 | +5 | Moderate HP, strong mana |
| Psionicist | +2 | +5 | Lower HP, strong mana |
| Mage | +1 | +7 | Lowest HP, highest mana |
Example — Level 1→2 for a Dwarf Warrior (CON 18, INT 8, DEX 9):
HP: 18 * 4 + 8 = 80 HP gained
Mana: 8 * 3 + 0 = 24 mana gained
Moves: 9 * 2 + 10 = 28 moves gained
Example — Level 1→2 for a Sprite Mage (CON 8, INT 20, DEX 12):
HP: 8 * 4 + 1 = 33 HP gained
Mana: 20 * 3 + 7 = 67 mana gained
Moves: 12 * 2 + 10 = 34 moves gained
The warrior gets 2.4x the HP. The mage gets 2.8x the mana. Both feel right for their roles, and the numbers come directly from player stat choices — your build matters.
Stat Training
Players earn 1 train per level. Spending a train:
stat_cost = 1 train per +1, up to stat 25
2 trains per +1, from 26-35
3 trains per +1, from 36-50
Diminishing returns prevent infinite stat stacking while still rewarding long-term investment. Stats above 25 represent genuine commitment.
XP & Progression
XP Required Per Level
Gentle exponential — early levels fly by, endgame is a real commitment:
xp_to_level(n) = 100 * n * (1 + n/50)
| Level | XP to Next | Cumulative | Feel |
|---|---|---|---|
| 1→2 | 102 | 102 | Instant |
| 5→6 | 560 | 1,830 | Minutes |
| 10→11 | 1,200 | 6,600 | A session |
| 20→21 | 2,800 | 28,200 | A few sessions |
| 50→51 | 10,000 | 195,000 | Days of play |
| 100→101 | 30,000 | 1,350,000 | Weeks |
| 150→151 | 60,000 | 3,900,000 | Serious investment |
| 200→201 | 100,000 | 8,000,000 | Endgame grind |
Mob Base XP
Scales with mob level, slight quadratic to keep higher-level mobs rewarding:
mob_xp(level) = level * 10 + (level * level / 10)
| Mob Level | Base XP |
|---|---|
| 1 | 10 |
| 5 | 52 |
| 10 | 110 |
| 20 | 240 |
| 50 | 750 |
| 100 | 2,000 |
| 150 | 3,750 |
| 200 | 6,000 |
Kills to level at equal-level content: roughly 10-15 kills per level across the entire range. Stays consistent — the grind doesn’t suddenly spike or collapse.
Level-Difference XP Scaling
Gradual penalty for farming easy content, bonus for fighting above your weight:
level_diff = player_level - mob_level
scaling:
diff <= 5: 100% :: on-level, full XP
diff 6-10: 75% :: slightly below, mild penalty
diff 11-15: 40% :: noticeably below
diff 16-20: 10% :: gray mobs, barely worth it
diff 21+: 1 XP :: token — not zero, but not worth farming
diff -1 to -5: 120% :: slightly above, small bonus
diff -6 to -10: 150% :: significantly harder, real reward
diff < -10: 200% :: danger zone, double XP
Why this works:
- 5-level grace window lets you clear an area without outleveling it mid-quest
- Bonus XP for harder mobs rewards risk-taking and group content
- Floor of 1 XP (not zero) avoids the psychologically punishing hard cutoff
- No penalty for mobs your level or above — you’re never punished for pushing forward
Quest XP
Quests from quest masters award XP independently of mob kills:
quest_xp = player_level * 50 :: scales with you, always relevant
A level 20 player gets 1,000 XP per quest — roughly half a level’s worth. Quests are meaningful supplements to combat XP, not replacements.
Guest Level Cap
When a guest earns enough XP to exceed guest-level-cap (default 30), the
level-up is blocked. XP continues to accumulate (not wasted) but the guest
stays at the cap level until they claim an @p and become a citizen.
On XP gain:
if player is guest AND character.level >= world.config.guest-level-cap:
store XP (it counts if they upgrade to citizen)
do NOT level up
emit: "You've reached the limit of what a wanderer can achieve.
Claim your true name to continue growing."
The XP is banked, not lost. If the guest later upgrades to citizen, they immediately level up with their accumulated XP. This feels fair — your effort wasn’t wasted, it’s just waiting for you.
Group XP
When players group:
total_xp = mob_xp * (1 + 0.1 * (group_size - 1)) :: 10% bonus per extra member
split_xp = total_xp / group_size
:: Example: 3 players kill a mob worth 500 XP
:: total = 500 * 1.2 = 600
:: each gets 200 (vs 500 solo — grouping costs XP but has safety/speed benefits)
The 10% bonus per extra member partially offsets the split. Grouping is slightly less efficient than solo for raw XP/hour, but much safer and more fun — the right tradeoff.
Chat & Communication
Channel Architecture
Communication has three ranges and two modes (synchronous and asynchronous):
SYNCHRONOUS (real-time, requires both parties online):
Room ← say "You say, 'hello'"
Only players in the same room see it
Adjacent rooms get: "You hear talking nearby..."
Area ← yell "You yell, 'anyone around?'"
All players in any room in the same area hear it
Formatted distinctly: "[Darkwood Forest] Wanderer yells, '...'"
Direct ← tell "You tell Aragorn, 'nice sword'"
Private, both must be online
If target offline: "Aragorn is not here." (use mail instead)
World ← gossip "Wanderer gossips, 'first time here, any tips?'"
All connected players in this world hear it
Guests: available after level 5 (anti-spam gate)
Help ← newbie "Wanderer asks, 'how do I equip a sword?'"
All connected players hear it, styled for help context
Admin ← announce "[ANNOUNCEMENT] Server maintenance in 30 minutes."
Bright, distinct formatting, cannot be muted
Admin-only send, all players receive
Admin ← immtalk "Lasher: 'someone's botting in Dark Forest, checking'"
Admin-only channel, invisible to players
ASYNCHRONOUS (stored, delivered on login or immediately if online):
Mail ← mail Stored on host world, delivered on next login
If recipient online: delivered immediately (feels like tell)
Persists across sessions, capped at 50 per player
The “Overheard” Mechanic
When a player uses say in a room, the agent iterates that room’s exits and sends [%overheard ~] to all players in directly adjacent rooms:
Wanderer says "hello" in Room 42
│
├── Room 42: all players see "Wanderer says, 'hello'"
├── Room 41 (north exit): players see "You hear talking nearby..."
├── Room 43 (east exit): players see "You hear talking nearby..."
└── Room 45 (south exit): players see "You hear talking nearby..."
No content is leaked — just awareness that someone is nearby. Creates a sense of inhabited space. Players in adjacent rooms might move toward the voices. Costs almost nothing to implement (just emit to exit-connected rooms).
Mail System
> mail Aragorn Hey, found a great farming spot in Shadowfen. Let me
know when you're online and I'll show you.
"Message sent to Aragorn."
(If Aragorn is online: delivered immediately as a tell-like notification)
(If Aragorn is offline: stored, delivered on next login)
> mail
Your messages:
1. [unread] From Aragorn (2 hours ago): "Thanks! I'll be on tonight"
2. [read] From Luna (yesterday): "Can you craft me a leather helm?"
3. [read] From [System] (3 days ago): "Your quest reward has arrived."
> mail 1
From: Aragorn
Date: March 26, 2026 14:32
"Thanks! I'll be on tonight around 8pm. Meet at Darkwood Bridge waypoint?"
> mail-delete 3
Message deleted.
On login notification:
Welcome back, Wanderer.
You have 2 unread messages. Type 'mail' to read them.
Mail storage: Mailboxes keyed by character name, stored in %mud-world state. 50 message cap per player — oldest messages dropped when full. System messages (quest rewards, admin notices) also delivered via mail.
Guest restrictions: Guests can receive mail but can only send mail after level 5 (same anti-spam gate as gossip). Prevents throwaway guest spam.
Bulletin Boards
Physical objects placed in rooms by builders. Players interact with them like any room feature.
The Town Square of Aylor
A weathered stone fountain stands at the center of a bustling square.
Merchants hawk their wares from colorful stalls, and the smell of fresh
bread drifts from a nearby bakery.
A large wooden notice board stands near the fountain, covered in pinned
notes and official decrees.
Exits: [north] [east] [south] [west]
> read board
=== Aylor Town Notice Board ===
[pinned] 1. ANNOUNCEMENT: Dragon sighting near Shadowfen (Admin)
[pinned] 2. New adventurers: read 'help newbie' for tips (Admin)
3. LFG Shadowfen Caves tonight — whisper Aragorn (Aragorn, 2h ago)
4. Selling enchanted leather armor, 500g — mail Luna (Luna, 5h ago)
5. Has anyone found the secret room in Old Library? (Wanderer, 1d ago)
> read board 3
=== Post #3 by Aragorn ===
Date: March 26, 2026 18:45
Subject: LFG Shadowfen Caves tonight
Looking for 2-3 people to clear Shadowfen Caves. I'm a level 25
paladin, could use a healer and some DPS. Meet at Darkwood Bridge
waypoint around 9pm. Mail me if interested.
> post board
Subject: WTB fire resist gear
Body: Looking to buy any gear with fire resistance. Heading into
the Volcanic Depths soon. Mail me with offers. —Wanderer
Posted to Aylor Town Notice Board.
Board placement: Builders place boards during area creation. A board is a property of a room — boards=(map room-id board) in world state. Not every room has one. Good locations: town squares, inns, guild halls, crossroads.
Board rules:
- Anyone can read
- Guests can post after level 5
- Authors can remove their own posts
- Admins can remove any post and pin/unpin
- Oldest unpinned posts fall off when board is full (default 50 posts)
- Pinned posts always show at top, don’t count toward capacity
- Posts have no threading/replies — use mail for direct responses
Boards as world-building tools:
- NPCs can “post” announcements (scripted via area building) — quest hooks, lore drops, world events
- Admins pin important notices — rule reminders, event announcements, maintenance warnings
- Players self-organize — LFG, trading, tips, social
- Different boards in different areas can have different names and vibes:
- “Aylor Town Notice Board” (general)
- “Adventurer’s Guild Job Board” (quest-focused)
- “The Rusty Anchor’s Scrawled Notes” (tavern, informal)
- “Mage Tower Registry” (class-specific)
Chat Display in Web Client
The web client’s <ChannelTabs> component routes messages by channel:
| Tab | Sources | Color |
|---|---|---|
| Game | Room descriptions, combat, system, loot, XP | Default/varied |
| Say | say, yell, overheard | Green |
| Tell/Mail | tell, mail notifications | Magenta |
| Gossip | gossip | Yellow |
| Newbie | newbie | Bright green |
Admin announce messages appear in all tabs simultaneously — cannot be missed.
Mail has its own UI (accessed via mail command or a mailbox icon in the sidebar). Board content renders in the main game pane when you read board.
Party/Group System
Formation
> group invite Aragorn
You invite Aragorn to join your group.
(Aragorn sees: "Wanderer invites you to join their group. Type 'group accept' or 'group decline'.")
> group accept
You join Wanderer's group.
> group leave
You leave the group.
The player who invites first becomes leader. Leader can kick members and transfer leadership. Groups disband when the leader leaves (or leadership auto-transfers to longest member). Max group size: 6.
Hoon Types
+$ group
$: id=@uv
leader=session-id
members=(list session-id) :: ordered, leader first
loot-policy=loot-rule
created=@da
==
::
+$ loot-rule ?(%round-robin %free-for-all)
::
+$ group-invite
$: from=session-id
to=session-id
expires=@da :: auto-expire after 60 seconds
==
State Additions
:: Add to state-0:
groups=(map @uv group) :: active groups
player-group=(map session-id @uv) :: player → group id
pending-invites=(map session-id group-invite) :: target → pending invite
XP Sharing in Groups
Uses the formula from XP & Progression:
total_xp = mob_xp * (1 + 0.1 * (group_size - 1))
split_xp = total_xp / group_size
Only members in the same room as the mob when it dies get XP. Members in other rooms don’t share — prevents leeching.
Aggro/Threat in Groups
When a group member attacks a mob, the mob targets that member. Other members can use Taunt (paladin) or Rescue (warrior) to redirect aggro.
Without explicit taunt/rescue:
- Mob targets whoever dealt the most damage this round
- Healing generates threat: heal amount / 2
- First attacker gets initial aggro
This is simple but functional. The warrior/paladin tank skills (Taunt, Rescue, Shield of Faith) provide the tools to control aggro properly.
Group Loot Rules
When a mob dies and drops loot in a group:
Round-robin (default):
- Items rotate through group members in order
- Each item goes to the next person in rotation
- Gold is split evenly
- Protected/soulbound items always go to whoever triggered the drop
Free-for-all:
- First to
get <item>takes it - Gold still split evenly
Leader sets the policy: group loot round-robin or group loot ffa
Group Chat
gtell messages go to all group members regardless of which room they’re in. Styled distinctly in the chat panel:
[Group] Wanderer: "ready to pull the boss?"
[Group] Aragorn: "one sec, need mana"
Subscription Updates
:: Add to mud-update:
[%group-invite from=@t] :: you've been invited
[%group-join name=@t] :: someone joined your group
[%group-leave name=@t] :: someone left your group
[%group-disband ~] :: group disbanded
[%gtell from=@t text=@t] :: group chat message
[%group-info =group] :: full group state (on join/change)
Player Trading
Direct Trade
Players in the same room can give items and gold to each other:
> give sword to Aragorn
You give a rusty sword to Aragorn.
(Aragorn sees: "Wanderer gives you a rusty sword.")
> give 500 gold to Aragorn
You give 500 gold to Aragorn.
Rules
- Both players must be in the same room
- Item must be in your inventory (not equipped —
removefirst) %soulbounditems cannot be given- Guests can give items to other players (including citizens)
- No confirmation prompt —
giveis instant (keeps it simple, matches MUD convention) - Giving an item transfers the
item-instance— the same unique instance, same overrides, same origin-world
Subscription Updates
:: Add to mud-update:
[%item-given item=@t to=@t] :: you gave something
[%item-received item=@t from=@t] :: you received something
[%gold-given amount=@ud to=@t]
[%gold-received amount=@ud from=@t]
Hidden & Locked Exits
Door States
Exits can optionally have a door with state:
+$ door
$: state=door-state
key=(unit item-id) :: item required to unlock (~ = no key needed)
pick-dc=@ud :: difficulty to pick (0 = unpickable)
description=@t :: "a heavy iron door"
==
::
+$ door-state ?(%open %closed %locked)
Room Exit Update
:: Update room type — exits can have doors:
+$ exit-info
$: target=room-id
door=(unit door) :: ~ = open passage, no door
==
::
:: Room exits become:
:: exits=(map direction exit-info) :: instead of (map direction room-id)
Commands
> north
The heavy iron door is closed.
> open north
You open the heavy iron door.
> north
[movement succeeds]
---
> north
The heavy iron door is locked.
> unlock north
You unlock the heavy iron door with the iron key.
(key is consumed if it's a one-use key, or stays if reusable)
> open north
You open the heavy iron door.
---
> pick north
You attempt to pick the lock...
[Thief skill check: DEX + pick_lock proficiency vs door.pick-dc]
Success: "Click. The lock gives way."
Failure: "The lock resists your efforts."
Door Behavior
- Doors block movement when closed or locked
openworks on closed doors, fails on locked doorsunlockrequires the matching key item in inventorylockrequires the matching key and the door to be closedpickis a thief skill — check DEX + proficiency vs difficulty class- Doors can reset to their original state on area reset
- Some doors are one-way (locked from one side only)
- NPCs don’t open doors — players must clear the way
Area Reset Interaction
The existing reset command already handles doors:
{"type": "door", "room": 1012, "dir": "east", "state": "locked"}
On area reset, doors return to their reset state. Players must re-unlock.
Subscription Updates
:: Add to mud-update:
[%door-state dir=direction state=door-state desc=@t]
Auto-Quest Generation
How Quest Masters Work
Quest master NPCs (flagged %questmaster) generate procedural quests on demand. These are separate from hand-crafted quest lines — they’re repeatable, randomized, and always available.
> quest
The Quest Master studies you for a moment.
"I have a task for you. A shadow wolf has been terrorizing the
Overgrown Trail. Find it and put it down."
Quest: Kill a shadow wolf
Location: Overgrown Trail (Darkwood Forest)
Time limit: 15 minutes
Reward: 750 XP, 120 gold, 8 QP
Generation Algorithm
On [%quest ~] poke from player:
1. Check preconditions:
- Player not already on an auto-quest (one at a time)
- Cooldown elapsed since last quest (5 minutes)
- Player is in a room with a questmaster NPC
2. Select quest type (weighted random):
- %kill (60%): kill a specific mob
- %fetch (25%): find and bring back an item
- %explore (15%): visit a specific room
3. Select target based on type:
%kill:
- Pick a random mob-template from areas within ±5 levels of player
- Pick a random room where that mob spawns (from resets or encounters)
- Target: "Kill [mob short-desc] in [room name] ([area name])"
%fetch:
- Pick a random item-template from areas within ±5 levels of player
- Must be an item that drops from mobs (in loot tables) or spawns in rooms
- Target: "Find [item short-desc] and bring it to [questmaster name]"
%explore:
- Pick a random room in an area within ±5 levels of player
- Prefer rooms the player hasn't visited (check explored rooms if tracked)
- Target: "Find your way to [room name] in [area name]"
4. Calculate rewards:
quest_xp = player_level * 50
quest_gold = player_level * 8 + random(0, player_level * 4)
quest_qp = 5 + (player_level / 5)
5. Set timer: 15 minutes (configurable per world)
6. Create active-quest, emit [%quest-assigned ...]
Hoon Types
+$ auto-quest
$: id=quest-id
player=session-id
=quest-type :: %kill, %fetch, %explore
target-name=@t :: "a shadow wolf"
target-id=@ud :: mob-id, item-id, or room-id
target-area=area-id
target-room=(unit room-id) :: specific room (for %kill/%explore)
timer=@da :: deadline
rewards=auto-quest-rewards
==
::
+$ auto-quest-rewards
$: xp=@ud gold=@ud qp=@ud ==
Completion
Auto-quests complete through the same passive triggers as hand-crafted quests:
%kill: target mob dies while quest active%fetch: player picks up (or already has) the target item — must return to questmaster%explore: player enters the target room
On completion, player must return to the questmaster to turn in:
> quest
The Quest Master nods approvingly.
"Well done. Here is your reward."
Quest complete! Earned: 750 XP, 120 gold, 8 QP
Timer
If the timer expires:
Your quest has expired. The Quest Master will have another task
for you in a few minutes.
[%quest-failed reason="Time expired"]
5-minute cooldown before a new quest can be requested.
Score & Equipment Display
Score Command
score shows a full character summary:
> score
═══════════════════════════════════════════
Wanderer the Human Warrior Level 15
───────────────────────────────────────────
HP: 485/620 Mana: 120/180 Moves: 245/310
STR: 18 INT: 10 WIS: 10
DEX: 14 CON: 16 LUCK: 12
Gold: 2,340 Quest Points: 87
Trains: 3 Practices: 4
XP: 12,450 / 24,000 (to next level)
Kills: 342 Deaths: 5 Quests: 28
Time Played: 14h 32m
───────────────────────────────────────────
Group: Wanderer's Party (3 members)
World: ~sneagan-world (Darkwood Forest)
═══════════════════════════════════════════
Equipment Command
equipment shows what’s worn in each slot:
> equipment
── Equipped Items ─────────────────────────
Head: a leather helm [+3 DEF]
Neck: (empty)
Torso: chainmail armor [+12 DEF, +5% slash resist]
Arms: (empty)
Hands: leather gloves [+2 DEF]
Waist: (empty)
Legs: iron greaves [+6 DEF]
Feet: sturdy boots [+2 DEF, +1 DEX]
Finger L: (empty)
Finger R: ring of strength [+2 STR]
Wrist L: (empty)
Wrist R: (empty)
About: a shadowhide cloak [+8 DEF, +15% shadow resist]
Wield: a steel longsword [3d6 slash, +2 STR]
Shield: wooden buckler [+4 DEF]
Hold: (empty)
──────────────────────────────────────────
Total Defense: 37 (absorbs 12 per hit)
Resistances: slash 5%, shadow 15%
Subscription Updates
:: Add to mud-update:
[%score =character] :: full character data for score display
[%equipment slots=(map wear-slot @t)] :: slot → item description string
The client renders these — score as a styled panel/modal, equipment as the slot list. Both can be triggered by clicking UI elements (character name in status bar opens score, armor icon opens equipment).
Death & Respawn
The Rule
When you die, you go home.
Citizens respawn in their own Urbit’s home world. Guests respawn at the world’s spawn point. No exceptions.
Why This Works
Home world investment: Even players who never build a full world will customize their spawn room — a bed, a chest, some trophies. Death makes home matter.
Soft penalty, real consequence: You don’t lose levels or gear. But you lose position. If you were deep in a dungeon, you have to travel back. This is meaningful without being punishing.
Fast-travel mitigates frustration: Discovered waypoints let you get close to where you died. The last stretch is the penalty — not the entire journey.
Natural cross-world boundary: Death ejects you from the host world entirely. You have to consciously re-enter through a portal. This reinforces the world-as-a-place-you-visit metaphor.
Death Flow
Player HP reaches 0:
FOR CITIZENS:
1. Combat ends. Mob disengages.
2. Player's corpse appears in the room (contains equipped items — see below)
3. Emit [%you-died ~] to player
4. Emit "X has been slain!" to others in room
5. 3-second delay (dramatic pause, client shows death screen)
6. Host world pokes citizen's ship: "your player died, respawn them"
7. Citizen's %mud-character agent receives death event
8. Player appears in their home world spawn room, full HP/mana/moves
9. Host world removes player from session/room
10. Player's subscription to host world ends
11. Player sees: "You awaken in the familiar comfort of home..."
FOR GUESTS:
1. Combat ends. Mob disengages.
2. Corpse appears (same as citizen)
3. Emit [%you-died ~]
4. 3-second delay
5. Guest respawns at world spawn point (configurable per world)
6. Full HP/mana/moves restored
7. Player sees: "The world blurs... you awaken at the crossroads."
What You Lose
| Loss | Citizens | Guests |
|---|---|---|
| Position | Yes — sent home to own Urbit | Yes — sent to world spawn |
| Gold | 10% of carried gold (dropped on corpse) | 10% of carried gold (dropped on corpse) |
| XP | None | None |
| Levels | None | None |
%none items | On corpse — anyone can loot | On corpse — anyone can loot |
%protected items | On corpse — only you can retrieve | On corpse — only you can retrieve |
%soulbound items | Come with you (skip corpse) | Come with you |
| Quest progress | Active quest fails | Active quest fails |
| Buffs/spells | All temporary effects removed | All temporary effects removed |
Item Binding System
Every item has a binding classification that determines what happens to it on death and whether it can be traded:
+$ item-binding
$? %none :: lootable from corpse, freely tradeable
%protected :: stays on corpse but only owner retrieves
%soulbound :: never leaves your person, skips corpse
==
| Binding | On Death | Others Can Loot | Can Drop/Trade |
|---|---|---|---|
%none | Stays on corpse | Yes | Yes |
%protected | Stays on corpse | No — owner only | Yes |
%soulbound | Comes with you to respawn | No | No |
What gets which binding:
| Source | Default Binding | Rationale |
|---|---|---|
| Mob drops (common) | %none | Churn. Items enter via drops, leave via looting. Healthy economy. |
| Shop-bought gear | %none | You can buy another one. |
| Quest rewards | %protected | Earned through effort. |
| Area goal rewards | %protected | Achievement-based. |
| GM-created uniques | %soulbound | One-of-a-kind, narratively significant. |
| Key/story items | %soulbound | “You are the chosen one” artifacts. |
| Cross-world items | Keep original binding | You chose to bring it. Risk is yours. |
Binding is set on the template but overridable per-instance via the item override system. All “Rusty Swords” are %none by default, but a GM can override a specific instance to %protected if it’s a narrative prop.
Cross-world items keep their original binding. A %none sword from sneagan’s world that you bring to a friend’s world — if you die there, it’s on your corpse, and yes, locals can loot it. You chose to bring it into danger. This creates a real decision: bring your best gear and risk it, or stash it at home and go light.
Corpse System
+$ corpse
$: owner-ship=@p :: citizen @p (for retrieval tracking)
owner-name=@t
owner-session=(unit session-id) :: session if still connected (guests)
room=room-id
lootable=(list instance-id) :: %none items — anyone can take
protected=(list instance-id) :: %protected items — owner only
gold=@ud :: 10% of player's gold (lootable)
created=@da
==
- Corpse appears in the room where you died
%soulbounditems are not on the corpse — they travel with you to respawn%noneitems and gold are lootable by anyone:loot corpse%protecteditems are visible but only the owner can retrieve them:retrieve corpse- Corpses persist indefinitely — no auto-despawn timer
- Your character tracks where your corpses are (can have multiple)
- Items only return automatically if the world host wipes/resets the world (host sends items back to citizen’s ship via the world-record system)
Corpse display:
The corpse of Wanderer lies here.
Lootable: a rusty sword, a leather helmet, 34 gold
Protected: Blade of the Forest Guardian, Ring of the Lost
Players see exactly what they can take. No ambiguity.
Multiple corpses: If you die again before recovering a previous corpse, both corpses exist independently. You might have corpses scattered across multiple areas — or multiple worlds. Your character sheet tracks them:
> corpses
Your remains can be found at:
1. The Dark Forest, Room of Shadows (~sneagan-world) — 3 items, 34 gold
2. Crystal Caverns, Deep Pool (~friend-world) — 1 item, 0 gold
This creates emergent gameplay: “I need to do a corpse run through the Dark Forest, anyone want to escort me?” Social, cooperative, memorable.
Item Recovery for Offline Worlds
If a world host goes offline while your corpse is there:
- Your character sheet still shows the corpse location
- When the host comes back online, your corpse is still there (persistent state)
- If the host permanently shuts down or wipes the world:
- Host should send all corpse items back via world-record before wiping
- If host disappears without notice: items are lost (the real consequence of a decentralized system — choose which worlds you trust with your gear)
World Loot Policy
World operators can override the binding system globally:
+$ loot-policy
$? %standard :: bindings work as described above
%protected-all :: all items act as %protected (casual/friendly)
%hardcore :: all items act as %none except %soulbound (full loot)
==
Add to world-config: loot-policy=loot-policy
A casual world sets %protected-all — no corpse looting at all, ever. A hardcore PvP world sets %hardcore — everything except soulbound is fair game. sneagan’s flagship runs %standard — the designed experience.
Death Protection
- Players below level 5 don’t drop items on death (newbie protection)
- No gold loss below level 5
- Tutorial area mobs can’t kill you (they stop attacking at 1 HP)
- Safe rooms (inns, clan halls) prevent combat entirely
Fast-Travel System
Waypoints
Waypoints are discoverable locations in the world that players can teleport back to after finding them. They appear on the map permanently once discovered.
+$ waypoint
$: id=room-id :: the room that IS the waypoint
name=@t :: "Darkwood Crossroads"
area=area-id
discovered-by=(set @p) :: citizens who've found it
:: guests track discovery in their session
==
Discovery
- Waypoints are specific rooms flagged as
%waypointin room flags - When a player enters a waypoint room for the first time:
"You sense a connection to this place. You will remember it."- Waypoint appears on their map permanently (golden marker)
- For citizens: discovery stored on their ship (persists across sessions)
- For guests: discovery stored in guest session (persists while session alive)
Travel
> waypoint
Available waypoints:
1. Aylor Crossroads (Aylor)
2. Darkwood Bridge (Darkwood Forest)
3. Mountain Gate (Granite Peaks)
> waypoint 2
You close your eyes and focus on the memory of Darkwood Bridge...
The world shifts around you.
[movement to Darkwood Bridge room]
Costs and restrictions:
- Costs moves (flat amount, e.g., 20 moves regardless of distance)
- Cannot be used in combat
- 60-second cooldown between waypoint uses
- Cannot waypoint while carrying another player’s corpse (anti-exploit)
- Waypoints are one-way teleports (you appear at the waypoint room)
Waypoint Placement Strategy
For sneagan’s flagship world:
- One waypoint per major area hub (every 20-30 rooms of content)
- Tutorial area has a waypoint at the exit (first one players find)
- Major cities always have waypoints
- Dungeon entrances have waypoints (not deep inside — you earn the depth)
- Continental crossroads have waypoints
- No waypoints in endgame areas — getting there IS the challenge
Map Integration
Waypoints show on the Canvas 2D map as:
- Undiscovered: Not shown (fog of war)
- Discovered: Golden diamond marker, always visible even if surrounding rooms are fogged
- Current location: Pulsing golden diamond
The web client’s map panel renders waypoints as a separate layer above rooms, so they’re always visible as landmarks for navigation.
Data: Character Addition
:: Add to character type:
+$ character
$: ...existing fields...
discovered-waypoints=(set room-id) :: waypoints this player has found
last-waypoint-use=@da :: for cooldown tracking
==
Data: Room Flag Addition
:: Add %waypoint to room-flag:
+$ room-flag
$? %dark %no-mob %indoors %no-recall %no-magic
%no-flee %no-pk %safe %pet-shop %waypoint
==
Data: Update Addition
:: Add to mud-update:
[%waypoint-discovered name=@t room=room-id]
[%waypoint-travel room=room-id]
Command Processing Flow
Example: Player types “kill wolf”
1. Client sends poke:
{command: {kill: {target: "wolf"}}}
2. on-poke receives %mud-command [%kill "wolf"]
3. Validate session (is this player connected?)
4. Find player's current room
5. Match "wolf" against mobs in room:
- Search mob-instances where room matches
- Match "wolf" against mob-template short-desc
- If multiple matches: use first (or ask "which wolf?")
- If no match: emit error "You don't see that here."
6. Check preconditions:
- Player not already in combat
- Player not dead/sleeping
- Target mob exists and is alive
7. Initiate combat:
- Set player combat-state to %fighting
- Set player target to mob instance-id
- Set mob target to session-id
- Add session-id to active-combats set
8. Process first round immediately:
- Player attacks mob (roll hit, roll damage)
- Mob attacks player (roll hit, roll damage)
9. Emit updates:
- [%combat-start "a snarling wolf"] to player
- [%combat-round [...lines...]] to player
- [%vitals ...] to player
- [%player-enters "Warrior is fighting a wolf!"] to others in room
(or however we handle combat visibility)
10. Future ticks continue processing via active-combats set
Example: Player types “north”
1. Client sends poke:
{command: {move: {dir: "north"}}}
2. Validate session
3. Check preconditions:
- Not in combat (must flee first)
- Not sleeping
- Has moves > 0
4. Look up current room's exits for %north
- If no north exit: "You can't go that way."
5. Move player:
- Remove from old room's player list
- Emit [%player-leaves name] to players in old room
- Add to new room's player list
- Emit [%player-enters name] to players in new room
- Decrement moves by 1
6. Send new room to player:
- [%room-enter room players mobs items]
- [%vitals ...] (moves changed)
- [%map-room room-summary] (for client map cache)
7. Check quest triggers for this room:
- For each active quest on this player:
- Current step type == %explore AND this room in target-rooms?
→ Mark step complete, emit [%system-message on-complete.text]
→ Unlock next step, update journal
- Current step type == %interact AND this room is target-room?
→ NPC interaction now available (triggered by talking to NPC)
- Check encounter triggers for this room
Quest Step Completion (Passive Triggers)
Quest steps complete passively — no explicit “complete quest step” command. The agent checks triggers during normal gameplay:
EXPLORE steps:
Trigger: player enters a target room
Checked in: movement handler (after room-enter)
Complete when: all target-rooms visited
KILL steps:
Trigger: target mob dies while quest active
Checked in: combat death handler (after mob HP reaches 0)
Complete when: target mob is dead
FETCH steps:
Trigger: player picks up / already has N of target item
Checked in: get-item handler AND on quest accept (might already have them)
Complete when: count reached
INTERACT steps:
Trigger: player talks to target NPC in target room
Checked in: say/dialogue handler when NPC is quest-target
Complete when: NPC interaction happens
DELIVER steps:
Trigger: player gives target item to target NPC
Checked in: give-item handler
Complete when: item delivered
ESCORT steps:
Trigger: NPC reaches destination room alive
Checked in: movement handler (NPC follows player)
Complete when: NPC in destination room
On step completion:
1. Mark step in quest-progress.completed-steps
2. Emit on-complete.text as [%system-message ...]
3. Set any flags from on-complete.set-flags
4. If on-complete.unlock-step exists: set quest-progress.current-step
5. If on-complete.return-to-giver: mark quest as "return to giver"
6. Update journal: emit [%quest-step-complete step-id journal-text]
On quest turn-in (player talks to quest giver with all steps done):
1. NPC delivers "complete" dialogue
2. Grant rewards (XP, gold, QP, items)
3. Apply world-effects (flags, description changes, NPC relocations, etc.)
4. Activate followup-quests (if any)
5. Emit [%quest-complete ...]
Guest Session Lifecycle
1. POST /mud/api/guest/create
→ Generate session-id and guest token
→ Create character with starting stats
→ Place in tutorial room (or world spawn point)
→ Return token and session-id
→ Set cookie
2. Client opens Eyre channel, subscribes to /game/[session-id]
3. Player plays normally via poke/subscription
4. On disconnect (on-leave or explicit quit):
→ Character state preserved in agent state
→ Token remains valid for resume
5. On resume (POST /mud/api/guest/resume):
→ Look up token in guest-tokens map
→ Restore session, resubscribe
→ Player appears where they left off
6. On purge (after N days inactive):
→ Cron-like check on area reset tick
→ Remove guest sessions older than threshold
→ Reclaim the character name
Citizen Session Lifecycle
1. Citizen authenticates via Eyre +code (standard Urbit login)
2. Citizen's ship pokes host %mud-world with %mud-portal-enter:
→ Character snapshot + per-world XP credentials
→ Host validates: banned? plausible? meets world rules?
→ Host checks XP credentials against its trust whitelist
→ Host sets local effective level based on trusted XP
→ Creates session with %citizen player-type
→ Places character in last known room (or world spawn)
3. Subscribes to /game/[session-id]
4. Plays normally — all game state tracked by host
5. On disconnect (clean or unclean):
→ Host signs a world-record of everything earned during visit
→ Pokes citizen's %mud-character with the signed record
→ Host RETAINS citizen's world-record in its own state
(citizen can re-request it if the poke was lost)
→ Session cleaned up, player removed from rooms
6. On reconnect:
→ Citizen sends character snapshot again
→ Host checks for retained world-record, resumes from last state
→ If citizen already has this world's record, picks up where they left off
Cross-World Protocol
Core Concept: Per-World XP and Trust Whitelists
XP is not global. Every world tracks its own XP for every citizen that visits. When you enter a new world, that world decides how much of your external progression it respects.
This prevents the “farm world” problem: a world that gives 1,000,000 XP for killing a rat has no effect on your standing in other worlds unless those worlds explicitly trust it.
World Records
A world-record is a signed attestation from a world host about what a citizen accomplished there. It’s the atomic unit of cross-world trust.
+$ world-record
$: world=@p :: host ship that created this record
citizen=@p :: player this record belongs to
timestamp=@da :: when record was last updated
:: progression
xp-earned=@ud :: total XP earned in THIS world
levels-earned=@ud :: levels gained in THIS world
quests-completed=@ud
quest-points-earned=@ud
kills=@ud
deaths=@ud
time-played=@dr :: total time in THIS world
:: achievements / game state
flags=(set @tas) :: world-specific flags: "killed-dragon",
:: "completed-main-quest", "found-secret-room"
:: social
reputation=@sd :: world's opinion of this player (signed)
guild-memberships=(list @tas) :: guilds/clans joined in this world
:: items
items-held=(list item-snapshot) :: items currently in possession from this world
:: integrity
signature=@uv :: host's cryptographic signature over this record
==
::
+$ item-snapshot
$: id=instance-id :: unique instance identity (persists across worlds)
template-id=item-id
template-name=@t :: human-readable, for display in foreign worlds
overrides=(map @tas @t) :: instance overrides
origin-world=@p :: world that created this item
=item-binding
==
Where World Records Live
Citizen's ship (~citizen):
%mud-character agent stores:
world-records=(map @p world-record) :: keyed by world host @p
:: One record per world visited. Updated on disconnect.
Host world (~host):
%mud-world agent stores:
citizen-records=(map @p world-record) :: retained copy for every citizen
:: NOT deleted on disconnect. Citizen can re-request.
:: Pruned after configurable inactivity period (default 90 days).
Dual storage is intentional. If the citizen’s ship is offline when the host tries to send the record, the host keeps it. When the citizen reconnects to any world, their ship can poke old hosts to retrieve missed records. Belt and suspenders.
Trust Whitelists
Each world maintains a whitelist of other worlds whose XP it respects:
trusted-worlds, trust-policy, and delegations are defined in world-config (see Data Structures section).
Trust/delegation admin commands: %trust-world, %untrust-world, %delegate, %undelegate (see %mud-admin mark).
How Trust Affects Entry
When a citizen enters a world, the host calculates their effective level:
1. Citizen presents world-records from all worlds they've visited
2. Host filters: only records from worlds in trusted-worlds set
3. Host sums trusted XP across those records
4. Host applies its own XP curve to determine effective level
5. Citizen plays at that effective level in this world
Example:
Citizen has:
~sneagan-world: 500,000 XP (trusted)
~friend-world: 200,000 XP (trusted)
~sketchy-farm: 99,999,999 XP (NOT trusted)
Host sums trusted XP: 700,000
Host XP curve says 700,000 = level 85
Citizen enters at effective level 85
The 99M from the farm world is ignored entirely
World-Specific Flags (Game State)
The flags set in a world-record tracks meaningful game state:
Examples for sneagan's world:
"completed-tutorial"
"killed-forest-dragon"
"found-secret-library"
"completed-main-quest-act-1"
"allied-with-merchant-guild"
"betrayed-shadow-council"
These are opaque to other worlds — they’re meaningful only to the world that set them. But they persist on the citizen’s ship, so when the citizen returns to that world, their story continues. You killed the dragon last month? The dragon is still dead when you come back.
Other worlds could read another world’s flags if they wanted to create cross-world storylines: “I see you completed sneagan’s main quest. Here’s a special portal that only heroes of that world can enter.”
Portal Marks
:: Citizen entering a world
+$ mud-portal-enter
$: ship=@p
character=character-snapshot
world-records=(map @p world-record) :: all records for host to evaluate
==
::
+$ character-snapshot
$: name=@t
=player-race
=player-class
=stats
skills=(map @tas @ud) :: skill proficiencies
inventory=(list item-snapshot)
equipment=(map wear-slot item-snapshot)
total-played=@dr
==
::
:: Host responding to entry request
+$ mud-portal-response
$% [%admitted session=session-id effective-level=@ud]
[%rejected reason=@t]
==
::
:: Host sending record on disconnect (or on request)
+$ mud-portal-exit
$: world=@p
reason=?(%quit %death %disconnect %kicked)
=world-record :: signed, updated record
==
::
:: Citizen requesting a missed record
+$ mud-record-request
$: citizen=@p
world=@p :: "give me my record from your world"
==
Disconnect / Reconnect Resilience
Normal disconnect:
1. Host signs world-record with latest data
2. Host pokes citizen's ship with %mud-portal-exit
3. Host retains record in citizen-records map
4. Citizen's %mud-character applies record to local state
5. Done
Unclean disconnect (network partition, ship crash):
1. Host detects dropped subscription (on-leave)
2. Host signs world-record with latest data
3. Host attempts poke to citizen — may fail if citizen is offline
4. Host retains record regardless
5. When citizen comes back online (to any world):
→ Citizen's ship can poke old hosts with %mud-record-request
→ Host sends the retained record
→ Citizen applies it
Citizen re-entering same world:
1. Host checks citizen-records for existing record
2. If found: citizen resumes with their world-specific state intact
(flags, reputation, guild memberships, waypoints, etc.)
3. XP is cumulative — new visits add to the existing record
Anti-Farm Protection Summary
| Attack | Defense |
|---|---|
| World gives absurd XP for easy mobs | Other worlds’ trust whitelists ignore it |
| Player creates own world to self-farm | Other worlds won’t trust unknown worlds by default |
| Trusted world gets compromised | World operators can remove from whitelist at any time |
| Player fabricates world-records | Records are cryptographically signed by host — can’t forge |
| Player replays old records | Records have timestamps; hosts track cumulative XP, not deltas |
Default Trust Configuration
For sneagan’s flagship world:
trusted-worlds: {~sneagan-world} :: initially just itself
trust-policy: %whitelist-only
:: As the ecosystem grows, add trusted community worlds:
:: trusted-worlds: {~sneagan-world, ~friend-world, ~vetted-world}
New worlds start trusting nobody — players begin fresh. As worlds prove legitimate, operators add them to whitelists. This is an organic, decentralized reputation system. No central authority decides which worlds are “real.” Each operator makes their own call.
State Size Estimates
| Data | Count (small world) | Count (large world) | Size Each | Total |
|---|---|---|---|---|
| Rooms | 200 | 3,000 | ~500 bytes | 100KB–1.5MB |
| Mob templates | 50 | 500 | ~300 bytes | 15KB–150KB |
| Mob instances | 100 | 2,000 | ~100 bytes | 10KB–200KB |
| Item templates | 100 | 1,000 | ~400 bytes | 40KB–400KB |
| Item instances | 200 | 5,000 | ~80 bytes | 16KB–400KB |
| Player sessions | 10 | 200 | ~1KB | 10KB–200KB |
| Guest characters (stored) | 50 | 500 | ~1KB | 50KB–500KB |
| Total | ~250KB–3MB |
Well within Urbit’s loom limits (2-8 GB). Even a massive world with thousands of rooms and hundreds of concurrent players would be under 10MB of agent state.
Distribution & Deployment
Two Things Being Distributed
The web client (glob) — served to browsers by the world host. Guests and citizens both get this. No install required.
The desk (app) — installed on a citizen’s ship. Contains %mud-character agent, marks, and optionally %mud-world for hosting your own world.
These are separate concerns. A world host needs both. A citizen needs the desk. A guest needs neither — they just visit a URL.
Desk Structure
%mud/
├── app/
│ ├── mud-world.hoon :: world host agent
│ └── mud-character.hoon :: citizen character agent
│
├── sur/
│ └── mud.hoon :: all shared types (room, mob, item, character, etc.)
│
├── mar/
│ ├── mud/
│ │ ├── command.hoon :: %mud-command mark
│ │ ├── admin.hoon :: %mud-admin mark
│ │ ├── guest-auth.hoon :: %mud-guest-auth mark
│ │ ├── update.hoon :: %mud-update mark (subscription updates)
│ │ ├── portal-enter.hoon :: %mud-portal-enter mark
│ │ ├── portal-exit.hoon :: %mud-portal-exit mark
│ │ ├── portal-response.hoon :: %mud-portal-response mark
│ │ └── record-request.hoon :: %mud-record-request mark
│ └── ...
│
├── lib/
│ ├── mud-combat.hoon :: combat formula library
│ ├── mud-xp.hoon :: XP curves, level calc
│ └── mud-utils.hoon :: shared helpers
│
├── sys/
│ └── kelvin :: kernel version compatibility
│
├── desk.bill :: agents to start on install
├── desk.docket-0 :: app metadata, glob source, tile config
└── desk.ship :: publisher ship
Docket File
:~
title+'MUD'
info+'A Multi-User Dungeon on Urbit'
color+0x1a.1a2e :: our dark navy theme color
image+'https://[url]/mud-icon.png'
glob-ames+[~sneagan-ship 0v0] :: glob served from publisher over Ames
base+'mud'
version+[0 1 0]
website+'https://[mud-website-url]'
license+'MIT'
==
Glob: Building and Uploading
# 1. Build the SolidJS frontend
cd mud-client/
npm run build # → dist/
# 2. Upload to ship via globulator
# Navigate to http://your-ship:8080/docket/upload
# Select the %mud desk
# Choose the dist/ directory
# Click "glob!"
# Or for HTTP-hosted glob (CDN/S3):
# Upload dist/ contents to your CDN
# Use glob-http in docket file instead of glob-ames
Glob contents (what gets served at /mud):
index.html
assets/
index-[hash].js :: SolidJS app bundle
index-[hash].css :: styles
favicon.ico
When a guest visits https://your-ship.urbit.org/mud, Eyre serves index.html from the glob. The SolidJS app boots, connects back to Eyre via channels, and the game begins. Zero install.
What Gets Installed Where
World host (sneagan’s ship):
Install %mud desk → starts %mud-world and %mud-character agents
Upload glob → web client served at /mud
Load world content → rooms, mobs, items, areas into %mud-world state
Open for business → guests can visit the URL, citizens can portal in
Citizen (player’s ship):
|install ~sneagan-ship %mud → installs desk, starts %mud-character agent
%mud-character stores: character data, world-records, home world rooms
Player visits any world host's /mud URL → web client connects
Guest (no ship):
Visit https://sneagan-ship.urbit.org/mud in a browser
Web client loads from glob
Create guest character via /mud/api/guest/create
Play. Nothing installed anywhere.
Update Flow
Agent updates (new features, bug fixes, balance changes):
Developer pushes new code to %mud desk on publisher ship
Citizens receive OTA update automatically via Kiln
%mud-character agent migrates state via on-load
%mud-world agent migrates state via on-load (world hosts update separately)
Frontend updates (UI changes, new panels, bug fixes):
Developer builds new SolidJS bundle
Uploads new glob to publisher ship
Glob propagates to world hosts via Ames (or they re-glob manually)
Players see new UI on next page load — no action required
World content updates (new areas, mobs, items):
These are NOT code updates — they're state changes
World host uses admin/builder commands or OLC to add content
No desk update needed, no glob update needed
Content is live immediately
This separation is important: game content is state, not code. Adding a new dungeon doesn’t require an OTA. It’s just pokes to the running %mud-world agent.
Development Workflow
# Start fake ships for testing
urbit -F zod # host world
urbit -F bus # citizen
# On ~zod: install desk from local
|new-desk %mud
|mount %mud
# copy files into /path/to/zod/mud/
|commit %mud
|install our %mud
# On ~bus: install from ~zod
|install ~zod %mud
# Frontend dev: run vite dev server pointing at ~zod's Eyre
cd mud-client/
VITE_SHIP_URL=http://localhost:8080 npm run dev
# Hot reload on localhost:3000, proxying to ~zod for API calls
# When ready: build and glob
npm run build
# Upload dist/ via globulator
Open Decisions
All 12 original open decisions have been resolved:
Combat formulas→ Combat Formulas section (deterministic, no miss rolls)Skills/spells→ Skills & Spells section (7 classes, 6-7 abilities each)Death penalty→ Death & Respawn section (die → go home, corpse persists)Level-up gains→ Level-Up Gains section (CON×4+class HP, INT×3+class mana)Starting stats→ Character Creation Constants (base 10 + race + class + 12 free)XP curve→ XP & Progression section (100×n×(1+n/50))Mob XP scaling→ XP & Progression section (gradual penalty, 5-level grace)Item instances→ item-instance type (template + overrides map)Cross-world protocol→ Cross-World Protocol section (per-world XP, trust whitelists, world-records)Admin delegation→ Admin Delegation (owner/admin/builder, flat hierarchy)Glob packaging→ Distribution & Deployment (%docket glob + desk via Kiln)Item binding→ Item Binding System (%none/%protected/%soulbound, persistent corpses)
Implementation Additions (2026-04-02)
Comprehensive record of types and features added during implementation beyond the original spec.
Room Type Extensions
image=(unit @t)— optional image URL for roomaudio=(unit @t)— optional audio cue for roomnarration=(unit @t)— pre-generated TTS audio URL for room description (plays on entry, one-shot)hooks=(list hook)— scripted room triggers (see DESIGN-hooks.md)
Item Template Extensions
transforms=(map @tas item-id)— environmental item transformation map. Keys are effect names (e.g.%wet), values are the template ID the item becomes when that effect is active and a%transform-itemshook fires. Example: crackers (template 42) withtransforms={"wet": 43}become soggy crackers (template 43) when the player has the “wet” effect.
NPC Dialogue Extensions
greeting-audio=(unit @t)onstatic-dialogue— audio URL for the NPC’s greeting linetopic-audio=(map @t @t)onstatic-dialogue— map of topic keyword to audio URL for that response
Character Type Extensions
explored-rooms=(set room-id)— tracks all rooms the player has visitedroom-notes=(map room-id @t)— per-player annotations on rooms
State Extensions
reports=(list player-report)— player report queue for admin reviewnext-report-id=@ud— counter for report IDsfriends=(map @t (set @t))— player name to set of friend namesblocked=(map @t (set @t))— player name to set of blocked names
Performance Indices in State
room-sessions=(map room-id (set session-id))— reverse index: which sessions are in each room (avoids full session scan for room broadcasts)session-group=(map session-id @uv)— maps session to its group ID for grouped contentmob-spawn-count=(map mob-id @ud)— tracks how many instances of each mob template are alive (prevents over-spawning on reset)unknown-commands=(map @t @ud)— tracks unrecognized player commands and their frequency (for UX improvement and admin dashboard)
New Types
hook-trigger—?(%on-enter %on-look %on-kill %on-use %on-talk)hook-condition— flag checks, item checks, level gates, kill counts, random chance, room flagshook-action— message, broadcast, set/clear flag, give/take gold/items/xp, teleport, spawn mob, heal, damage, add-effect, transform-itemshook—[trigger conditions actions]player-report—[id reporter reporter-ship target reason room filed status]
New Hook Actions
%add-effect— apply a temporary named status effect to the player. Fields:name=@t(effect name, e.g. “wet”, “burning”),duration=@ud(ticks until expiry). Effects are stored on the session and checked by conditions and transform hooks.%transform-items— scan the player’s inventory for items whose template has a matchingtransformsentry for the given effect. Fields:effect=@tas(the transform key to look up). Each matching item is replaced with the transformed template. Example: walking through a waterfall with%add-effect "wet"followed by%transform-items "wet"turns crackers into soggy crackers.
New Commands
report <player> <reason>— file a player report, mails world ownerfriend add/remove/list,friends— friends list with online statusblock <player>,unblock <player>— block hides chat and prevents mailadmin-reports— list open reportsadmin-dismiss <id>— dismiss a reportadmin-acted <id>— mark report as acted uponnote <text>— annotate current room (stored per-player)
Starter Equipment
create-starter-equipmentarm inmud-characterlib- Automatically creates and equips a level-appropriate weapon + armor on character creation
- Paladins additionally receive a starter shield
- Items are %none binding (tradeable) so new players can upgrade freely
Security Hardening
- Portal identity: cross-world portal arrival uses
src.bowl(cryptographic ship identity) instead of payload-supplied @p. Prevents identity spoofing on portal entry. - Ban enforcement: ban checks run on all login paths — citizen auth, guest creation, and portal arrival. Previously only checked on citizen login.
- Setname validation: character names must be 2-20 characters, alphanumeric plus hyphens only, and unique within the world. Rejects empty, too-long, or duplicate names.
- Mailbox cap: maximum 50 messages per player mailbox. Oldest messages pruned on new mail arrival.
- Report cap: maximum 100 open reports in queue. New reports rejected (with message) if cap reached.
- Description/note length limits: room notes capped at 500 characters. Player descriptions capped at 500 characters.
Admin Dashboard
/mud/adminendpoint serves an inline HTML dashboard (no external assets) for world operators/mud/api/admin/dashboard— JSON stats endpoint returning: online player count, total characters, mob counts, room count, uptime, recent commands, unknown command frequency/mud/api/admin/goto— teleport the requesting admin to any room by ID- Unknown command tracking feeds into dashboard, sorted by frequency, to identify missing features and common typos
- Player list shows: name, current room, combat status (fighting / idle), idle time since last command
- Dashboard accessible only to world owner and admins (role check on HTTP request)
Hook Engine
Rooms can have scripted hooks that fire on enter, look, or kill. Hooks check conditions (flags, level, items, random chance) and execute actions (messages, rewards, teleports, mob spawns, effects, item transforms). Max 20 hooks per room, 5 conditions per hook, 10 actions per hook. See DESIGN-hooks.md for full documentation.
On-Load
Agent uses simple on-init for on-load to avoid type migration issues. When state types change (new fields on room/character), old state is discarded and the agent reinitializes. State migration via mole/!< with type annotations causes compile-time nest failures and should not be used. Proper state versioning is a TODO (see TODO-design-gaps.md).