---
number: 6
title: Play it in your browser — the multiplayer world, live
date: 2026-08-03
summary: A link on this page that opens the Godot web export of the Stage 3 multiplayer client, connected to a live world on the droplet — two or more players in the same world seeing each other move, from nothing but a browser tab.
epic: server-architecture
status: delivered
due-with: server-architecture/007
artifacts: ["land-of-lor/pocs/multiplayer-poc/world-server/client/","land-of-lor/pocs/multiplayer-poc/handoff/stage-3.md","land-of-lor/pocs/multiplayer-poc/director/cli-test-client/verify-live-droplet.ts"]
cta: [object Object]
---

Delivered 2026-08-03. The Stage 3 web client is live on `lor-game-server-01`, connected to a real, running director and world instances on that droplet — not a local stand-in.

> **Reading note:** the button above is the link to use (HTTPS, since 2026-08-04 — see the update section near the bottom). The sections between here and there are the original 2026-08-03 record and quote the old plain-HTTP `209.38.29.22` link, which real browsers refuse to run the Godot canvas from (no Secure Context). Historical prose left as written, per the log's conventions.

## What this will be

A tap/click from this site straight into the actual multiplayer world — no download, no install, just a browser tab talking to `lor-game-server-01` over a live WebSocket connection.

## Acceptance criteria

