Programming WebAssembly with Rust Part 2: Building With Rust

Matthew MacFarquhar
13 min readOct 14, 2024

--

Introduction

I have been reading through this book which goes through the intricacies and benefits of building to and using Wasm for creating modules that can be run in polyglot environments. I will be augmenting and summarizing the book chapters throughout these articles.

In the previous section of this series, we took a deep dive into WebAssembly by building a WebAssembly (WASM) module manually using WebAssembly Text Format (WAT). While WAT provides a good foundation for understanding the low-level mechanics of WASM, it’s not something developers typically use for large-scale projects. Instead, most opt for higher-level languages like Rust that can compile down to WebAssembly.

In this section, we will follow a more practical and widely-used approach by utilizing Rust to build a WebAssembly module. Our goal is to create a simple checkers game in Rust, then compile it to WebAssembly, and finally load and “play” it in the browser. Through this, you’ll see how Rust simplifies WebAssembly development, while still providing the performance and low-level control that make WebAssembly so powerful.

The code for this article can be found in this github repo

Rust Checkers: Structuring the Game

To build our checkers game in Rust, we’ll follow a modular design pattern to keep the code organized and scalable. This approach separates the logic into distinct modules, each responsible for a specific aspect of the game. By structuring the code this way, we ensure that each part of the program can be developed, tested, and maintained independently.

Here’s how we’ll break down the game logic:

1. The Board Module

The board module will handle the game’s core data structures and manage the movement of pieces. This includes:

  • Defining the Board Structure: We’ll create a grid that represents the 8x8 checkers board.
  • Piece Representation: Each piece (either a regular piece or a king) will be stored with attributes like color (black or white) and type.
  • Handling Piece Movement: The board will handle piece placement, movement, and removal after a jump.

By keeping these responsibilities in the board module, we isolate the basic mechanics of the game from the higher-level rule enforcement and game flow.

2. The Game Module

The game module will handle the rules and the overall flow of the checkers game. This will include:

  • Validating Moves: Ensure that only valid moves are executed according to checkers rules (e.g., diagonal movements, jumps, etc.).
  • Turn Management: Alternating turns between players.

This module acts as the controller, overseeing the interaction between players and the game logic.

3. Connecting Everything with WebAssembly

Finally, we will expose our Rust game logic to the browser through WebAssembly by defining functions in a lib module. This library will:

  • Provide WASM Bindings: Expose methods to interact with the checkers game, like initializing the game, making moves, and getting the board state.
  • Communicate with Host: We wiil define a set of functions the host provides which we will in turn call throughout our Rust code to do things like notify a piece has been crowned or a move has been made.

Board Module: Game Piece and Coordinates

The board module is responsible for handling the fundamental game components: the pieces on the checkers board, their coordinates, and the moves they make.

1. Game Piece and Piece Color

At the core of the checkers game are the game pieces, which can be either black or white. In Rust, we define a PieceColor enum to represent the two possible colors:

#[derive(Debug, Copy, Clone, PartialEq)]
pub enum PieceColor {
WHITE,
BLACK
}

Next, we define a GamePiece struct, which represents an individual game piece on the board:

#[derive(Debug, Copy, Clone, PartialEq)]
pub struct GamePiece {
pub color: PieceColor,
pub crowned: bool
}

Here, color holds the piece’s color (WHITE or BLACK), and crowned is a boolean that determines if the piece has been promoted to a king.

To create new game pieces, we implement a simple constructor. We also provide a method to take a piece and return a crowned version of itself.

impl GamePiece {
pub fn new(color: PieceColor) -> GamePiece {
GamePiece {
color,
crowned: false
}
}

pub fn crown(piece: GamePiece) -> GamePiece {
GamePiece {
color: piece.color,
crowned: true
}
}
}

2. Coordinate System

In checkers, every piece occupies a position on the board, which we represent using a Coordinate struct. Each coordinate is defined by an (x, y) pair of unsigned integers:

#[derive(Debug, Copy, Clone, PartialEq)]
pub struct Coordinate (pub usize, pub usize);

To ensure that any given coordinate is valid (i.e., within the bounds of the board), we define an is_on_board method:

impl Coordinate {
pub fn is_on_board(self) -> bool {
let Coordinate(x, y) = self;
x <= 7 && y <= 7 && x >= 0 && y >= 0
}
}

