Railway-Rivals
A full-stack multiplayer implementation of a train board game, with bot opponents that plan routes under uncertainty.
The problem
I wanted to understand how real-time multiplayer state stays consistent when players act simultaneously, and how to write an opponent that plays well without being able to see hidden cards.
Building it
Overview
Railway Rivals is a browser version of the classic “claim train routes across a map” board game genre, inspired by Ticket to Ride. Two to five players connect cities across North America by collecting colored train cards and spending them to claim routes. They race to complete secret destination tickets and try to build the longest continuous line on the board.
It started as a way to play the game with friends without anyone installing anything. It grew into a full product:
- Real-time multiplayer rooms with public, private and host-approved lobbies
- A complete rules engine: tunnels, stations, double routes, wild-card restrictions, two scoring modes and the longest-route bonus
- Bot opponents at two difficulty levels, so the game is playable solo
- Optional sign-in with Discord (guests are always welcome), with a stats profile that tracks wins, routes, trains and tunnels
- A mobile-friendly interface, in-game chat with an automatic turn log, and small moments of polish like a claim jingle, confetti and animated station drops
The project was a deliberate exercise in building a complete, deployed, stateful real-time application without a framework. It also turned into a long lesson in how many small things have to go right before a multiplayer game feels solid.
Goals
- Zero-friction multiplayer. Share a four-letter room code and you’re playing. No install, no account required.
- Faithful rules. The fun of the genre lives in its edge cases (tunnels, parallel routes, the “no wild as your second draw” rule), so they all had to be right.
- Server-authoritative state. Clients should never be trusted with the rules or with information they aren’t supposed to see.
- Playable alone. Bots had to be good enough that a solo game is worth playing, and robust enough that they never freeze a game.
- Keep it simple to run. One Node process, one HTML page, no build step. Every feature had to fit that shape.
Architecture
Browser (index.html) Node server
┌───────────────────────────┐ ┌────────────────────────────────────┐
│ Vanilla JS + SVG map │ Socket.io │ handlers.js (auth, wiring) │
│ applyState() renderer │◄──────────►│ ├─ lobbyHandlers.js │
│ Chat + turn log │ │ ├─ gameplayHandlers.js │
│ Supabase JS (auth only) │ │ └─ chatHandlers.js │
└─────────────┬─────────────┘ │ roomManager.js (rooms, players) │
│ │ gameState.js (rules engine) │
│ Discord OAuth │ botAI.js (bot opponents) │
▼ │ statsTracker.js (end-of-game stats)│
┌──────────────┐ service role │ rateLimiter.js │
│ Supabase │◄─────────────────┤ index.js (Express, /api/my-stats) │
│ Auth + Postgres └────────────────────────────────────┘
└──────────────┘
A server-authoritative rules engine
All game logic lives in a single GameState class on the server. Clients only send intentions (drawFaceUp, claimRoute, attemptTunnel, placeStation). The server validates each one against the current turn, phase and hand, mutates state, and broadcasts the result. A client can’t claim a route it can’t afford, draw out of turn or see another player’s hand, because none of that logic runs in the browser.
Personalized state instead of one broadcast
The genre is built on hidden information: your hand and your destination tickets are secret. So the server never broadcasts one shared state object. After every action, broadcastState() builds a separate view for each player with stateForPlayer(id). That view includes their own hand and tickets, and only counts for everyone else’s.
The same mechanism enforces scoring modes. In the hidden-score mode, everyone’s score is null until the game ends. The server hides the numbers; the client isn’t asked to.
Modular handlers
The Socket.io layer started as one large file and was split by domain: lobby, gameplay and chat, plus a small shared handlerUtils.js for broadcasting and end-of-game checks. Each module registers its own events on the socket, so adding a feature usually means touching one file.
A deliberately framework-free client
The whole client is one HTML file with inline JavaScript and a stylesheet. The map is hand-built SVG: 36 cities, 93 routes (13 of them tunnels) and 46 destination tickets. Double routes are drawn as offset parallel tracks. One central applyState(state) function re-renders from each server update, with a small render-state cache so unchanged routes aren’t redrawn.
I considered moving to Next.js when adding accounts and decided against it. The single-page architecture was working, it deploys as static files next to the socket server, and accounts could be added on top without a rewrite.
The rules engine
Translating a board game into code is mostly about edge cases. Some of the ones that shaped the design:
-
Drawing cards. You get two draws per turn. A face-up wild counts as both and ends your turn immediately, and a face-up wild can’t be your second draw. If three wilds ever show face up at once, the whole row is discarded and redealt.
-
Tunnels. When you attempt a tunnel, three cards are revealed from the deck. Each one matching the color you played costs an extra card, which you either pay or forfeit the attempt. The cards you committed leave your hand at attempt time, so a player can’t spend the same cards twice while the tunnel resolves.
-
Stations. A station lets you borrow one opponent’s route through a city for your ticket connections. The cost rises with each station (1, then 2, then 3 cards of one color).
-
Double routes. In games with three or fewer players, only one of a parallel pair can be claimed.
-
Ticket completion is checked with a breadth-first search over a graph of the player’s claimed and borrowed routes. It runs after every claim, tunnel, station placement and ticket pick, so picking up a ticket you’ve already connected completes it immediately.
-
Two scoring modes.
- Race mode: the final round triggers when someone reaches 100 points or runs low on trains. Scores are visible throughout.
- Classic mode: scores stay hidden until the end, and the final round only triggers when someone runs out of trains.
Both end with the longest-continuous-route bonus.
Getting “100 points” to mean 100 points
One bug report shaped how scoring works. A player hit 126 points, which triggered the final round, and then finished on 90. Their unfinished tickets were subtracted at the end.
The fix was to make the rules and the display agree on one number. The trigger now uses a net score: current points minus the value of every ticket not yet completed.
netScore(playerId) {
const pending = (this.tickets[playerId] || [])
.filter(t => !t.completed)
.reduce((sum, t) => sum + t.points, 0);
return (this.scores[playerId] ?? 0) - pending;
}
Your own score chip shows that net figure with the pending penalty beside it (84pt −17). Opponents only ever see your raw score, because how many points you stand to lose would reveal how many tickets you’re still holding.
Bot opponents
Bots made solo play possible. They were also the most demanding part of the system to get right.
Two personalities
- Beginner bots claim whatever routes they can afford and draw otherwise. They sometimes “hesitate” and pass instead of drawing. That makes them feel less mechanical, and it stops a table of beginner bots from hoarding the whole deck.
- Expert bots plan. Each turn they compute the shortest train-weighted path for their destination tickets, including tunnels, and work toward the next hop. They keep the colors that plan needs in reserve, so they don’t spend cards they’ll want later. They attempt a tunnel only when they hold a randomized safety buffer of spare cards to cover the reveal. When drawing, they prefer a face-up wild because it counts as both draws.
Never letting a bot freeze the game
A frozen bot freezes the whole table, so turn scheduling went through several rounds of hardening.
Each scheduled bot turn gets a unique token. A bot acts only if its token is still current, which stops a stale timer from an earlier turn from firing in the middle of a new one. Every scheduled turn also gets an independent 15-second watchdog that forces a pass if the turn never completes.
Two subtle bugs lived here:
- The watchdog was quietly disabled. The token was being cleared before the bot’s turn ran. The watchdog checks that the token is still current, so it could never fire during the part of a turn most likely to hang. The fix was to release the token inside the turn, on every exit path.
- Consecutive bots were silently skipped. Broadcasting new state is what schedules the next bot. That broadcast happened while the current bot still held its token, so the next bot’s scheduling was refused, and a game with several bots could stall indefinitely. The token is now released before broadcasting, on every path.
Event ordering
Human actions always run in the same order: update state, broadcast the new state, then emit the action event (cardDrawn, routeClaimed). The client’s chat turn log depends on that order. It decides whether a player’s turn is over by checking the already-updated state when the event arrives.
Bot turns originally emitted their events before broadcasting. So a bot’s turn-ending draw arrived while the client still thought the bot was mid-turn, and the log entry never closed. The fix routes a bot’s events through a small deferred emitter, which holds them until after the broadcast:
function makeDeferredEmitter(io) {
const queue = [];
return {
to: (room) => ({ emit: (event, payload) =>
queue.push(() => io.to(room).emit(event, payload)) }),
flush: () => { queue.splice(0).forEach(send => send()); },
};
}
Every bot action now ends in one finish() helper: release the token, broadcast state, flush the queued events, check for game end. Bots follow exactly the same order as humans.
Accounts and stats
Accounts were added on top of a game that already worked for guests, so the constraint was clear: signing in must never be required, and an auth outage must never break the game.
- Discord OAuth through Supabase. No passwords to store. The browser uses Supabase only to sign in and to get an access token.
- The server verifies identity. The token is passed to Socket.io and checked server-side with Supabase’s service-role key. The database tables have row-level security enabled with zero policies, so the browser can’t read or write them directly at all. Only the server can.
- Stats are recorded once, at game end.
statsTracker.jswrites agamesrow and onegame_playersrow per signed-in player: trains placed, routes claimed, tunnels attempted and succeeded, points, longest route and win/loss. It runs fire-and-forget, so a slow or failing database write can never delay the end of a game. - Only games against real opponents count. Games with any bots, or games where fewer than two humans are left at the end, are skipped, so stats can’t be farmed against bots.
- A profile page reads everything through a server endpoint (
/api/my-stats) that verifies the bearer token before aggregating the rows. - Discord avatars are captured at sign-in and stored with the player record, ready for leaderboards and match history.
Frontend and experience
The map
The SVG map is the whole game, so it got most of the polish:
- Routes are drawn as individual train-car segments in the owner’s color, and double routes as offset parallel tracks.
- Clicking a player highlights everything they’ve built.
- A ticket-highlight mode traces your destinations with dashed lines.
- City labels have per-city offsets so dense regions stay readable, and labels don’t block clicks.
- Claimed routes, completed tickets and newly placed stations get short animations: a pulse, a pop, and a station “drop-in” with an impact ring.
The turn log
The chat panel doubles as a game log. Other players’ actions are buffered per turn and summarized in one line (“Bot 2 drew 2 cards”, “Alice claimed Denver → Omaha +7 pts”). Your own draws get a private recap with color swatches. It’s built locally in your own browser from the cards the server confirmed to you, so the colors are never sent to anyone else.
Mobile
A dense board game on a phone needed its own pass:
- Touch detection sets an
is-touch-deviceclass, which proved more reliable than viewport media queries alone. - The ticket picker was restructured around a single scroll container, with a larger map preview.
- In the mid-game ticket picker, you can tap a route on the preview map to see which card colors it needs.
- A rotate-to-landscape hint appears on narrow portrait screens.
End of game
The results screen shows final standings with confetti. The host can start a rematch in the same room. A View Board button hides the overlay so players can look over (and screenshot) the finished map, and Esc brings the results back.
Security and robustness
Players type names that every other player’s browser then renders, so a name is an injection vector. I hardened it at three layers:
- Server-side sanitization is the real boundary.
sanitizeName()strips angle brackets, trims whitespace and caps the length before a name is ever stored. - Escaping at every render site. An audit found six places that inserted names with
innerHTMLunescaped, including the chat sender name sitting right next to message text that was escaped. - No string-built event handlers. The in-game kick button was built as an
onclick="kickPlayer('…')"string, so a crafted name could break out of the attribute. It now usesaddEventListenerwith a closure.
Other hardening:
- Rate limiting on room creation, joining, room listing and chat, using sliding windows stored on each socket. Limits are per connection rather than per IP, because client IPs aren’t reliable behind Cloudflare and Railway’s proxies. Storing them on the socket means they’re cleaned up automatically on disconnect.
- Connect first, authenticate second. After a hosting migration, the site stopped creating rooms entirely, with nothing in the server logs. The client was waiting for the Supabase session check before opening its socket, so any auth failure meant it never connected. The socket now connects immediately. Authentication happens in parallel, and the token is sent over a late
authenticateevent whenever it’s ready. - Automatic CSS cache-busting. Express serves
index.htmlthrough a small route that stamps the stylesheet URL with the file’s modification time. Deploys invalidate cached CSS on their own, and nobody has to remember to bump a version number.
Deployment
The game runs as a single Node process on Railway, deployed from GitHub on every push. Cloudflare provides DNS for the apex domain. Supabase hosts Postgres and auth.
Midway through the project I moved the app to a different Railway account. That meant re-creating environment variables, re-pointing DNS, and updating the allowed redirect URLs in Supabase and the Discord developer portal. It also surfaced the connect-before-auth bug above. The site worked in every environment where auth happened to succeed, and failed silently in the one where it didn’t.
Selected bugs worth remembering
A few problems taught me more than the features did:
- A missing function silenced the celebration. Crossing 100 points played no jingle and showed no final-round banner. The banner function was called in three places but had never been defined. The
ReferenceErrorstopped the rest of the event handler, jingle included, without any visible error in the game. - A game mode that never existed. While testing the net-score change, I found that the per-player setup loop in
start()reset the game mode to 1 on every start. The hidden-score mode had never actually run. The default now lives in the constructor. - The race between a reply and a broadcast. Your private draw recap sometimes showed one card, then carried the other into your next turn. The server sends the updated game state before its reply to your draw. The client posted the recap on “turn changed” and missed the card that was still on its way. The fix was to post the recap when each card arrives, since by then the state already says whether that draw ended your turn.
- Centered text that wasn’t centered. The instructions text was centered within its box, but the box itself sat off-center. A
float: rightclose button was narrowing the flex container that followed it. Switching tomargin-left: autofixed it. - The chat that appeared too early. The chat panel showed up on the home screen for a moment on first load, because its CSS default was visible and only JavaScript hid it. Making the stylesheet hide it by default fixed that at the source.
How I tested
A live multiplayer game makes bugs hard to reproduce by hand, so most fixes were verified with small throwaway harnesses before they shipped:
- Server logic was driven directly in Node: construct a
GameState, script a scenario (force a wild into the face-up row, set a player’s trains to zero), and assert on the result. - Bot scheduling was run end-to-end with the real
botAI.jsand a fakeiothat recorded every emitted event, then fed through the client’s chat-log logic, to catch problems like merged turn summaries. - Event ordering was checked over a real Socket.io connection, with two clients and the actual gameplay handlers, logging the exact order in which the state broadcasts and replies arrived.
- Client behavior was tested in jsdom: rendering, escaping, button states and keyboard handling. jsdom has no real layout engine, so visual problems still needed a real browser.
What I learned
- Order of events is part of your API. Many of the hardest bugs weren’t wrong values. They were right values arriving in an unexpected order. Once humans and bots were forced through the same “update, broadcast, then announce” sequence, a whole class of bugs went away.
- The server has to own hidden information. Personalized state per player made scoring modes, secret tickets and private draw recaps all follow from one design decision.
- Make failures visible. Silent failures cost the most time: a handler dying halfway through, a watchdog that could never fire, a socket that never connected. Most fixes were really about making that failure structurally impossible by hardening the code rather than patching the symptom.
- Optional systems shouldn’t be able to take down core ones. Auth, stats and analytics are all layered so that any of them failing leaves the game playable.
- Rules and display must agree. If the game triggers the final round, players have to be able to see the rule that triggered it.
What’s next
- End-of-game ticket reveal: show every player’s tickets and trace the routes they built for them, on the final board.
- Persistent game state (e.g. Redis), so a server restart doesn’t end games in progress.
- Spectator mode for friends who want to watch.
- Leaderboards and match history, using the stats and avatars already being collected.
- Skill-based rating (ELO), once there are enough regular players for it to mean something.
What I’d do differently
- I would integrate user feedback more often. I went long stretches just writing code and adding quality of life updates that I thought were useful while i was playing the games. I failed to consider what a user with no prior knowledge of the game and interfaces would need in order to have a smooth experience. Small things like the rgb color values being hard to read, being unable to see what other people’s claimed routes are, and a chat log to see what other people have done including what cards were drawn from the deck.
- During the early to mid stage of this project the code for this was built like a piece of patchwork cloth: a bunch of features and rules stitched together to form the game. However the need for better debugging and in order to better comment and reduce server/user load times I had to rewrite and clean up a lot of code. I often found functions with similar uses that had code blocks similar enough that I can combine the function and utilise it for the same purpose (the map renderer can be used for the initial ticket picker, the playable svg map and alsop the midgame ticket picker).
- I wish i had integrated a better testing method for my commits. Before my addition of Bot AI i had to use two browser windows to host and join the game to test any bug. Whether it be deployment issues, gameplay features or just UI changes. After I implemented the Bot AI and deployed the website onto cloudflare I was able to just go to https://railwayrivals.com and just play a bot game to verify my commits.
What I learned
Authoritative server state is non-negotiable. Implementation of scenes and rendering of different objects using the same CSS on desktop and mobile systems. The bot AI taught me more about scoring heuristics than about search: the interesting problem wasn't looking further ahead, it was deciding what a partially-built route is worth. Lastly website deployment with discord account authorization.