DEV Community

Cover image for My Mistakes Making a Canvas Based Game with Rust and WebAssembly
Alex Fallenstedt
Alex Fallenstedt

Posted on • Edited on

My Mistakes Making a Canvas Based Game with Rust and WebAssembly

I began looking into WebAssembly as a means to process pixels from a video feed. That experience was very fun and rewarding. I wanted another Rust and WebAssembly challenge, so I built Gobblet, a two player game played on a 4x4 board with 12 pieces of different sizes.

GitHub logo Fallenstedt / rust-goblet

A canvas-based game built using Rust and WebAssembly

Gobblet is a game where players take turns to place a piece on the board or move their piece that already is on the board. Bigger pieces can be placed so that they cover smaller ones. A player wins by placing four pieces of the same color in a horizontal, vertical, or diagonal row. Basically Gobblet is advanced tic tac toe.

Below is a summary of what I learned, and what I will avoid the next time I use Rust and WebAssembly.

Adding Interaction

I usually reach for RxJS when building complex user interaction, but not today. I was building Gobblet with Rust because I was curious to know where the pain points can exist.

I began by creating event listeners to process three mouse events: mouse down, mouse move, and mouse up. These three events combined represent a drag event, and can be used to influence the state of the game. After each mouse up event, I used Rust to see if either player had 4 pieces in a row.

// inside of lib.rs
#[wasm_bindgen]
pub fn start_game(canvas: HtmlCanvasElement, name1: String, name2: String) {

    // process mousedown
    {
        let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
            // do stuff
        }) as Box<dyn FnMut(_)>);
        canvas
            .add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())
            .unwrap();
        closure.forget();
    }

    // process mouse move
    {


        let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
            // do stuff
        }) as Box<dyn FnMut(_)>);
        canvas
            .add_event_listener_with_callback("mousemove", closure.as_ref().unchecked_ref())
            .unwrap();
        closure.forget();
    }

    //process mouse up
    {
        let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
            // do stuff
        }) as Box<dyn FnMut(_)>);
        canvas
            .add_event_listener_with_callback("mouseup", closure.as_ref().unchecked_ref())
            .unwrap();
        closure.forget();
    }
}

Enter fullscreen mode Exit fullscreen mode

I'll be honest, adding an event listener is a bit over the top in Rust. If I would do this over again, I would not use Rust to add or remove event listeners. What's happening here is we are creating a closure defined in Rust and passing it to JavaScript. We pass this to JS because the closure must persist beyond the call to this one function start_game.

Also of note here is the .as_ref().unchecked_ref() chain, which is how you can extract &Function, what web-sys expects, from a Closure which only hands you &JsValue via AsRef.

đź‘Ť This is when I started to regret adding event listeners with Rust.

It's a lot. I don't enjoy it. Just creating these closures was the tip of the iceberg. I then had to create Reference Counters for my game logic and game graphics, and pass clones of these to each closure. In the majority of cases, ownership is clear: you know exactly which variable owns a given value. However, there are cases when a single value might have multiple owners. For example, in graph data structures, multiple edges might point to the same node, and that node is conceptually owned by all of the edges that point to it. A node shouldn’t be cleaned up unless it doesn’t have any edges pointing to it.

To enable multiple ownership, Rust has a type called Rc<T>, which is an abbreviation for reference counting. The Rc<T> type keeps track of the number of references to a value which determines whether or not a value is still in use. If there are zero references to a value, the value can be cleaned up without any references becoming invalid.

To complicate things further, these mouse events will mutate the state of the game logic and the game graphics. So I opted for RefCell<T> to handle the mutation of data in a single area of memory.

Then, when we use Rc<RefCell<T>>, we are saying we have shared, mutable ownership of data in our application. You can check out this pattern here in the Rust Book