In checkers, pieces can either move or jump over an opponent’s piece. We can calculate both moves and jumps based on a piece’s current coordinates.

  • Move Targets: These are adjacent diagonal positions that a piece can move to.
  • Jump Targets: These are positions that a piece can move to after jumping over an opponent’s piece.

To handle this, we define the following methods:

impl Coordinate {
pub fn is_on_board(self) -> bool {
let Coordinate(x,y) = self;
x <= 7 && y <= 7 && x >= 0 && y >= 0
}

pub fn get_jump_targets(&self) -> impl Iterator<Item = Coordinate> {
let mut jumps = Vec::new();
let Coordinate(x,y) = *self;

if (y >= 2) {
if (x >= 2) {
jumps.push(Coordinate(x-2, y-2));
}
if (x <= 5) {
jumps.push(Coordinate(x+2, y-2));
}
}
if (y <= 5) {
if (x >= 2) {
jumps.push(Coordinate(x-2, y+2));
}
if (x <= 5) {
jumps.push(Coordinate(x+2, y+2));
}
}

jumps.into_iter()
}

pub fn get_move_targets(&self) -> impl Iterator<Item = Coordinate> {
let mut moves = Vec::new();
let Coordinate(x,y) = *self;

if (y >= 1) {
if (x >= 1) {
moves.push(Coordinate(x-1, y-1));
}
if (x <= 6) {
moves.push(Coordinate(x+1, y-1));
}
}
if (y <= 6) {
if (x >= 1) {
moves.push(Coordinate(x-1, y+1));
}
if (x <= 6) {
moves.push(Coordinate(x+1, y+1));
}
}

moves.into_iter()
}
}
  • get_jump_targets: This method checks all possible jumps from the current coordinate. It adds potential jump targets to a vector and returns an iterator over them.
  • get_move_targets: This method calculates all potential diagonal moves a piece can make from its current position and returns an iterator over these moves.

3. Move Struct

To encapsulate the action of moving a piece from one coordinate to another, we define a Move struct:

#[derive(Debug, Copy, Clone, PartialEq)]
pub struct Move {
pub from: Coordinate,
pub to: Coordinate
}

impl Move {
pub fn new(from: (usize, usize), to: (usize, usize)) -> Move {
Move {
from: Coordinate(from.0, from.1),
to: Coordinate(to.0, to.1)
}
}
}

This struct holds two coordinates: from, the current position of the piece, and to, the target position. The new method allows us to create a move by passing in two (x, y) pairs.

Game Engine

The execution of our game logic lives inside a single sturct called GameEngine.

pub struct MoveResult {
pub mv: Move,
pub crowned: bool
}

pub struct GameEngine {
board: [[Option<GamePiece>; 8]; 8],
current_turn: PieceColor,
move_count: u32
}

