Play / GMCP

GMCP

What the game sends, when it sends it, and the three things that will trip you up. Examples are in Mudlet because it is what most people here use, but nothing below is Mudlet-specific.

GMCP lets your client read the game directly. Instead of scraping health out of your prompt with a trigger, the server hands your client a structured message every time something changes. Kallisti speaks nineteen of them, covering your character, your equipment, the room, combat, your group and your account.

If you don't script, you can stop reading — nothing here changes how the game plays or looks.

Turning it on

A GMCP-capable client negotiates automatically and asks for a standard set of modules on connect. That standard set was written for other MUDs, so it covers most of what we send — but two of our modules are Kallisti-specific and no client knows to ask for them:

sendGMCP([[Core.Supports.Add ["Server 1","Account 1"]]])

Run that once and the subscription lasts for the session. Without it, Server and Account simply never arrive — and that is by far the most common reason a script "doesn't work". The server is not broken and neither is your client: you were never subscribed.

Staff can check this for you. An immortal running gmcp <yourname> sees exactly which packages your client would receive and which are being withheld for want of a subscription. If something isn't turning up, that answers it in one line.

Reading it

Everything lands in the global gmcp table, keyed by package name, and raises an event of the same name:

gmcp.Char.Vitals.hp
gmcp.Room.Info.name

registerAnonymousEventHandler("gmcp.Char.Vitals", function()
  local v = gmcp.Char.Vitals
  cecho(string.format("<green>%d<white>/%d hp\n", v.hp, v.maxhp))
end)
React to the event, don't poll the table. The tables persist — Char.Items.Add holds the last Add you ever received and sits there forever. Code that reads it on a timer will re-process the same item indefinitely. The event is the only "this just happened" signal.

The three things that catch people out

Packages replace, they don't merge
When Char.Vitals arrives, your client swaps the whole table. Don't stash extra fields inside a GMCP table expecting them to survive.
Key equipment on slot, never on the name
Six slots are fingers and four are wrists. slot is a unique identifier (finger_left_middle); it is not what you show a player.
Numbers are numbers
Other MUDs send GMCP values as quoted strings. We don't. hp is 842, not "842".

Character

Char.Name once, at login
{"name":"Kensho","fullname":"Kensho the Wanderer"}

fullname equals name when you have no title, so you never have to test for a missing key.

Char.Vitals the moment anything moves, including mid-combat
{"hp":842,"maxhp":1200,"mana":300,"maxmana":450,
 "stamina":90,"maxstamina":100}

The one you'll build bars from.

Char.Base / Char.Status Base: rarely (a level, a remort) · Status: whenever it moves
Char.Base   {"level":50,"race":"Rakshasa","class":"Templar",
             "remort_laps_total":2,"remort_levels":[...]}

Char.Status {"position":"Standing","stance":"Aggressive","whoflags":"<O>",
             "bardsong":"","alignment":350,"wimpy":42,
             "hunger":12,"thirst":8,"ranged":false}

One character sheet split in two, on purpose. Your race and class never change, and bundling them with your stance would re-send them on every swing.

Char.Stats / Char.MaxStats / Char.PermStats current: with buffs and gear · max: on a stat gain · perm: almost never
Char.Stats     {"str":18,"int":12,"wis":14,"dex":16,"con":15,"luk":11,
                "hitroll":24,"damroll":19,"ac":-84}
Char.MaxStats  {"str":20,"int":14,...}
Char.PermStats {"str":17,"int":12,...}

Three packages rather than one for the same reason as above — a spell touching your strength shouldn't re-send your permanent stats.

Char.Worth / Char.Experience Worth: on loot and purchases · Experience: on every kill
Char.Worth      {"gold":1200,"bank_gold":50000,"qpoints":42,
                 "bank_qpoints":300,"practice":7}

Char.Experience {"experience":184000,"experience_max":200000,
                 "experience_tnl":16000,
                 "hero_points":3,"hero_points_tnl":1200,
                 "noble_points":0,"noble_points_tnl":5000}

Separate so that killing something doesn't re-send your bank balance.

Char.Affects on gain or loss, and once per mud hour as durations tick (~75s)
{"affects":[{"name":"sanctuary","duration":12},
            {"name":"bless","duration":8}]}

Equipment

Char.Items.List / .Add / .Remove / .Update List at login · Add and Remove the instant you wear or remove · Update when condition changes
Char.Items.List   {"location":"worn","items":[ ... ]}
Char.Items.Add    {"location":"worn","item":{...}}
Char.Items.Remove {"location":"worn","item":{...}}
Char.Items.Update {"location":"worn","item":{...}}

item = {"slot":"finger_left_middle","name":"a golden dwarven ring","condition":100}
       // "twohanded":true also appears on a two-handed weapon or shield

You get deltas, not the whole set — wearing a ring sends one Add, not a fresh list of everything.

Combat, group and mount

These three describe something temporary, so each carries an explicit flag telling you when it has ended. Test the flag — don't test for an empty name or a zero, because a real opponent can legitimately be at 0% health.

