DEV Community is a community of 605,211 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

dev.family

2048 WASM

It was interesting to try these technologies on something more difficult than calculating factorial, so the choice fell on the well-known 2048 game.

Game

The original game represents a 4 by 4 square field. Each square can be either empty or occupied by a tile with a number that is a power of 2. The starting state of the field has 2 filled cells.

The player can make a move in one of 4 directions: left, right, up and down, moving all tiles all the way in the selected direction. A new tile appears after each move. The new tile will contain a 2 with a 90% probability or a 4
with a probability of 10%.

For example, let's move from the starting state shown above to the right:

The tile on the second row rested against the right border, and a 2 appeared on the first row, the third column.

If a tile rests on another tile containing the same number, they are merged into one tile of the next power of two.

Let's move up from the previous state:

Twos of the last column of the first and second rows merged into one, and in addition, a four appeared on the third row.

Purpose of the game: reach the tile with the number 2048.

Preparation

Since one of the goals of this experiment for me was to play with Rust, there is no task of choosing a language.

The most popular option - Yew - looks interesting, especially if you have experience with React.

Instructions for creating a project can be found here:

cargo new --lib rust-2048 && cd rust-2048

[dependencies]
yew = "0.17"
wasm-bindgen = "0.2.67"

The example provides code for creating a simple counter. src/lib.rs:

use wasm_bindgen::prelude::*;
use yew::prelude::*;
struct Model {
value: i64,
}
enum Msg {
}
impl Component for Model {
type Message = Msg;
type Properties = ();
Self {
value: 0,
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
match msg {
}
true
}
fn change(&mut self, _props: Self::Properties) -> ShouldRender {
// Should only return "true" if new properties are different to
// This component has no properties so we will always return "false".
false
}
fn view(&self) -> Html {
html! {
<div>
<p>{ self.value }</p>
</div>
}
}
}
#[wasm_bindgen(start)]
pub fn run_app() {
App::<Model>::new().mount_to_body();
}

Create a directory for static files:

mkdir static

Inside it we create index.html with the following content:

<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8" />
<title>Yew Sample App</title>
<script type="module">
import init from "./wasm.js";
init();
</script>
<body></body>
</html>

Build the project using wasm-pack (installed via cargo install wasm-pack):

wasm-pack build --target web --out-name wasm --out-dir ./static

I got the following error while building:

Error: crate-type must be cdylib to compile to wasm32-unknown-unknown. Add the following to your Cargo.toml file:
[lib]
crate-type = ["cdylib", "rlib"]

Okay, so let's do it. Open Cargo.toml and add at the end:

[lib]
crate-type = ["cdylib", "rlib"]

Now we can start any static server and check the result (in the example they suggest installing miniserve, but I will use a standard Python module):

cd static
python -m SimpleHTTPServer

Open http://127.0.0.1:8000 and see the working counter. Great, now we can start building the game itself.

Basics

Here I described the basic logic of the game and implemented moving tiles without merging. Also, I've added helper structures and methods for convenience.

Let's consider the logic of the movement of tiles using this field as an example:

2, 4, 0, 0
0, 0, 0, 0
0, 0, 0, 0
0, 0, 0, 0

Suppose a move was made to the right. The following result is expected:

0, 0, 2, 4
0, 0, 0, 0
0, 0, 0, 0
0, 0, 0, 0

This behavior can be achieved if you go from the end of the selected direction and move all the tiles in the selected direction.
I will iterate manually for a better understanding:

1. Selected direction is right => go from right to left.
/-------- empty cell, skip
2, 4, 0, 0
0, 0, 0, 0
0, 0, 0, 0
0, 0, 0, 0

2.

/-------- empty cell, skip
2, 4, 0, 0
0, 0, 0, 0
0, 0, 0, 0
0, 0, 0, 0

3.

/-------- start to shift to the right
2, 4, 0, 0
0, 0, 0, 0
0, 0, 0, 0
0, 0, 0, 0

4.

/-------- continue
2, 0, 4, 0
0, 0, 0, 0
0, 0, 0, 0
0, 0, 0, 0

5.

/-------- reached the end, return to the next position from which we began to move
2, 0, 0, 4
0, 0, 0, 0
0, 0, 0, 0
0, 0, 0, 0

6.

/-------- move to the right
2, 0, 0, 4
0, 0, 0, 0
0, 0, 0, 0
0, 0, 0, 0

7.

/-------- continue
0, 2, 0, 4
0, 0, 0, 0
0, 0, 0, 0
0, 0, 0, 0

8.

/-------- stop and go to the next line
0, 0, 2, 4
0, 0, 0, 0
0, 0, 0, 0
0, 0, 0, 0

It is also worth noting that in the case of horizontal moves, the rows move independently of each other, and in the case of vertical moves, the columns.

In the code, this algorithm is implemented by the methods Direction#build_traversal (building a path to traverse the field), Grid#traverse_from (movement of a specific cell in the direction), and Grid#move_in is a public method using the previous two.

Merging

Combining tiles has one nuance: tiles do not merge more than once, in the case of overlapping 4 identical tiles, the result will be 2 tiles of the next power. So, the tiles should have a certain state that is needed to be dropped before each move.

Additionally, I had to change the structure of TestCase, because correct behavior can only be tested in more than
one move.

The rand package is used for random, which also works in the WASM environment by adding the wasm-bindgen feature.

In order not to break previous tests, which are not prepared for new tiles I've added a new flag field enable_new_tiles to the Grid structure.

Since new tiles need to be added only in the case of a valid move (i.e. at least one field change occurred during the move), the traverse_from signature changes. The method now returns a boolean value that indicates whether there was movement.

UI

The UI part is pretty simple to implement, especially for those familiar with React and/or JSX. You can read about the html! Macro from Yew here,
and about components in general here. It reminds me a lot of React in pre-hook times.

There was no documentation of working with the keyboard, and, in principle, the services are not documented in any way, so you need to read the sources, watch examples.

Animations

To make the interface look more lively, we need to add animations.

They are implemented on top of regular CSS transitions. We make tiles to remember their previous position while rendering we display the tile immediately in the old position, and on the next tick - in the new one.

Discussion (1)

Ben Halpern

This is a great walkthrough! I'm going to give this a try.