Revisiting Quarto by Coding it as a WASM Component
One of the goals of WebAssembly is to write once, run anywhere 1 2. I wanted to explore that idea more, and so I set out to write the game logic for the board game Quarto with the intention of never writing it ever again and being able to re-use that logic in JavaScript, Godot Script, Java, and pretty much any other language that supports WASM now and in the future.
The final Quarto game can be played here.
The interface for the WASM module an be found here.
As I've said in my previous WASM posts, the core of WASM can only accept and return numbers. Obviously that sucks if you want to treat Web Assembly like a code library. It's not a good experience to be passing around memory pointers and lengths. As Ryan Levick said in his talk at Wasm I/O 2024, this sort of low-level interaction uses shared-everything (shared memory) and an ad hoc protocol for communication - there is no standard protocol for creators and consumers of WASM modules.
This is where WebAssembly Components and the Wasm Interface Type (wit) format come in.
The big idea is that when you're building a WebAssembly module, the starting point should be building the high level interface between your WASM module and the environment. This interface specifies the imports, exports, and data types using the Wasm Interface Type format. So although a WASM function can't directly accept or return structured data, you could still describe the desired interface and then auto-generate the code that allows seamless interoperation between the WASM Module and the environment. And that's essentially what wasm-bindgen does if you want to use Rust in JavaScript - but the grand vision is that if you have a WASM Module and a WIT file, you could generating bindings for any programming language.
To give a concrete example of this, take a look at these files in my project repository:
- quarto.wit - The WIT interface for using the Quarto WebAssembly Component
- lib.rs - The Rust code that implements the interface functions. Also note the bindings.rs file which is auto-generated by wit-bindgen whenever you change the wit file.
- The generated JavaScript bindings and TypeScript interfaces
- game.mjs - JavaScript using the interface to have the computer play Quarto against itself.
- docs - Generated documentation showing the required imports/exports to use this WASM module
My project uses cargo component to create the Quarto WebAssembly components using Rust as the component's implementation language. I compile to WebAssembly and then use jco to transpile the WASM and WIT file into a JavaScript library that calls the WebAssembly module under the hood.
I begin this project using low level data structures like [i32; 16]
for the game board/pieces and even writing an implementation using a single i128
to hold the entire game state. This is not necessary, but this is what I did originally and so I kept it. The implementation worked and I didn't want to revisit it just to swap out data structures.
After that, I discovered WebAssembly Components and the cargo component tool created by the Bytecode Alliance. I followed the instructions in the "Getting Started" section and then moved my original Rust code into the project repo. You'll notice in lib.rs that I'm calling into my existing library functions when adding code for the guest interfaces.
Finally, I setup a powershell build script to handle all the tasks needed to go from Rust all the way to JavaScript, TypeScript, and into my Godot game project. See build.ps1 for all the details but in a nutshell...
First I build the rust project with the wasm target.
cargo build --release --target=wasm32-unknown-unknown
Then I embedded the WIT interface into the WASM module. Think of this like adding metadata to the WASM file.
wasm-tools component embed `
wit/quarto.wit ./target/wasm32-unknown-unknown/release/quarto.wasm `
-o ./target/wasm32-unknown-unknown/release/quarto.wasm
Then I created a new WASM component (not the same as a WASM module)
wasm-tools.exe component new ./target/wasm32-unknown-unknown/release/quarto.wasm -o ./dist/quarto.wasm;
Then I transpiled the JavaScript/TypeScript bindings with jco.
jco transpile ./dist/quarto.wasm -o dist/quarto-bindings --map math=./quarto-imports.js;
Then I re-generated the type information because the previous command didn't seem to automatically include the documentation comments.
jco types wit/quarto.wit -o dist/quarto-bindings;
Finally, I created type documentation using typedoc
typedoc --out docs dist/quarto-bindings/quarto.d.ts"
A few random notes
WASM Maturity
I should not that WASM tooling is still in its infancy - Godot doesn't yet support WASM modules with it's HTML5 target 1 2. That doesn't mean there aren't workarounds, but it means that the standardized solutions aren't there yet. When I was working on this project, I had to move the WASM into the HTML shim and then have Godot call into the browser via the JavaScriptBridge.
There are a still design decisions happening in the WASM space and tooling is waiting for that to settle down.
How jco maps WIT Types to JavaScript Types
When using jco, the error variant of the "result" type is mapped to a thrown Error in JavaScript. You might want to choose the "option" type instead of "result" if you don't want a thrown error in your JavaScript bindings.
How does jco handle WASM imports?
My Quarto module requires that a "random" function be imported at the math.random
path. You can tell jco
which file can provide the imports with the --map
command line argument, e.g.
jco transpile ./dist/quarto.wasm -o dist/quarto-bindings --map math=./quarto-imports.js
quarto-imports.js
contains this code. After I transpiled, I needed to create this file manually.
export function random() {
return Math.random();
}
Reference Links
- Cargo Component cargo subcommand tool: https://github.com/bytecodealliance/cargo-component
- Web Assembly Component Model Documentation : https://component-model.bytecodealliance.org/introduction.html
wasm-tools
command line tool: https://github.com/bytecodealliance/wasm-toolsjco
command line tool : https://github.com/bytecodealliance/jco- Godot WASM : https://github.com/ashtonmeuser/godot-wasm/wiki/Imports-&-Exports
Is this really "run anywhere"?
Consider this question: What does it take to...
- Run the game in a terminal?
- Run the game in the browser (desktop and mobile)?
- Run the game on android or iOS?
- Run the game in a desktop client?
- Run the game on an embedded system with a screen and a mini-keypad?
First, there is the pure game logic. It's enforcing game mechanisms, keeping track of state, checking if someone won, making an algorithm for the AI player, communicating what moves the AI player, made and so forth. It's an API that is the same regardless of the host system. This is sort of what I built into my WASM code, minus some of the safety mechanism.
Second, it's building the specifics of the host-system. You might use simple console logs and prompts for the terminal app. You can build the browser application in three.js or paper.js or make something even simpler. The host-system provides menus and UI rendering and the user experience. None of this is included in my WASM code. And then of course you have to consider that the host has to call the logical functions in the right order.
If I really wanted to get closer to "code once, run anywhere", I could probably write some functions to coordinate function calls and keep track of the step in the game and make callouts to the host system. But as it stands, my WASM module is more like a very portable library as opposed to a full fledged application.