# Forsaken Lands GMCP Specification
Client-side reference for what the server emits on the wire. Field names, types, emit timing, and escaping rules.
{toc}
---
## 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:
```json
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:**
```json
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:**
```json
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:**
```json
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:**
```json
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):**
```json
Char.Combat {"target":"Durim","condition":"quite a few wounds","hp_pct":54}
```
**Shape (not fighting):**
```json
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:**
```json
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:**
```json
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:**
```json
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:**
```json
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
```json
{
"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)
```json
{"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`
```json
{
"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):**
```json
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):**
```json
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:**
```json
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:**
```json
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):**
```json
Comm.Channel {"channel":"say","speaker":"Durim","text":"Hello there."}
```
**Extended shape (say, yell, tell, gtell add language and direction metadata):**
```json
Comm.Channel {
"channel":"say",
"speaker":"Durim",
"text":"xak zee nog",
"language":"foreign",
"understood":false
}
```
```json
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.
**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](#iac-state-machine), echo toggle, and package dispatch. `send`, `writeToTerminal`, `fixupANSI`, `setPasswordMode`, and the `update*` functions are yours to supply.
```javascript
// 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.
```lua
-- 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
```lua
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
```lua
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
```lua
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
```lua
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
```lua
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.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` 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](#ansi-color-codes) 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:
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.