Move notes to docs/how, docs/why, docs/lessons

how/  - how things work (terrain generation, command system)
why/  - design philosophy (telnet-first, text worlds)
lessons/ - things we learned the hard way (charset vs mtts)

Removes notes/ — DAYDREAMING.txt became DREAMBOOK.md, charset-vs-mtts
expanded into docs/lessons/ with the connect_maxwait fix documented.
This commit is contained in:
Jared Miller 2026-02-07 13:29:39 -05:00
parent 0d0c142993
commit 25bb565091
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
8 changed files with 273 additions and 226 deletions

View file

@ -14,9 +14,29 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv.
- `src/mudlib/` - the engine (commands, world, combat, render, store)
- `tests/` - pytest tests
- `worlds/` - world definition files (yaml/toml, version controlled)
- `notes/` - design docs, daydreaming
- `docs/` - project knowledge (see below)
- `DREAMBOOK.md` - the vision, philosophy, wild ideas. not a spec
- `repos/` - symlinked reference repos (telnetlib3, miniboa). gitignored, not our code
## Docs
Three categories in `docs/`. Plain text, not markdown.
- `docs/how/` - how things work. write one when you build something non-obvious.
terrain generation, command system, etc. aimed at someone reading the code
who wants the "why did we do it this way" context.
- `docs/why/` - design philosophy. telnet-first, text worlds, etc. the reasoning
behind big decisions. doesn't change often.
- `docs/lessons/` - things we learned the hard way. write one when you hit a
real bug or gotcha that cost time. charset-vs-mtts, etc. include the fix so
we don't repeat it.
Update docs when:
- you build a new system (add a how/)
- you make a design decision worth explaining (add a why/)
- you debug something painful (add a lessons/)
- existing docs become wrong (update them)
## Architecture
- telnetlib3 is a **dependency**, not vendored. contribute fixes upstream

58
docs/how/commands.txt Normal file
View file

@ -0,0 +1,58 @@
command system
==============
commands are registered in a simple dict mapping names to async handlers.
async def my_command(player: Player, args: str) -> None:
player.writer.write("hello\r\n")
await player.writer.drain()
register("mycommand", my_command, aliases=["mc"])
dispatch parses input, looks up the handler, calls it:
await dispatch(player, "mycommand some args")
if the command isn't found, the player gets "Unknown command: ..."
movement
--------
8 directions, each with short and long aliases:
n/north s/south e/east w/west
ne/northeast nw/northwest se/southeast sw/southwest
movement checks passability before updating position. impassable terrain
gives "You can't go that way."
nearby players see arrival/departure messages:
jared leaves east.
jared arrives from the west.
"nearby" = within viewport range (10 tiles) of old or new position.
look
----
look/l renders the viewport. player is always @ at center. other players
show as *. output is ANSI-colored.
adding commands
---------------
1. create src/mudlib/commands/yourcommand.py
2. import register from mudlib.commands
3. define async handler(player, args)
4. call register() with name and aliases
5. import the module in server.py so registration runs at startup
code
----
src/mudlib/commands/__init__.py registry + dispatch
src/mudlib/commands/movement.py direction commands
src/mudlib/commands/look.py look/l
src/mudlib/commands/quit.py quit/q
src/mudlib/player.py Player dataclass + registry

View file

@ -0,0 +1,64 @@
terrain generation
==================
the world is a 2D grid of tiles generated deterministically from a seed.
same seed = same world, always.
how it works
------------
1. generate a permutation table from the seed (shuffled 0-255)
2. compute elevation at 1/4 resolution using layered Perlin noise (3 octaves)
3. normalize elevation to [0, 1]
4. bilinear interpolate up to full resolution
5. derive terrain from elevation thresholds:
> 0.75 mountain ^
> 0.55 forest T
> 0.25 grass .
> 0.15 sand :
<= 0.15 water ~
6. trace rivers from random high-elevation points downhill to water
the 1/4 resolution trick is why generation takes ~1s instead of ~9s for a
1000x1000 map. terrain features are large enough that interpolation doesn't
lose visible detail.
geography emerges naturally from noise: mountain ranges form along ridges,
forests cluster at foothills, sand appears at shorelines, oceans fill low
basins. rivers connect highlands to water bodies.
world config lives in worlds/<name>/config.toml:
[world]
name = "Earth"
seed = 42
width = 1000
height = 1000
rendering
---------
viewport is centered on the player (@). size comes from NAWS (terminal
dimensions) — eventually. for now it's a fixed 21x11.
ANSI colors per terrain type:
. grass green
^ mountain dark gray
~ water blue
T forest green
: sand yellow
@ player bold white
* entity bold red
passability
-----------
mountains and water are impassable. forest, grass, sand are walkable.
water will eventually have shallow/deep variants (wadeable vs boat-only).
code
----
src/mudlib/world/terrain.py world generation + noise
src/mudlib/render/ansi.py ANSI color mapping
worlds/earth/config.toml earth config

