This is an old revision of the document!
Table of Contents
MOSS v0.1 Design Notes
This document explains how Mossworld v0.1 was built: the moving parts, the choices behind them, and a file-by-file tour. It assumes basic experience with PHP, JavaScript, and MySQL.
Executive summary
Mossworld is a browser client (dumb terminal) talking over a persistent WebSocket to a single long-lived PHP daemon that holds the whole world and every player in memory and pushes events out in real time. A classic PHP session login establishes who you are; a one-time ticket in the database hands that identity across to the daemon, which binds your socket to your account and enforces one live session per user. The server owns the world, the client owns the display, durable data lives in MariaDB, and everything is tuned to cost almost nothing when idle.
The big picture: two programs, not one
A normal PHP website is one kind of program: the browser asks for a page, PHP builds it, sends it back, and forgets everything. Each click is a fresh, independent request. That model cannot show you another player moving in real time, because nobody is “always there” holding the shared world in memory.
So Mossworld is split into two cooperating programs:
- The client — plain HTML/CSS/JavaScript running in each player's browser. It draws the terminal (a scrolling output area plus a one-line input) and does nothing clever: it sends whatever you type, and prints whatever it receives.
- The server (the “daemon”) — a single long-lived PHP program running on the host. It holds the whole world in memory, knows every connected player, and pushes messages out to them the instant something happens.
Browser (client) Server (daemon)
+------------------+ +-----------------------+
| index.php | WebSocket (wss) | moss-server.php |
| - terminal UI | <=================> | - the live world |
| - send/recv | stays OPEN | - all players |
+------------------+ +-----------------------+
|
v
MariaDB (accounts,
rooms, traffic log)
The clean rule we follow: the server owns the world; the client owns the display. Colours, for example, are 100% a client concern — the colormode command never even reaches the server.
What is a WebSocket?
Normal web traffic is request/response: the browser opens a connection, asks for one thing, gets one answer, and the connection closes. The server can never speak first. That is fine for documents, useless for a live shared world.
A WebSocket is a connection that stays open in both directions. After a brief handshake (which starts life as an ordinary HTTPS request and then “upgrades”), the browser and server hold a persistent two-way pipe. Either side can send a short text message at any moment, with almost no overhead.
For Mossworld this is exactly right:
- You press Enter and the client sends one line (e.g.
east) down the pipe. - The server moves you, and pushes “Appledog leaves east.” to everyone else in the room — without them asking. That push is the thing plain HTTP can't do.
The secure form is
wss://
(WebSocket-over-TLS), the encrypted sibling of https: connection from an . Browsers refuse an insecure ws:https:.
page, so we always use wss:
What is a "daemon"?
A daemon is just a program that keeps running in the background instead of starting up, doing one job, and exiting. (The name is old Unix tradition; the trailing “d” in httpd, sshd means “daemon”.)
Normal PHP is the opposite of a daemon: it boots, builds one page, and dies, many times per second. That's why a normal PHP site can't “remember” who is online. Nothing stays alive between requests.
Our game server is a PHP program run from the command line that never exits. It loads the world once, opens the WebSocket port, and then sleeps until someone sends a message. Because it stays alive, it can keep the list of players and the map in plain memory.
Two things make this practical and cheap:
- Event-driven, not polling. The daemon does not loop (poll). Tthat would peg a CPU. Instead it sleeps inside the operating system until a real event arrives (a new connection, an incoming message, or a timer). An idle server with players just sitting there uses near 0% CPU.
- One process. A single process comfortably handles thousands of these tiny text connections, because typing is slow and messages are small.
We use a library called Workerman to do the low-level socket and event-loop work, so our code only has to answer three questions: a player connected, a player sent a line, a player disconnected.
Player Context
Each connected player needs a little bundle of state: who they are, what room they're in, whether they want tutorial hints, and so on. We put that in a small class called Context (php/Context.php).
The important design idea: the server keeps a list of Contexts, one per connected player. “Multiplayer” is then almost free: to support N players you just keep N Contexts in the list and process each one's commands against the shared world. A command handler is written as process(context, command), so it already works the same whether there is one player or a hundred.
A Context is deliberately tiny right now (id, name, room, account id, a few display flags). It is the natural place to grow later: inventory, a body object, permissions, an input buffer, etc.
The world engine
The actual game logic — what rooms exist, what east does, what you “see” lives in Engine (php/Engine.php). It is deliberately transport-agnostic: it knows nothing about WebSockets, browsers, or networking. You hand it (context, “east”) and it returns text. That separation means we could drive the exact same engine from a command-line test, an old-style telnet server, or the WebSocket daemon; only the delivery changes.
The proof-of-concept world was a 2×2 square of four rooms. Exits are stored as four columns on each room (n_to, s_to, e_to, w_to), each holding the destination room id (0 = no exit). Dead simple to start; when we want stairs, portals, or locked doors we'll graduate to a proper exits table.
The whole map is loaded into memory once when the server starts, so moving around is a memory lookup — it never touches the database. The database is for durable things (accounts), not for the per-keystroke game loop.
Authentication: bridging the web login to the live game
This is the trickiest seam in the system.
We have two separate worlds of identity:
- The web side is a classic PHP site (login form,
$_SESSION, a cookie). This is well-understood and where account signup/login lives. - The daemon is a different process entirely. It does not share PHP's sessions or cookies — it just has open sockets. So when a browser opens a WebSocket, the daemon has no idea who that is.
The bridge is a one-time ticket:
- You log in on the web → a normal PHP session marks you as authenticated.
- When the game page (
index.php) loads, it checks that session. No session → you are redirected to the sign-in page (there are no guests). - If you are logged in, the page mints a random ticket, stores it in a
ticketstable (your uid + a timestamp), and embeds it in the page. - The browser opens the WebSocket and its very first message is
auth <ticket>. - The daemon looks the ticket up in the database, finds your uid and username, and binds this socket to your account. Now it knows who you are.
- Until that succeeds, the daemon accepts no other command.
So the database is the shared notebook that lets the web side and the daemon agree on identity without sharing memory.
One session per user. When you authenticate, the daemon kicks any other socket already signed in as you. To stop an infinite “kick each other” loop (the booted client would otherwise auto-reconnect and kick you back), the kick is announced over a control message and the kicked browser is told not to reconnect. Newest login wins; the loser stays put.
Out-of-band "control channel"
Most messages from the server are just text to print. But some are instructions for the UI, not content (ex “your name is Groo” to fill the status bar) or “you were kicked.” We send those as control frames: a message that begins with an invisible ESC byte followed by a tiny JSON object. The client spots the ESC, parses the JSON, acts on it, and never prints it. This keeps UI signals out of the visible text stream and is easy to extend (a future HUD could receive room name, health, etc. the same way).
Bandwidth and CPU choices
The host is a modest metered server, so two policies are baked in:
- CPU. Single process, event-driven (sleeps when idle), world cached in RAM, and no fast timers. The only periodic job runs every 10 seconds. Result: an idle world costs almost nothing.
- Outbound bandwidth. On a metered host, outgoing bytes cost money while incoming is free. So every send goes through one function (
tx()) that tallies the byte count by category (look, say, presence, system, …). A 10-second timer flushes those tallies into atraffictable in one batched insert. We get a full picture of where bandwidth goes, without putting a database write in the hot path. The in-gamestatscommand shows live totals.
Security choices
- Source code is not web-served. The folders
db/,php/, andserver/each carry a.htaccessthat denies all web access. PHP stillincludes those files from disk (that's a filesystem read, unaffected), but a browser asking for them directly gets 403 — so the DB password, the engine, etc. are never downloadable.sec/andadmin/are web-served, because they are real pages. - Passwords are hashed with Argon2id (a modern, deliberately-slow hash). We never store or compare plaintext.
- Privileges use a
rolestable (named roles likeadmin) rather than a single “security level” number — more granular and practical. - Signup is protected with a CSRF token; usernames are restricted so the account name is also a clean in-game name.
- The WebSocket ticket is short-lived and tied to your account, so an open socket can't impersonate someone.
Deployment Protocol
deploy.shpushes the whole tree to the live server in one rsync over SSH (fast, delta-only), skipping editor/tooling files.- The daemon runs unprivileged on
127.0.0.1:2346speaking plainws:through to the local daemon. This way the daemon needs no privileges and never touches the private key.. Apache sits in front, terminates TLS using the real certificate, and proxieswss:helloneo.ca/mossworld/ws server/moss-ctl.shstarts/stops/restarts the daemon. A restart is required after changing daemon code — a long-running program holds its code in memory, unlike normal per-request PHP.
File-by-file tour
Top level
index.php— the game client. Top half is PHP: require login, mint the WebSocket ticket. Bottom half is the browser terminal (scrolling output + input box), the WebSocket connect/reconnect logic, the colour profiles, and the control-frame handling. This is the only game file a browser loads directly.deploy.sh— one-pass rsync deploy to the live host.composer.json— declares the Workerman dependency (installed intovendor/on the server, which is not committed).movetest.php— a tiny command-line harness that drives the Engine with no network, to prove movement logic in isolation.
db/ (web-locked; durable data)
mdb.php— MeekroDB, a small library wrapping MySQL with safe parameterised queries (DB::query(…)). Also holds the DB credentials.schema.php— every table definition as acreate_table_*()function:rooms,traffic,members(accounts),roles,tickets.install.php— command-line installer: (re)creates/seeds the tables.User.php— the account class: load by id, check username/email, verify Argon2id password, remember-me key, and the role methods (has_role,add_role,is_admin)..htaccess— denies all web access to this folder.registry.php,iplog.php,roles.phpare legacy helpers carried over from an earlier project, currently unused (kept as reference).
php/ (web-locked; shared code)
Engine.php— the transport-agnostic world engine: loads rooms, renders a room, processes movement/look commands.Context.php— the per-player state bundle described above.globalvars.php— site constants (host, app base path) and URL helpers.cregistry.php— cookie helpers (prefixed, Secure, HttpOnly, path-scoped).ncrypt.php— a small random-token generator (utility)..htaccess— denies all web access.
server/ (web-locked; the daemon)
moss-server.php— the WebSocket daemon. Holds the player list, thetx()metering helper, the control/assist/broadcast helpers, the auth gate and ticket validation, the one-session-per-user kick, and the connect / message / disconnect handlers. This is the live heart of the game.moss-ctl.sh— start / stop / restart / status wrapper (with a force-kill fallback so a stuck server can always be replaced)..htaccess— denies all web access.
sec/ (web-served; the login surface)
security.php— starts the session and sets$user. Include this and you know who's logged in.chrome.php— a slim Bootstrap-5 (dark) page header/nav/footer used only by the web pages (login, admin). The game does not use Bootstrap.sign-in.php/login.php— the login form and its handler.new-user-form.php/create-new-user.php— the signup form and its- handler (CSRF-checked; on success you're logged in and sent to the game).
logout.php— clears the session and remember-me key.bootstrap.php,doing.php,new.php,settings.php— legacy nelsonacademy auth scaffolding, unused (kept for future forums/ticketing).
admin/
admin.php— admin panel stub: shows who you're logged in as and whether you hold theadminrole. Real tools will grow here.design-notes.txt— this document.
bootstrap/ and js/
- Vendored Bootstrap 5 (CSS/JS) and a couple of jQuery helpers, used only by the web pages. Static assets.
Known gaps and next steps
- No brute-force rate-limiting on login/signup yet.
- The WebSocket ticket is reusable within a short TTL (convenient for reconnects; could be hardened to strictly one-time).
- Half-open sockets aren't actively reaped yet (a heartbeat/ping would do it).
- The world is a 4-room placeholder; the object model (objects, properties, verbs, containment) is the real next chapter.
- Legacy files noted above are candidates to remove or adapt.
