December 14, 2020 by Olivier Goffart and Simon Hausmann
Learn SixtyFPS: Memory Game Tutorial (Rust)
SixtyFPS is a new project aiming at making a UI toolkit. The previous blog post was introducing the project. This blog post explains, through a small tutorial, how to use SixtyFPS to create a small game.
In this tutorial, we are going to demonstrate how to create a simple memory puzzle game with SixtyFPS.
We are going to combine the
.60
language for the graphics with the game rules implemented in the Rust programming language.
The SixtyFPS framework also supports other programming languages like C++ or JavaScript.
Update: The Rust tutorial has been moved to the online documentation.
Update: We also published a C++ version of this tutorial.
The game consists of a grid of 16 rectangular tiles. When clicking on a tile, an icon underneath is uncovered. We know that there are 8 different icons in total, so each tile has a sibling somewhere in the grid with the same icon. The objective is to locate all icon pairs. Only two tiles can be uncovered at the same time. If they are not the same, then the icons will be obscured again. We need to remember under which tiles the matching graphics are hiding. If two tiles with the same icon are uncovered, then they remain visible - they are solved.
This is how the game looks like in action:
You can also play in your web browser.
Getting Started
We assume that you are a somewhat familiar with Rust, and that you know how to create a Rust application with
cargo new
. The Rust Getting Started Guide"
can help you get set up.
First, we create a new cargo project:
cargo new memory
cd memory
Then we edit Cargo.toml
to add the sixtyfps dependency:
[dependencies]
sixtyfps = "0.0.6"
Finally we copy the hello world program from the SixtyFPS
documentation into our src/main.rs
:
sixtyfps::sixtyfps!{
MainWindow := Window {
Text {
text: "hello world";
color: green;
}
}
}
fn main() {
MainWindow::new().run();
}
We run this example with
cargo run
and a window will appear with the green "Hello World" greeting.
Memory Tile
With the skeleton in place, let's look at the first element of the game, the memory tile. It will be the
visual building block that consists of an underlying filled rectangle background, the icon image. Later we'll add a
covering rectangle that acts as a curtain. The background rectangle is declared to be 64 logical pixels wide and tall,
and it is filled with a soothing tone of blue. Note how lengths in the .60
language have a unit, here
the px
suffix. That makes the code easier to read and the compiler can detect when your're accidentally
mixing values with different units attached to them.
We copy the following code inside of the sixtyfps!
macro:
MemoryTile := Rectangle {
width: 64px;
height: 64px;
background: #3960D5;
Image {
source: @image-url("icons/bus.png");
width: parent.width;
height: parent.height;
}
}
MainWindow := Window {
MemoryTile {}
}
Inside the Rectangle
we place an Image
element that loads an icon with the @image-url()
macro. The path is
relative to the folder in which the Cargo.toml
is located. This icon and others we're going to
use later need to be installed first. You can download a Zip
archive that we have prepared and
extract it with the following two commands:
curl -O https://sixtyfps.io/blog/memory-game-tutorial/icons.zip
unzip icons.zip
This should unpack an icons
directory containing a bunch of icons.
Running the program with cargo run
gives us a window on the screen that shows the icon of a bus on a
blue background.
Polishing the Tile
Next, let's add a curtain like cover that opens up when clicking. We achieve this by declaring two rectangles
below the Image
, so that they are drawn afterwards and thus on top of the image.
The TouchArea
element declares a transparent rectangular region that allows
reacting to user input such as a mouse click or tap. We use that to forward a callback to the MainWindow
that the tile was clicked on. In the MainWindow we react by flipping a custom open_curtain property.
That in turn is used in property bindings for the animated width and x properties. Let's look at the two states a bit
more in detail:
open_curtain value: | false | true |
---|---|---|
Left curtain rectangle | Fill the left half by setting the width width to half the parent's width | Width of zero makes the rectangle invisible |
Right curtain rectangle | Fill the right half by setting x and width to half of the parent's width | width of zero makes the rectangle invisible. x is moved to the right, to slide the curtain open when animated |
In order to make our tile extensible, the hard-coded icon name is replaced with an icon
property that can be set from the outside when instantiating the element. For the final polish, we add a
solved property that we use to animate the color to a shade of green when we've found a pair, later. We
replace the code inside the sixtyfps!
macro with the following:
MemoryTile := Rectangle {
callback clicked;
property <bool> open_curtain;
property <bool> solved;
property <image> icon;
height: 64px;
width: 64px;
background: solved ? #34CE57 : #3960D5;
animate background { duration: 800ms; }
Image {
source: icon;
width: parent.width;
height: parent.height;
}
// Left curtain
Rectangle {
background: #193076;
width: open_curtain ? 0px : (parent.width / 2);
height: parent.height;
animate width { duration: 250ms; easing: ease-in; }
}
// Right curtain
Rectangle {
background: #193076;
x: open_curtain ? parent.width : (parent.width / 2);
width: open_curtain ? 0px : (parent.width / 2);
height: parent.height;
animate width { duration: 250ms; easing: ease-in; }
animate x { duration: 250ms; easing: ease-in; }
}
TouchArea {
clicked => {
// Delegate to the user of this element
root.clicked();
}
}
}
MainWindow := Window {
MemoryTile {
icon: @image-url("icons/bus.png");
clicked => {
self.open_curtain = !self.open_curtain;
}
}
}
Note the use of root
and self
in the code. root
refers to the outermost
element in the component, that's the MemoryTile
in this case. self
refers
to the current element.
Running this gives us a window on the screen with a rectangle that opens up to show us the bus icon, when clicking on it. Subsequent clicks will close and open the curtain again.
From One To Multiple Tiles
After modeling a single tile, let's create a grid of them. For the grid to be our game board, we need two features:
- A data model: This shall be an array where each element describes the tile data structure, such as the url of the image, whether the image shall be visible and if this tile has been solved. We modify the model from Rust code.
- A way of creating many instances of the tiles, with the above
.60
markup code.
In SixtyFPS we can declare an array of structures using brackets, to create a model. We can use the for
loop to create many instances of the same element. In .60
the
for loop is declarative and automatically updates when the model changes. We instantiate all the different
MemoryTile
elements and place them on a grid based on their index with a
little bit of spacing between the tiles.
First, we copy the tile data structure definition and paste it at top inside the sixtyfps!
macro:
sixtyfps::sixtyfps!{
// Added:
struct TileData := {
image: image,
image_visible: bool,
solved: bool,
}
MemoryTile := Rectangle {
// ...
Next, we replace the MainWindow
:= { ... } section
at the bottom of the sixtyfps!
macro with the following snippet:
MainWindow := Window {
width: 326px;
height: 326px;
property <[TileData]> memory_tiles: [
{ image: @image-url("icons/at.png") },
{ image: @image-url("icons/balance-scale.png") },
{ image: @image-url("icons/bicycle.png") },
{ image: @image-url("icons/bus.png") },
{ image: @image-url("icons/cloud.png") },
{ image: @image-url("icons/cogs.png") },
{ image: @image-url("icons/motorcycle.png") },
{ image: @image-url("icons/video.png") },
];
for tile[i] in memory_tiles : MemoryTile {
x: mod(i, 4) * 74px;
y: floor(i / 4) * 74px;
width: 64px;
height: 64px;
icon: tile.image;
open_curtain: tile.image_visible || tile.solved;
// propagate the solved status from the model to the tile
solved: tile.solved;
clicked => {
tile.image_visible = !tile.image_visible;
}
}
}
The for tile[i] in memory_tiles :
syntax
declares a variable tile
which contains the data of one element from the memory_tiles
array,
and a variable i
which is the index of the tile. We use the i
index to calculate the position
of tile based on its row and column, using the modulo and integer division to create a 4 by 4 grid.
Running this gives us a window that shows 8 tiles, which can be opened individually.
Creating The Tiles From Rust
The tiles in the game should have a random placement. We'll need to add the rand
dependency to
Cargo.toml
for the randomization.
[dependencies]
sixtyfps = "0.0.6"
rand = "0.8" # Added
What we'll do is take the list of tiles declared in the .60 language, duplicate it, and shuffle it.
We'll do so by accessing the memory_tiles
property through the Rust code. For each
top-level property, a getter and a setter function is generated - in our case get_memory_tiles
and set_memory_tiles
. Since memory_tiles
is an array in the .60
language, it is represented as a
Rc<dyn sixtyfps::Model>
.
We can't modify the model generated by the .60, but we can extract the tiles from it, and put it
in a VecModel
which
implements the Model
trait. VecModel
allows us to make modifications and we
can use it to replace the static generated model.
We modify the main function like so:
fn main() {
use sixtyfps::Model;
let main_window = MainWindow::new();
// Fetch the tiles from the model
let mut tiles: Vec<TileData> =
main_window.get_memory_tiles().iter().collect();
// Duplicate them to ensure that we have pairs
tiles.extend(tiles.clone());
// Randomly mix the tiles
use rand::seq::SliceRandom;
let mut rng = rand::thread_rng();
tiles.shuffle(&mut rng);
// Assign the shuffled Vec to the model property
let tiles_model =
std::rc::Rc::new(sixtyfps::VecModel::from(tiles));
main_window.set_memory_tiles(
sixtyfps::ModelHandle::new(tiles_model.clone()));
main_window.run();
}
Note that we clone the tiles_model
because we'll use it later to update the game logic.
Running this gives us a window on the screen that now shows a 4 by 4 grid of rectangles, which can show or obscure the icons when clicking. There's only one last aspect missing now, the rules for the game.
Game Logic In Rust
We'll implement the rules of the game in Rust as well. The general philosophy of SixtyFPS is that merely the user
interface is implemented in the .60
language and the business logic in your favorite programming
language. The game rules shall enforce that at most two tiles have their curtain open. If the tiles match, then we
consider them solved and they remain open. Otherwise we wait for a little while, so the player can memorize
the location of the icons, and then close them again.
We'll modify the .60
markup inside the sixtyfps!
macro to signal to the Rust code
when the user clicks on a tile. Two changes to MainWindow
are needed: We need to add a
way for the MainWindow to call to the Rust code that it should check if a pair of tiles has been solved. And we need
to add a property that Rust code can toggle to disable further tile interaction, to prevent the player from opening more
tiles than allowed. No cheating allowed! First, we paste the callback and property declarations into MainWindow
:
...
MainWindow := Window {
callback check_if_pair_solved(); // Added
property <bool> disable_tiles; // Added
width: 326px;
height: 326px;
property <[TileData]> memory_tiles: [
{ image: img!"icons/at.png" },
...
The last change to the .60
markup is to act when the MemoryTile
signals
that
it was clicked on. We add the following handler:
...
MainWindow := Window {
...
for tile[i] in memory_tiles : MemoryTile {
x: mod(i, 4) * 74px;
y: floor(i / 4) * 74px;
width: 64px;
height: 64px;
icon: tile.image;
open_curtain: tile.image_visible || tile.solved;
// propagate the solved status from the model to the tile
solved: tile.solved;
clicked => {
// old: tile.image_visible = !tile.image_visible;
// new:
if (!root.disable_tiles) {
tile.image_visible = !tile.image_visible;
root.check_if_pair_solved();
}
}
}
}
On the Rust side, we can now add an handler to the check_if_pair_solved
callback, that will check if
two tiles are opened. If they match, the solved
property is set to true in the model. If they don't
match, start a timer that will close them after one second. While the timer is running, we disable every tile so
one cannot click anything during this time.
Insert this code before the main_window.run()
call:
// ...
let main_window_weak = main_window.as_weak();
main_window.on_check_if_pair_solved(move || {
let mut flipped_tiles =
tiles_model.iter().enumerate().filter(|(_, tile)| {
tile.image_visible && !tile.solved
});
if let (Some((t1_idx, mut t1)), Some((t2_idx, mut t2))) =
(flipped_tiles.next(), flipped_tiles.next())
{
let is_pair_solved = t1 == t2;
if is_pair_solved {
t1.solved = true;
tiles_model.set_row_data(t1_idx, t1.clone());
t2.solved = true;
tiles_model.set_row_data(t2_idx, t2.clone());
} else {
let main_window = main_window_weak.unwrap();
main_window.set_disable_tiles(true);
let tiles_model = tiles_model.clone();
sixtyfps::Timer::single_shot(
std::time::Duration::from_secs(1),
move || {
main_window
.set_disable_tiles(false);
t1.image_visible = false;
tiles_model.set_row_data(t1_idx, t1);
t2.image_visible = false;
tiles_model.set_row_data(t2_idx, t2);
}
);
}
}
});
main_window.run();
Notice that we take a Weak pointer of our
main_window
. This is very important because capturing a copy of the main_window
itself
within the callback handler would result in a circular ownership. The MainWindow
owns the
callback handler, which itself owns a reference to the MainWindow
, which must be weak
instead of strong to avoid a memory leak.
And that's it, now we can run the game!
Ideas For The Reader
The game is visually a little bare. Here are some ideas how you could make further changes to enhance it:
- The tiles could have rounded corners, to look a little less sharp. The border-radius property of Rectangle can be used to achieve that.
- In real world memory games, the back of the tiles often have some common graphic. You could add an image with the help of another Image element. Note that you may have to use Rectangle's clip property element around it to ensure that the image is clipped away when the curtain effect opens.
Let us know in the comments on Github Discussions how you polished your code, or feel free to ask questions about how to implement something.
Running In A Browser Using WebAssembly
Right now, we used cargo run
to build and run our program as a native application.
Native applications are the primary target of the SixtyFPS framework, but we also support WebAssembly
for demonstration purposes. So in this section we'll use the standard rust tool wasm-bindgen
and
wasm-pack
to run the game in the browser. The wasm-bindgen
documentation explains all you need to know about using wasm and rust.
Make sure to have wasm-back installed using
cargo install wasm-pack
You'll need to edit your Cargo.toml
to add the dependencies.
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { version = "0.2" }
getrandom = { version = "0.2.2", features = ["js"] }
And it is also necessary to switch to Cargo's new feature resolver, by adding a resolver = "2"
switch at the
top of Cargo.toml
:
[package]
name = "memory"
version = "0.1.0"
authors = ["..."]
edition = "2018"
resolver = "2" # This line added
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
The 'cfg(target_arch = "wasm32")'
ensures that these dependencies will only be active
when compiling for the wasm32 architecture. Note that the rand
dependency is now duplicated,
in order to enable the "wasm-bindgen"
feature.
While you are editing the Cargo.toml, one last change is needed: you need to turn the binary into a library by adding the following:
[lib]
path = "src/main.rs"
crate-type = ["cdylib"]
This is required because wasm-pack require rust to generate a "cdylib"
You also need to modify the main.rs
by adding the wasm_bindgen(start)
attribute to the main function and export it with the pub
keyword:
#[cfg_attr(target_arch = "wasm32",
wasm_bindgen::prelude::wasm_bindgen(start))]
pub fn main() {
//...
}
Now, we can compile our program with wasm-pack build --release --target web
. This
will create a pkg
directory containing a few files, including a .js
file
named after your program name. We just have to import that from a HTML file. So let's create a minimal
index.html
that declares a <canvas>
element for rendering and loads our generated wasm
file. The SixtyFPS runtime expects the <canvas>
element to have the id id = "canvas"
.
(Replace memory.js
by the correct file name).
<html>
<body>
<!-- canvas required by the SixtyFPS runtime -->
<canvas id="canvas"></canvas>
<script type="module">
// import the generated file.
import init from './pkg/memory.js';
init();
</script>
</body>
</html>
Unfortunately, loading ES modules is not allowed for files on the file system when accessed from a
file://
URL, so we can't simply open the index.html. Instead we need to serve it
through a web server. For example, using Python, it is as simple as running
python3 -m http.server
and then you can now access the game on http://localhost:8000/
Conclusion
In this tutorial, we have demonstrated how to combine some built-in SixtyFPS elements with Rust code to build a little game. There are many more features that we have not talked about, such as layouts, widgets, or styling. Have a look at the examples in the SixtyFPS repo to see how these look like and can be used, such as the todo example.
A slightly more polished version of this memory puzzle game is available in the SixtyFPS repository. And you can play the wasm version in your browser.
Slint is a Rust-based toolkit for creating reactive and fluent user interfaces across a range of targets, from embedded devices with limited resources to powerful mobile devices and desktop machines. Supporting Android, Windows, Mac, Linux, and bare-metal systems, Slint features an easy-to-learn domain-specific language (DSL) that compiles into native code, optimizing for the target device's capabilities. It facilitates collaboration between designers and developers on shared projects and supports business logic development in Rust, C++, JavaScript, or Python.