forums wiki races classes cabals religions world history immortals all pages bugs items helps stats changes calendar map source login donate play now

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

  1. Protocol basics
  2. Handshake and client identification
  3. Package reference
  4. Client to server messages
  5. Scripting recipes
  6. 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 with NOWHO are 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:true with the actual language name.
  • Each listener's packet is evaluated separately via the same can_hear_lang logic the terminal uses (skill % roll, com_lan affect, IS_IMMORTAL, illithid race, divine language).
  • When understood:false, text is phoneme-garbled per the speaker's language to match the terminal output, and language is "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.Affects always 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.Channel text 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:

  1. Confirm IAC DO 201 was sent.
  2. Confirm your terminal isn't stripping IAC sequences (some unix tail setups do).
  3. Core.Hello isn'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.

Last edited by Erelei