Char.Combat with the round, the moment the blow lands
{"fighting":true,"opponent":"the Lounge Lizard","level":30,
 "health":91,"stamina":99,"number":1}

{"fighting":false}      // combat ended — clear your bar

health and stamina are percentages.

Char.Group once per combat round (2.5s), and only if something changed
{"grouped":true,"level":200,"members":[
   {"name":"Kensho","health":100,"mana":88,"stamina":100,
    "class":"Templar","race":"Rakshasa","npc":"pc",
    "position":"Standing","flags":"A",
    "with_leader":1,"with_you":1,"is_leader":1,"is_subleader":0,
    "level":50}, ... ]}

{"grouped":false}

Deliberately slower than everything else: six people in a fight is six sets of moving numbers, and once a round is plenty for a group panel.

Char.Mount on mount, on change, on dismount
{"mounted":true,"name":"a pegasus","health":88,"maxhealth":120,
 "stamina":60,"maxstamina":100}

{"mounted":false}

Room

Room.Info when the room changes — including a door opening or the weather turning
{"num":"3001","name":"The Temple Of Midgaard","area":"Midgaard",
 "terrain":"Inside","weather":"clear","minlevel":1,"maxlevel":20,
 "exits":{"north":"3002","down":"3054","east":"C"}}

An exit value is the destination room number, or "C" closed, "L" locked, or "?" for somewhere you can see but not identify. num is a string, and is "?" in rooms you aren't allowed to place. It will not re-send while you stand still in an unchanging room.

Room.OtherPlayers when it changes
{"in_room":false,"in_zone":true}

Whether anyone else is nearby — not who. Your own group doesn't count, and neither do immortals.

Server and Account

Both need Core.Supports.Add, as above.

Server every pulse — uptime is always moving
{"id":"KallistiMUD","snippet_version":8,"time":1788530081,
 "uptime":21,"world_time":15,"queue":0,"prompt":"A"}

queue is your own pending command backlog and prompt your prompt flags, so this is really "state about your session" as much as about the server.

Account at login, and again when you explore a new room, kill a new mob or identify a new object
{"name":"Kensho","paragon":57,"created":1549304547,
 "explored":812,"explored_pct":24.71,
 "mobs":1044,"mobs_pct":13.45,
 "objects":377,"objects_pct":4.66,
 "clan":"The Ivory Hand","clan_rank":"Warden"}

Percentages are rounded to two places, matching what whois shows you. clan and clan_rank are omitted entirely on an anonymous account.

Keeping your own worn table

worn = worn or {}
local function set(t) worn[t.slot] = t end

registerAnonymousEventHandler("gmcp.Char.Items.List", function()
  worn = {}
  for _, it in ipairs(gmcp.Char.Items.List.items or {}) do set(it) end
end)
registerAnonymousEventHandler("gmcp.Char.Items.Add",    function() set(gmcp.Char.Items.Add.item) end)
registerAnonymousEventHandler("gmcp.Char.Items.Update", function() set(gmcp.Char.Items.Update.item) end)
registerAnonymousEventHandler("gmcp.Char.Items.Remove", function()
  worn[gmcp.Char.Items.Remove.item.slot] = nil
end)

If you reload your script mid-session, your table is empty and nothing will refill it — already-worn items never send an Add. Ask for a fresh list:

sendGMCP("Char.Items.Worn")

How often things arrive

PackageCadence
Char.Vitals, Char.Combat, Char.Statuswith the combat round, the moment the blow lands — and otherwise on the pulse, when something changed
Char.Items.Add / .Removeimmediately, at the moment you wear or remove
Serverevery pulse, always
Char.Stats, Char.Worth, Char.Experience, Char.Mount, Room.*at most once per pulse, and only if something changed
Char.Grouponce per combat round, 2.5s
Char.Affectson gain or loss; durations tick once per mud hour (~75s)
Accountlogin, then on a new room, mob or object
Char.Name, Char.Baselogin, then almost never

"The pulse" is not a steady tick. It fires from more than one game timer, so gaps between roughly 0.4 and 2.0 seconds are normal, averaging about 1.5. Don't build anything that assumes a fixed interval — and if you want a smooth countdown, decrement locally between messages rather than waiting for the next one. The three combat packages are the exception: they are pushed with the round itself, so your health bar moves with the damage message rather than trailing it.

A worked example

-- a one-line status bar, updated live
registerAnonymousEventHandler("gmcp.Char.Vitals", function()
  local v = gmcp.Char.Vitals
  local c = gmcp.Char.Combat
  local bar = string.format("%d/%d hp  %d/%d mana  %d/%d st",
    v.hp, v.maxhp, v.mana, v.maxmana, v.stamina, v.maxstamina)
  if c and c.fighting then
    bar = bar .. string.format("   vs %s (%d%%)", c.opponent, c.health)
  end
  setTextFormat("statusbar", 0,0,0, 255,255,255)
  echo("statusbar", bar)
end)