← MegaMOO Guide

The Web Client

Playing in a browser, and extending the client yourself.

Turning it on

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.

One port, both halves

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.

What players get

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.

The map

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.

The files

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.

FileWhat it does
index.htmlPage structure
client.cssAll styling, including the colour classes the server emits
client.jsSocket, scrollback, input line, event bus, public API
automap.jsThe corner map
panels.jsRenders script-declared UI panels
scripting.jsScript manager, host registry, the API scripts call
scripts-ui.jsThe Scripts dialog
hosts/js-worker.jsJavaScript scripting host
hosts/lua.jsLua 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.

On dependencies

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.

Wiring it up

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.

1. The server

--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.

2. The page

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.

3. Your world

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.

The wire protocol

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

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.

Player scripting

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.

CallDoes
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/setPersistent, 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.

Which language to offer

The two hosts differ in how their sandbox is built, and it matters:

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.

Extending the client

The client is deliberately small and unbundled. Everything hangs off one event bus, so most additions are a new file that subscribes to it.

Adding a panel of your own

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.

The API a module gets

window.MegaMOO is the same object player scripts talk to through their host:

CallDoes
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
connectedWhether 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.

Sending a new package from the world

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.

Reloading your changes

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.

Putting it on the internet

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.