A multiplayer board game in Rust and WebAssembly
Pont is an online implementation of Qwirkle, a board game by Mindware Games. It was written for my parents, so they could play with friends and family during the COVID-19 stay-at-home era.
Play is split into rooms,
which are identified by a three-word code
size moody shape in the image above).
Within each room,
the game distributes pieces,
enforces the game rules,
and provides a local chat window.
Keep in mind, I'm not a web developer, so this is probably a weird outsider architecture for web applications. Here's what the system looks like:
The system uses Let's Encrypt for certificates: both static assets and WebSocket communication are encrypted between the client and the server. The game server does not communicate securely with the NGINX proxy, but if anyone is on the server watching, I've got bigger problems.
wasm bundle and
pont-server executable are both
written in Rust and managed in the
They both depend on
which defines basic types and logic for gameplay
(e.g. so that both the client and server can check whether a move is legal).
The game server using async Rust, which was... exciting:
- There's a "very polite cold war" going on between the two major (incompatible?) runtimes, with packages that only work in one or the other
- An infinite supply of options for channels
- General confusion between
futures_util(exacerbated by the fact that Googling for things will land you into a random version of the docs)
- Error messages that rival C++ in incomprehensibility
To be fair, the Rust
async ecosystem is relatively new,
so most of this can be ascribed to growing pains;
I'm sure that the ergonomics and library situation
will improve over time.
I ended up using the
because it's got relatively few dependencies,
and I appreciated that it wasn't trying to own the entire async universe
(unlike Tokio and
After getting over those hurdles, the server architecture is relatively straightforward. A bunch of independent tasks run asynchronously, communicating to the outside world via WebSockets and internally via unbounded MPSC queues.
Here's an example of the server running one game (with two players), plus one new client who has just connected. Each rectangle represents an async task:
The system has
2 + n_players + n_games async tasks running at one time:
- A top-level task which accepts incoming connections.
- A top-level task which logs the number of active rooms, once per minute
- One async task per client connection, which passes messages between the WebSocket connection and the application's internal queues.
- One async task per room, which handles game state
These tasks each map to a
(As a small optimization,
the first player's
Task handles both the player communication
and running the room, which is why they're both blue in the diagram above)
The server compiles down to a 5 MB static binary. The whole system is hosted on the smallest VM offered by Digital Ocean, which is a $5/month machine. I'm looking forward to the inevitable Hacker News DDOS, where I can see how well it scales!
The client is 2000 lines of framework-less excitement.
It uses a
state machine pattern
to represent the flow of the game,
accepting messages from the server
and updating the state accordingly:
for example, top-level state flows from
The game board is represented as an SVG;
everything else is standard HTML elements.
In fact, the whole UI is pre-constructed in
and revealed on demand.
The client has a bit of polish: Pieces are animated as they move around the board, and there's an optional color-blind mode, which adds corner markings to indicate color.
I use direct DOM manipulation
to control the system.
This exercise has left me appreciating the usefulness of virtual DOMs,
but I didn't want to bring in the complexity of a framework.
(Is there a Rust +
wasm version of Svelte yet?)
and serving the resulting
wasm blob from the same server as the other static assets.
The main challenges on the client side were (of course) dealing with cross-browser compatibility: Safari, in particular, supports fewer features and has funky handling of touch events.
After a bit of a learning curve, this all worked surprisingly well!
The client side is still a bit messy, with animations, UI inputs, and server events all fighting to break the system's invariants. For example, there was a nasty bug where dragging the board while an animation was running could drop the system into an invalid state.
This isn't surprising: stateful UIs are hard, which explains the popularity of declarative approaches. At this point, the client is feature-complete and hasn't quite collapsed under its own weight, so I'm not inclined to do any dramatic refactorings.
Rust as a language continues to be great, despite minor complaints. I already discussed the async ecosystem above, and won't dwell on that any further. On the client side, there are often impedance mismatches with WebAssembly: for example, using Rust closures as callbacks requires cryptic boilerplate.
Still, with all of the pieces in place, making changes is pleasantly fast, and I trust the compiler to check that I haven't broken anything.
I'm particularly happy with the combination of WebSockets, Serde, and bincode: having both the client and server process a strongly-typed stream of events makes things easy to reason about.
Questions? Comments? Send me an email!