View file

@ -0,0 +1,72 @@
charset negotiation vs mtts: why tintin++ says WILL then REJECTED
=================================================================
the problem
-----------
telnetlib3 defaults to connect_maxwait=4.0, waiting for CHARSET encoding
negotiation to resolve. MUD clients like tintin++ reject CHARSET but advertise
encoding support through MTTS instead. result: 4 seconds of dead air on every
connection.
the fix (our side)
------------------
pass connect_maxwait=0.5 to create_server(). the actual option negotiation
finishes in ~500ms. CHARSET gets rejected almost immediately but telnetlib3
doesn't have a fallback path — it just waits for the timeout. 0.5s is plenty.
server = await telnetlib3.create_server(
host="127.0.0.1", port=PORT, shell=shell, connect_maxwait=0.5
)
the upstream fix (telnetlib3)
-----------------------------
when CHARSET is rejected, telnetlib3 could check MTTS bit 3 for UTF-8 support
and resolve encoding immediately instead of running out the clock. this would
be a good contribution. the data is already there:
MTTS 2825 → bit 3 set → client supports UTF-8
what happens on the wire
------------------------
telnetlib3 sends DO CHARSET. tintin++ responds WILL CHARSET ("i know what that
is") then immediately REJECTED ("but i don't want to use it"). this is normal
for MUD clients — they prefer MTTS.
+0.000 server send DO CHARSET
+0.100 client recv WILL CHARSET
+0.100 server send SB CHARSET REQUEST UTF-8 ... US-ASCII
+0.200 client recv SB CHARSET REJECTED
meanwhile, in the same negotiation, tintin++ already told us via MTTS:
ttype3: MTTS 2825
bit 0 (1) = ANSI color
bit 3 (8) = UTF-8
bit 8 (256) = 256 colors
bit 9 (512) = OSC color palette
bit 11 (2048) = true color
two protocols, one answer. MTTS (via TTYPE round 3) is what the MUD ecosystem
uses. RFC 2066 CHARSET is technically correct but practically ignored.
will echo and mud clients
-------------------------
related discovery: telnetlib3 intentionally skips WILL ECHO for detected MUD
clients. tintin++ is in the MUD_TERMINALS fingerprint list. MUD clients
interpret WILL ECHO as "password mode" and mask input, so the server avoids
sending it. the client handles its own local echo.
this means will_echo is always False for tintin++, and that's correct. don't
try to fight it or wait for it.
see also
--------
- MTTS spec: https://tintin.mudhalla.net/protocols/mtts/
- RFC 2066: CHARSET option
- telnetlib3 source: server.py _negotiate_echo(), fingerprinting.py _is_maybe_mud()

32
docs/why/telnet-first.txt Normal file
View file

@ -0,0 +1,32 @@
why telnet first
================
from the dreambook: "we honor telnet. the protocol is the interface."
telnet is the native interface. a person with a terminal emulator gets the full
experience. no browser, no client download. `telnet mud.example.com 6789` and
you're in.
telnetlib3 gives us:
- NAWS (terminal dimensions — adapt viewport to window size)
- TTYPE/MTTS (client capability detection)
- GMCP/MSDP (structured data for rich clients like Mudlet)
- SGA (suppress go-ahead for modern line handling)
- async server with reader/writer streams
the MUD ecosystem has decades of protocol extensions built on telnet. we get
all of them for free by staying in the protocol.
a web client, when it comes, should just be a terminal emulator (xterm.js)
pointing at the telnet port. same protocol, different transport.
design consequences
-------------------
- all output is text + ANSI escape codes. no binary formats.
- line endings are \r\n (telnet NVT standard)
- input is line-based (readline). no raw keystroke handling (yet)
- server-side echo is complicated — MUD clients handle their own echo
(see docs/lessons/charset-vs-mtts.txt for the will_echo story)
- NAWS tells us the terminal size. viewport adapts. small terminal = smaller
map view. this is a feature, not a limitation.

26
docs/why/text-worlds.txt Normal file
View file

@ -0,0 +1,26 @@
why text
========
from the dreambook, drawing on david jacobson's research at brandeis:
narrow bandwidth doesn't impoverish experience, it enriches imagination. when
you read "a torch flickers against wet stone walls" your brain builds a
cathedral. VR hands you someone else's cathedral.
text worlds are collaborative hallucinations between the author and the reader.
the MOO community proved this for decades. LambdaMOO, MediaMOO, PMC-MOO —
people formed real relationships, built real communities, lived real social
lives through nothing but text on a terminal.
presence isn't about polygons. presence is about engagement, attention, the
feeling that you are *there* and that what happens *matters*.
what the research showed
------------------------
- presence comes from engagement, not fidelity
- identity is constructed through language (description, emotes, speech)
- community forms through shared space and shared creation
- the mask enables authenticity — removing physical cues lets people construct
identity more deliberately. that's not deception, it's freedom.

View file

@ -1,64 +0,0 @@
i like to imagine blending concepts with a MUD, interactive fiction, text
based adventures.
so in one part it's moving around like you are in a mud, room names, short
description perhaps, long description perhaps, and a little ascii
representation that perhaps renders on the left or right or above or beneath
room description and available exits
however, i love what trenton gave us. the ascii worlds that update on
movement/look and have you centered in them
i love the math of the combat, you could understand damage with percentages as
a function of your powerlevel, which was also your health. stamina emptying
also had you passing out, iirc. tbh i dont entirely remember how it worked. im
actually quite fuzzy on it atm
let me type some of the combat commands
punch right/left [target, not needed if u have an active target aka in combat]
roundhouse [target]
dodge right/left
duck
jump
parry high/low
you might see:
jake arrives from the west
jake pulls back his right fist
this informs you that you had better parry high or dodge left
parry high was roughly needed.. in a small window.. not RIGHT away, and not too
late
all commands took time so you could just parry high .9 seconds oto early and
then instnatly fire it agtain, yhou had to fail the parry first or something
we will honestly have to tweak all tthese timings
---
so i said we're on an ascii map, that was earth
you could travel around earth and find mobs to find
if your powerlevel was 100
and you fought a mob that had the same pl let us say, you could earn no mopre
than 10 pl from then, 10%. but if you were 1000pl and killed a 100pl, you
wouldnt gain 10%, the function doews account your actual power level too
(diminishign returns i guess idk, means yhou gotta move on and fight more
p[owerful mobs)
what i really want to think about though isnt all these details from ONE dbz
mud i played
but, the.. framework to suport:
ascii mobs, combat, that kinda stuff
tweaking it, expanding it, etc

View file

@ -1,161 +0,0 @@
charset negotiation vs mtts: why tintin++ says WILL then REJECTED
=================================================================
the problem
-----------
when connecting to a telnetlib3 server, there's a 4-second delay before the
shell starts. the server is waiting for encoding to resolve. it never does.
what the logs show
------------------
the full option negotiation between telnetlib3 and tintin++ takes about 500ms.
everything resolves quickly - TTYPE, SGA, NAWS, NEW_ENVIRON. but CHARSET
negotiation goes like this:
26.220 server sends DO CHARSET
26.332 tintin responds WILL CHARSET ("i can do charset negotiation")
26.332 server sends SB CHARSET REQUEST UTF-8 UTF-16 LATIN1 ... US-ASCII
26.442 tintin responds SB CHARSET REJECTED
tintin++ said "i support charset negotiation" and then rejected a list of 16
charsets including UTF-8 and US-ASCII. every charset the server knows about.
the server marks encoding as unresolved, waits for connect_maxwait (4.0s),
gives up, falls back to US-ASCII, and finally starts the shell.
30.122 server "encoding failed after 4.00s: US-ASCII"
30.122 server "negotiation complete after 4.00s."
so the actual work is done in 500ms. the remaining 3.5 seconds is dead air.
but tintin++ already told us the answer
----------------------------------------
in the same connection, tintin++ sent this through NEW_ENVIRON:
CHARSET=ASCII
CLIENT_NAME=TinTin++
CLIENT_VERSION=2.02.61
MTTS=2825
TERMINAL_TYPE=tmux-256color
and through TTYPE cycling (3 rounds):
ttype1: TINTIN++
ttype2: tmux-256color
ttype3: MTTS 2825
MTTS is the MUD Terminal Type Standard, a bitmask the MUD community created
for advertising client capabilities. 2825 in binary:
2825 = 2048 + 512 + 256 + 8 + 1
bit 0 (1) = ANSI color
bit 3 (8) = UTF-8
bit 8 (256) = 256 colors
bit 9 (512) = OSC color palette
bit 11 (2048) = true color
bit 3 is set. tintin++ explicitly advertises UTF-8 support through MTTS.
two standards, one winner
-------------------------
RFC 2066 CHARSET (1997) is a proper telnet standard. it defines a full
negotiation dance:
1. one side sends DO CHARSET, other responds WILL CHARSET
2. requester sends SB CHARSET REQUEST <sep> <charset-1> <sep> <charset-2> ...
3. responder either ACCEPTED <charset> or REJECTED
this is thorough but cumbersome. the MUD community needed something simpler.
MTTS piggybacks on the TTYPE mechanism that already existed. instead of adding
a new option negotiation, it uses the third round of TTYPE cycling to send a
bitfield encoding all client capabilities in a single integer. no extra
round-trips, no subnegotiation, works with any server that already does TTYPE.
round 1: client name "TINTIN++"
round 2: terminal type "tmux-256color"
round 3: MTTS bitfield "MTTS 2825"
the MUD world settled on MTTS. clients implement it properly. RFC 2066 CHARSET
gets a polite WILL ("sure i know what that option is") followed by REJECTED
("but i don't actually want to use it"). tintin++ is not alone in this - most
MUD clients behave the same way.
what telnetlib3 could do better
-------------------------------
right now telnetlib3 waits for CHARSET to resolve encoding. when it gets
REJECTED, it has no fallback path except running out the clock. but by the time
CHARSET is rejected (26.442), the server already has:
- MTTS=2825 via NEW_ENVIRON (26.595) or will have it momentarily
- MTTS 2825 via TTYPE round 3 (26.595)
if the server checked MTTS bit 3 after CHARSET rejection, it could resolve
encoding to UTF-8 immediately and start the shell in under 600ms instead of
waiting the full 4 seconds.
this would be a good upstream contribution to telnetlib3: when CHARSET is
rejected, check MTTS flags for encoding hints before falling back to the
timeout.
the full annotated session
--------------------------
timestamps are seconds within the connection. total negotiation takes ~477ms.
the 4-second mark is the connect_maxwait timeout.
+0.000 server Connection from <Peer 127.0.0.1 51910>
-- phase 1: TTYPE discovery --
+0.000 server send IAC DO TTYPE
+0.101 tintin recv IAC WILL TTYPE
+0.101 server send IAC SB TTYPE SEND IAC SE (ask for ttype1)
-- phase 2: advanced negotiation begins after WILL TTYPE --
+0.102 server send IAC WILL SGA
+0.102 server send IAC WILL BINARY
+0.102 server send IAC DO NAWS
+0.102 server send IAC DO CHARSET
+0.212 tintin recv IAC SB TTYPE IS 'TINTIN++' (ttype1: client name)
+0.212 server (recognizes MUD client, skips WILL ECHO)
+0.213 server send IAC DO NEW_ENVIRON
+0.213 server send IAC SB TTYPE SEND IAC SE (ask for ttype2)
+0.213 tintin recv IAC DO SGA -> local_option[SGA] = True
+0.214 tintin recv IAC DONT BINARY -> local_option[BINARY] = False
+0.214 tintin recv IAC WILL NAWS
+0.214 tintin recv IAC SB NAWS (cols=89, rows=56) (terminal size!)
+0.214 tintin recv IAC WILL CHARSET
+0.214 server send IAC WILL CHARSET (reciprocating)
+0.214 server send IAC SB CHARSET REQUEST UTF-8 ... US-ASCII IAC SE
-- charset rejected, but negotiation continues --
+0.323 tintin recv IAC WILL NEW_ENVIRON
+0.323 server send IAC SB NEW_ENVIRON SEND ...
+0.323 tintin recv IAC SB TTYPE IS 'tmux-256color' (ttype2: terminal)
+0.324 server send IAC SB TTYPE SEND IAC SE (ask for ttype3)
+0.324 tintin recv IAC SB CHARSET REJECTED *** rejected ***
+0.434 tintin recv NEW_ENVIRON IS: CHARSET=ASCII
+0.477 tintin recv NEW_ENVIRON IS: CLIENT_NAME=TinTin++
+0.477 tintin recv NEW_ENVIRON IS: CLIENT_VERSION=2.02.61
+0.477 tintin recv NEW_ENVIRON IS: MTTS=2825 *** utf-8 is here ***
+0.477 tintin recv NEW_ENVIRON IS: TERMINAL_TYPE=tmux-256color
+0.477 tintin recv IAC SB TTYPE IS 'MTTS 2825' (ttype3: capability bits)
-- all options resolved, but encoding still "pending" --
+4.004 server "encoding failed after 4.00s: US-ASCII"
+4.004 server "negotiation complete after 4.00s."
-- shell finally starts, 3.5 seconds of dead air later --