GMCP
Forsaken Lands GMCP Specification
Client-side guide for scripting on top of GMCP. Everything in this document reflects what the current server emits — field names, types, emit timing, and escaping rules.
Contents
- Protocol basics
- Handshake and client identification
- Package reference
- Client → server messages
- Scripting recipes
- Caveats and gotchas
Protocol basics
GMCP (Generic Mud Communication Protocol) rides on top of TELNET option 201 (0xC9). Every GMCP message is:
IAC SB 201 <package-name> <space> <json-payload> IAC SE
Example:
IAC SB 201 Char.Vitals {"hp":850,"maxhp":900,...} IAC SE
Package names are dotted namespaces. Payloads are JSON (object, array, or primitive). The MUD emits output actively — no subscription or negotiation step beyond enabling the option.
Enabling GMCP
- The server sends
IAC WILL 201as soon as you connect. - Your client replies
IAC DO 201to turn it on.
Most mainstream MUD clients (Mudlet, Mudlet-Web, tintin++, Blightmud, MUSHclient) do this automatically. The built-in web client (play.html) always negotiates it.
There is no Core.Supports.Set step — once GMCP is enabled, the server emits every package it knows about at the appropriate times. You choose what to consume on the client side.
Packet ordering
GMCP messages interleave with normal telnet output. A single look command can produce a burst:
Room.Info {...}
Room.Chars [...]
Room.Items [...]
Map.Tiles {...}
<then the plain-text room description>
Your client should buffer packets by name and always keep the latest value for each package (old values are stale).
String escaping
- Payloads are valid JSON (
",\\,\n,\t, etc.). - Backtick color codes are stripped server-side before text goes into a JSON field. You get clean text. Helper:
`6,`(255),`[123], and`` resets — all removed. - Room names, character names, channel text — all come pre-stripped.
Handshake and client identification
Server → Client
Sent immediately after TELNET negotiation succeeds:
IAC WILL 201 (from server)
IAC DO 201 (from client — your job)
Client → Server
Three optional messages the server watches for:
Core.Hello
Standard GMCP client fingerprint. Send once, right after you DO-ack the option.
Core.Hello {"client":"Mudlet","version":"4.17.2"}
Fields: client (string, your client's name), version (string). Stored server-side for stats; no functional consequence.
Client.Fingerprint.Report (optional)
Free-form JSON payload used by some client scripts. Forsaken Lands accepts it and stores it per-connection. Harmless to skip.
Proxy.Ident (restricted)
Used only by internal web proxies to forward the real client IP. Only accepted from RFC1918/loopback source IPs; ignored from the public internet. You don't need to send this.
Package reference
Char.Vitals
Emitted every prompt. The ones you'll drive HP/mana/move bars off.
Shape:
Char.Vitals {"hp":850,"maxhp":900,"mana":760,"maxmana":820,"move":250,"maxmove":250}
Fields:
| Field | Type | Notes |
|---|---|---|
hp |
int | current hit points |
maxhp |
int | max hit points with buffs |
mana |
int | current mana |
maxmana |
int | max mana with buffs |
move |
int | current movement |
maxmove |
int | max movement |
Emitted on: every prompt fire (after any action or pulse that triggers one).
Char.Status
Slower-changing identity fields. Redraw character-sheet widgets off this.
Shape:
Char.Status {"name":"Gibble","level":50,"race":"gnome","class":"Psi"}
Fields:
| Field | Type | Notes |
|---|---|---|
name |
string | character name |
level |
int | character level |
race |
string | race table name, lower-case short key |
class |
string | class table name (may include display casing) |
Emitted on: login, level gain.
Char.Worth
Currency and soft progression counters.
Shape:
Char.Worth {"gold":12345,"bank":99000,"exp":812400,"tnl":45085,"trains":2,"practices":0,"cps":1713,"rps":9}
Fields:
| Field | Type | Notes |
|---|---|---|
gold |
int | carried gold |
bank |
int | banked gold |
exp |
int | lifetime earned experience |
tnl |
int | experience remaining to next level (0 if capped) |
trains |
int | unspent training sessions |
practices |
int | unspent practice sessions |
cps |
int | cabal points (CPs) |
rps |
int | roleplay points (RPs) |
Emitted on: every prompt and login.
Char.Affects
All active spells, skills, and songs — the same list that appears under affects in the MUD.
Shape:
Char.Affects {"affects":[
{"kind":"spell","name":"bless","duration":6,"level":50,"location":"hitroll","modifier":4},
{"kind":"spell","name":"armor","duration":44,"level":50,"location":"ac","modifier":-20},
{"kind":"song","name":"bagatelle of bravado","duration":8,"level":50,"location":"damroll","modifier":3}
]}
Fields per affect:
| Field | Type | Notes |
|---|---|---|
kind |
string | "spell" or "song" — use this to group, do not rely on name alone |
name |
string | stripped of color codes |
duration |
int | ticks remaining (a "tick" ≈ 30s-1min in game-time); -1 is permanent |
level |
int | level the affect was cast at |
location |
string | stat it modifies ("hitroll", "damroll", "ac", "str", "mana", "none", …) |
modifier |
int | magnitude of the modifier (negative = better for AC, etc.) |
The payload replaces the whole list — treat it as "this is the full state now," not a diff.
Emitted on: login, every prompt, and whenever any affect is added, removed, or expires.
Why kind matters: a spell and a song can share a numeric ID internally. Without kind, "control weather" (spell sn 18) and a song that happens to be at song-table index 18 would collide. Always split on kind when you render them.
Char.Combat
Set when you're fighting; cleared to an empty object when you're not.
Shape (fighting):
Char.Combat {"target":"Durim","condition":"quite a few wounds","hp_pct":54}
Shape (not fighting):
Char.Combat {}
Fields:
| Field | Type | Notes |
|---|---|---|
target |
string | name of the char you're fighting (stripped) |
condition |
string | flavor text: "excellent", "a few scratches", "small wounds", "quite a few wounds", "big nasty wounds", "pretty hurt", "awful", "bleeding to death" |
hp_pct |
int | 0-100; clamp to 0 on death |
Emitted on: prompt, and again as {} when combat ends (the empty object is the signal to tear down any combat HUD).
Room.Info
The room you're standing in.
Shape:
Room.Info {
"num": 16601,
"name": "The Academy Courtyard",
"area": "Val Miran",
"terrain": "city",
"exits": {"north":16600,"east":16602,"west":16603}
}
Fields:
| Field | Type | Notes |
|---|---|---|
num |
int | room vnum |
name |
string | stripped of color codes |
area |
string | area name (zone) |
terrain |
string | one of: inside, city, field, forest, hills, mountain, water_swim, water_noswim, swamp, air, desert, lava, snow |
exits |
object | direction-name → destination room vnum; includes up/down when present |
Emitted on: every look at your current room (which fires on movement, force-looks, etc).
Room.Chars
Everyone else visible in your room.
Shape:
Room.Chars [
{"name":"Durim","npc":false},
{"name":"a city guard","npc":true}
]
Fields per entry:
| Field | Type | Notes |
|---|---|---|
name |
string | as you see them (handles invis/disguise/PERS()) |
npc |
bool | true for mobs, false for PCs |
Your own character is omitted. People you can't see are omitted.
Emitted on: every look at the current room.
Room.Items
Objects on the ground.
Shape:
Room.Items [
{"name":"a worn leather boot","type":"armor"},
{"name":"a pile of gold coins","type":"money"}
]
Fields per entry:
| Field | Type | Notes |
|---|---|---|
name |
string | short description, color-stripped |
type |
string | item type (weapon, armor, container, food, potion, wand, scroll, staff, money, light, corpse_npc, corpse_pc, …) |
Objects you can't see (e.g. invisible without detect-invis) are omitted.
Emitted on: every look at the current room.
Map.Tiles
The BFS-generated minimap around the player. This is the richest package; it lets clients draw a full tactical map.
Top-level shape:
Map.Tiles {
"r": 7,
"g": [ [ cell|null, cell|null, ... ], ... ],
"a": [ zlayer_room, ... ],
"b": [ zlayer_room, ... ],
"areas": { "<area_vnum>": {"name":"…","color":"#rrggbb"}, … },
"t": " ... @e ...| ..."
}
Top-level fields:
| Field | Type | Notes |
|---|---|---|
r |
int | radius of the BFS (4 = small, 7 = default, 10 = large). Grid is (2r+1) × (2r+1). |
g |
2D array | rows × columns. Positions beyond the BFS reach are null. Player is at center ([r][r]). |
a |
array | rooms reached via up exits, as a flat sparse list. |
b |
array | rooms reached via down exits. |
areas |
object | keyed by area vnum; includes every zone present in g. Value = {name, color}. color is a stable #rrggbb hashed from the vnum. |
t |
string | pre-rendered ASCII fallback grid (` |
Per-cell (g[y][x]) shape
{
"s": 1,
"e": "neswud",
"l": 2,
"h": 1,
"ar": 30,
"f": "s$b",
"d": {"n":"locked","s":"closed"},
"ex": {"n":16600,"e":16602,"w":16603,"u":16800,"d":16900},
"p": {"48171":"Cugrurg","48299":"Gibble"}
}
| Field | Type | Notes |
|---|---|---|
s |
int | sector index (0-12; same order as the terrain enum in Room.Info). |
e |
string | exit letters: n e s w u d. Uppercase = exit leads somewhere the map couldn't include (off-grid or teleport-style). |
l |
int | light level 0-4: 0 = dark + night, 1 = dark, 2 = indoors, 3 = outdoors + night, 4 = outdoors + day. |
h |
int | present and 1 iff this cell is where you're standing. Omitted otherwise. |
ar |
int | area vnum. Cross-reference with top-level areas for name + color. |
f |
string | flag string — any of: s (safe/no-PK), $ (shop), b (bank), t (trainer), h (healer). Omitted when no flags. |
d |
object | door state dict — present only when the room has at least one visible door. Keys: direction letter; values: "open", "closed", "locked", "hidden" (imms only). |
ex |
object | exit destinations as direction-letter → vnum. Omitted if no exits. |
p |
object | players-in-room dict, keyed by numeric char id → visible name. Excludes you. Omitted if no other visible PCs. |
Per-cell in a / b (z-layers)
{"x":7,"y":7,"s":0,"e":"u","vnum":2051}
These are the rooms you'd reach going up/down from a grid cell at (x,y). They share the same sector/exits shape as main cells but don't carry the extended v2 fields (no d, p, etc.).
Secret exits
Non-imms never see secret passages or closed-and-secret doors. Those exits are omitted from e, ex, and d entirely. Imms see them with d value "hidden".
Emitted on: every look at the current room.
Backwards compatibility: the original minimal fields (r, g[y][x].s, g[y][x].e, g[y][x].h, t) are unchanged — clients that only read those keep working.
Group.Info
Your group roster. Empty ({}) when you're solo.
Shape (grouped):
Group.Info {
"leader": "Durim",
"members": [
{"name":"Durim","level":50,"class":"Dkn","hp_pct":78,"mana_pct":55,"move_pct":93,"tnl":1250},
{"name":"Gibble","level":50,"class":"Psi","hp_pct":91,"mana_pct":40,"move_pct":88,"tnl":0}
]
}
Shape (solo):
Group.Info {}
Top-level fields:
leader(string): leader's visible name.members(array): every member including you. Characters with theNOWHOflag are omitted.
Per-member fields:
| Field | Type | Notes |
|---|---|---|
name |
string | as you see them |
level |
int | 1-60 |
class |
string | short class key (e.g. Dkn, Psi, War) for PCs; "mob" for charmies |
hp_pct, mana_pct, move_pct |
int | 0-100 |
tnl |
int | exp to next level; 0 for NPCs/capped chars |
Emitted on: prompt, login, and whenever someone joins/leaves the group.
World.Time
In-game clock and weather.
Shape:
World.Time {"hour":14,"day":12,"month":3,"year":2026,"sunlight":"light","sky":"cloudy"}
Fields:
| Field | Type | Notes |
|---|---|---|
hour |
int | 0-23 game-time |
day |
int | day-of-month |
month |
int | 0-17 (see game calendar) |
year |
int | game year |
sunlight |
string | "dark", "rise", "light", "set" |
sky |
string | "cloudless", "cloudy", "raining", "lightning" |
Emitted on: login, and broadcast to all GMCP-enabled connections on each weather tick (every ~2 minutes real time).
Comm.Channel
One message per chat-like event you hear.
Base shape (all channels):
Comm.Channel {"channel":"say","speaker":"Durim","text":"Hello there."}
Extended shape (say / yell / tell / reply add language + direction metadata):
Comm.Channel {
"channel":"say",
"speaker":"Durim",
"text":"xak zee nog",
"language":"foreign",
"understood":false
}
Comm.Channel {
"channel":"tell",
"speaker":"Gibble",
"text":"meet at the altar",
"language":"common",
"understood":true,
"direction":"received"
}
Base fields (always present):
| Field | Type | Notes |
|---|---|---|
channel |
string | see table below |
speaker |
string | visible name of the speaker. For tell/reply, it's the other end of the conversation from your perspective. |
text |
string | color-stripped; garbled per listener on language-aware channels |
Optional fields (say / yell / tell / reply only):
| Field | Type | Notes |
|---|---|---|
language |
string | actual language name ("common", "drow", "orcish", …) if the listener understands; "foreign" when they don't. Never leaks which foreign language it is. |
understood |
bool | true → text is the original; false → text is phoneme-garbled to match the terminal's "foreign tongue" rendering. |
direction |
string | "sent" on the packet the sender receives; "received" on the packet the recipient receives. Only on tell/reply where a two-way relationship exists. |
Channel values:
| Value | Source command | Language-aware | Direction field |
|---|---|---|---|
say |
say |
yes | no |
yell |
yell |
yes | no |
tell |
tell (sent to both parties) |
yes | yes |
reply |
reply (sent to both) |
yes | yes |
pray |
pray |
no | no |
newbie |
newbie |
no | no |
cabal |
cabal chat |
no | no |
clan |
clan chat |
no | no |
faction |
faction chat |
no | no |
immortal |
immtalk (imms only) |
no | no |
imp |
imptalk (IMP+ only) |
no | no |
For tell/reply, both endpoints receive a Comm.Channel. The speaker field from the sender's side is the recipient's name, so both sides can log/render the conversation with one code path — and now direction tells you unambiguously which side of the conversation you're on.
Language semantics:
- The speaker's own packet is always marked
understood:truewith the actual language name. - Each listener's packet is individually evaluated via the same
can_hear_langlogic the terminal uses (language skill % roll,com_lanaffect,IS_IMMORTAL, illithid race, divine language). - When
understood:false, thetextfield is phoneme-garbled per the speaker's language (same garble the terminal shows), andlanguageis set to"foreign"rather than revealing which language it actually was. - On
tell/reply, if the recipient doesn't understand the sender's language, they see"language":"foreign","understood":falseplus garbled text — matching the terminal's "tells you in a foreign tongue" behavior.
Imm/imp chat: Imm and imp channels emit to everyone with the appropriate trust whose channel toggle is on. No language filtering — imms always understand each other. No direction field (broadcast, not directed).
Emitted on: each matching channel message.
RNG note: Language comprehension is a per-call dice roll in the underlying engine (unless skill is 0 or 100). GMCP and terminal output evaluate separately, so on rare edge cases they could disagree for a single middle-skill listener. Treat the GMCP value as authoritative for your client's language-aware rendering.
Client → server messages
Besides Core.Hello, Client.Fingerprint.Report, and Proxy.Ident (all described above), the server does not currently accept client-initiated gameplay commands through GMCP. Drive input via normal text commands; GMCP is an output channel for state.
Scripting recipes
Mudlet — HP/mana bars from Char.Vitals
registerAnonymousEventHandler("gmcp.Char.Vitals", function()
local v = gmcp.Char.Vitals
hpBar:setValue(v.hp, v.maxhp)
manaBar:setValue(v.mana, v.maxmana)
moveBar:setValue(v.move, v.maxmove)
end)
Mudlet — combat target widget
registerAnonymousEventHandler("gmcp.Char.Combat", function()
local c = gmcp.Char.Combat
if not c or not c.target then
combatHUD:hide()
else
combatHUD:show()
combatName:echo(c.target)
combatBar:setValue(c.hp_pct, 100)
end
end)
Mudlet — render songs separately from spells
registerAnonymousEventHandler("gmcp.Char.Affects", function()
local spells, songs = {}, {}
for _, a in ipairs(gmcp.Char.Affects.affects or {}) do
if a.kind == "song" then
table.insert(songs, a)
else
table.insert(spells, a)
end
end
renderSpells(spells)
renderSongs(songs)
end)
Mudlet — minimap with zone tints
registerAnonymousEventHandler("gmcp.Map.Tiles", function()
local m = gmcp.Map.Tiles
local r = m.r
for y = 1, #m.g do
for x = 1, #m.g[y] do
local cell = m.g[y][x]
if cell == vim.NIL then cell = nil end -- Mudlet uses vim.NIL for JSON null
if cell then
local tint = m.areas and m.areas[tostring(cell.ar)] and m.areas[tostring(cell.ar)].color or "#444"
drawCell(x, y, cell.s, cell.e, tint, cell.l, cell.f, cell.h)
else
drawCell(x, y, nil)
end
end
end
end)
tintin++ — chat log split by channel
#action {Comm.Channel %*} {
#var CHAN %1[channel];
#var WHO %1[speaker];
#var MSG %1[text];
#line log chat-$CHAN.txt <$WHO>: $MSG
}
Generic — room-change trigger
Room.Info fires exactly once per look. Stash room.num to detect movement:
prevRoom = nil
onRoomInfo = function(info)
if info.num ~= prevRoom then
prevRoom = info.num
onEnterRoom(info)
end
end
Caveats and gotchas
Ordering
Prompt-driven packages (Char.Vitals, Char.Worth, Char.Combat, Group.Info) fire together on every prompt. Don't assume cross-package atomicity — treat each as independent state.
Stale packets
Char.Combat {}is the explicit "combat ended" signal. Don't wait for a timeout.Group.Info {}is the explicit "solo" signal.Char.Affectsalways sends the full list. Replace, don't merge.
Map timing
Map.Tiles is larger than the other packets (up to ~80KB at radius 10 with full v2 fields). Debounce any heavy redraw work — don't redraw per pixel.
Size limits
Char.Affects: up to 8KB; truncates silently if you somehow exceed it.Map.Tiles: up to 96KB.Group.Info,Room.Chars,Room.Items: up to 8KB.Comm.Channeltext field: up to ~1KB after JSON-escape.
Invisibility
Room.Chars, Map.Tiles.p, and Group.Info all apply the normal can_see() check. Your client sees what your character sees — an invis character doesn't appear unless you detect them.
Color stripping
Every string field is passed through json_escape_strip_color. You get plain text with quotes/backslashes JSON-escaped. If you want colored names, reconstruct from class/race/title in your client CSS.
Secret exits
Non-imms: secret passages and secret-closed doors are completely absent from Map.Tiles. Imms: they appear with d state "hidden".
Reconnection
After a copyover/hotboot the server re-emits login packages. Don't assume any package's content persists across disconnects.
When GMCP goes silent
If you stop receiving packets, check:
IAC DO 201was sent (you accepted the option).- Your terminal isn't stripping IAC sequences (this happens with some unix
tailsetups). Core.Helloisn't required but is recommended so server-side fingerprint/telemetry has a client name.
