Writing one line that reads correctly to everybody who sees it.
Everything a player reads passes through two passes, in this order:
substitution, which replaces &s and friends with
names and pronouns, and then colour, which turns
&r and &<245> into escape codes. Both use
the same prefix character, &, so the letter after it is the only
thing that decides which pass claims a token.
That sharing has one consequence worth knowing before you write anything.
Substitution only fills a token when it was given something to fill it
with — send a message with no indirect object and its &i survives
untouched, reaches the colour pass, and is read as a colour code. This is why
i and u are deliberately absent from the colour table:
&i is the indirect object and &u the noun, and
before they were removed an &i without an iobj inverted the rest
of the line. Nothing was lost — &<underline> and
&<reverse> spell those attributes unambiguously.
& and not %
% is Python's string-formatting operator, so
"%<245>%s" % name raises ValueError — the
most common formatting idiom in the language collided with the most
common display idiom in the engine. Counted across 5,217 literal output
strings in a real world, % appeared 1,132 times,
| 14, and & zero. It is the Diku/Merc
convention besides, so MUD authors arrive already knowing it.
Two calls cover almost everything. Use them rather than the raw
notify() builtin:
pobj.msg("You pick up &d.", dob=item)
pobj.location.msg_room("&S picks up &d.", exclude=[pobj], sub=pobj, dob=item)
msg sends to one object. msg_room sends to every player
in a location, and exclude is how you keep the actor from hearing
the third-person version of their own action.
Both are native methods on every object, which matters for speed: as a method
msg costs about 1.7µs against 168µs for the same thing written as a
verb, and because msg_room calls msg once per listener,
a forty-person room emit went from 6.7ms to 70µs when it stopped being a verb.
They are still overridable. Define a msg verb on any object — a
deafened character, a filtered channel — and msg dispatches to it
instead. The lookup happens per call, so a set_verb at runtime takes
effect immediately. Every msg_room implementation deliberately
delivers through msg rather than notify for exactly this
reason.
msg_room walks the room and calls msg on each
listener, so the tokens are resolved separately for every player. That is
what makes viewer-aware emits possible, and it is
also why &S capitalises correctly for everyone rather than
being baked once and shared.
Three objects can be named in a message, plus a noun: the
subject (who acted), the direct object (what
they acted on), and the indirect object (what they used or whom
they acted toward). Pass them as sub, dob and
iob.
| Token | Fills with | Needs |
|---|---|---|
&s / &S | the subject's name | sub= |
&d / &D | the direct object's name | dob= |
&i / &I | the indirect object's name | iob= |
&u / &U | a noun, not a full name | uob= |
&ps | he / she / they / it | sub= |
&po | him / her / them / it | sub= |
&pp | his / her / their / its | sub= |
&pa | his / hers / theirs / its | sub= |
&pr | himself / herself / themself / itself | sub= |
Capitalise by upper-casing the token's first letter. One rule,
everywhere: &S, &D, &Ps,
&Pp. There is no separate set of capitalised tokens to learn.
Pronouns come from the subject's gender property —
male, female, neutral or
ambiguous. Anything else, including an object with no gender at
all, falls back to ambiguous: they/them/their.
Numbers, coin words and any other plain string go in through numbered slots
rather than as objects. The keyword keeps its s; the token drops it:
pobj.msg("You pay &1 for &d.", dob=item, s1="three silver")
The convention is worth following: objects go through
&d/&i, strings through
&1/&2. Slots are numbered from
&0, and higher indices are replaced first so &1
can never clobber &10.
Two escaping rules matter, and they pull in opposite directions.
Names are protected for you. A name containing a sigil would
otherwise be re-read by a later pass — an object called a &d
inserted at the subject step had its &d replaced by the direct
object's name at the next one. Names inserted through
&s/&d/&i are neutralised
automatically, so you never think about this.
Raw slots are not. Slot values are inserted verbatim and are never rescanned, which is deliberate — but it also means a slot is the right place for text you do not control, and the wrong place for text you want substituted. If a verb echoes something a player typed, double the sigil yourself:
spoken = args.replace('&', '&&')
pobj.msg('You say, "%s"' % spoken)
That is what say and act do. Without it, anybody could
put colour codes — or a stray &S naming somebody else — into a
line attributed to them. A doubled && survives substitution
intact so the colour pass still has its own half of the escape to consume.
A verb that prints a property's literal value — @examine,
a decompiler, anything showing you what is stored — has to double
& on the way out, or a message template stored in a
property will render instead of being displayed.
An emit has three audiences: the one who acted, the one it happened to, and everybody watching. A combat line needs all three —
You attack Bramble.
Malifax attacks you.
Malifax attacks Bramble.— and writing that as three strings means three things to keep in step, with nothing checking that they agree. Two token families collapse it into one string. Because substitution runs once per recipient, each of them can ask who is reading this.
&y… is the subject, &t… the target, and both
take the same five cases as the pronoun tokens:
| Token | To that person | To anyone else |
|---|---|---|
&ys | you | Malifax (the name) |
&yo | you | him |
&yp | your | his |
&ya | yours | his |
&yr | yourself | himself |
&ts &to | you | Bramble (the name) |
&tp | your | her |
&ta | yours | hers |
&tr | yourself | herself |
So the three lines above are one string:
here.msg_room("&Ys &v(attack) &to.", sub=pobj, dob=target)
Capitalisation is the same rule as everywhere else: &Ys,
&Tp.
&ys renders the name in the third person, not
"he". Subject position is where a line says who it is about, and "He
smiles at Bramble" arriving cold has no antecedent. The other cases take
pronouns because by then the name has been said.
For the same reason &to renders the name while
&yo renders "him" — nothing has named the target yet at
that point. &ts and &to are identical,
since "you" is caseless; both exist so the two families are learned
once.
Once one string serves both "you" and "Malifax", the verb has to move with it — "you smile" but "Malifax smiles". Wrap the bare verb:
| Token | Agrees with |
|---|---|
&v(smile) | the subject (sub) |
&vd(dangle) | the direct object (dob) |
&vd exists because the two are not always the same. In
"&D &vd(dangle) from &yp ear." the possessive belongs to
the wearer and the verb to the earring, so sub stays the character
while dob drives the agreement.
The bare form is used when any of these is true, and the -s form
otherwise:
plural property set true;some,
several, many — so "some drapes hang".
Spelling follows the ordinary rules: add -s, -es after
s x z ch sh o, y becomes -ies after a
consonant. Only be and have are table entries;
do and go come out right from the -o rule.
a pair of is singular — "a pair of boots is slung
over your shoulder" — and an item with no article at all is singular too,
because it is more often proper-named ("Excalibur gleams") than plural.
Set plural where the article misleads.
Gender is not consulted. A they/them character takes the bare form behind
the pronoun — "they smile" — but not behind their name, and
"Robin smiles" is what English wants. A sentence built on
&ps rather than &ys has to set
plural or write its own agreement.
Both are available directly when a verb is assembling text rather than emitting it:
su.conjugate('smile') # smiles
su.conjugate('smile', plural=True) # smile
su.takes_plural_verb(obj, viewer) # True / False
Where esub thinks in terms of subject and objects,
psub1 and psub2 think in terms of an
enactor and a target. They are the right tool
for a message template stored on an object — a social, a weapon's hit line —
where there is no verb call supplying sub= and dob=.
| Token | Fills with | Function |
|---|---|---|
&N / &CN | the enactor's name | psub1 |
&EPS &EPO &EPP &EPR | the enactor's pronouns | psub1 |
&CEPS &CEPO &CEPP &CEPR | the same, capitalised | psub1 |
&T / &CT | the target's name | psub2 |
&OPS &OPO &OPP &OPR | the target's pronouns | psub2 |
&COPS &COPO &COPP &COPR | the same, capitalised | psub2 |
su.psub1("&CN draws &EPP sword.", eobj=player)
su.psub2("&CN bows to &T.", eobj=player, tobj=other)
These families spell capitalisation with a C prefix rather than by
upper-casing the first letter, which is the one place the two conventions
differ. Replacement runs longest-token-first — reflexive, then possessive, then
objective, then subjective — so &EPS cannot match inside a
longer token.
psub1a and psub2a are the same with three positional
strings, filling &1, &2 and
&3.
su is available in every verb without importing anything. Most of it
is the small English machinery that message-building needs.
| Call | Does |
|---|---|
su.listtoenglish([…]) | "a sword, a shield and a helm" from a list of strings |
su.tlisttoenglish([…]) | the same from a list of objects, using their names |
su.english_list(…) | as above with the separators spelled out, and a word for the empty case |
su.capitalise(s) | upper-case the first character, leave the rest alone |
su.a_or_an(word) | "a" or "an", by what the word starts with |
su.pluralise(word, count) | naive English plural |
su.ordinal(n) | 1 → "1st", 2 → "2nd" |
su.listtomenu([…]) | a numbered menu |
su.columnize([…]) | two side-by-side numbered columns |
su.wrapstringlist([…], width) | wrap each string and join with newlines |
su.collapse(block) | a pasted block — a sign, a menu board — as one newline-joined string |
su.find_prefix(p, candidates) | index of the one candidate p matches, else −1 |
su.index_delimited(s, target) | index of a word within a delimited string, else −1 |
su.msg_list(text, targets) | send one string to every object in a list |
su.capitalise, never .capitalize()
Python's str.capitalize() lower-cases everything after the
first letter, so "a LOOT sack" becomes "A loot sack".
su.capitalise raises the first character and leaves the rest
alone. su.capitalize is spelled both ways and does the same
correct thing.
Single letters for the basic sixteen, lower-case for normal and upper-case for bright:
| Code | Colour |
|---|---|
&x &r &g &y &b &m &c &w | black, red, green, yellow, blue, magenta, cyan, white |
&X &R &G &Y &B &M &C &W | the bright variants |
&n | reset — clears everything |
&h &f | bold, blink |
&<245> | xterm-256 index, foreground |
&<dim> | a name for 245, the house grey; &<bgdim> behind |
&<bg21> | xterm-256 index, background |
&<#FF0000> | hex RGB, and &<bg#FF0000> behind |
&<underline> &<reverse> | attributes, spelled out |
Always close with &n. For interface chrome — labels, borders,
anything that should recede — &<245> is the house grey,
and &<dim> is a readable name for exactly that index;
&<240> is too dark to read on some terminals.
&<dim> is a colour, not the ANSI faint attribute.
It sets the foreground to one specific grey rather than fading whatever colour
you were already using, so it cannot be layered over another colour. The
composable attribute is deliberately unavailable: the browser client defines no
rule for it, and a fair share of terminals draw it as ordinary text.
Codes are stripped rather than rendered for clients that cannot show colour, and width calculations strip them before measuring, so a coloured line still boxes and centres correctly.
Long lines are wrapped at WRAP_WIDTH, 121 characters, on the
telnet path only. The browser client never wraps server-side — it wraps
in CSS, so the same text reflows when the window changes size. Setting
WRAP_WIDTH to 0 turns server-side wrapping off entirely.
This is why anything drawn as art — a banner, a box, a map — should be sent as a grid rather than as prose, and why the browser client lays text out on a character cell instead of leaving it to the font.