impl GameEngine {
pub fn new() -> GameEngine {
let mut engine = GameEngine {
board:[[None; 8]; 8],
current_turn: PieceColor::BLACK,
move_count: 0
};

engine.initialize_pieces();
engine
}

pub fn initialize_pieces(&mut self) {
[1, 3, 5, 7, 0, 2, 4, 6, 1, 3, 5, 7]
.iter()
.zip([0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2].iter())
.map(|(a,b)| (*a as usize, *b as usize))
.for_each(|(x,y)| {
self.board[x][y] = Some(GamePiece::new(PieceColor::WHITE));
});

[0, 2, 4, 6, 1, 3, 5, 7, 0, 2, 4, 6]
.iter()
.zip([5, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, 7].iter())
.map(|(a,b)| (*a as usize, *b as usize))
.for_each(|(x,y)| {
self.board[x][y] = Some(GamePiece::new(PieceColor::BLACK));
});
}

pub fn get_piece(&self, coord: Coordinate) -> Result<Option<GamePiece>, ()> {
let Coordinate(x, y) = coord;
if x <= 7 && y <= 7 && x >= 0 && y >= 0 {
Ok(self.board[x][y])
} else {
Err(())
}
}

pub fn move_piece(&mut self, mv: &Move) -> Result<MoveResult, ()> {
let legal_moves = self.legal_moves();
if (!legal_moves.contains(mv)) {
return Err(());
}

let Coordinate(fx, fy) = mv.from;
let Coordinate(tx, ty) = mv.to;
let jumped_coordinate = self.get_jumped_coordinate(fx, fy, tx, ty);
if let Some(Coordinate(x,y)) = jumped_coordinate {
self.board[x][y] = None;
}

let piece = self.board[fx][fy].unwrap();
self.board[tx][ty] = Some(piece);
self.board[fx][fy] = None;

let crowned = match (self.should_crown(piece, mv.to)) {
true => {
self.crown_piece(mv.to);
true
},
false => false,
};

self.advance_turn();

Ok(MoveResult {
mv: mv.clone(),
crowned: crowned,
})
}

fn advance_turn(&mut self) {
if self.current_turn == PieceColor::BLACK {
self.current_turn = PieceColor::WHITE
} else {
self.current_turn = PieceColor::BLACK
}
self.move_count += 1;
}

pub fn get_current_turn(&self) -> PieceColor {
return self.current_turn;
}

fn crown_piece(&mut self, coordinate: Coordinate) {
let Coordinate(x, y) = coordinate;
let piece = self.board[x][y].unwrap();
self.board[x][y] = Some(GamePiece::crown(piece));
}

fn should_crown(&mut self, piece: GamePiece, to: Coordinate) -> bool {
let Coordinate(_, y) = to;

(y == 0 && piece.color == PieceColor::BLACK) || (y == 7 && piece.color == PieceColor::WHITE)
}

fn get_jumped_coordinate(&mut self, from_x: usize, from_y: usize, to_x: usize, to_y: usize) -> Option<Coordinate> {
if to_x == from_x + 2 && to_y == from_y + 2 {
Some(Coordinate(from_x + 1, from_y + 1))
} else if to_x == from_x - 2 && to_y == from_y - 2 {
Some(Coordinate(from_x - 1, from_y - 1))
} else if to_x == from_x - 2 && to_y == from_y + 2 {
Some(Coordinate(from_x - 1, from_y + 1))
} else if to_x == from_x + 2 && to_y == from_y - 2 {
Some(Coordinate(from_x + 1, from_y - 1))
} else {
None
}
}

fn legal_moves(&mut self) -> Vec<Move> {
let mut moves: Vec<Move> = Vec::new();
for x in 0..8 {
for y in 0..8 {
if let Some(piece) = self.board[x][y] {
if piece.color == self.current_turn {
let loc = Coordinate(x,y);
let mut valid_moves = self.get_valid_moves(loc);
moves.append(&mut valid_moves);
}
}
}
}
moves
}

fn get_valid_moves(&mut self, loc: Coordinate) -> Vec<Move> {
let Coordinate(x, y) = loc;
if let Some(piece) = self.board[x][y] {
let mut jumps = loc
.get_jump_targets()
.filter(|t| self.valid_jump(&piece, &loc, &t))
.map(|ref t| Move {from: loc.clone(), to: t.clone()})
.collect::<Vec<Move>>();

let mut moves = loc
.get_move_targets()
.filter(|t| self.valid_move(&piece, &loc, &t))
.map(|ref t| Move {from: loc.clone(), to: t.clone()})
.collect::<Vec<Move>>();


jumps.append(&mut moves);
jumps
} else {
Vec::new()
}
}

fn valid_jump(&mut self, piece: &GamePiece, from: &Coordinate, to: &Coordinate) -> bool {
// on board
if (!to.is_on_board() || !from.is_on_board()) {
return false;
}

//is empty space
let Coordinate(tx, ty) = *to;
let Coordinate(fx, fy) = *from;
if let Some(_) = self.board[tx][ty] {
return false;
}

// jumpable piece
let midcoord = self.get_jumped_coordinate(fx, fy, tx, ty);
if midcoord.is_none() {
return false;
}
let midpiece = self.board[midcoord.unwrap().0][midcoord.unwrap().1];
if midpiece.is_some() {
if (midpiece.unwrap().color == piece.color) {
return false;
}
} else {
return false;
}

let mut valid = false;

if ty > fy && piece.color == PieceColor::WHITE {
// white moves down
valid = true;
}
if ty < fy && piece.color == PieceColor::BLACK {
// black moves up
valid = true;
}
if ty > fy && piece.color == PieceColor::BLACK && piece.crowned {
// crowned black mv down
valid = true;
}
if ty < fy && piece.color == PieceColor::WHITE && piece.crowned {
// crowned white mv up
valid = true;
}
valid
}

fn valid_move(&mut self, piece: &GamePiece, from: &Coordinate, to: &Coordinate) -> bool {
if (!to.is_on_board() || !from.is_on_board()) {
return false;
}

let Coordinate(tx, ty) = *to;
if let Some(_) = self.board[tx][ty] {
return false;
}

let Coordinate(fx, fy) = *from;
let mut valid = false;

if ty > fy && piece.color == PieceColor::WHITE {
// white moves down
valid = true;
}
if ty < fy && piece.color == PieceColor::BLACK {
// black moves up
valid = true;
}
if ty > fy && piece.color == PieceColor::BLACK && piece.crowned {
// crowned black mv down
valid = true;
}
if ty < fy && piece.color == PieceColor::WHITE && piece.crowned {
// crowned white mv up
valid = true;
}
valid
}
}

