MUD on Urbit

Gall Agent

design
Contents

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

ResourceUsed ByRegeneration
ManaSpells (mage, cleric, psionicist, paladin, ranger)WIS/5 per regen tick (~30 sec)
MovesPhysical skills (warrior, thief, ranger), movementDEX/3 per regen tick
CooldownsMost skills, measured in combat roundsAutomatic, 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

SkillLvlCostCDTargetEffect
Bash110 mv3 rndEnemyBonus attack + target loses next attack
Kick35 mv2 rndEnemyQuick bonus damage (STR/3)
Rescue815 mv5 rndAllyPull aggro from mob attacking your ally
Berserk1520 mv10 rndSelf+50% damage, -25% defense, 5 rounds
Disarm2015 mv6 rndEnemyTarget drops weapon, -30% damage 3 rounds
Rally2525 mv15 rndGroup+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

SkillLvlCostCDTargetEffect
Magic Missile110 mnnoneEnemyReliable damage (INT/3 + 5)
Frost Bolt520 mn2 rndEnemyDamage + target loses 1 attack next round
Shield830 mn12 rndSelf+30% magic resistance, 10 rounds
Identify1015 mnnoneItemReveals item stats
Fireball1540 mn3 rndEnemyHeavy damage (INT/2 + 15)
Lightning Storm2560 mn6 rndRoomModerate 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

SkillLvlCostCDTargetEffect
Heal115 mnnoneAllyHeal (WIS/3 + 10)
Bless320 mn10 rndAlly+10% damage, 8 rounds
Cure Poison510 mnnoneAllyRemove poison effect
Smite1025 mn2 rndEnemyHoly damage (WIS/4 + 12), 2x vs undead
Sanctuary2050 mn15 rndAllyTarget takes 25% less damage, 6 rounds
Resurrect30100 mn60 rndAllyRevive 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

SkillLvlCostCDTargetEffect
Backstab115 mv4 rndEnemy2x damage, must initiate combat (opener)
Sneak35 mvnoneSelfMove without being seen (toggle, breaks on attack)
Hide35 mvnoneSelfBecome hidden in current room (breaks on action)
Peek55 mvnoneEnemySee mob’s inventory and gold
Poison Blade1010 mv8 rndSelfNext 3 attacks add poison DOT (3 rounds each)
Evade2020 mv6 rndSelf+50% dodge chance, 3 rounds
Mug2515 mv10 rndEnemySteal 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

SkillLvlCostCDTargetEffect
Track110 mvnoneShows direction to a named mob in the area
Dual Wield5passiveSelfEquip second weapon (offhand at 70% damage)
Forage55 mvnoneSelfFind food/herbs in wilderness rooms
Entangle1020 mn5 rndEnemyRoot target in place, 3 rounds (can’t flee)
Nature’s Touch1525 mn4 rndAllyHeal (WIS/4 + 8) — weaker than cleric
Volley2015 mv3 rndEnemyTwo quick attacks in one round
Call Animal2540 mn30 rndSelfSummon 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

SkillLvlCostCDTargetEffect
Lay Hands130 mn15 rndAllyBig heal (WIS/2 + 20)
Holy Strike515 mn2 rndEnemyMelee + holy damage, 2x vs undead
Taunt810 mv4 rndEnemyForce mob to attack you instead of ally
Shield of Faith1025 mn10 rndGroup+15% defense for group, 5 rounds
Aura of Protection2040 mn20 rndGroupSmall damage reduction for group, 8 rounds
Divine Wrath2550 mn8 rndEnemyHeavy 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

SkillLvlCostCDTargetEffect
Mind Blast112 mnnoneEnemyPsionic damage (INT/3 + 6)
Confusion520 mn5 rndEnemyTarget’s damage reduced 30%, 3 rounds
Mind Shield825 mn12 rndSelf+40% psionic resistance, 8 rounds
Psychic Drain1230 mn4 rndEnemyDamage + restore mana equal to damage dealt
Dominate2045 mn8 rndEnemyTarget mob fights for you, 3 rounds
Mindstorm2555 mn6 rndEnemyHeavy 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

ClassHPManaDamageHealingGroup ValueSolo
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:

ConditionAttacks/Round
Base1
Level 20+ OR warrior class2
Level 50+ AND warrior/ranger/paladin3
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:

DefenseEffectTrigger
ParryReduce incoming damage by 30%Automatic, requires weapon, skill-dependent chance per round (50-80%)
DodgeReduce incoming damage by 25%Automatic, DEX-dependent chance (30-60%)
Shield blockFlat damage absorbed (= shield defense value)Automatic, requires shield, 40-70% chance
ArmorFlat reduction on every hitAlways active
ResistancePercentage reduction by damage typeAlways 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 entirelyEvery attack hits
THAC0 (confusing, lower = better)Flat formula, easy to understand
Parry/dodge negate entire attackParry/dodge reduce damage
Miss spam fills the screenEvery log line is meaningful
Damage cap with weird progressive softeningClean formula, scales naturally
Need to understand AC, THAC0, hitroll, damrollSTR = 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

ClassHP BonusMana BonusRole
Warrior+8+0Highest HP, no mana bonus
Paladin+5+3Hybrid tank/caster
Ranger+5+2Hybrid melee/utility
Thief+4+1Moderate HP, light mana
Cleric+3+5Moderate HP, strong mana
Psionicist+2+5Lower HP, strong mana
Mage+1+7Lowest 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)
LevelXP to NextCumulativeFeel
1→2102102Instant
5→65601,830Minutes
10→111,2006,600A session
20→212,80028,200A few sessions
50→5110,000195,000Days of play
100→10130,0001,350,000Weeks
150→15160,0003,900,000Serious investment
200→201100,0008,000,000Endgame 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 LevelBase XP
110
552
10110
20240
50750
1002,000
1503,750
2006,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:

