Code Sample - chukinas/trans-siberian-railroad GitHub Wiki

I spent several months at the end of 2024 developing a browser-based implementation of the game Trans-Siberian Railroad. Unfortunately, I was forced to put it aside in order to focus my energies on my job search. But even in its incomplete state, I think it does a good job of highlighting my developer skills and approach.

image-20250605134304420

tl;dr

image

Background

First, let's give credit where credit is due. The original Trans-Siberian Railroad is a board game designed by Tom Russell and published by Rio Grande Games. I own my own copy and highly recommend it as a middle-weight game.

I'm drawn to it because of its excellent gameplay. But also, I've been learning Russian for the past year. It's fun to have game components written in Cyrillic, to learn the correct pronunciation of the city names, and experience this historical period.

Advertisement from Spielbox (Issue 1 - 2021)

Jugando primera partida

Don't get the board game confused with the completely unrelated TransSiberian Railway Simulator video game.

App Overview

This is an Elixir/Phoenix/LiveView/Tailwind application hosted on fly.io. There are two main sections of the code:

  • The part that runs the frontend chukinas.com/ demo. This is a relatively simple LiveView.
  • The backend containing a large chunk of the game engine, plus a ton of unit tests.

These would ultimately be merged into one unified codebase one day.

Frontend Demo

Let's dive into the chukinas.com demo. Looking at router.ex, we can see that TsrWeb.GameLive is the responsible LiveView. It creates a TsrWeb.GameState "view model" struct. Every half second, the LiveView ticks over and adds a new rail link to one of the six company colors. Simple enough.

Backend

The backend is built around Event Sourcing, inspired largely by:

I love this architecture:

  • The source of truth is an append-only list of commands and events for each game
  • I can ask questions of the events list like "Whose turn is it?", "How much money does player 2 have?", or "Which company built the Moscow-Kazan rail link?"
  • It's very human-readable. I could print of the list of events, hand it to a non-programmer board gamer, and they could probably reproduce the current game state.
  • No data goes to waste. Every state change is captured and saved.
  • Time travel: It's very easy to figure out what the game state looked like at any given point in time.
  • Debugging almost comes for free. Because the main data structure is a list of "things that have occurred", I don't have to do any print statements or tracing when a but happens. I just look at the list of events. "Oh strange, when player X placed an invalid bid, the code didn't produce the correct bid_rejected event. Lemme go fix that."
  • Auditing also essentially comes for free. Your data is already very much in the shape you need.
  • Testing is easy. I can build a list of events (current game state), submit a command, and then check to see if I got the expected event(s) as a result. Not need for a database, so they are super fast. All this setup is easily collected into setups (see example). Very easy to use a Given-When-Then organization in each test (see example).

Let's look at how the system works using these three messages as an example:

  • command: "add_player"
  • event: "player_added"
  • event: "player_rejected"

These three messages (along with every message in the game) are defined in Tsr.Messages. These messages and their payloads are the contract that are parts of the system must follow. In this module, commands and events are grouped semantically. The fact that these three messages appear together hint very strongly that a frontend-issued "add_player" command will result in either a "player_added" or "player_rejected" event.

A game's top-level struct is the Tsr.Game. It holds all the command and events that have occurred in a game. When "add_player" is created on the frontend (which doesn't yet exist), it's passed to Tsr.Game.queue_command/2. Every time the game ticks over, execute/1 is called. This complicated function recursively works through all queued commands, generating and storing all the needed events and aggregations.

We can watch this work at a high level by looking the test suite: Tsr.Aggregator.SetupTest. We have an "add_player -> player_added" test and a "add_player -> player_rejected" describe block. The latter tests all the various reasons the command might be rejected (e.g. already too many players). The actual construction of the "player_added" command is hidden in some test helpers and factories like add_player/1 called on line 36.

A key past of the Event Sourcing system is that each event is only ever issued by a single service. In the case of our "player_added" and player_rejected" events, the service is Tsr.Aggregator.Setup. Like every other aggregator in this system, it's fed every command and event fed to the Tsr.Game struct. It reacts to a small subset of them and ignores the rest. We can see all the code related to our three messages on lines 45-61.

  • handle_command "add_player" watches for "add_player" and emits either "player_added" or "player_rejected".
  • That same "player_added" events is handled on the next tick via handle_event "player_added". This block updates the aggregator's private internal state.

This concludes the tour of the major system elements.