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.
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:
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:
- 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
,std::Future
, andfutures_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 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:
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 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.
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
Questions? Comments? Send me an email!