GMCP Reference

Audience: Engine Developer (also load-bearing for Player Reference / Mudlet Samples) Status: ✅ Ready

GMCP (Generic Mud Communication Protocol) is how rich clients — Mudlet, custom web clients — receive structured game state alongside the text stream: HP/mana gauges, a minimap, an inventory list, channel data. It rides telnet option 201, framed as IAC SB 201 <package> SP <json> IAC SE. This page is the reference for the base package surface TelosMUD emits and how content extends it.

Related: Edge & Protocol (the telnet/Play plumbing), Mudlet Samples (client recipes), Lua Sandbox Internals (gmcp.send).

Three emitters, three layers

Layer File Responsibility
Codec / framing internal/telnet/gmcp.go OfferGMCP (WILL 201), inbound parse (cap 8 KiB, fail-closed), WriteGMCP (no-op until the client enables GMCP, caps at 1 MiB — sheds rather than errors). Knows nothing of package semantics.
Core.* authority + outbound filter internal/gate/gmcp.go Tracks the client’s advertised supports set; supported(pkg) matches a package or any ancestor (“Char” implies “Char.Vitals”). Handles inbound Core.* locally, forwards a small whitelist of client requests (Char.Items.Contents) to the world, and drops everything else. The outbound filter emits a package only if the client advertised it.
HUD / payload builder internal/world/gmcp.go Builds the semantic GmcpOut frames alongside the prompt, unconditionally (the gate filters). Change-detection via per-session last* buffers avoids re-emitting identical payloads.

Every display name funnels through gmcpText = NeutralizeBidi(colormarkup.Strip(...)) — it strips {{TOKEN}} color markup (a rich client would otherwise show literal braces) and neutralizes Trojan-Source bidi, because the GMCP path bypasses the telnet output sanitizer.

Wire framing

IAC(255) SB(250) 201 <package-bytes> SP <json-bytes> IAC SE(240)

Any 0xFF in the payload is doubled to IAC IAC. A data-less message omits the space and payload (e.g. a Core.Ping reply). Inbound, an IAC inside the body is the terminator only when followed by SE; IAC IAC is an escaped literal.

Base package surface (what is actually emitted)

Core (gate-owned, inbound)

Package Direction Carries
Core.Hello in {client, version} — client name sanitized, stored for logging only
Core.Supports.Set / .Add / .Remove in a JSON array of "Package Version" strings; mutates the advertised set (cap 256 entries)
Core.Ping in → out latency echo; the gate replies with its own data-less Core.Ping

The gate never sends Core.Hello, and there is no Core.Goodbye or server-initiated Core.Ping timer — Core.Ping is reply-only.

Char (world-owned)

Char HUD frames are built alongside the prompt and are change-detected against per-session buffers — an identical payload is never re-sent. With vitals on, the live HUD (Char.Vitals + Char.Status + Char.Stats) is additionally pushed on two cadences: at each combat-round boundary (with a fresh text prompt), and on a throttled ~2 s non-combat pulse (no prompt) — so a rich client’s gauges track passive regen and out-of-combat changes, not only at prompts.

Package Carries / when
Char.Vitals content-defined resource pools {"<ref>": cur, "max<ref>": max} filtered to the HUD gauge set, and skipping any pool this character has no capacity in (max <= 0) — so an opt-in pool a character doesn’t have is absent rather than reported as 0/0; change-detected (see the live-HUD cadences above)
Char.Stats content attributes flagged stat: true → resolved value; on change
Char.Status {state, target?} where state ∈ standing/fighting/dead, target = canSee-filtered opponent name; on change
Char.Items.List full {location, items[]} snapshot. location is "inv" or "room" on the first emit per location (login / reconnect / handoff arrival) — or a container id when it answers a Char.Items.Contents request (see Inbound requests)
Char.Items.Add / .Update / .Remove incremental deltas thereafter, keyed by stable item id (i<runtimeID> for a singleton, g<hash> for a coalesced group — a count change is a same-id Update). attrib flags: w=wearable, c=container, W=worn