TabSourcesColor
GameRoom descriptions, combat, system, loot, XPDefault/varied
Saysay, yell, overheardGreen
Tell/Mailtell, mail notificationsMagenta
GossipgossipYellow
NewbienewbieBright 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 — remove first)
  • %soulbound items cannot be given
  • Guests can give items to other players (including citizens)
  • No confirmation prompt — give is 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
  • open works on closed doors, fails on locked doors
  • unlock requires the matching key item in inventory
  • lock requires the matching key and the door to be closed
  • pick is 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

  1. 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.

  2. 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.

  3. Fast-travel mitigates frustration: Discovered waypoints let you get close to where you died. The last stretch is the penalty — not the entire journey.

  4. 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

LossCitizensGuests
PositionYes — sent home to own UrbitYes — sent to world spawn
Gold10% of carried gold (dropped on corpse)10% of carried gold (dropped on corpse)
XPNoneNone
LevelsNoneNone
%none itemsOn corpse — anyone can lootOn corpse — anyone can loot
%protected itemsOn corpse — only you can retrieveOn corpse — only you can retrieve
%soulbound itemsCome with you (skip corpse)Come with you
Quest progressActive quest failsActive quest fails
Buffs/spellsAll temporary effects removedAll 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
  ==
BindingOn DeathOthers Can LootCan Drop/Trade
%noneStays on corpseYesYes
%protectedStays on corpseNo — owner onlyYes
%soulboundComes with you to respawnNoNo

What gets which binding:

SourceDefault BindingRationale
Mob drops (common)%noneChurn. Items enter via drops, leave via looting. Healthy economy.
Shop-bought gear%noneYou can buy another one.
Quest rewards%protectedEarned through effort.
Area goal rewards%protectedAchievement-based.
GM-created uniques%soulboundOne-of-a-kind, narratively significant.
Key/story items%soulbound“You are the chosen one” artifacts.
Cross-world itemsKeep original bindingYou 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
  • %soulbound items are not on the corpse — they travel with you to respawn
  • %none items and gold are lootable by anyone: loot corpse
  • %protected items 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 %waypoint in 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

AttackDefense
World gives absurd XP for easy mobsOther worlds’ trust whitelists ignore it
Player creates own world to self-farmOther worlds won’t trust unknown worlds by default
Trusted world gets compromisedWorld operators can remove from whitelist at any time
Player fabricates world-recordsRecords are cryptographically signed by host — can’t forge
Player replays old recordsRecords 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

DataCount (small world)Count (large world)Size EachTotal
Rooms2003,000~500 bytes100KB–1.5MB
Mob templates50500~300 bytes15KB–150KB
Mob instances1002,000~100 bytes10KB–200KB
Item templates1001,000~400 bytes40KB–400KB
Item instances2005,000~80 bytes16KB–400KB
Player sessions10200~1KB10KB–200KB
Guest characters (stored)50500~1KB50KB–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:

  1. Combat formulas → Combat Formulas section (deterministic, no miss rolls)
  2. Skills/spells → Skills & Spells section (7 classes, 6-7 abilities each)
  3. Death penalty → Death & Respawn section (die → go home, corpse persists)
  4. Level-up gains → Level-Up Gains section (CON×4+class HP, INT×3+class mana)
  5. Starting stats → Character Creation Constants (base 10 + race + class + 12 free)
  6. XP curve → XP & Progression section (100×n×(1+n/50))
  7. Mob XP scaling → XP & Progression section (gradual penalty, 5-level grace)
  8. Item instances → item-instance type (template + overrides map)
  9. Cross-world protocol → Cross-World Protocol section (per-world XP, trust whitelists, world-records)
  10. Admin delegation → Admin Delegation (owner/admin/builder, flat hierarchy)
  11. Glob packaging → Distribution & Deployment (%docket glob + desk via Kiln)
  12. 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 room
  • audio=(unit @t) — optional audio cue for room
  • narration=(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-items hook fires. Example: crackers (template 42) with transforms={"wet": 43} become soggy crackers (template 43) when the player has the “wet” effect.

NPC Dialogue Extensions

  • greeting-audio=(unit @t) on static-dialogue — audio URL for the NPC’s greeting line
  • topic-audio=(map @t @t) on static-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 visited
  • room-notes=(map room-id @t) — per-player annotations on rooms

State Extensions

  • reports=(list player-report) — player report queue for admin review
  • next-report-id=@ud — counter for report IDs
  • friends=(map @t (set @t)) — player name to set of friend names
  • blocked=(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 content
  • mob-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 flags
  • hook-action — message, broadcast, set/clear flag, give/take gold/items/xp, teleport, spawn mob, heal, damage, add-effect, transform-items
  • hook[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 matching transforms entry 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 owner
  • friend add/remove/list, friends — friends list with online status
  • block <player>, unblock <player> — block hides chat and prevents mail
  • admin-reports — list open reports
  • admin-dismiss <id> — dismiss a report
  • admin-acted <id> — mark report as acted upon
  • note <text> — annotate current room (stored per-player)

Starter Equipment

  • create-starter-equipment arm in mud-character lib
  • 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/admin endpoint 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).