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.
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)The three things that catch people out
Character
{"name":"Kensho","fullname":"Kensho the Wanderer"}fullname equals name when you have no title, so you never have to test for a missing key.
{"hp":842,"maxhp":1200,"mana":300,"maxmana":450,
"stamina":90,"maxstamina":100}The one you'll build bars from.
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 {"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 {"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.
{"affects":[{"name":"sanctuary","duration":12},
{"name":"bless","duration":8}]}Equipment
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 shieldYou 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.
{"fighting":true,"opponent":"the Lounge Lizard","level":30,
"health":91,"stamina":99,"number":1}
{"fighting":false} // combat ended — clear your barhealth and stamina are percentages.
{"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.
{"mounted":true,"name":"a pegasus","health":88,"maxhealth":120,
"stamina":60,"maxstamina":100}
{"mounted":false}Room
{"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.
{"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.
{"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.
{"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
| Package | Cadence |
|---|---|
| Char.Vitals, Char.Combat, Char.Status | with the combat round, the moment the blow lands — and otherwise on the pulse, when something changed |
| Char.Items.Add / .Remove | immediately, at the moment you wear or remove |
| Server | every pulse, always |
| Char.Stats, Char.Worth, Char.Experience, Char.Mount, Room.* | at most once per pulse, and only if something changed |
| Char.Group | once per combat round, 2.5s |
| Char.Affects | on gain or loss; durations tick once per mud hour (~75s) |
| Account | login, then on a new room, mob or object |
| Char.Name, Char.Base | login, 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)