forums wiki races classes cabals religions world history immortals all pages bugs items helps socials changes map login play now

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

  1. Protocol basics
  2. Handshake and client identification
  3. Package reference
  4. Client → server messages
  5. Scripting recipes
  6. 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 201 as soon as you connect.
  • Your client replies IAC DO 201 to 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 the NOWHO flag 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 truetext is the original; falsetext 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:true with the actual language name.
  • Each listener's packet is individually evaluated via the same can_hear_lang logic the terminal uses (language skill % roll, com_lan affect, IS_IMMORTAL, illithid race, divine language).
  • When understood:false, the text field is phoneme-garbled per the speaker's language (same garble the terminal shows), and language is 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":false plus 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.Affects always 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.Channel text 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:

  1. IAC DO 201 was sent (you accepted the option).
  2. Your terminal isn't stripping IAC sequences (this happens with some unix tail setups).
  3. Core.Hello isn't required but is recommended so server-side fingerprint/telemetry has a client name.
Last edited by Erelei