A multiplayer board game in Rust and WebAssembly

Click to play!

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.

Unusually, it's a web-based multiplayer game without any Javascript: both the client and server are written in Rust, which is compiled into WebAssembly to run on the browser. (There's a Javascript shim to load the WebAssembly module, but I didn't have to write it myself)

Architecture

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:

Diagram

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.

The wasm bundle and pont-server executable are both written in Rust and managed in the pont repository. They both depend on pont-common, 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 client and server communicate via WebSockets. Messages are strongly typed as an enum in pont-common, serialized using Serde, and packed into bincode to be sent as binary WebSocket messages.

Server

The game server using async Rust, which was... exciting:

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 smol runtime because it's got relatively few dependencies, and I appreciated that it wasn't trying to own the entire async universe (unlike Tokio and async-std).

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:

Server

The system has 2 + n_players + n_games async tasks running at one time:

These tasks each map to a smol::Task. (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!

Client

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 Connecting to CreateOrJoin to Playing.

The game board is represented as an SVG; everything else is standard HTML elements. In fact, the whole UI is pre-constructed in index.html 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.

Color-blind mode

I use direct DOM manipulation (from the web-sys crate) 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?)

For deployment, I'm using wasm-pack 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.

Conclusions

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.

Links

Play the game here, or check out the source on Github

Discussion on Hacker News

Questions? Comments? Send me an email!