Introduction

This tutorial will introduce you to the Slint UI framework in a playful way by implementing a little memory game. We're going to combine the .slint language for the graphics with the game rules implemented in Rust.

The game consists of a grid of 16 rectangular tiles. Clicking on a tile uncovers an icon underneath. 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. You can uncover two tiles at the same time. If they aren't the same, the icons will be obscured again. If you uncover two tiles with the same icon, then they remain visible - they're solved.

This is how the game looks like in action:

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.

We recommend using rust-analyzer and our editor integrations for .slint files for following this tutorial.

First, we create a new cargo project:

cargo new memory
cd memory

Then we edit Cargo.toml to add the slint dependency using cargo add:

cargo add [email protected]

Finally we copy the hello world program from the Slint documentation into our src/main.rs:

fn main() {
    MainWindow::new().unwrap().run().unwrap();
}

slint::slint! {
    export component MainWindow inherits Window {
        Text {
            text: "hello world";
            color: green;
        }
    }
}

We run this example with cargo run and a window will appear with the green "Hello World" greeting.

Screenshot of initial tutorial app showing Hello World

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's filled with a soothing tone of blue. Note how lengths in the .slint language have a unit, here the px suffix. That makes the code easier to read and the compiler can detect when your are accidentally mixing values with different units attached to them.

We copy the following code inside of the slint! macro:

component MemoryTile inherits Rectangle {
    width: 64px;
    height: 64px;
    background: #3960D5;

    Image {
        source: @image-url("icons/bus.png");
        width: parent.width;
        height: parent.height;
    }
}

export component MainWindow inherits Window {
    MemoryTile {}
}

Inside the Rectangle we place an Image element that loads an icon with the @image-url() macro. When using the slint! macro, the path is relative to the folder in which the Cargo.toml is located. When using .slint files, it's relative to the folder of the .slint file containing it. 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://slint.dev/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.

Screenshot of the first tile

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:falsetrue
Left curtain rectangleFill the left half by setting the width width to half the parent's widthWidth of zero makes the rectangle invisible
Right curtain rectangleFill the right half by setting x and width to half of the parent's widthwidth 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 slint! macro with the following:

component MemoryTile inherits Rectangle {
    callback clicked;
    in property <bool> open_curtain;
    in property <bool> solved;
    in 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;
        x: 0px;
        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();
        }
    }
}

export component MainWindow inherits 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.

Note that we export the MainWindow component. This is necessary so that we can later access it from our business logic.

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:

  1. 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.
  2. A way of creating many instances of the tiles, with the above .slint markup code.

In Slint 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 .slint 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 slint! macro:


// Added:
struct TileData {
    image: image,
    image_visible: bool,
    solved: bool,
}

component MemoryTile inherits Rectangle {

Next, we replace the export component MainWindow inherits Window { ... } section at the bottom of the slint! macro with the following snippet:

export component MainWindow inherits Window {
    width: 326px;
    height: 326px;

    in 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, using the cargo command.

cargo add [email protected]

What we'll do is take the list of tiles declared in the .slint 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 .slint language, it's represented as a Rc<dyn slint::Model>. We can't modify the model generated by the .slint, 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 slint::Model;

    let main_window = MainWindow::new().unwrap();

    // 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(slint::VecModel::from(tiles));
    main_window.set_memory_tiles(tiles_model.into());

    main_window.run().unwrap();
}

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 Slint is that merely the user interface is implemented in the .slint 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 .slint markup inside the slint! 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:

    export component MainWindow inherits Window {
        width: 326px;
        height: 326px;

        callback check_if_pair_solved(); // Added
        in property <bool> disable_tiles; // Added

        in-out property <[TileData]> memory_tiles: [
           { image: @image-url("icons/at.png") },

The last change to the .slint markup is to act when the MemoryTile signals that it was clicked on. We add the following handler in MainWindow:

        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 can't click anything during this time.

Insert this code before the main_window.run() call:

    // Assign the shuffled Vec to the model property
    let tiles_model = std::rc::Rc::new(slint::VecModel::from(tiles));
    main_window.set_memory_tiles(tiles_model.clone().into());

    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);
                t2.solved = true;
                tiles_model.set_row_data(t2_idx, t2);
            } else {
                let main_window = main_window_weak.unwrap();
                main_window.set_disable_tiles(true);
                let tiles_model = tiles_model.clone();
                slint::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().unwrap();

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.

These were the last changes and running the result gives us a window on the screen that allows us to play the game by the rules.

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 Slint 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-pack 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"] }

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 Slint 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 Slint 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 isn't 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's 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 Slint elements with Rust code to build a little game. There are many more features that we haven't talked about, such as layouts, widgets, or styling.

We recommend the following links to continue:

  • Examples: In the Slint repository we have collected a few demos and examples. These are a great starting point to learn how to use many Slint features.
  • Slint API Docs: The reference documentation for the main Rust crate.
  • Slint Interpreter API Docs: The reference documentation for Rust crate that allows you to dynamically load .slint files.