Skip to main content
The Griljor custom letters from title screen

Griljor Goes Live — Deploying It, and Playtesting with Old Friends

11 min 2,396 words

The first post covered the asset pipeline and a static renderer. The second post turned that renderer into an actual game — combat, inventory, doors and keys, click-to-move, a live lobby. But everything in those first two posts ran on localhost. Two browser tabs on my own laptop, talking to a Node server on port 3001. To play with a friend, that friend had to be me, in another tab.

This post is about closing that gap. Getting Griljor onto the public internet at griljor.com, with nginx and Let’s Encrypt so the WebSockets actually connect from a real browser, the processes supervised so they come back on crash and reboot — and then the part I’d been looking forward to since the beginning: inviting back a few of the friends who helped make the original in 1989, and watching them play it.

As before, Claude Code wrote every line of code that ships. My job was planning, prompting, and playing.

Phase 10 — One repo, several things to deploy

The thing that makes Griljor a little more difficult to deploy than a static site is that it isn’t one artifact. There’s:

  • the client — a Vite/TypeScript bundle of static files,
  • the lobby server — a single Node process on port 3000 that lists running games and pushes updates over its /watch WebSocket,
  • and the game serversone Node ws process per map. The lobby runs on 3000; each map gets its own port (castle on 3002, paradise on 3003, and so on), holding the authoritative state for that map.

The client is just files; any web server can hand them out. The Node processes are long-lived and stateful — they can’t be a serverless function, and a game server can’t go down without dropping everyone in that match. So the deployment target was never going to be Netlify or a static host. It’s a plain VPS — a small Hetzner Cloud box (a CX22: 2 vCPU, 4 GB RAM, about $6/month), separate from the one this blog runs on, with the processes supervised so they survive crashes and reboots.

I asked Claude to start by writing down the topology before any config, the same way we’d started every other phase. It produced docs/deployment-plan.md: nginx out front terminating TLS, serving the client’s static client/dist/ directly, and reverse-proxying everything else to the Node processes with the WebSocket Upgrade headers passed through. The routing it landed on:

  • / → the static client build,
  • /games and /watch → the lobby on port 3000,
  • /ws/<mapname> → the game server for that map.

For supervision it chose PM2 rather than hand-written systemd units — one ecosystem.config.js lists the lobby and every game server, pm2 start brings them all up, pm2 save plus pm2 startup makes them come back after a reboot. Environment-specific values — ports, the public origin, the lobby URL the client should dial — got pulled out of the source and into env vars, because the one thing I knew from the start was that the client could not have localhost hardcoded anywhere once it left my laptop.

Phase 11 — Shipping it: CI, and a one-command deploy

Once the box was configured and I’d watched two browser windows side by side find each other on the live server, the next job was to make shipping a new version boring and safe.

I’ll be honest about what I’d imagined versus what I built. At the end of the last post I’d teased a full GitHub Actions pipeline that builds and rsyncs to the VPS on every merge. What I actually landed on is simpler, and I like it more for a project with a handful of players:

Tests run in CI on every push and PR. There’s a test.yml workflow that runs the full suite — 436 tests as of this writing, 319 server and 117 client — for both client/ and server/ on every pull request and every push to main. A separate lint.yml keeps formatting honest. That count was 400 when I wrote the last post; the suite grows with every feature because I make Claude grow it. Given that I haven’t written a single one of those tests, it’s a little funny how much I trust them. But they’ve earned it. They catch the logic regressions; I catch the feel.

The deploy itself is one command, run deliberately. I didn’t wire CI to push to the box. Instead, deploying is: SSH in, git pull, and run scripts/rebuild-restart-production.sh. That script rebuilds the server and client, re-grants nginx read access to the freshly rebuilt client/dist/, and runs pm2 restart all to bounce the lobby and every game server. It’s a deliberate act rather than an automatic consequence of merging, and for a game where I want to be the one watching the first post-deploy session, that’s the right amount of friction.

A few decisions here that I’m happy with:

The two layers restart independently. The lobby and each game server are separate PM2 processes, so I can pm2 restart castle without touching the lobby or the other maps. Restarting a game server does drop the players on that map — there’s no state migration, by design, since the whole project decided up front to have no persistence — but in practice “a deploy boots that room” is acceptable for a game where matches are short and there are, realistically, a handful of us.

Config is isolated, not compiled in. There are no secrets in the repo — the SSH key and host live in my own environment, not the tree — and the client’s production lobby URL is injected at build time from a gitignored .env.production. The server’s domain, ports, and public wss:// addresses do live in the tree, but corralled into two dedicated files — ecosystem.config.js (the PM2 process list) and nginx-example.conf — as editable templates read through env vars at runtime. The difference from the 1989 binary isn’t that nothing is written down; it’s that changing the domain is a one-line edit to a config file plus a restart, not a recompile-and-reship with a hard-coded path baked into the executable. That contrast is most of the moral of this series in one sentence, so I’ll stop pulling on it.

The end state is the thing I actually wanted: a green test suite on every change, and a single command that takes the box from old version to new. I don’t hand-edit anything on the server anymore — I pull and run one script.

griljor-initial-screen

Phase 12 — The unglamorous production hardening