Our game engine handles has helper methods for getting pieces, getting turns, crowning pieces, etc… but the two core components are setting up the board and making a move.

1. Board Initialization (initialize_pieces)

The initialize_pieces method sets up the initial positions of the WHITE and BLACK pieces on the board. The method places pieces in the correct rows for each color using loops and functional iterators

2. Moving a Piece (move_piece)

This function handles moving a piece from one position to another, updating the board state and managing rules like crowning and jump captures.

  • Step 1: Check if the move is legal: It retrieves all legal moves for the current player and checks if the move exists in that list. If it’s illegal, it returns an error.
  • Step 2: Handle jumping: If a piece jumps over another, the piece in between is removed (captured). This is done by calculating the “jumped” coordinates and setting that position to None.
  • Step 3: Move the piece: The piece is moved from its original position (mv.from) to the target position (mv.to). The original position is cleared.
  • Step 4: Handle crowning: If a piece reaches the opposite end of the board (row 0 for BLACK, row 7 for WHITE), the piece is crowned. The crown_piece method is called to update the piece's crowned state.
  • Step 5: Advance the turn: After a move is completed, the turn is switched to the other player, and the move count is incremented.

All the above code was purely just to get a checkers game implemented in Rust, we have not done any WASM integration yet and — after a few tweaks to complete the game logic— we could ship this checkers game in a completely non WASM context.

WebAssembly (WASM) Interface for Game Integration

To effectively connect Rust with a WebAssembly (WASM) environment, it’s crucial to handle the interaction through an isolated module, ensuring a clear separation of concerns.

Global Game State with lazy_static

Rust does not allow mutable global variables by default because of its strict ownership and concurrency rules. However, for our WASM interface, we need to maintain a globally accessible game state that can be shared between function calls. This is solved by using the lazy_static crate, which allows us to initialize static data only once and share it safely across the application.

#[macro_use]
extern crate lazy_static;

lazy_static! {
pub static ref GAME_ENGINE: MutStatic<GameEngine> = MutStatic::from(GameEngine::new());
}

In this snippet, we use lazy_static! to create a static reference to GAME_ENGINE, which is our checkers game engine. This engine can be read or written to throughout the lifetime of the game, allowing the JavaScript host to interact with it via exported WASM functions.

Mapping GamePiece to WASM-Compatible Data

WASM does not support complex Rust data types directly, so we need a way to convert rich structs like GamePiece into simpler types that can be passed to and from the JavaScript environment. In this case, we use bit flags to represent the state of each game piece.

const PIECEFLAG_BLACK: u8 = 1;
const PIECEFLAG_WHITE: u8 = 2;
const PIECEFLAG_CROWN: u8 = 4;

impl Into<i32> for GamePiece {
fn into(self) -> i32 {
let mut val: u8 = 0;
if self.color == PieceColor::BLACK {
val += PIECEFLAG_BLACK;
} else if self.color == PieceColor::WHITE {
val += PIECEFLAG_WHITE;
}
if self.crowned {
val += PIECEFLAG_CROWN;
}
val as i32
}
}

Here, the GamePiece struct is converted into an integer using bitwise flags. Each flag represents a characteristic of the game piece—whether it's black or white, and if it's crowned. This conversion makes it easy to pass this data between WASM and JavaScript.

Importing Host Functions

Our WASM module needs to interact with functions provided by the JavaScript host, such as notifying the host when a piece is moved or crowned. These host functions are declared as extern "C" blocks, making them accessible to Rust.

extern "C" {
fn notify_piecemoved(from_x: i32, from_y: i32, to_x: i32, to_y: i32);
fn notify_piececrowned(x: i32, y: i32);
}

These imported functions, notify_piecemoved and notify_piececrowned, are defined in the JavaScript environment and are called from Rust whenever a piece is moved or crowned.

Exporting Functions for WASM

