diff --git a/docs/how/protocols.rst b/docs/how/protocols.rst
new file mode 100644
index 0000000..d320bd4
--- /dev/null
+++ b/docs/how/protocols.rst
@@ -0,0 +1,169 @@
+protocol negotiation — GMCP, MSDP, and client detection
+========================================================
+
+how the server offers GMCP and MSDP to connecting clients, how clients
+accept or reject them, and how the game guards against sending data to
+clients that don't support it.
+
+see also: ``mud.tin`` (tintin++ client config), ``docs/lessons/charset-vs-mtts.txt``
+(related telnet negotiation gotcha).
+
+
+the lifecycle
+-------------
+
+1. client connects via telnet
+2. telnetlib3 calls ``begin_negotiation()`` on our ``MudTelnetServer``
+3. server sends ``IAC WILL GMCP`` and ``IAC WILL MSDP``
+4. client replies ``IAC DO `` (accept) or ``IAC DONT `` (reject)
+5. telnetlib3 updates its internal option state tables
+6. ``Player.gmcp_enabled`` / ``Player.msdp_enabled`` check those tables
+7. every send function guards on the relevant property before transmitting
+
+all of this happens during the telnet handshake, before the player sees the
+name prompt. by the time game code runs, the protocol state is settled.
+
+
+server side — MudTelnetServer
+-----------------------------
+
+``server.py`` subclasses telnetlib3's ``TelnetServer``::
+
+ class MudTelnetServer(TelnetServer):
+ def begin_negotiation(self) -> None:
+ super().begin_negotiation()
+ self.writer.iac(WILL, GMCP)
+ self.writer.iac(WILL, MSDP)
+
+``begin_negotiation()`` is called once by telnetlib3 right after the TCP
+connection is established. the parent handles baseline telnet options (TTYPE,
+NAWS, etc). we add GMCP and MSDP offers on top.
+
+there's no ``begin_advanced_negotiation()`` override — GMCP/MSDP offers go
+in the initial phase because clients need to know about them before the game
+starts sending data.
+
+
+player properties
+-----------------
+
+``player.py`` exposes two boolean properties::
+
+ @property
+ def gmcp_enabled(self) -> bool:
+ if self.writer is None:
+ return False
+ return bool(
+ self.writer.local_option.enabled(GMCP)
+ or self.writer.remote_option.enabled(GMCP)
+ )
+
+checks both ``local_option`` and ``remote_option`` because telnet negotiation
+is symmetric — either side can initiate. ``local_option`` reflects our ``WILL``
+offer being accepted (client sent ``DO``). ``remote_option`` would reflect a
+client-initiated ``DO`` that we accepted with ``WILL``. in practice the server
+always offers first, but checking both is correct per the telnet spec.
+
+same pattern for ``msdp_enabled``.
+
+
+the guard pattern
+-----------------
+
+every function in ``gmcp.py`` that sends data checks the property first::
+
+ def send_char_vitals(player: Player) -> None:
+ if not player.gmcp_enabled:
+ return
+ player.send_gmcp("Char.Vitals", { ... })
+
+ def send_msdp_vitals(player: Player) -> None:
+ if not player.msdp_enabled:
+ return
+ player.send_msdp("HEALTH", ...)
+
+this is the only guard needed. the game loop calls these functions
+unconditionally for all players; the functions themselves decide whether to
+actually send. simple clients that don't negotiate GMCP/MSDP just never
+receive the data.
+
+
+what gets sent over each protocol
+---------------------------------
+
+**GMCP** (Generic MUD Communication Protocol) — structured JSON packages:
+
+- ``Char.Vitals`` — hp, max_hp, stamina, max_stamina
+- ``Char.Status`` — name, level, location name
+- ``Room.Info`` — room id, coordinates, exits
+- ``Room.Map`` — ASCII map fragment for client-side rendering
+
+sent on-demand when state changes (room enter, damage taken, etc).
+
+**MSDP** (MUD Server Data Protocol) — key/value variable updates:
+
+- ``HEALTH``, ``HEALTH_MAX``, ``STAMINA``, ``STAMINA_MAX``
+
+sent on a timer, once per second (every ``TICK_RATE`` ticks in the game loop).
+intended for status bar gauges.
+
+
+client side — tintin++ example
+------------------------------
+
+tintin++ does NOT auto-negotiate GMCP or MSDP. it sends ``IAC DONT`` by
+default, rejecting the server's offer. the ``CATCH`` event intercepts the
+incoming ``IAC WILL`` before tintin's default handler runs::
+
+ #EVENT {CATCH IAC WILL GMCP} {#send {\xFF\xFD\xC9\}}
+ #EVENT {CATCH IAC WILL MSDP} {#send {\xFF\xFD\x45\}}
+
+the hex bytes:
+
+- ``\xFF\xFD\xC9`` = ``IAC DO GMCP`` (255, 253, 201)
+- ``\xFF\xFD\x45`` = ``IAC DO MSDP`` (255, 253, 69)
+
+MSDP variables arrive as subnegotiation events and get stored in tintin
+variables for the status bar::
+
+ #EVENT {IAC SB MSDP} {
+ #variable {MSDP_%0} {%1};
+ #showme {[HP: $MSDP_HEALTH/$MSDP_HEALTH_MAX] ...} {-2}
+ }
+
+``{-2}`` puts the status bar on the split line at the bottom of the terminal.
+
+this was the trickiest part of the integration — the CATCH mechanism is
+not well-documented in tintin++ and the raw byte sending syntax changed
+across versions.
+
+
+the client command
+------------------
+
+players can type ``client`` in-game to see what got negotiated::
+
+ client protocols
+ GMCP: active
+ MSDP: active
+ terminal
+ type: xterm-256color
+ size: 120x40
+ color: 256
+ ...
+
+useful for debugging connection issues — if a player reports missing status
+bars, ``client`` shows whether MSDP actually negotiated.
+
+
+adding a protocol
+-----------------
+
+to add support for another telnet option:
+
+1. import the option byte constant (or define it)
+2. add ``self.writer.iac(WILL, NEW_OPTION)`` in ``MudTelnetServer.begin_negotiation()``
+3. add a ``new_option_enabled`` property on ``Player``
+4. guard all send functions on that property
+5. add it to the ``client`` command output
+6. add CATCH handler in ``mud.tin`` if tintin++ doesn't auto-negotiate it
diff --git a/docs/index.rst b/docs/index.rst
index 0886427..44f8f4a 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -27,6 +27,7 @@ mostly standalone subsystems.
- ``docs/how/terrain-generation.txt`` — perlin noise, elevation thresholds, river tracing
- ``docs/how/persistence.txt`` — SQLite storage, what's persisted vs runtime
- ``docs/how/prompt-system.txt`` — modal prompts, color markup, per-player customization
+- ``docs/how/protocols.rst`` — GMCP/MSDP negotiation, client detection, guard pattern
combat
------