Playing in a browser, and extending the client yourself.
MegaMOO ships a browser client in the web/ folder. The game
server serves it itself — there is no web server to install and no build
step. In development it is already on — megamoo --dev starts it and prints the address. To serve it from a world you are not running in dev mode, ask for it:
megamoo world.db --web
The client takes the first free port from 8888 up, and the startup log tells you which one it got:
Web client listening on 0.0.0.0:8888
Open http://localhost:8888/ and you get the login screen. Use
--web-port 9000 to pin an exact port instead; a named port is a
request for that port, so a conflict fails loudly rather than
quietly landing somewhere you weren't expecting.
The same listener serves the static files and accepts the WebSocket
on /ws. Players connecting this way share the world with
telnet players — same database, same rooms, same conversation. The
transport is the only difference.
The terminal behaves the way a MUD client should: scrollback with the world's colours, command history on the up and down arrows, and exits you can click. Passwords are masked automatically — the server flags the prompt, since a WebSocket has no equivalent of telnet's echo suppression.
A dropped connection reconnects on its own, with a backoff, so a server restart doesn't cost anyone their tab.
A map of the world draws itself in the corner as players explore. It covers in-character rooms only — the entry hall and chargen never appear, and the panel hides while a player is standing in one.
The map is exact rather than approximate. Every room reports its own object
number, and the server derives a canonical (x, y, z) for each
room once, by walking the exit graph: one cell per compass direction, one
level per u/d. Because that layout comes from the
world rather than from the route a player happened to walk, every player's
map has the same shape, loops close correctly, and up and down are real
levels.
What the server does not send is where each exit leads. That would hand players the topology of rooms they have never visited, so unexplored exits draw as short stubs — a visible frontier rather than a spoiler.
Everything the browser loads lives in web/. It is plain HTML,
CSS and JavaScript with no bundler, so editing a file and reloading the page
is the whole development loop.
| File | What it does |
|---|---|
index.html | Page structure |
client.css | All styling, including the colour classes the server emits |
client.js | Socket, scrollback, input line, event bus, public API |
automap.js | The corner map |
panels.js | Renders script-declared UI panels |
scripting.js | Script manager, host registry, the API scripts call |
scripts-ui.js | The Scripts dialog |
hosts/js-worker.js | JavaScript scripting host |
hosts/lua.js | Lua scripting host |
vendor/wasmoon/ | Lua 5.4 compiled to WebAssembly (MIT) |
The server half is moo/web/: server.py (static
files and the WebSocket upgrade), connection.py (one player's
session, mirroring the telnet connection), websocket.py (RFC
6455 framing) and color.py, which turns the world's colour
codes into HTML. Room coordinates come from moo/roommap.py.
The engine itself has no third-party dependencies and never will.
The one exception anywhere in the project is
web/vendor/wasmoon/ — the Lua interpreter used by the
optional Lua scripting host, vendored so there is still nothing to
install. It is a static file the server already serves, and it is
downloaded by the browser only when a player actually enables a Lua
script.
Three things connect: the server has to serve the client, the page has to load its modules in the right order, and your world has to tell the client what it wants shown.
--web is the whole server side. It reads
network.websocket_enabled, so you can turn it on permanently in
a config file instead:
{
"network": {
"websocket_enabled": true,
"websocket_port": 8888,
"websocket_allowed_origins": []
}
}
The static files ship inside the installed package and are served from there, so there is no folder to place, keep beside the server, or accidentally move.
index.html loads the modules in a fixed order, and the order is
load-bearing:
<script src="automap.js"></script>
<script src="panels.js"></script>
<script src="scripting.js"></script>
<script src="hosts/js-worker.js"></script>
<script src="hosts/lua.js"></script>
<script src="scripts-ui.js"></script>
<script src="client.js"></script>
scripting.js defines the host registry, so the two hosts must
load after it to register themselves. client.js loads
last: it opens the socket and attaches every module, so
anything it attaches has to already exist. A module added after
client.js is simply never wired in — which looks like the
module being broken rather than misplaced.
Each module exposes an object with an attach(api) method, and
the bottom of client.js connects them:
if (window.Automap) window.Automap.attach(api);
if (window.MegaMOOScripting) window.MegaMOOScripting.attach(api);
The guards mean a module you delete costs you that feature and nothing else.
Two things the client reads that are yours to set.
The map needs in-character rooms. It only draws rooms whose
is_icroom is true — defined on #17 ICRoom and
inherited, so any room descended from it is mapped and OOC rooms are not. A
world whose playable rooms don't descend from #17 gets no map
at all, which is the usual reason for an empty corner.
Anything else is a GMCP package you send. From a verb:
from moo.network import get_connection_for_player
conn = get_connection_for_player(player.objnum)
if conn and hasattr(conn, 'send_gmcp_sync'):
conn.send_gmcp_sync('Char.Vitals', {'hp': 42, 'max': 100})
The hasattr check is what makes this safe for telnet players
too — they have no send_gmcp_sync, so the same code runs
harmlessly for everyone. See Extending the client
for drawing the result.
Everything crosses the socket as JSON. From the server:
{"type": "text", "data": "<html>"}
{"type": "prompt", "data": "> "}
{"type": "echo", "enabled": false}
{"type": "gmcp", "package": "Room.Info", "data": {}}
And from the client:
{"type": "input", "data": "look"}
{"type": "gmcp", "package": "...", "data": {}}
text arrives as HTML because moo/web/color.py has
already converted the world's & colour codes — and any ANSI a
verb emitted — into spans, escaping everything else. That module is the only
thing in the system that produces markup, which is what keeps a player who
names themselves <script> from being a problem.
echo is the WebSocket stand-in for telnet's IAC WILL
ECHO: the server sends enabled: false ahead of a password
prompt and the client masks the next line.
Out-of-band data is anything the server tells the client that isn't meant to be printed — the room you just entered, your vitals, whatever your world wants to publish. MegaMOO sends it as GMCP packages, and the client republishes every package on its event bus, whether or not it knows what the package means:
MegaMOO.on('gmcp', ({package, data}) => { ... }); // everything
MegaMOO.on('gmcp:Room.Info', (data) => { ... }); // one package
That matters when you extend the world. Send a new package from a verb and it is immediately visible to the client and to every player script — no client change required.
conn.send_gmcp_sync('Char.Vitals', {'hp': 42, 'max': 100})
The package MegaMOO sends today is Room.Info, carrying the
room's number, name, description, obvious exits, map coordinates, and
whether it is in-character.
Players can write their own triggers, aliases and heads-up displays from the Scripts button, in either Lua or JavaScript. Both get an identical API, so logic ports between them.
| Call | Does |
|---|---|
moo.echo(text) | Write a line to the scrollback |
moo.send(command) | Send a command as if typed |
moo.send_gmcp(pkg, data) | Send a client-to-server package |
moo.on(event, fn) | Subscribe to a client event |
moo.trigger(pattern, fn) | Run fn on matching output |
moo.alias(pattern, fn) | Claim matching input before it is sent |
moo.timer(ms, fn) | Repeating timer |
moo.storage.get/set | Persistent, per-script storage |
moo.panel(id, spec) | Declare or update a UI panel |
moo.on('gmcp:Room.Info', function(room)
moo.panel('where', {title = 'Location', body = {
type = 'stack',
children = {
{type = 'text', text = room.name, strong = true},
{type = 'text', text = 'exits: ' .. tostring(#room.exits), muted = true},
},
}})
end)
moo.alias('^h$', function() moo.send('go north') end, {regex = true})
Scripts never produce markup. A panel is described as data — a
small tree of widgets: text, bar,
gauge, row, stack,
table — which trusted client code renders. Colours are chosen
from a fixed set of names rather than given as CSS. A script cannot express
markup, so it cannot inject any.
The two hosts differ in how their sandbox is built, and it matters:
fetch, WebSocket,
XMLHttpRequest, importScripts,
indexedDB and Worker itself removed. That
last one matters — leave it in place and a script spawns a nested
worker to get a fresh global with everything restored. It is a
blocklist: correct as far as it goes, and only as good as
the list.So: JavaScript is fine for scripts a player wrote themselves. For a script pack passed between players — which is how MUD scripting has always worked — prefer Lua. A hostile JavaScript script that finds a reference the blocklist missed runs as that player, in their session. The client says as much when someone picks JavaScript.
The client is deliberately small and unbundled. Everything hangs off one event bus, so most additions are a new file that subscribes to it.
Suppose your world sends a Char.Vitals package from a verb.
A new file that draws it is about this long:
// web/vitals.js
(function () {
const el = document.getElementById('vitals');
window.Vitals = {
attach(api) {
api.on('gmcp:Char.Vitals', (v) => {
el.hidden = false;
el.textContent = `${v.hp} / ${v.max}`;
});
},
};
})();
Add the element to index.html, load the script before
client.js, and attach it at the bottom of
client.js where the other modules attach:
if (window.Vitals) window.Vitals.attach(api);
That is the whole pattern. automap.js is a longer worked
example of exactly this shape, and a good one to read before writing your
own.
window.MegaMOO is the same object player scripts talk to
through their host:
| Call | Does |
|---|---|
on(event, fn) | Subscribe. Events: text, line, prompt, input, gmcp, gmcp:<Package>, connect, disconnect |
send(command) | Send a command |
sendGmcp(pkg, data) | Send a package |
echo(text) | Write a line to the scrollback |
intercept(fn) | Claim input before it is sent |
connected | Whether the socket is open |
Use line rather than text when you want to match
on output: text fires per network message, which is not the
same as per line, while line fires once per completed line of
plain text with the markup already stripped.
From a verb, reach the player's connection and send:
from moo.network import get_connection_for_player
conn = get_connection_for_player(player.objnum)
if conn and hasattr(conn, 'send_gmcp_sync'):
conn.send_gmcp_sync('Char.Vitals', {'hp': 42, 'max': 100})
Telnet connections have no send_gmcp_sync, which is why the
check is there — the same code is then safe for every player regardless of
how they connected.
Anything under web/ is read from disk per request, so
editing a file and reloading the page is enough. Anything under
moo/ needs the server restarted. The client watches for
this: it reconnects a dropped socket without reloading the
page, so after a restart it checks whether the files changed and puts
a banner up when the page it is running has been superseded.
Two things change when the client is reachable from outside your machine.
First, name the origins allowed to open a socket:
megamoo --dev --web --web-origins https://play.example.com
Without that, any page a logged-in player happens to visit can open an authenticated socket to your world in their name. The browser same-origin policy does not cover WebSockets, which is what makes this necessary rather than paranoid. On localhost you can leave it off.
Second, put it behind a TLS-terminating proxy. The client picks
wss:// or ws:// from the page's own scheme, so
serving the page over HTTPS is all it takes — there is nothing to configure
in the client. The socket does need the upgrade headers passed through:
server {
listen 443 ssl;
server_name play.example.com;
location / {
proxy_pass http://127.0.0.1:8888;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 1h;
}
}
The long read timeout matters: a player idling in a room sends nothing for minutes at a time, and a proxy's default timeout will drop them.