- A link on the deliverables page opens the Godot **web export** of the Stage 3 client (`server-architecture/007`).
- That export is served from the droplet (`lor-game-server-01`, `209.38.29.22` — see [deliverable #5](/deliverables/5)).
- The client connects to a **live world** on that droplet, not a local/offline stand-in.
- **Two or more simultaneous players in the same world see each other move** — the same bar Stage 3's own definition of done already sets, just witnessed from a browser instead of a headless test client.

## What was verified (2026-08-03, from the container, externally)

Two checks, matching this session's task brief exactly:

1. **The web client URL returns the export, HTTP 200, sane content**: `http://209.38.29.22/play/index.html` returns 200 with the correct `<title>lor-world-server</title>`; `index.wasm` (39,513,091 bytes, `Content-Type: application/wasm`) and `index.pck` (150,380 bytes) both return 200 with correct sizes.
2. **The existing CLI test-client machinery, pointed at the live droplet over the public internet** (`director/cli-test-client/verify-live-droplet.ts`, `npm run verify-live-droplet` from `director/`) — not a local/spawned-in-process stand-in: two accounts created against the real droplet director (through Caddy's `/director/*` proxy on port 80), a world created and joined by both, a real Godot world-server OS process spawned live on the droplet, both game clients connecting **directly** to that process's dynamically-allocated `gamePort` (opened through the droplet firewall for exactly this purpose — see below) over the public internet, mutual movement visibility proven in both directions, and an illegal (out-of-bounds) intent proven refused live. **9/9 checks passed.**

**Not verified from this container — genuinely can't be**: actually loading `http://209.38.29.22/play/index.html?director=ws://209.38.29.22/director/` in a browser and watching the canvas render. This container has no display and no browser. The check above proves the *exact same protocol traffic* a browser tab would generate works correctly, live, against this droplet — but the visual "two tabs, watch the balls move" moment is still Kris's own check, same as Stage 3's own handoff note already says for the desktop client. If that check ever fails despite the above, the gap is almost certainly Web-export/browser-specific (e.g. `JavaScriptBridge` query-param reading, WASM loading) rather than the underlying multiplayer plumbing, which this session did prove live.

## Known prerequisites, carried over from deliverable #5 — resolved or updated

- **Deploy key**: attempted via the GitHub API (this container's token has repo admin *read* on the permissions object but the deploy-keys endpoint itself returned 403 — a fine-grained PAT without the "Administration" repository permission, which isn't grantable from inside this session). **Worked around** using deliverable #5's own original method: a `git bundle` transferred over the existing SSH channel, fast-forwarded on the droplet. A genuine **read-only** deploy keypair was generated on the droplet (`/home/lor/.ssh/lor_deploy_key`, public key `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE1rO3dm1LV10Xi6DoFi2uNkXJuhGCK2iSkb+p6JZIMY lor-game-server-01-deploy-readonly`) but is **not yet registered** with GitHub — needs a human with repo admin access to add it at `github.com/krisrandall/land-of-lor/settings/keys` (read-only). Once added, `git pull` on the box (deliverable #5 §"Deploying code to it") would work without the bundle workaround. Follow-up carried forward, not blocking — the bundle method works today.
- **Plain HTTP, no domain or TLS** — unchanged, as documented. `ws://` from `http://` is what's actually deployed.
- **Direct game-channel connections need a firewall hole**: the architecture decision (game clients connect directly to a world's dynamically-allocated `gamePort`, not through the director/Caddy) means the droplet's `ufw` now additionally allows `32768:60999/tcp` (Linux's ephemeral port range, where `join()`'s `allocateFreePort()` actually lands) — a deliberate, PoC-scoped tradeoff, consistent with deliverable #5's existing "fine for a small number of trusted testers, not something to point real players at" posture. A future move to more players/production should narrow this to a fixed, small configured port range instead of the whole ephemeral range — not done this session (would need a `worldInstance.ts` code change, out of scope for a deploy step).

## Update 2026-08-04 — HTTPS, and why the first link failed in a real browser

Kris tried the original plain-HTTP link in Firefox and hit Godot's refusal to run outside a Secure Context — exactly the browser-specific gap the "not verified from this container" note above anticipated. Fixed the same day:

- Kris pointed **`lor-server.cocreations.com.au`** at the droplet (DNS details in the `server-instance` epic overview); Caddy obtained a Let's Encrypt certificate automatically.
- Both WebSocket channels became `wss://`: the director via Caddy's existing `/director/*` proxy, and the game channel via a new **`/game/<port>` TLS path proxy** (an HTTPS page may not open plain `ws://`, and the dynamically-allocated gamePorts carry no certificates). The client picks the path-proxy mode whenever its `?director=` URL is `wss://`; plain `ws://` keeps the old direct-port behaviour, so nothing existing regressed.
- Verified externally over TLS with a full 9/9 run (`director/cli-test-client/verify-live-droplet-wss.ts` — same nine checks as the plain verifier: accounts, world join, mutual movement visibility both directions, illegal-intent refusal), plus a 9/9 regression run of the original plain-HTTP verifier. Wiring brief and executed outcome: `land-of-lor/pocs/multiplayer-poc/handoff/https-wiring-brief.md`.

**Later the same day — the page loaded but joined nothing, so the commons was born.** With HTTPS working, the client authenticated and then sat on a blank canvas forever: joining a world had only ever been driven by a `--world-id` command-line argument (minted by the desktop two-player setup script), which a browser never receives — and even with one, two fresh browser accounts could never pass the members-only gate on each other's worlds. The fix is a real feature, not a patch: a **public commons world**. A new director request (`join_public_world`) finds-or-creates a shared world named *The Commons* and auto-members any account that asks; the client falls back to it whenever no world id is supplied (an explicit `?world=<id>` query param also now works). Private worlds keep their members-only gate — the commons is the one deliberate exception. Shaking this out live also surfaced and fixed two cold-start races (two simultaneous first-joiners could double-spawn the world process, stranding one of them, or double-create the commons row) and one browser-specific trap: Godot's `user://` storage is shared across tabs, so tabs reused one saved account — the web client now mints a fresh account per tab, which is what makes the two-tab demo two *players*. Covered by a new director test suite run (30/30) and a live verifier (`verify-public-world.ts`, 5/5 against this droplet over wss: two strangers, no world id exchanged, same world, mutual movement observed).

## Artifacts

- **Live link:** `https://lor-server.cocreations.com.au/play/index.html?director=wss://lor-server.cocreations.com.au/director/`
- Original plain-HTTP link (still works for the protocol, but real browsers refuse the Godot canvas without HTTPS): `http://209.38.29.22/play/index.html?director=ws://209.38.29.22/director/`
- Director: `lor-director.service` (systemd, Node 22, built from `director/dist/`), Postgres `lor_game` (existing, schema auto-migrated on director start).
- World processes: spawned on demand by the director (Godot 4.7.1 headless, `/usr/local/bin/godot4`) — not separate always-on systemd units; the old placeholder `lor-world@1.service` was stopped/disabled as no longer part of the actual architecture (director spawns world OS processes itself).