Between “it deploys” and “I’d let my friends see it” there was a short, dull, necessary phase of the things you only need once other people — even friendly ones — are on the other end of the socket:

  • Keep the connection warm. The client sends a PING to its game server every 60 seconds. It’s a small thing, but idle WebSocket connections get reaped by intermediaries and load balancers if nothing crosses them, and a player who’s standing still reading chat shouldn’t get silently dropped.
  • Kick the truly idle. A player who genuinely goes away — five minutes with no input — gets a GM warning, then a one-per-minute countdown, then a kick after five more. (The keep-alive ping deliberately does not count as activity; only real actions reset the timer.) It frees up the slot and the avatar so a returning friend can rejoin as their old character.
  • Reap dead game servers from the lobby. The lobby already heartbeated game instances; the hardening added a purgeStale sweep so a game server that dies or stops heartbeating drops out of the lobby within seconds, instead of lingering as a ghost row with a Join button that goes nowhere.

The reunion playtest

Here’s the part I’d been waiting for since the first commit.

Griljor was never a solo project, not in 1989 and not now. I made the original with friends, in the labs at Berkeley, and we spent two or three years play testing it late at night precisely because it was fun to play it together. So once the live server was solid enough to not embarrass me, I sent the link to a few of the people who’d been there for the original — and one evening we got on a call and played.

The first thing, before any feedback, was just watching them log in. These are people I made a game with 37 years ago, picking avatars off the title screen — the same 32×32 faces I’d been startled to lay eyes on again back in Phase 1 — and walking into a room together. One of them recognized his old avatar immediately. Somebody threw a grenade within about ninety seconds. It felt, instantly and unmistakably, like the game we used to play. The project was really about learning Claude Code — but seeing the old game come back to life with friends in it was the moment I’d been quietly hoping for through every phase, and it landed.

Then, of course, they started finding things.

Playing with other people surfaced bugs solo testing never could. My integration tests fire one action at a time; a room full of people does not. The whole class of bugs that only appears with more than one human in the world showed up the moment we played together — and they were almost all about awareness. Players who’d walked out of a room still appeared to be standing in it, lingering as ghosts. People who joined didn’t show up in anyone’s player list until you happened to cross paths with them in person. There was no clean distinction between someone deliberately leaving and someone’s connection simply dropping. And in a crowd it was genuinely hard to tell who was on your team. None of these are things a unit test thinks to check, because writing the test requires already suspecting the bug exists. They got fixed in a pass I think of as the “player awareness” cleanup — join and leave announcements, a ghost fix, a proper “disconnected” message distinct from “left,” and team colors in the player list and on the map. The only reason they were tractable was that the server is the single source of truth: there was one place to add logging, not four clients to reconcile. Server-authoritative state, which I’ve praised in all three of these posts, paid for itself again.

Latency is a feeling, not a number. Locally, everything is instant; you forget round-trips exist. With friends on home connections across actual distance, the half-second-per-move pacing that I’d been calling “invisible” since the very first planning post was mostly still invisible — that slow tempo is exactly why the original survived a 1989 LAN and why JSON-over-WebSockets is fine today — but combat had moments where a thrown weapon and the report of a hit didn’t line up with what a player felt should have happened. Nobody asked for faster netcode. What they wanted was for the animation to read more honestly: clearer feedback on the exact instant of a hit, so the server’s authoritative truth and the player’s gut agree. That’s a feel problem, and it went on the list for a later phase.

The feedback was about feel and legibility, almost never about features. I’d braced for “you should add X.” Instead it was about understanding what was happening on screen: the mouse-button widget was too tall and didn’t clearly say what each button did, there was no way to discover the hotkeys, and a newcomer had nothing to teach them how to pick things up or manage inventory. Those turned into a help screen, a hotkey-discovery panel, a key-remap pass, and a redesigned action-card UI — small affordances, the same category as the Phase 5 tooltips, the stuff you only notice once someone other than the author is holding the controls.

And here’s the honest part, because it’s central to what this project actually was: this is a 1989 game. By the standards of anything you’d play today it is slow and small — you walk one tile at a time around 20×20 rooms and throw grenades at people. It was never going to draw a crowd, and drawing a crowd was never the goal. The goal was to recreate the original faithfully, to experiment with it, and — the real reason I started — to see how far I could get building something real end-to-end with Claude Code. So what the playtest gave back wasn’t proof that we’d made a great modern game. It was a couple of evenings of people who’d built the original in college walking their old avatars around again, recognizing rooms, trash-talking. The fun was in the reminiscing, and in already knowing the game — not in it competing with anything current. That was exactly the verification I was after, and no test produces it.

Reflections from going live

A few things from this stretch I want to keep:

A deploy you trust is a deploy you stop fearing. It isn’t fully automated and I’ve decided I don’t want it to be — but running the full suite in CI on every change, and letting Claude keep that suite growing after every feature, means the one command that ships is a command I run without holding my breath. The tests I didn’t write are load-bearing, and I’m fine with that, because I’m the one who plays every build before it goes out.

Real players are the test suite you can’t write. Tests catch logic. Playing alone catches feel. Playing with other people catches the entire class of awareness and latency and legibility problems that only exist when more than one human is in the room — the ghosts, the missing join announcements, the can’t-tell-who’s-who. And playing with these particular people caught the only question that mattered for a project like this: not “is it a good game by 2026 standards” — it isn’t, and it wasn’t trying to be — but “was it worth bringing back, and did I learn how to build it.” Both yes.

The game is up at griljor.com. It comes back on its own after a crash or a reboot, the lobby cleans up dead servers, and some of the people who built the original have walked their old avatars around the new one and had a genuinely good time doing it. For a project whose real aim was recreation and learning the tools rather than reach, that’s the whole win.