GMCP
Forsaken Lands GMCP Specification
Client-side reference for what the server emits on the wire. Field names, types, emit timing, and escaping rules.
Protocol basics
GMCP rides on TELNET option 201 (0xC9). Every message is:
IAC SB 201 <package-name> <space> <json-payload> IAC SE
(FF FA C9 ... FF F0)
Example:
IAC SB 201 Char.Vitals {"hp":850,"maxhp":900,...} IAC SE
Package names use dotted namespaces. The payload is UTF-8 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 (FF FB C9). Your client responds IAC DO 201 (FF FD C9). Mudlet, tintin++, Blightmud, and MUSHclient do 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 trigger. Discard packets you don't want on the client side.
Packet ordering
GMCP messages interleave with normal telnet output. A single look produces a burst like Room.Info {...}, Room.Chars [...], Room.Items [...], Map.Tiles {...}, followed by the plain-text room description.
Buffer packets by name and keep only the latest value for each. Older values are stale.
String escaping
Payloads are valid JSON (", \\, \n, \t, etc. are escaped). The server strips backtick color codes before any string enters a JSON field. The reset code, single-character codes like `6, and 256-color forms like `(255) and `[123] are all removed. Room names, character names, and channel text arrive clean.
IAC state machine
A raw telnet client has to pull GMCP out of the byte stream itself. This minimal state machine does it:
| State | Byte | Action |
|---|---|---|
| 0 (normal) | FF (IAC) |
go to state 1 |
| 0 (normal) | anything else | append to text output |
| 1 (got IAC) | FF |
escaped 0xFF literal. Append to text, go to state 0 |
| 1 (got IAC) | FB/FC/FD/FE (WILL/WONT/DO/DONT) |
save verb, go to state 2 |
| 1 (got IAC) | FA (SB) |
start subneg buffer, go to state 3 |
| 1 (got IAC) | anything else | ignore (GA, NOP, etc.), go to state 0 |
| 2 (got verb) | any byte | option number. If WILL 0xC9, reply DO 0xC9. Go to state 0 |
| 3 (in subneg) | FF (IAC) |
go to state 4 |
| 3 (in subneg) | anything else | append to subneg buffer |
| 4 (IAC in subneg) | F0 (SE) |
subneg complete. If the first byte is 0xC9, the rest is GMCP. Go to state 0 |
| 4 (IAC in subneg) | FF |
escaped 0xFF. Append to buffer, go to state 3 |
| 4 (IAC in subneg) | anything else | protocol error, go to state 0 |
On a complete subneg whose first byte is 0xC9, drop that byte and split the rest at the first space. The left side is the package name. The right side is the JSON body.
Echo toggle (password prompts)
Password prompts use standard telnet echo negotiation:
IAC WILL ECHO (FF FB 01) server will echo, client should HIDE input
IAC WONT ECHO (FF FC 01) server stops echoing, client should SHOW input
On IAC WILL ECHO, switch your input to password mode. On IAC WONT ECHO, switch back.
ANSI color codes
The server sends two kinds of ANSI sequences:
| Type | Wire format | Example | ESC byte present? |
|---|---|---|---|
| 16-color | [0;31m |
red text | no, bare CSI with no 0x1B prefix |
| 256-color | \x1b[38;5;166m |
orange text | yes, standard CSI |
| reset | [0m |
reset all | no |
| clear screen | [2J |
clear | no |
A standard terminal emulator (xterm.js, etc.) needs the bare sequences fixed up by prepending 0x1B.
Fixup: scan the stream. When you hit [ (0x5B) not preceded by 0x1B, look ahead. If digits and/or semicolons follow and end in a final byte (0x40 to 0x7E), it's a bare CSI. Prepend 0x1B. Literal brackets in prose like [Exits: north] don't start with a digit, so they're left alone.
Handshake and client identification
Server to client
Right after telnet negotiation:
IAC WILL 201 (server)
IAC DO 201 (client, your job)
Client to server
Three optional packages the server listens for.
Core.Hello
Standard GMCP fingerprint. Send once right after accepting the option:
Core.Hello {"client":"Mudlet","version":"4.17.2"}
Fields:
| Field | Type | Notes |
|---|---|---|
client |
string | client name |
version |
string | client version |
Stored for statistics. No gameplay effect.
Client.Fingerprint.Report
Free-form JSON. Accepted and stored per connection. Optional.
Proxy.Ident
Only for internal web proxies forwarding the real client IP. Accepted only from RFC1918 or loopback source addresses. Public clients don't send it.
Package reference
The packages the server emits to connected clients.
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.
Usage: hp / maxhp gives a percentage. Common thresholds are red below 25%, yellow below 50%, green above.
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. Hidden affects (sneak, noquit, etc.) are filtered out.
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.
Usage: buff and debuff bars. Use duration for countdowns and highlight affects at 1 or 2 ticks left.
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",
"sector": 1,
"region": 0,
"climate": "Temperate",
"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 | sector name, see terrain table below |
sector |
int | sector index 0 to 12, or -1 when unknown. Same index Map.Tiles sends in s, so a mapper can color a room without matching on the name. |
region |
int | climate region index, see region table below |
climate |
string | climate region name for the same index |
exits |
object | direction-name to destination room vnum. Directions are north, south, east, west, up, down. |
Terrain / sector values:
sector |
terrain |
Meaning |
|---|---|---|
| 0 | inside |
indoor room |
| 1 | city |
city streets |
| 2 | field |
open field, grassland |
| 3 | forest |
forest, woods |
| 4 | hills |
hilly terrain |
| 5 | mountain |
mountains |
| 6 | water_swim |
shallow, swimmable water |
| 7 | water_noswim |
deep water (needs boat or fly) |
| 8 | swamp |
swamp, bog |
| 9 | air |
open air (needs fly) |
| 10 | desert |
desert |
| 11 | lava |
lava, volcanic |
| 12 | snow |
snow, frozen terrain |
Region values:
region |
climate |
|---|---|
| 0 | Temperate |
| 1 | Coastal North |
| 2 | Coastal South |
| 3 | Desert |
| 4 | Tundra |
| 5 | Mountain North |
| 6 | Mountain South |
| 7 | Mountain East |
Emitted on: every look at your current room (movement, force-look, etc).
Usage: automappers key on num and link rooms through exits.
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.
Usage: occupant panels. Make names clickable for look <name> or kill <name>.
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: light, scroll, wand, staff, weapon, treasure, armor, potion, clothing, furniture, trash, container, drink, key, food, money, boat, corpse_npc, corpse_pc, fountain, pill, map, gem, jewelry, etc. |
Items you can't see are omitted (e.g. invisible without detect-invis).
Emitted on: every look at the current room.
Usage: item panels. Make items clickable for get <name> or look <name>.
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 (|-separated rows). 0-9 are sector digits, a-c are desert/lava/snow, @ is you, space is empty. Current floor only. |
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.
Sector colors and exit offsets
Suggested tile colors per sector index:
s |
Terrain | Suggested color |
|---|---|---|
| 0 | inside | warm brown #5a4e3c |
| 1 | city | gray #787369 |
| 2 | field | green #3c6928 |
| 3 | forest | dark green #1c4616 |
| 4 | hills | earth brown #695532 |
| 5 | mountain | cold gray #55555f |
| 6 | water (swim) | light blue #284b87 |
| 7 | water (deep) | dark blue #14235a |
| 8 | swamp | murky green #374628 |
| 9 | air | sky blue #6473a5 |
| 10 | desert | sand #af9150 |
| 11 | lava | orange red #af3214 |
| 12 | snow | light gray #afb9c3 |
Exit letters map to grid neighbors like this:
| Letter | Direction | Grid offset |
|---|---|---|
n |
north | y - 1 |
e |
east | x + 1 |
s |
south | y + 1 |
w |
west | x - 1 |
u |
up | none on g, see zr |
d |
down | none on g, see zr |
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.
Shape (grouped):
Group.Info {
"leader": "Durim",
"members": [
{"id":1769388810,"name":"Durim","level":50,"class":"Dkn","hp_pct":78,"mana_pct":55,"move_pct":93,"tnl":1250},
{"id":1769401002,"name":"Gibble","level":50,"class":"Psi","hp_pct":91,"mana_pct":40,"move_pct":88,"tnl":0}
]
}
Shape (solo):
Group.Info {}
Top-level fields:
| Field | Type | Notes |
|---|---|---|
leader |
string | leader's name |
members |
array | every member including you and any charmed followers. The leader is always included. Members with NOWHO are omitted. |
Per-member fields:
| Field | Type | Notes |
|---|---|---|
id |
int | stable per-character identity. Key your roster on this, not name. Two members can share a name (e.g. identical summons); id never collides. |
name |
string | member's real name (PCs) or short description (charmies). Not filtered by visibility, so blindness does not turn group members into someone. |
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
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
Moon phases and alignment state.
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
Channel messages delivered to your character.
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.
Usage: route messages to chat tabs by channel and color each channel differently.
Package summary
| Package | Emitted on | Typical use |
|---|---|---|
Char.Vitals |
every prompt | HP, mana, move bars |
Char.Status |
login, level gain | name, level, race, class |
Char.Worth |
every prompt, login | gold, exp, trains, practices, cabal |
Char.Affects |
login, every prompt, affect change | buff and debuff bars |
Char.Combat |
prompt, combat end | target health bar |
Room.Info |
room look | room name, area, terrain, exits |
Room.Chars |
room look | NPCs and players in room |
Room.Items |
room look | items on the ground |
Map.Tiles |
room look | minimap |
Group.Info |
prompt, login, group join/leave | party frames |
World.Time |
login, weather tick | day/night, weather |
World.Moons |
login, moon state change | moon phase tracker |
Comm.Channel |
channel message | chat tabs |
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
JavaScript, minimal raw telnet GMCP client
Implements the IAC state machine, echo toggle, and package dispatch. send, writeToTerminal, fixupANSI, setPasswordMode, and the update* functions are yours to supply.
// Telnet constants
const IAC = 0xFF, SB = 0xFA, SE = 0xF0;
const WILL = 0xFB, WONT = 0xFC, DO = 0xFD, DONT = 0xFE;
const GMCP = 0xC9; // 201
const TELOPT_ECHO = 0x01;
// State machine
let iacState = 0, iacVerb = 0;
let subneg = [];
let textBuf = [];
function processBytes(data) {
for (let i = 0; i < data.length; i++) {
const b = data[i];
switch (iacState) {
case 0: // normal
if (b === IAC) iacState = 1;
else textBuf.push(b);
break;
case 1: // got IAC
if (b === IAC) { textBuf.push(0xFF); iacState = 0; }
else if (b === WILL || b === WONT || b === DO || b === DONT)
{ iacVerb = b; iacState = 2; }
else if (b === SB) { subneg = []; iacState = 3; }
else iacState = 0;
break;
case 2: // got IAC + verb, b = option
if (iacVerb === WILL && b === GMCP) {
send(new Uint8Array([IAC, DO, GMCP]));
}
if (b === TELOPT_ECHO) {
if (iacVerb === WILL) setPasswordMode(true);
else if (iacVerb === WONT) setPasswordMode(false);
}
iacState = 0;
break;
case 3: // inside subneg
if (b === IAC) iacState = 4;
else subneg.push(b);
break;
case 4: // IAC inside subneg
if (b === SE) {
handleSubneg(subneg);
iacState = 0;
} else if (b === IAC) {
subneg.push(0xFF);
iacState = 3;
} else iacState = 0;
break;
}
}
// Flush text to terminal
if (textBuf.length > 0) {
writeToTerminal(fixupANSI(new Uint8Array(textBuf)));
textBuf = [];
}
}
function handleSubneg(data) {
if (data.length < 1 || data[0] !== GMCP) return;
// Strip option byte, split "Package.Name {json}"
const payload = new TextDecoder().decode(new Uint8Array(data.slice(1)));
const spaceIdx = payload.indexOf(' ');
if (spaceIdx < 0) return;
const pkg = payload.substring(0, spaceIdx);
const json = JSON.parse(payload.substring(spaceIdx + 1));
switch (pkg) {
case 'Char.Vitals': updateVitals(json); break;
case 'Char.Status': updateStatus(json); break;
case 'Char.Worth': updateWorth(json); break;
case 'Char.Affects': updateAffects(json.affects); break;
case 'Char.Combat': updateCombat(json); break;
case 'Room.Info': updateRoom(json); break;
case 'Room.Chars': updateRoomChars(json); break;
case 'Room.Items': updateRoomItems(json); break;
case 'Map.Tiles': renderMap(json); break;
case 'Group.Info': updateGroup(json); break;
case 'World.Time': updateTime(json); break;
case 'World.Moons': updateMoons(json); break;
case 'Comm.Channel': appendChat(json.channel, json.speaker, json.text); break;
}
}
Mudlet, handler for every package
Mudlet parses GMCP natively. Register event handlers and read from the gmcp table.
-- Vitals
registerAnonymousEventHandler("gmcp.Char.Vitals", function()
local v = gmcp.Char.Vitals
-- v.hp, v.maxhp, v.mana, v.maxmana, v.move, v.maxmove
end)
-- Status
registerAnonymousEventHandler("gmcp.Char.Status", function()
local s = gmcp.Char.Status
-- s.name, s.level, s.race, s.class
end)
-- Worth
registerAnonymousEventHandler("gmcp.Char.Worth", function()
local w = gmcp.Char.Worth
-- w.gold, w.bank, w.exp, w.tnl, w.trains, w.practices, w.cps, w.rps, w.cabal
end)
-- Affects
registerAnonymousEventHandler("gmcp.Char.Affects", function()
for _, aff in ipairs(gmcp.Char.Affects.affects or {}) do
-- aff.kind, aff.name, aff.duration, aff.level, aff.location, aff.modifier
end
end)
-- Combat
registerAnonymousEventHandler("gmcp.Char.Combat", function()
local c = gmcp.Char.Combat
if c.target then
-- fighting: c.target, c.condition, c.hp_pct
else
-- combat ended (empty object)
end
end)
-- Room info
registerAnonymousEventHandler("gmcp.Room.Info", function()
local r = gmcp.Room.Info
-- r.num, r.name, r.area, r.terrain, r.sector, r.region, r.climate, r.exits
centerview(r.num) -- update Mudlet mapper
end)
-- Room characters
registerAnonymousEventHandler("gmcp.Room.Chars", function()
for _, c in ipairs(gmcp.Room.Chars) do
-- c.name, c.npc
end
end)
-- Room items
registerAnonymousEventHandler("gmcp.Room.Items", function()
for _, item in ipairs(gmcp.Room.Items) do
-- item.name, item.type
end
end)
-- Map tiles
registerAnonymousEventHandler("gmcp.Map.Tiles", function()
local m = gmcp.Map.Tiles
-- m.r, m.g, m.zr, m.areas, m.t
end)
-- Group
registerAnonymousEventHandler("gmcp.Group.Info", function()
local g = gmcp.Group.Info
if g.leader then
for _, m in ipairs(g.members) do
-- m.id, m.name, m.level, m.class, m.hp_pct, m.mana_pct, m.move_pct, m.tnl
end
else
-- solo (empty object)
end
end)
-- Time and weather
registerAnonymousEventHandler("gmcp.World.Time", function()
local t = gmcp.World.Time
-- t.hour, t.day, t.month, t.year, t.sunlight, t.sky
end)
-- Moons
registerAnonymousEventHandler("gmcp.World.Moons", function()
local m = gmcp.World.Moons
-- m.moons, m.eclipse, m.triad, m.near_alignment
end)
-- Chat
registerAnonymousEventHandler("gmcp.Comm.Channel", function()
local c = gmcp.Comm.Channel
cecho(string.format("\n[%s] %s: %s", c.channel, c.speaker, c.text))
end)
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++, event handler for every package
tintin++ exposes GMCP through #event.
#event {IAC WILL GMCP} {
#send {$IAC $DO $GMCP\};
}
#event {IAC SB GMCP Char.Vitals IAC SE} {
#var {vitals} {%0};
#showme {HP: $vitals[hp]/$vitals[maxhp] Mana: $vitals[mana]/$vitals[maxmana]};
}
#event {IAC SB GMCP Char.Status IAC SE} { #var {status} {%0}; }
#event {IAC SB GMCP Char.Affects IAC SE} { #var {affects} {%0}; }
#event {IAC SB GMCP Char.Combat IAC SE} { #var {combat} {%0}; }
#event {IAC SB GMCP Char.Worth IAC SE} {
#var {worth} {%0};
#showme {Gold: $worth[gold] Exp: $worth[exp] TNL: $worth[tnl]};
}
#event {IAC SB GMCP Room.Info IAC SE} {
#var {room} {%0};
#showme {Room: $room[name] ($room[terrain])};
}
#event {IAC SB GMCP Room.Chars IAC SE} { #var {roomchars} {%0}; }
#event {IAC SB GMCP Room.Items IAC SE} { #var {roomitems} {%0}; }
#event {IAC SB GMCP Map.Tiles IAC SE} { #var {map} {%0}; }
#event {IAC SB GMCP Group.Info IAC SE} { #var {group} {%0}; }
#event {IAC SB GMCP World.Time IAC SE} {
#var {time} {%0};
#showme {Time: $time[hour]:00 Sky: $time[sky]};
}
#event {IAC SB GMCP World.Moons IAC SE} { #var {moons} {%0}; }
#event {IAC SB GMCP Comm.Channel IAC SE} {
#var {chan} {%0};
#showme {[$chan[channel]] $chan[speaker]: $chan[text]};
}
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 applies the normal can_see() check. Your client only sees what your character sees. Group.Info is the exception: it always names your own group members (you are grouped with them, so their identity is not hidden), so blindness does not collapse the roster to someone.
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.
Bare ANSI sequences
16-color codes arrive without the ESC byte. Terminal emulators need the fixup or they print [0;31m as literal text.
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.
Aabahran