Room (world-owned)

Package Carries / when
Room.Info {num, name, zone, environment?, coord?, exits{dir→num}}. num is a stateless FNV-1a hash of the ProtoRef, so a minimap stays consistent across shards and restarts. On room change.
Room.Players visible creature occupants (players + mobs), each {id, name, type}; canSee-filtered, viewer excluded, ground items excluded (those ride Char.Items). On change.

Comm (gate-owned)

Package Carries / when
Comm.Channel.List sorted array of the player’s usable channel refs (enabled ∩ hearable); on every config apply (login, handoff arrival, hear-set change)
Comm.Channel.Text {channel, talker, text, msg} mirror of a delivered channel line (text = rendered, msg = raw); bidi-neutralized + color-stripped
Comm.Channel.Players {channel, players[]} — the players currently listening on a channel, aggregated cross-shard by the leader director (poll-and-diff; concealed players are omitted, matching the who rule). The gate subscribes a channel’s roster only for channels the player hears
Comm.Status {"available": true\|false} on a mid-session comms bus up/down transition

Inbound requests (client → world)

Almost all of GMCP is server-push, but a rich client can also ask the world for data. Char.Items.Contents is the first — and currently only — such request: it opens a container’s contents panel by sending the container’s GMCP item id.

sequenceDiagram
    participant C as rich client
    participant G as telos-gate
    participant W as telos-world zone
    C->>G: Char.Items.Contents request (container id)
    G->>G: whitelist check (forwardableGMCP)
    G->>W: forward as ClientFrame GmcpIn
    W->>W: route to zone, rate-limit, reach-scope resolve
    W-->>G: Char.Items.List reply (keyed to the container id)
    G-->>C: rendered as a container panel

Because this is client→world input, it is treated as hostile and guarded three ways:

  • Gate whitelist — the gate forwards only the request packages it knows (forwardableGMCP, today just Char.Items.Contents); Core.* is handled locally and everything else is dropped, so a client can never push arbitrary GMCP into the world.
  • Per-session rate limit — a request forces O(container) work on the shared zone goroutine, so each session carries a token bucket (~5 requests/sec, burst 10); an over-budget flood is silently dropped, and the payload is capped at 4 KiB at the world ingress.
  • Reach scope — the named container is resolved only within the requester’s own reach (their inventory plus their visible room floor, dark-room/canSee-filtered). A guessed or stale id can’t peek into another player’s container or anything they couldn’t already see; a closed container, or a corpse still inside its loot-ownership window, silently yields nothing (never revealing whether such an entity exists).

The reply is an ordinary Char.Items.List push frame keyed to the container’s id, so the same client-side handler that renders the inventory and room lists renders the panel.

Extending GMCP from content

Content Lua emits custom frames with gmcp.send(player, package[, table]) (internal/world/luagmcp.go). Three fail-closed guards make this safe against untrusted content:

  1. Namespace allowlist (load-bearing): only the Mud.* namespace is sanctioned. Content cannot spoof Core.* / Char.* / Room.* / Comm.* to feed a client false HUD- or auth-shaped data. This is enforced at the source, because the gate’s outbound filter only checks advertisement, not provenance.
  2. Charset + length: the package name must be alphanumeric + ., ≤ 64 chars, no edge dots.
  3. Bounded encoding: depth ≤ 16, ≤ 4096 nodes, ≤ 6 KiB (deliberately tighter than the gate’s 1 MiB so a passing frame is never gate-dropped); functions/userdata/cycles are rejected; string leaves are bidi-neutralized.

The frame then rides the normal send path, so the gate’s outbound support filter still applies — a client that never advertised Mud.* stays silent. A builder therefore designs a client-side handler for their Mud.<Something> package and emits it from Lua; see Mudlet Samples for a worked tab-complete recipe built on Char.Items plus a custom event.