I’m building a fan-made web adaptation of The Lord of the Rings: Duel for Middle-earth. It supports the base game and the Allies expansion. This is a private-entertainment project and is not affiliated with the game’s publishers or rights holders.

The board game has a problem a browser doesn’t solve for free: each player holds information the other player must never see (facedown cards, hidden Ally choices), and a full turn can take place with only one player at the keyboard, hours or days apart. I wanted two people to be able to play a real game of Duel over the internet, live or by turns, without either browser secretly knowing more than its player is supposed to.

The shape that made it work: a rules engine, not a page

The code is a small monorepo with three packages: an engine, a server, and a client. The engine is plain TypeScript with no runtime dependencies. It exports four functions modeled on the setup / apply / legalActions / view contract used by the open-source project Rally the Troops. I looked at reusing Rally the Troops’ server code directly and decided against it; the licensing and content scope didn’t fit a publisher-adjacent fan project, so I reimplemented the contract from scratch in TypeScript instead of porting their server. That decision is written up as an ADR (architecture decision record) in the repository.

apply() is the important one: a browser submits a proposed move, and apply() calls legalActions() internally and rejects anything that isn’t on that list before it touches game state. The browser never decides what’s legal: it just puts a candidate move in front of the server. view() builds a redacted copy of the state for a given seat, so the redaction isn’t a filter bolted onto the server afterward, it’s a property of the engine itself: there is no code path that hands a client the full state and trusts it to hide the parts it shouldn’t render. Setup uses a seeded random number generator, so a given seed reproduces a given shuffle. That’s useful for replaying and testing full games deterministically.

The server (Express, the ws library for WebSockets, SQLite via better-sqlite3) is the only thing that ever calls apply(). It keeps a socket per connected player, and broadcasts each seat’s redacted view() after every accepted move. For asynchronous play, none of that changes: the state and the redaction rules are identical, and the only difference is whether a socket happens to be open when it happens. Because the board lives in SQLite, a persisted game survives a server restart or a player closing the tab, and reconnecting rebuilds the same redacted view.

One board, two ways to play

Two people can play live in separate browsers, with both boards updating as moves happen. They can also use the same room asynchronously, more like play by email: a player can close the page and come back whenever they’re next free. The room flow supports public, unlisted, and password-protected games; players choose seats in a lobby, and private participant details stay with the people in the room. Both modes are exercised by dedicated end-to-end tests: one drives two real browser sessions through creating, joining, and alternating live moves and checks that hidden information never reaches the wrong seat or a spectator; a second restarts the server mid-game and confirms both clients reconnect to the correct redacted view and that a game with only one player currently connected still works and still notifies the other player when it’s their turn.

The game works on desktop and on phones. On a smaller screen the board is split into focused surfaces for the table, map, player state, opponent state, and chronicle instead of shrinking the full desktop layout until it is hard to use.

A redesign I had to make: the quest track

The quest track (the sliding piece that tracks each side’s progress toward their win condition) went through a real redesign, not just a polish pass. My first implementation got the geometry wrong relative to the physical board component. I went back to source: the printed rulebook page and the exact 3D models and textures from the Tabletop Simulator mod for this game, matched by their file hashes, to rebuild the slider assembly correctly, with the true aspect ratio, a transparent slider, and the Nazgûl piece mounted the way the physical component actually sits. That correction is documented in the repository’s dated planning notes, and the fix replaced the first version outright rather than patching around it.

The notification system went through a similar course-correction, but on paper before code: an early plan to send turn-notification email through Cloudflare was reviewed and replaced with a plan built on AWS SES instead, documented as an explicit correction in the repository before the SES-based version was implemented.

Identity, notifications, and chat

Guest play only needs a name, kept alive by a durable session cookie. A player can optionally verify an email address to keep the same identity across browsers and devices and receive a notification when a turn is ready. That email-verification flag and the notification opt-in are deliberately separate bits of state in the database. A player can be signed in and verified without being subscribed to turn emails, and unsubscribing from turn emails doesn’t touch their ability to sign in. Signing out or switching accounts only replaces the current device’s session row; a bug in an earlier version briefly coupled login eligibility to the notification setting, and the fix now has a regression test that signs in on a second browser while the first browser’s session stays valid.

Turn-ready email itself is a small outbox system rather than a direct send-and-hope call: an event queues a row, a worker sends it through an SES adapter, and a separate EMAIL_DELIVERY_ENABLED switch defaults to off so nothing can ship with live sending silently turned on.

Room participants can write messages beside the game chronicle. Chat is stored and displayed in chronological order with game actions, but it does not change the authoritative game state. Spectators cannot read or post those messages.

Verification

The engine has its own unit-test suite covering setup, costs, effects, Allies, alliances, landmarks, redaction, and full simulated games. The server has integration tests for the lobby, rooms, auth, and the email subsystem. On top of that, Playwright drives real two-browser end-to-end scenarios: the live-play, restart/async, and cross-device-login flows described above, plus a set of visual and mobile-layout screenshot checks. None of this is wired into a CI pipeline yet; the checks run locally before a change ships.

Where it stands

I still need to settle the access model and the rights-safe treatment of the game’s artwork and trademarks before I expose a public play link.