Mudlet Samples

Audience: Player Status: ✅ Ready

Copy-paste Mudlet recipes: a one-click auto-login trigger, plus recipes that turn TelosMUD’s GMCP surface into a rich UI — HP/mana gauges, a live automap, and keyword tab-completion. The GMCP recipes register a handler for a specific GMCP event and read the payload documented in the GMCP Reference.

How Mudlet delivers GMCP

Mudlet enables GMCP automatically and, on each incoming message, parses the JSON into the global gmcp table and raises an event named after the package — e.g. a Char.Vitals message updates gmcp.Char.Vitals and raises the event gmcp.Char.Vitals. You wire a recipe up by registering a handler for that event name. The simplest way is one line in a Mudlet Script:

registerAnonymousEventHandler("gmcp.Char.Vitals", "updateVitals")

Content-defined keys: TelosMUD’s Char.Vitals payload uses the content pack’s resource refs. The built-in demo world uses hp/mana, so the fields are gmcp.Char.Vitals.hp / .maxhp / .mana / .maxmana. On a different pack, use that pack’s refs.

When you connect, the gate prints your one-click OAuth link and waits for you to sign in (Player Reference → Connecting). This is a plain text trigger — not GMCP — that spots that prompt and pops the link open in your browser for you.

The gate prints three lines: the prompt, a blank line, then the indented URL. Build a multiline (AND) trigger with these three regex conditions and a line margin of 3, so all three match across consecutive lines:

^To sign in, open this link in your browser:$
^$
^\s+(https?://\S+/login/\S+)$

Then set the trigger’s script to open the captured URL — matches[2] is the capture group from the third line:

openUrl(matches[2])

openUrl launches your default browser; you approve on GitHub, and the OAuth page closes itself after a few seconds and hands you back to the MUD. (If you’d rather pin it to your own server, replace https?://\S+ with your host, e.g. https://play\.your-mud\.example.)

Matching note: this keys off the gate’s login screen text, which is engine code, not content — so a world changing its content (rooms, mobs, even its login message of the day) leaves this trigger working. Only a fork that edits the auth code’s prompt wording would require updating the first regex line to match.

HP / mana bars

A pair of Geyser gauges driven by Char.Vitals. Put the setup in a Script (runs once) and register the handler:

-- run once (Script body)
hpBar = hpBar or Geyser.Gauge:new({
  name = "hpBar", x = "70%", y = "2%", width = "28%", height = "22px"
})
hpBar:setColor(140, 20, 20)
manaBar = manaBar or Geyser.Gauge:new({
  name = "manaBar", x = "70%", y = "26px", width = "28%", height = "22px"
})
manaBar:setColor(20, 60, 160)

function updateVitals()
  local v = gmcp.Char.Vitals
  if v.hp and v.maxhp then
    hpBar:setValue(v.hp, v.maxhp, "HP " .. v.hp .. "/" .. v.maxhp)
  end
  if v.mana and v.maxmana then
    manaBar:setValue(v.mana, v.maxmana, "MP " .. v.mana .. "/" .. v.maxmana)
  end
end

registerAnonymousEventHandler("gmcp.Char.Vitals", "updateVitals")

Char.Vitals fires whenever a pool changes, so the bars track combat and regen live. For a stats panel, do the same against gmcp.Char.Stats; for a “standing / fighting / dead” indicator (and your current target), read gmcp.Char.Status.state and .target.

Automapper from Room.Info

Room.Info carries {num, name, zone, coord?, exits{dir→num}}, where num is a stable hash of the room — consistent across shards and restarts, which is exactly what a mapper needs. A minimal mapper that creates rooms on the fly and draws exits:

local DIRS = { north=1, south=2, east=3, west=4, up=5, down=6,
               northeast=7, northwest=8, southeast=9, southwest=10 }

function onRoomInfo()
  local r = gmcp.Room.Info
  local id = r.num
  if not roomExists(id) then
    addRoom(id)
    setRoomName(id, r.name)
    if r.coord then
      setRoomCoordinates(id, r.coord[1], r.coord[2], r.coord[3])
    end
    -- put the room on an area named after its zone
    local area = getAreaTable()[r.zone] or addAreaName(r.zone)
    setRoomArea(id, r.zone)
  end
  for dir, dest in pairs(r.exits or {}) do
    if DIRS[dir] then setExit(id, dest, dir) end
  end
  centerview(id)
end

registerAnonymousEventHandler("gmcp.Room.Info", "onRoomInfo")

Layer gmcp.Room.Players on top to show who (players and mobs) is in the current room — it’s the visible, canSee-filtered occupant list.

GMCP-driven tab-completion

TelosMUD emits your inventory over Char.Items.List (a full snapshot per location) and then Char.Items.Add / .Update / .Remove deltas. Feed those item names into Mudlet’s command-line suggestion list so pressing Tab completes keywords for get, wear, drop, etc.:

-- rebuild suggestions from the current inventory snapshot
function refreshItemSuggestions()
  clearCmdLineSuggestions()
  local items = gmcp.Char.Items.List and gmcp.Char.Items.List.items or {}
  for _, item in ipairs(items) do
    if item.name then
      addCmdLineSuggestion(item.name)   -- Tab now completes this keyword
    end
  end
end

registerAnonymousEventHandler("gmcp.Char.Items.List",   "refreshItemSuggestions")
registerAnonymousEventHandler("gmcp.Char.Items.Add",    "refreshItemSuggestions")
registerAnonymousEventHandler("gmcp.Char.Items.Remove", "refreshItemSuggestions")

Char.Items.List arrives on login, reconnect, and handoff arrival; the Add/Remove deltas keep the set current as you loot and drop. (For a fuller build you’d track the item set incrementally rather than re-reading a snapshot on every delta, but re-reading is the simplest correct recipe.) You can extend the same pattern to room occupants (Room.Players) so Tab also completes the name of the mob you’re about to kill.

Chat window (channels out of the main window)

A classic layout: no channel chatter in the main output, and every channel collected into one side console. Two facts about how the engine delivers a channel make this work:

  • Every channel line you’re subscribed to arrives twice — once as a normal text line in the main window, and (for GMCP clients) once as a Comm.Channel.Text frame {channel, talker, text}. The text line comes first, then the GMCP mirror.
  • channels off <chan> on the MUD unsubscribes you entirely — no text and no GMCP. That’s genuinely leaving the channel; you stop receiving it everywhere.

So the recipe is: gag the main-window copy client-side (you stay subscribed, still receiving) and render the GMCP copy into a separate miniconsole. Create the console once in a Script:

-- run once (Script body): a docked chat console on the right
chatWin = chatWin or Geyser.MiniConsole:new({
  name = "chatWin", x = "70%", y = "2%", width = "29%", height = "40%"
})
chatWin:setColor(12, 12, 16)
chatWin:enableScrollBar()

-- one color per channel (fall back to grey)
local CHAN_COLOR = { gossip = "yellow", newbie = "cyan", guild = "green" }

function onChannelText()
  local m = gmcp.Comm.Channel.Text
  if not m then return end
  local color = CHAN_COLOR[m.channel] or "grey"
  cecho("chatWin", "<"..color..">["..m.channel.."] <white>"..(m.talker or "")..": "..(m.text or "").."\n")
end
registerAnonymousEventHandler("gmcp.Comm.Channel.Text", "onChannelText")

Then gag the main-window copy. The channel line’s on-screen format is content-defined (the demo pack renders [gossip] Name: text), so match your pack’s format. A single regex trigger on the channel prefix, with deleteLine() as its script, keeps the main window clean:

^\[(gossip|newbie|guild)\] 
deleteLine()   -- trigger script: drop this line from the main window only

Because the text line is processed before the GMCP frame arrives, this standing gag fires in time — the line never shows in the main window, but onChannelText still populates chatWin.

Hide vs. leave. Hiding the console (chatWin:hide() / :show(), e.g. from an alias) is purely client-side — you’re still subscribed and messages keep accumulating. To actually drop a channel — stop receiving it as text and GMCP — use the MUD command channels off <chan>; channels on <chan> re-joins. Build one console per channel instead of a shared one by keying the miniconsoles off m.channel if you prefer separate tabs.

You can also show a roster of who’s currently listening on a channel: advertise Comm.Channel.Players in Core.Supports, then register gmcp.Comm.Channel.Players and read its players name-list into a side panel keyed by its channel. The roster is aggregated across shards and refreshes every couple of seconds.

Requesting container contents

Most of GMCP is server-push, but a rich client can also ask the world to open a container. Send a Char.Items.Contents request with the container’s item id (the id from a Char.Items.List entry, e.g. i42); the world replies with a Char.Items.List frame whose location is that container id. Advertise Char.Items.Contents in Core.Supports first.

-- ask the world for a container's contents (call from an alias or a button)
function openContainer(itemId)
  sendGMCP("Char.Items.Contents " .. yajl.to_string({ container = itemId }))
end

-- render the reply: a Char.Items.List keyed to the container id
function onItemsList()
  local L = gmcp.Char.Items.List
  if not L then return end
  if L.location ~= "inv" and L.location ~= "room" then
    -- container panel: L.location is the container's id; draw L.items into it
  end
end
registerAnonymousEventHandler("gmcp.Char.Items.List", "onItemsList")

The request is rate-limited (about five per second) and reach-scoped — you can only open a container you’re carrying or can see on the floor — so a button that spams requests is throttled server-side, and a stale or guessed id simply returns nothing.


See the GMCP Reference for the full payload of every package, and Player Reference for the non-GMCP basics.