# 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.
Once GMCP is on, every package fires at its trigger. Discard packets you don't want on the client side. The one exception is the `Chart` packages, which stay off until the client opts in with `Core.Supports.Set ["Chart 1"]`.
### 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.
---
### `Char.Needs`
Hunger and thirst.
**Shape:**
```json
Char.Needs {"hunger":12,"thirst":-7,"hunger_state":"ok","thirst_state":"parched","harm":true}
```
**Fields:**
| Field | Type | Notes |
|---|---|---|
| `hunger` | int | -24 to 48. Higher is better fed. `-99` means the character never gets hungry. |
| `thirst` | int | same scale. `-99` means the character never gets thirsty. |
| `hunger_state` | string | `"full"` (41 to 48), `"ok"` (6 to 40), `"hungry"` (-4 to 5), `"starving"` (-18 to -5), `"dying"` (-24 to -19), `"immune"` (-99) |
| `thirst_state` | string | `"full"`, `"ok"`, `"thirsty"`, `"parched"`, `"dying"`, or `"immune"` at the same thresholds |
| `harm` | bool | `true` when hunger or thirst at 0 or below hurts this character. That covers mortal PK characters, and adventurers above level 10 who are not ghosts. `false` at level 51 and up, while fasting, magically asleep, in ecstasy, in catalepsy or turned to stone, and in a room that pauses hunger. The game also holds the damage back while a character is idle, and `harm` does not track that. |
The states match the game's own messages. "You are hungry." at 5 or below, "You are starving." at -5 or below, "You are starving to death!" at -19 or below. Thirst uses "You are thirsty.", "You are parched.", and "You are dying of thirst!" at the same values.
**Emitted on:** login, and on a prompt when any field changed.
**Usage:** food and water meters. Warn the player before hunger or thirst starts to hurt.
---
### `Char.Stats`
Stats, hitroll and damroll, armor class and saves, as the score sheet shows them. Luck goes out as the bar `score` draws, never as a number.
**Shape:**
```json
Char.Stats {
"str": {"cur":22,"perm":20,"max":23,"over":1},
"int": {"cur":18,"perm":18,"max":20,"over":0},
"wis": {"cur":17,"perm":17,"max":19,"over":0},
"dex": {"cur":21,"perm":20,"max":22,"over":0},
"con": {"cur":23,"perm":21,"max":23,"over":2},
"luck": {"bar":4,"of":6},
"hitroll": 31,
"hitroll_pct": 90,
"damroll": 27,
"ac": {"slash":-310,"pierce":-295,"blunt":-280,"magic":-150},
"ac_pct": {"slash":71,"pierce":72,"blunt":74,"magic":94},
"saves": {"spell":-12,"afflictive":-3,"maledictive":0,"mental":-5,"breath":0}
}
```
**Fields:**
| Field | Type | Notes |
|---|---|---|
| `str`, `int`, `wis`, `dex`, `con` | object | `cur` is the stat in use (3 to `max`), `perm` the trained stat (never above `max`), `max` the limit for the race and class, `over` how far gear and spells push past `max`. The score sheet shows `over` as `^N`. |
| `luck` | object | `bar` is how many of the `of` (6) steps of the score sheet's luck bar are lit. The exact luck value is not sent. |
| `hitroll` | int | hitroll as `score` shows it |
| `hitroll_pct` | int | the share of hitroll that counts, the percent beside it on the score sheet |
| `damroll` | int | damroll as `score` shows it |
| `ac` | object | armor class for `slash`, `pierce`, `blunt`, and `magic`. Lower is better. |
| `ac_pct` | object | the effectiveness percent `score` shows beside each armor class |
| `saves` | object | `spell`, `afflictive`, `maledictive`, `mental`, and `breath`. Lower is better, and each point below zero adds 1% to the chance to save. |
**Emitted on:** login, and on a prompt when any number changed.
**Usage:** a stats panel that stays current without the player typing `score`.
---
### `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.
---
### `Chart.Open` and `Chart.Guide`
Chart items (maps the player reads) and the `directions` walkthrough. Both are opt-in. Send `Core.Supports.Set ["Chart 1"]` and the server hands the drawing to your client instead of printing it. The game then prints only the sentence about where the player is and the list of what is around.
**Chart.Open fields:**
| Field | Type | Notes |
|---|---|---|
| `rows` | array | the map as a grid of tokens. Each room carries its sector in `ground`. |
| `here` | object | the player's square on the grid |
| `where` | string | the sentence about where the player is |
| `legend` | object | token to meaning, for rendering the grid |
| `places` | array | up to 12 places, each with the walk to it |
**Chart.Guide shape (guiding):**
```json
Chart.Guide {"next":"east","left":4,"name":"the Temple of Thanatos"}
```
**Chart.Guide shape (stopped):**
```json
Chart.Guide {}
```
**Chart.Guide fields:**
| Field | Type | Notes |
|---|---|---|
| `next` | string | the direction to walk next |
| `left` | int | steps remaining |
| `name` | string | the destination |
**Emitted on:** `Chart.Open` when the player reads a chart item. `Chart.Guide` on `directions` and after each step while the walk runs, then `{}` when it stops.
**Usage:** render the map in a panel and list the places with their walks. Treat the empty `Chart.Guide` object as the "clear the guide arrow" signal.
**Full field list:** see `gmcp-spec.md`.
---
### `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 |
| `Char.Needs` | login, prompt after a change | hunger and thirst |
| `Char.Stats` | login, prompt after a change | stats, luck bar, hitroll, damroll, armor class, saves |
| `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 |
| `Chart.Open` | reading a chart item (opt in with `Chart 1`) | a whole map with the player's square and walks to places |
| `Chart.Guide` | `directions`, each step after, and `{}` when it stops | the next direction while a map guides the player |
| `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 'Char.Needs': updateNeeds(json); break;
case 'Char.Stats': updateStats(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)
-- Hunger and thirst
registerAnonymousEventHandler("gmcp.Char.Needs", function()
local n = gmcp.Char.Needs
-- n.hunger, n.thirst, n.hunger_state, n.thirst_state, n.harm
end)
-- Score sheet numbers
registerAnonymousEventHandler("gmcp.Char.Stats", function()
local s = gmcp.Char.Stats
-- s.str/int/wis/dex/con each have cur, perm, max, over
-- s.luck.bar of s.luck.of, s.hitroll, s.hitroll_pct, s.damroll
-- s.ac, s.ac_pct, s.saves
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, opt in to the Chart packages
Charts and the `directions` guide stay off until you ask for them. Send the opt-in once per session, then handle the two packages.
```lua
registerAnonymousEventHandler("sysConnectionEvent", function()
sendGMCP([[Core.Supports.Set ["Chart 1"]]])
end)
registerAnonymousEventHandler("gmcp.Chart.Open", function()
local c = gmcp.Chart.Open
-- c.rows, c.here, c.where, c.legend, c.places
drawChart(c)
end)
registerAnonymousEventHandler("gmcp.Chart.Guide", function()
local g = gmcp.Chart.Guide
if g.next then
guideArrow:echo(string.format("%s, %d left (%s)", g.next, g.left, g.name))
else
guideArrow:clear()
end
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.Needs IAC SE} { #var {needs} {%0}; }
#event {IAC SB GMCP Char.Stats IAC SE} { #var {stats} {%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.