//lib rs
pub fn start_game(canvas: HtmlCanvasElement, name1: String, name2: String) {
    let graphics = Rc::new(RefCell::new(Graphics::new(canvas.clone())));
    let manager = Rc::new(RefCell::new(Manager::new(name1, name2)
    // process mousedown
    {
        let graphics = graphics.clone();
        let manager = manager.clone();
//...
Enter fullscreen mode Exit fullscreen mode

Managing logic

This was the probably the most fun I had when building this game. I created a simple game manager to coordinate the game state as the user interacts with the board. It consists of two players, a board, and the current turn.

// manager.rs
#[derive(Debug)]
pub struct Manager {
    player1: Player,
    player2: Player,
    board: Board,
    turn: PlayerNumber,
}

impl Manager {
    pub fn new(name1: String, name2: String) -> Self {
        let board = Board::new();
        let player1 = Player::new(name1, PlayerNumber::One);
        let player2 = Player::new(name2, PlayerNumber::Two); 

        Manager{ player1, player2, board, turn:  Manager::random_turn() }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Board is a struct used to manage pieces on the board. It is a two dimensional vector of Cells. Pieces can be added or removed from a cell.

pub struct Board {
    cells: Vec<Vec<Cell>>,
}

impl Board {
    pub fn new() -> Board {
        Board { cells: Board::build_cells() }
    }
    pub fn add_piece_to_board(&mut self, coord: &Coord, gobblet: Gobblet) -> Option<Gobblet> {
        let r = *coord.get_row() as usize;
        let c = *coord.get_column() as usize;
        let cell = &mut self.cells[r][c];

        return if cell.can_add(&gobblet) {
            cell.add(gobblet);
            None
        } else {
            Some(gobblet)
        }
    }

    pub fn remove_piece_from_board(&mut self, coord: &Coord, player: &PlayerNumber) -> Option<Gobblet> {
        let r = *coord.get_row() as usize;
        let c = *coord.get_column() as usize;
        let cell = &mut self.cells[r][c];

        return if cell.can_remove(&player) {
            Some(cell.remove())
        } else {
            None
        }
    }
    // Create 2 dimensonal array of cells. 
    // index in first vec represents row
    // index in second vec represent column
    // [
    //  [c, c, c, c],
    //  [c, c, c, c],
    //  [c, c, c, c],
    //  [c, c, c, c]
    // ]
    fn build_cells() -> Vec<Vec<Cell>> {
        vec![vec![Cell::new(); 4]; 4]
    }
}
Enter fullscreen mode Exit fullscreen mode

Calculating the winning move was also a fun challenge. After every move, either player could win, A chosen gobblet removed from a cell could reveal the opposing player's gobblet. If that chosen gobblet is not placed well, then the opposing player could win!

c5bknwrtf4rvt65mb7xl-2

I opted for a function which accepts a PlayerNumber enum as a parameter. I then counted the pieces on the board using gasp a double for-loop. This isn't ideal, I should have kept track of the player's positions per move but this was the quickest solution.

If the player has 4 pieces in any given row, column, diagonal or anti-diagonal, then they won:

// board.rs
    pub fn has_won(&self, number: PlayerNumber) -> bool {
        let mut rows: [u8; 4] = [0, 0, 0, 0];
        let mut columns: [u8; 4] = [0, 0, 0, 0];
        let mut diagonal: u8 = 0;
        let mut anti_diagonal: u8 = 0;
        for (r, row) in self.cells.iter().enumerate() {
            for (c, cell) in row.iter().enumerate() {
               if cell.is_empty() {
                   continue;
               }
                // check rows,
                // check columns,
                if player_number_match(cell.get_top_piece().get_player_number(), &number) {
                    rows[r] += 1;
                    columns[c] += 1;
                }

                // check diagonal,
                if r == c && player_number_match(cell.get_top_piece().get_player_number(), &number)  {
                    diagonal += 1;
                }

                // check anti diagonal
                if r + c == 3 && player_number_match(cell.get_top_piece().get_player_number(), &number) {
                    anti_diagonal += 1
                }
            }
        }

        return rows.contains(&4) || columns.contains(&4) || diagonal == 4 || anti_diagonal == 4
    }
Enter fullscreen mode Exit fullscreen mode

Graphics

At this point, I started to question everything I was doing. I had to repaint the frame for every mouse move when the player was dragging a piece. I had to check if the player dropped the piece on a valid spot. I had to grab the "top" piece from the gobblet stack. The list of requirements for the UI seemed never ending and it was clear I spent little to no time on planning the architecture for this game.

So I kept programming

The Graphics struct kept track of what piece was clicked, and rendering all the rectangles and circles on the canvas.

#[derive(Debug, Clone)]
pub struct Graphics {
    pub interaction: Interaction,
    element: HtmlCanvasElement,
    context: CanvasRenderingContext2d,
    rectangles: Vec<Rectangle>,
    circles: Vec<Circle>,
}

impl Graphics {

    pub fn new(element: HtmlCanvasElement) -> Graphics {
        let context = element
        .get_context("2d")
        .unwrap()
        .unwrap()
        .dyn_into::<CanvasRenderingContext2d>()
        .unwrap();

        let rectangles = Graphics::create_board(&context, &element);
        let circles = Graphics::create_hand(&context);
        let interaction = Interaction::new();
        Graphics { 
            interaction,
            element, 
            context, 
            rectangles, 
            circles, 
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The first challenge I had was creating the a checkerboard with Rust. I wanted it to be flexible in case I wanted to make an n * n checkerboard one day.

// graphics.rs
    fn create_board(context: &CanvasRenderingContext2d, element: &HtmlCanvasElement) -> Vec<Rectangle> {
        let light_purple: JsValue = JsValue::from_str("#6C5B7B");
        context.set_fill_style(&light_purple);
        context.fill_rect(0.0, 0.0, element.width() as f64, element.height() as f64);

        // board
        let w = 400.0;
        let h = 400.0;
        let n_row = 4.0;
        let n_col = 4.0;

        let w: f64 = w / n_row; // width of block
        let h: f64 = h / n_col; // height of block

        // colors
        let sea = JsValue::from_str("#5f506c");
        let foam = JsValue::from_str("#867297");

        let offset = (100.0, 200.0);
        let mut rectangles: Vec<Rectangle> = Vec::with_capacity(16);
        for i in 0..n_row as u8 { // row
            for j in 0..(n_col as u8) { // column
                // cast as floats
                let j = j as f64;
                let i = i as f64;

                if i % 2.0 == 0.0 {
                    if j % 2.0 == 0.0 { context.set_fill_style(&foam); } else { context.set_fill_style(&sea); };    
                } else {
                    if j % 2.0 == 0.0 { context.set_fill_style(&sea); } else { context.set_fill_style(&foam); };
                }

                let x = j * w + offset.0;
                let y = i * h + offset.1;

                let coord = Coord::new(i as u8, j as u8);
                let path = Path2d::new().unwrap();
                path.rect(x, y, w, h);
                context.fill_with_path_2d(&path);

                let rectangle = Rectangle::new(path, coord, x + (0.5 * w), y + (0.5 * h));
                rectangles.push(rectangle);
            }
        }
        rectangles
    } 

Enter fullscreen mode Exit fullscreen mode

This took a good minute to figure out, but once I did I was so excited. If you're curious, this is what happens when n_row and n_col are set to 40.0

40 x 40 game board

Things began to take a turn for the worse when I attempted to figure out which circle was clicked. Using the coordinates of the click event, I could determine which Path2D was chosen.

It was here when realized I was using functional decomposition to address complexity, and as a result, I was adding more complexity to my program. But that's ok, because I know that I will never build something like this in Rust ever again.

//graphics.rs
    pub fn set_largest_clicked_circle(&self, x: f64, y: f64) {
        let mut index: isize = -1;
        let mut clicked_circles = Vec::new();

        for (i, c) in self.circles.iter().enumerate() {
            if self.context.is_point_in_path_with_path_2d_and_f64(c.get_path(), x, y) {
                clicked_circles.push((i, c));
                index = i as isize;
            } 
        }

        if clicked_circles.len() == 0 {
            self.interaction.set_chosen_circle(index);
            return;
        }

        // sort circles by largest -> smallest
        clicked_circles.sort_by(|a, b| b.1.get_size().partial_cmp(&a.1.get_size()).unwrap());
        index = clicked_circles.get(0).unwrap().0 as isize;
        self.interaction.set_chosen_circle(index)
    }
Enter fullscreen mode Exit fullscreen mode

What did I learn?

Rust and WebAssembly are amazing tools. I can really see the impact they will have in the Web Development scene for many years. The primary goal of WebAssembly is to enable high-performance applications to run on web pages. Rust and WebAssembly are special in that they make it possible to write web applications when reliable performance is essential. With Rust and WebAssembly, you can build remote video augmented reality, scientific visualizations and simulations, games, image/video editing applications, and more.

That being said, using Rust and WebAssembly to build this canvas based game was equivalent to using a bulldozer to hammer in a nail.

Confusion is a feeling that precedes learning something. It means you should pay closer attention, not disengage. I learned so much about Rust then I ever did reading books and articles about it. When I built this game, there were days I considered giving up because I was stuck on a Rust problem. But I kept researching and experimenting. I am proud of what I built, even though it's not flexible and it's overly complex. This experience will help inform my decisions the next time I need to use Rust and WebAssembly.

Top comments (5)

Collapse
 
maxgy profile image
Maxwell Anderson

I made a game with Rust and WASM as well, and I had some of the same issues. I naturally just decided to put things like event listeners in JavaScript, but just about everything else worked in pure Rust with little complexity.

Collapse
 
kayis profile image
K

Macros aren't an option?

Collapse
 
maxgy profile image
Maxwell Anderson

I'm not sure about macros in this case, but the usual way to go about adding event listeners straight into Rust is using dynamic clojures.

Collapse
 
l4ngu0r profile image
L4ngu0r

Experienced the same with event listener, it's overkill, and too much pain đź‘Ť

Collapse
 
chasewilliam profile image
Chase William

Really like the final remarks stated below:

"Confusion is a feeling that precedes learning something. It means you should pay closer attention, not disengage."