GMCP
Forsaken Lands GMCP Specification
Client-side reference for what the server emits on the wire. Field names, types, emit timing, and escaping rules.
Contents
- Protocol basics
- Handshake and client identification
- Package reference
- Client to server messages
- Scripting recipes
- Caveats and gotchas
Protocol basics
GMCP rides on TELNET option 201 (0xC9). Each message looks like:
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 server emits actively. There is no subscription step beyond enabling the option.
Enabling GMCP
The server sends IAC WILL 201 at connect. Your client replies IAC DO 201. Mudlet, Mudlet-Web, tintin++, Blightmud, and MUSHclient handle this automatically. The built-in web client always negotiates it.
There is no Core.Supports.Set step. Once GMCP is on, every package fires at its appropriate trigger. Drop the ones you don't care about on the client side.
Packet ordering
GMCP messages interleave with normal telnet output. A single look produces a burst:
Room.Info {...}
Room.Chars [...]
Room.Items [...]
Map.Tiles {...}
<then the plain-text room description>
Buffer packets by name and keep the latest value for each one. Older values are stale.
String escaping
Payloads are valid JSON (", \\, \n, \t, etc.). Backtick color codes are stripped server-side before any string goes into a JSON field, so you get clean text. The reset , single-char codes like 6 ``, and 256-color forms `` (255) and `[123] `` are all removed. Room names, character names, and channel text arrive pre-stripped.
Handshake and client identification
Server to client
Sent right after telnet negotiation:
IAC WILL 201 (server)
IAC DO 201 (client, your job)
Client to server
Three optional packages the server watches for.
Core.Hello
Standard GMCP fingerprint. Send once, right after you accept the option.
Core.Hello {"client":"Mudlet","version":"4.17.2"}
Fields: client (string), version (string). Stored server-side for stats. No functional consequence.
Client.Fingerprint.Report
Free-form JSON. Forsaken Lands accepts it and stores it per-connection. Skip it if you don't need it.
Proxy.Ident
Used only by internal web proxies to forward the real client IP. Accepted only from RFC1918/loopback source IPs. Public clients don't need to send it.
Package reference
Char.Vitals
Drives HP, mana, and movement bars.
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.
Char.Status
Slow-changing identity fields.
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 (display casing preserved) |
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,"cabal":"Knight"}
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 |
rps |
int | roleplay points |
cabal |
string | current cabal name, or "none" if uncabaled |
Emitted on: every prompt and login.
Char.Affects
Every active spell, skill, and song. Same list the in-game affects command shows.
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". Group on this. Don't trust name alone (see below). |
name |
string | stripped of color codes |
duration |
int | ticks remaining (a tick is roughly 30 seconds to a minute of real time). -1 means permanent. |
level |
int | level the affect was cast at |
location |
string | stat it modifies ("hitroll", "damroll", "ac", "str", "mana", "none", etc.) |
modifier |
int | magnitude (negative is better for AC, etc.) |
The payload is the full state, not a diff. Replace your local list each time.
Emitted on: login, every prompt, and any add/remove/expire.
Why kind matters: spells and songs use disjoint internal indexes. Without kind, a spell at sn 18 (control weather) and a song at index 18 collide on numeric ID. Always split on kind before rendering.
Char.Combat
Set when fighting. Empty object when 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 to 100; clamps to 0 on death |
Emitted on: prompt. The empty object fires once when combat ends, so use it as the explicit "tear down combat HUD" signal.
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 to destination room vnum. Includes up/down when present. |
Emitted on: every look at your current room (movement, force-look, 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 |
You are 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, etc. |
Items you can't see are omitted (e.g. invisible without detect-invis).
Emitted on: every look at the current room.
Map.Tiles
BFS-generated minimap around the player. Lets clients render a tactical floor view plus arbitrary vertical depth.
Top-level shape:
Map.Tiles {
"r": 7,
"g": [ [ cell|null, cell|null, ... ], ... ],
"a": [ zlayer_room, ... ],
"b": [ zlayer_room, ... ],
"zr": [ zroom, ... ],
"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) x (2r+1). |
g |
2D array | rows by columns, current floor only. Positions outside the BFS reach are null. Player is always at [r][r]. |
a |
array | legacy: rooms reached via up exits, |z|=1 only with a 1-cardinal sample. Stable for older clients. New clients can ignore. |
b |
array | legacy mirror of a for down exits. |
zr |
array | full multi-Z BFS results, off-floor cells only (z != 0). Each entry is the same per-cell payload as g plus explicit x/y/z. The cardinal-first BFS prefers same-floor paths on tie. A zr entry may share its (x,y) with a populated g[y][x] when a room sits directly above or below it (a stacked floor). The client decides whether to render off-floor cells as a separate layer, a toggle, or in-place. Arbitrary depth, capped only by the radius hop count. |
areas |
object | keyed by area vnum. Includes every zone present in g or zr. Value is {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}
}
| Field | Type | Notes |
|---|---|---|
s |
int | sector index (0-12, same order as Room.Info terrain enum). |
e |
string | exit letters from n e s w u d. Uppercase means the 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 only on the cell you're standing in. Omitted everywhere else. |
ar |
int | area vnum. Cross-reference with top-level areas for name and color. |
f |
string | flag chars from s (safe/no-PK), $ (shop), b (bank), t (trainer), h (healer). Omitted when empty. |
d |
object | door state dict. Present only when the room has at least one visible door. Keys are direction letters, values are "open", "closed", "locked", or "hidden" (imms only). |
ex |
object | exit destinations as direction-letter to vnum. Omitted if no exits. |
Player-identity (p) per cell is intentionally not emitted. A passive PK radar over GMCP would erode the scan/where gameplay loop, so it was dropped. Use scan, scan pk, or where for nearby players. Room.Chars covers your current room.
Per-cell in a / b (legacy z-layers)
{"x":7,"y":7,"s":0,"e":"u","vnum":2051}
Rooms reached by going up or down from a grid cell at (x,y). Single-step (|z|=1) only and don't carry the extended cell fields (no d, f, l, etc.). Older clients still rely on this shape. Prefer zr for new code.
Per-room in zr
{
"x": 5, "y": 7, "z": -2,
"s": 0,
"e": "neud",
"l": 1,
"ar": 30,
"f": "s",
"d": {"u":"closed"},
"ex": {"n":3041,"e":3042,"u":3010,"d":3120}
}
| Field | Type | Notes |
|---|---|---|
x, y |
int | grid coordinate, same space as g[y][x]. May coincide with a populated g cell when a room is stacked directly above or below. |
z |
int | floor delta from the player. Positive is above (1 = one floor up), negative is below. Arbitrary depth, capped only by the radius. |
s, e, l, ar, f, d, ex |
as above | identical semantics to g[y][x]. h never appears in zr (player is always in g). |
Secret exits
Non-imms never see secret passages or closed-and-secret doors. Those exits are absent from e, ex, and d. 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. Old clients that only read those keep working.
Group.Info
Your group roster. Empty ({}) when 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. Members withNOWHOare omitted.
Per-member fields:
| Field | Type | Notes |
|---|---|---|
name |
string | as you see them |
level |
int | 1 to 60 |
class |
string | short class key (e.g. Dkn, Psi, War) for PCs, "mob" for charmies |
hp_pct, mana_pct, move_pct |
int | 0 to 100 |
tnl |
int | exp to next level. 0 for NPCs and capped chars. |
Emitted on: prompt, login, group join/leave.
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. Also broadcast to every GMCP-enabled connection on each weather tick (roughly every two minutes real time).
World.Moons
Phase state for the three moons (Lysenties, Nercuros, Dyphrities). The MUD's astronomy intentionally exposes phase but not fullness or time-to-full, so client scripts can't time alignment-driven procs to the tick.
Shape:
World.Moons {
"moons": [
{"name":"Lysenties","active":true,"phase":4,"phase_name":"full"},
{"name":"Nercuros","active":true,"phase":2,"phase_name":"first half"},
{"name":"Dyphrities","active":false,"phase":0,"phase_name":"new"}
],
"eclipse": false,
"triad": false,
"near_alignment": false
}
Top-level fields:
| Field | Type | Notes |
|---|---|---|
moons |
array | one entry per moon, in fixed order Lysenties / Nercuros / Dyphrities. |
eclipse |
bool | true while a solar or lunar eclipse is active. All moon-driven scalars collapse to zero during eclipse. |
triad |
bool | true when all three moons are full simultaneously. |
near_alignment |
bool | true when phases are close enough to trigger near-alignment buffs/effects. |
Per-moon fields:
| Field | Type | Notes |
|---|---|---|
name |
string | display name ("Lysenties", "Nercuros", "Dyphrities") |
active |
bool | false if the moon is currently dormant (rare; treat as inactive for any moon-derived effects). |
phase |
int | 0-7 phase index. 0 new, 1 waxing, 2 first half, 3 gibbous waxing, 4 full, 5 gibbous waning, 6 second half, 7 waning. |
phase_name |
string | display string for the phase. |
Emitted on: login. Also broadcast to every GMCP-enabled connection when any phase index flips, any active flag toggles, or any of the three world-state booleans changes. The broadcaster compares against a small static cache so re-firing on every weather tick when nothing changed costs nothing on the wire.
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, gtell add language and 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. For tell, this is the sender (you only receive a tell packet as the recipient). |
text |
string | color-stripped. Garbled per-listener on language-aware channels. |
Optional fields (say, yell, tell, gtell):
| Field | Type | Notes |
|---|---|---|
language |
string | language name ("common", "drow", "orcish", etc.) when the listener understood. "foreign" when they didn't. The actual foreign language never leaks. |
understood |
bool | true means text is the original. false means text is phoneme-garbled to match the terminal's foreign-tongue rendering. |
direction |
string | "received" on tell packets at the recipient. Outgoing tells do not echo to the sender's GMCP, so "sent" never appears in practice. |
Channel values:
| Value | Source | Language-aware | Direction field |
|---|---|---|---|
say |
say |
yes | no |
yell |
yell |
yes | no |
tell |
tell, reply, language-specific tells (tarchaic, tmogwei, etc.) |
yes | yes (recipient side only) |
gtell |
gtell and group-tell projection |
yes | no |
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 |
Tell delivery: only the recipient receives a Comm.Channel. The sender sees their own outgoing line in plain text but no GMCP echo. This was a deliberate change: when tells echoed both ways, naive triggers treated the echo as if the recipient had just sent a tell back. If your client needs a record of outgoing tells, parse them off the terminal line.
Language semantics:
- The speaker's own packet (for say, yell, gtell) is always
understood:truewith the actual language name. - Each listener's packet is evaluated separately via the same
can_hear_langlogic the terminal uses (skill % roll,com_lanaffect,IS_IMMORTAL, illithid race, divine language). - When
understood:false,textis phoneme-garbled per the speaker's language to match the terminal output, andlanguageis"foreign"rather than the real language name.
Imm/imp chat: broadcast to everyone with the right trust whose channel toggle is on. No language filtering. No direction field.
Emitted on: each matching channel message.
RNG note: language comprehension is a per-call dice roll for partial-skill listeners (skill 0 or 100 are deterministic). GMCP and terminal output evaluate independently, so a middle-skill listener can occasionally see them disagree on a single message. Treat the GMCP value as authoritative for client-side rendering.
Client to server messages
Besides Core.Hello, Client.Fingerprint.Report, and Proxy.Ident (covered above), the server doesn't accept gameplay commands over GMCP. Drive input through the normal text command stream. 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, songs separate 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
-- Off-floor rooms (towers, dungeons with stacked floors)
for _, z in ipairs(m.zr or {}) do
drawZRoom(z.x, z.y, z.z, z.s, z.e, z.ar)
end
end)
Mudlet, World.Moons phase tracker
registerAnonymousEventHandler("gmcp.World.Moons", function()
local m = gmcp.World.Moons
for _, moon in ipairs(m.moons) do
moonWidget[moon.name]:setPhase(moon.phase, moon.phase_name, moon.active)
end
eclipseIndicator:setVisible(m.eclipse)
triadIndicator:setVisible(m.triad)
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 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 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 the largest packet (up to about 80KB at radius 10 with the full payload). Debounce heavy redraw work. Don't redraw per pixel.
Size limits
Char.Affects: up to 8KB. Truncates silently on overflow.Map.Tiles: up to 96KB.Group.Info,Room.Chars,Room.Items: up to 8KB.Comm.Channeltext field: up to about 1KB after JSON-escape.
Invisibility
Room.Chars and Group.Info apply the normal can_see() check. Your client only sees what your character sees.
Color stripping
Every string field is passed through json_escape_strip_color. You get plain text with quotes and backslashes JSON-escaped. If you want colored names, reconstruct from class/race/title in your client CSS.
Secret exits
Non-imms: secret passages and closed-and-secret doors are absent from Map.Tiles. Imms see them with d value "hidden".
Reconnection
After copyover/hotboot the server re-emits the login package set. Don't assume any package's content survives across disconnects.
When GMCP goes silent
If you stop receiving packets:
- Confirm
IAC DO 201was sent. - Confirm your terminal isn't stripping IAC sequences (some unix
tailsetups do). Core.Helloisn't required, but sending it keeps server-side fingerprint and telemetry useful.
Source of truth
Everything here is what FL actually emits. If a field disagrees with the wire, the code is right and the doc is stale. File an issue or update this doc directly.
