In the last post we looked at setting up the codebase to build Rust crates, and then importing them into a JavaScript project.
Now let’s look at adding a bit of interactivity, allowing players to play a full game, and calculating a winner!
Interactivity
The first piece of interactivity we can add to our connect-4 is a way to switch between players.
Let’s add a new enum that encodes the player variants, and then add it to our larger game state which is the Board struct.
#[derive(Copy, Clone)]
pub enum Player {
Red,
Black,
}
pub struct Board {
row_count: usize,
col_count: usize,
// public so we can read it from the JavaScript side and display it
pub player_turn: Player,
}
#[wasm_bindgen]
impl Board {
// 1.
fn end_player_turn(&mut self) {
self.player_turn = match self.player_turn {
Player::Red => Player::Black,
Player::Black => Player::Red,
}
}
// 2.
pub fn select_col(&mut self, col_idx: u8) -> bool {
// board update logic will go here
self.end_player_turn();
}
}
1. end_player_turn
The end_player_turn function just flips the player turn state on the overall game state between red and black variants.
2. select_col
The select_col function is where majority of the game logic will go. Once all the required state has been updated, it’ll call end_player_turn and the other player can make their move.
JavaScript to match
Now let’s update our JavaScript to accomodate for these changes, by first adding another div that would display the player turn, updating the rendering logic, and adding an event handler.
import init, { Board, TileType } from "./connectors/connectors.js";
const ROW_COUNT = 6;
const COL_COUNT = 7;
const DISPLAY_TURN_MAP = {
[Player.Red]: "R",
[Player.Black]: "B",
};
// this duplication is necessary
// just so it's easy to render as a grid
const DISPLAY_TILE_MAP = {
[TileType.Empty]: "_",
[TileType.Red]: "R",
[TileType.Black]: "B",
};
const playerTurnEl = document.getElementById("player-turn");
const gameBoardEl = document.getElementById("game-board");
init().then(() => {
const board = Board.new(ROW_COUNT, COL_COUNT);
// 1. Render
const render = () => {
playerTurnEl.innerText = DISPLAY_TURN_MAP[board.player_turn];
const tiles = Array.from(board.get_board).map(
(tileEnumValue) => DISPLAY_TILE_MAP[tileEnumValue],
);
const fragment = document.createDocumentFragment();
tiles.forEach((tile, idx) => {
const span = document.createElement("span");
span.textContent = tile;
span.dataset.colIdx = Math.floor(idx % COL_COUNT);
fragment.appendChild(span);
});
gameBoardEl.innerHTML = "";
gameBoardEl.appendChild(fragment);
};
// 2.
gameBoardEl.addEventListener("click", (event) => {
const colIdx = parseInt(event.target.dataset.colIdx, 10);
board.select_col(colIdx);
render();
});
// initial render
render();
});
That’s a wall of code, however most of it is just a small refactor to ensure that we can render after each user event and not just once when the page loads.
1. Render refactor
We extract the rendering logic into it’s own function so that after each user interaction we can update the entire screen to display the updated state.
2. Adding event listeners
The event handler is applied to the entire grid, it extract the column index from the data attribute and calls the select_col function that we added to the rust code earlier, and finally calls render to update the screen.
If you rebuild and launch the app now you’ll see that clicking anywhere on the grid updates the Player turn back and forth between R and B.
Here’s the full commit.
Making Moves
So far we’ve been using random tiles, now let’s replace those with empty tiles.
#[wasm_bindgen]
pub struct Board {
row_count: usize,
col_count: usize,
pub player_turn: Player,
// 1. private
board: Vec<TileType>,
}
#[wasm_bindgen]
impl Board {
pub fn new(row_count: usize, col_count: usize) -> Self {
// initialize the entire board to empty values
//
// 2. initialize to Empty
// ↓
let board: Vec<TileType> = vec![TileType::Empty; row_count * col_count];
Self {
row_count,
col_count,
board,
player_turn: Player::Red,
}
}
#[wasm_bindgen(getter)]
pub fn get_board(&self) -> js_sys::Uint8Array {
let board: Vec<u8> = self.board.iter().map(|tile| tile.into()).collect();
js_sys::Uint8Array::from(&board[..])
}
}
1. private Vec field
Only simple types are directly exposed across the Wasm/JS boundary, so vector fields cannot be public. have to be converted into typed arrays.
2. Initializing Vec to Empty
Here we initialize entire board to TileType::Empty using the vec! macro instead of a randomized board.
Now let’s update the select_col function to update the state based on user’s selected column.
impl Board {
// 1. bounds check
fn is_in_bounds(&self, col_idx: u8) -> bool {
(col_idx as usize) < self.col_count
}
pub fn select_col(&mut self, col_idx: u8) -> bool {
if !self.is_in_bounds(col_idx) {
return false;
}
/////////////////////////////////////
///////// Finding Empty Row /////////
/////////////////////////////////////
// 2. chunking
let rows = self.board.chunks(self.col_count);
// 3. reversing rows
let rows_reversed = rows.rev();
let mut empty_row_idx: Option<usize> = None;
for (idx, row) in rows_reversed.enumerate() {
let tile_in_row = row.get(col_idx as usize).expect("This cannot happen");
if tile_in_row.is_empty() {
// as the rows are reversed we need to subtract from row_count
empty_row_idx = Some(self.row_count - idx - 1);
break;
}
}
match empty_row_idx {
Some(empty_row_idx) => {
let idx_to_update = self.col_count * empty_row_idx + col_idx;
// 4. updating the board
self.board = self
.board
.iter()
.enumerate()
.map(|(idx, tile)| {
if idx == idx_to_update {
return match self.player_turn {
Player::Red => TileType::Red,
Player::Black => TileType::Black,
};
} else {
return *tile;
}
})
.collect();
self.end_player_turn();
true
}
// if no empty row found for `col_idx` return false
None => false,
}
}
}
1. Bounds Checking
As we’re storing the entire grid as a single array, checking if the selected index is valid is trivial.
2. Chunking
Rust Vecs support non overlapping chunks out of the box.
3. Reversing the rows
As Connect-4 fills bottom up, we have to find the last row that’s empty to replace the tile.
There’s another way to combine the chunking and reversing via self.boards.rchunks which gives us an iterator over already reversed chunks but I wanted to break this down for clarity.
4. Updating the board
Once an empty row is found we can map over the board and replace it after updating the appropriate tile. If we go through all the rows without finding an empty row, we can just return false to signify that no update was made.
That should be it for interactivity!
Winning Move
As this requires changes in a lot of places I’ll only go over the crucial bits that use Rust features we haven’t explored before. The full diff can be found here. Currently the game never ends even if one of the player connects 4 of their tiles. The win condition requires a player to connect four of their tiles either horizontally, vertically, or diagonally. Let’s look at the first one.
As the state only changes when a player makes a move we can narrow things down to a player, a row, and a column.
// 1. PartialEq
// ↓
#[derive(Copy, Clone, PartialEq)]
pub enum Player {
Red,
Black,
}
#[wasm_bindgen]
pub struct Board {
// 2.
pub winner: Option<Player>,
}
impl Board {
fn is_game_over(&self, row_idx: usize, col_idx: usize) -> bool {
self.has_4_consecutive_tiles_in_row(self.player_turn, row_idx)
|| self.has_4_consecutive_tiles_in_col(self.player_turn, col_idx)
}
fn has_4_consecutive_tiles_in_row(&self, player: Player, row_idx: usize) -> bool {
// cell idx for the start of row
let row_start_idx = self.idx_from_row_col(row_idx, 0);
// cell idx for the end of row
let row_end_idx = row_start_idx + self.col_count;
// get a slice of tiles that belong to the row with `row_idx`
let row_tiles = &self.board[row_start_idx..row_end_idx];
let mut consecutive_count = 0;
for tile in row_tiles {
// 3.
if tile.does_belong_to(player) {
consecutive_count += 1;
if consecutive_count >= 4 {
return true;
}
} else {
consecutive_count = 0;
}
}
false
}
}
Similarly we can check if four tiles in a column belong to the expected player, I won’t write the code here but it’s part of the commit.
There are several ways of encoding the winning player but as we don’t advance the player turn after the winning move, it’s just simpler to combine the player_turn and is_game_over.
1. PartialEq trait
The partial eq trait is necessary to perform equality checks on enum variants.
The derive macro conveniently implements the trait for us here as the Player enum variants are trivial.
2. Option type
Rust has no null values, the idiomatic way to encode the absence of a value is to use an Option of the appropriate type. In this case Option<Player> can either store Some(Player::Red) or Some(Player::Black) or None. We initialize the winner to None when the game starts and replace it with the appropriate player that makes the winning move.
On the JavaScript side None shows up as undefined.
3. doesBelongTo
As we have more TileType variants than Player variants, we need a way to map whether a tile belongs to a player.
impl TileType {
pub fn does_belong_to(&self, player: Player) -> bool {
match self {
TileType::Empty => false,
TileType::Red => player == Player::Red,
TileType::Black => player == Player::Black,
}
}
}
The JavaScript changes are trivial (updating text and disabling click events after game ends) and I’ll skip those here.
This concludes all of the gameplay for Connect-4, we’ve exposed the smallest amount of API required to play a full game. There’s no clean-up required as refreshing the page frees the memory allocated in WASM and reallocates the memory when the game restarts.
Comments via 🦋