To allow the JavaScript host to control the game, we export several functions using the #[no_mangle] attribute. These functions are exposed to JavaScript and allow the host to query the state of the game and move pieces.

#[no_mangle]
pub extern "C" fn get_piece(x: i32, y: i32) -> i32 {
let engine = GAME_ENGINE.read().unwrap();
let piece = engine.get_piece(Coordinate(x as usize, y as usize));
match piece {
Ok(Some(p)) => p.into(),
_ => -1
}
}

#[no_mangle]
pub extern "C" fn get_current_turn() -> i32 {
let engine = GAME_ENGINE.read().unwrap();
GamePiece::new(engine.get_current_turn()).into()
}

#[no_mangle]
pub extern "C" fn move_piece(from_x: i32, from_y: i32, to_x: i32, to_y: i32) -> i32 {
let mut engine = GAME_ENGINE.write().unwrap();
let mv = Move{ from: Coordinate(from_x as usize, from_y as usize), to: Coordinate(to_x as usize, to_y as usize) };
let res = engine.move_piece(&mv);
match res {
Ok(mr) => {
unsafe {
notify_piecemoved(from_x, from_y, to_x, to_y);
}
if mr.crowned {
unsafe {
notify_piececrowned(to_x, to_y);
}
}
1
},
Err(_) => 0,
}
}
  • get_piece: Retrieves the piece located at the specified coordinates, returning an integer representation or -1 if there is no piece.
  • get_current_turn: Returns which player's turn it is, encoded as an integer.
  • move_piece: Attempts to move a piece from one coordinate to another. If successful, it notifies the JavaScript host using notify_piecemoved and notify_piececrowned.

Why Host Calls Are Wrapped in unsafe Blocks

Rust enforces strict safety rules, especially when dealing with memory. However, calls to external functions (like those from the host environment) bypass these safety checks. Since Rust cannot guarantee the safety of external functions (such as those defined in JavaScript), these calls must be enclosed in an unsafe block. This tells Rust that the developer is aware of the potential dangers and assumes responsibility for ensuring the correctness of the code.

Building the Project for WASM

To build the project for a WebAssembly target, we use Rust’s wasm32-unknown-unknown target, which is specifically designed for compiling Rust code into WASM binaries.

cargo build --release --target wasm32-unknown-unknown

We then copy the resulting .wasm file to the desired directory:

cp target/wasm32-unknown-unknown/release/rustcheckers.wasm demo/

JS Host

We can now load the checkers WASM into a JS host.

fetch("./rustycheckers.wasm")
.then(response => response.arrayBuffer())
.then(bytes => WebAssembly.instantiate(bytes, {
env: {
notify_piecemoved: (fromX, fromY, toX, toY) => {
console.log(`A piece moved from (${fromX}, ${fromY}) to (${toX}, ${toY})`);
},
notify_piececrowned: (x, y) => {
console.log(`A piece was crowned at (${x}, ${y})`);
}
}
}))
.then(results => {
const instance = results.instance;
console.log(`At start, current turn is ${instance.exports.get_current_turn()}`);
let piece = instance.exports.get_piece(0, 7);
console.log(`Piece at 0,7 is ${piece}`);
let res = instance.exports.move_piece(0, 5, 1, 4); // Black
console.log(`First move result: ${res}`);
console.log(`Turn after move: ${instance.exports.get_current_turn()}`);
let bad = instance.exports.move_piece(1, 4, 2, 3); // illegal move
console.log(`Illegal move result: ${bad}`);
console.log(`Turn after illegal move: ${instance.exports.get_current_turn()}`);
})
.catch(console.error)

The flow for using a WASM module is the same no matter how the module was built.

  1. We fetch the WASM module
  2. We inject our host functions
  3. We call WASM exported functions via the instance

Conclusion

Writing game logic in a higher-level language like Rust makes development more efficient and maintainable, especially when managing complex rules and interactions, as seen in the checkers game. By using Rust, we benefit from its safety features, strong typing, and performance, which are critical when building reliable game engines.

The game logic, though written in Rust, can be executed not only within a Rust environment but also in WebAssembly (WASM), ensuring versatility across platforms. By isolating the WASM interface in a single module, we simplify the process of connecting the Rust game engine to a JavaScript host, enabling seamless integration with web environments.

--

--

Matthew MacFarquhar
Matthew MacFarquhar

Written by Matthew MacFarquhar

I am a software engineer working for Amazon living in SF/NYC.

No responses yet