Introduction

This tutorial introduces you to the Slint UI framework in a playful way by implementing a memory game. It combines 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. 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. The player can uncover two tiles at the same time. If they aren't the same, the game obscures the icons again. If the player uncovers two tiles with the same icon, then they remain visible - they're solved.

This is how the game looks in action:

Getting Started

This tutorial uses Rust as the host programming language. Slint also supports other programming languages like C++ or JavaScript.

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

Slint has an application template you can use to create a project with dependencies already set up that follows recommended best practices.

Before using the template, install [cargo-generate](https://github.com/cargo-generate/cargo-generate):

cargo install cargo-generate

Use the template to create a new project with the following command:

cargo generate --git https://github.com/slint-ui/slint-rust-template --name memory
cd memory

Replace the contents of src/main.rs with the following:

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

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

Run the example with cargo run and a window appears with the green "Hello World" greeting.

Screenshot of an initial tutorial app showing Hello World

Memory Tile

With the skeleton code in place, this step looks at the first element of the game, the memory tile. It's the visual building block that consists of an underlying filled rectangle background, the icon image. Later steps add a covering rectangle that acts as a curtain.

You declare the background rectangle as 64 logical pixels wide and tall filled with a soothing tone of blue.

Lengths in Slint have a unit, here, the px suffix. This makes the code easier to read and the compiler can detect when you accidentally mix values with different units attached to them.

Copy the following code inside of the slint! macro, replacing the current content:

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 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 that contains the Cargo.toml file. When using Slint files, it's relative to the folder of the Slint file containing it.

You need to install this icon and others you use later first. You can download a pre-prepared Zip archive and extract it with the following commands:

curl -O https://slint.dev/blog/memory-game-tutorial/icons.zip
unzip icons.zip

This unpacks an icons directory containing several icons.

Running the program with cargo run opens a window that shows the icon of a bus on a blue background.

Screenshot of the first tile

Polishing the Tile

In this step, you add a curtain-like cover that opens when clicked. You do this by declaring two rectangles below the Image, so that Slint draws them after the Image 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. The element forwards a callback to the MainWindow indicating that a user clicked the tile.

The MainWindow reacts by flipping a custom open_curtain property. Property bindings for the animated width and x properties also use the custom open_curtain property.

The following table shows more detail on the two states:

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 moves to the right, sliding the curtain open when animated

To make the tile extensible, replace the hard-coded icon name with an icon property that can be set when instantiating the element.

For the final polish, add a solved property used to animate the color to a shade of green when a player finds a pair.

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;
        }
    }
}

The code uses root and self. root refers to the outermost element in the component, the MemoryTile in this case. self refers to the current element.

The code exports the MainWindow component. This is necessary so that you can later access it from application business logic.

Running the code opens a window with a rectangle that opens up to show the bus icon when clicked. Subsequent clicks close and open the curtain again.

From One To Multiple Tiles

After modeling a single tile, this step creates a grid of them. For the grid to be a game board, you need two features:

  1. A data model: An array created as a Rust model, where each element describes the tile data structure, such as:

    • URL of the image
    • Whether the image is visible
    • If the player has solved this tile.
  2. A way of creating multiple instances of the tiles.

With Slint you declare an array of structures based on a model using square brackets. Use a for loop to create multiple instances of the same element.

The for loop is declarative and automatically updates when the model changes. The loop instantiates all the MemoryTile elements and places them on a grid based on their index with spacing between the tiles.

First, add the tile data structure definition at the top of the slint! macro:


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

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

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. The code uses the i index to calculate the position of a tile, based on its row and column, using modulo and integer division to create a 4 by 4 grid.

Running the code opens a window that shows 8 tiles, which a player can open individually.

Creating The Tiles From Rust

This step places the game tiles randomly. The code uses the rand dependency for the randomization. Add it to the Cargo.toml file using the cargo command.

cargo add rand@0.8

Change the main function to the following:

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();
}

The code takes the list of tiles, duplicates it, and shuffles it, accessing the memory_tiles property through the Rust code.

For each top-level property, Slint generates a getter and a setter function. In this case get_memory_tiles and set_memory_tiles. Since memory_tiles is a Slint array represented as a Rc<dyn slint::Model>.

You can't change the model generated by Slint, but you can extract the tiles from it and put them in a VecModel which implements the Model trait. VecModel lets you make changes and you can use it to replace the static generated model.

The code clones the tiles_model because you use it later to update the game logic.

Running this code opens a window that now shows a 4 by 4 grid of rectangles, which show or hide the icons when a player clicks on them.

There's one last aspect missing now, the rules for the game.

Game Logic In Rust

This step implements the rules of the game in Rust.

Slint's general philosophy is that you implement the user interface in Slint and the business logic in your favorite programming language.

The game rules enforce that at most two tiles have their curtain open. If the tiles match, then the game considers them solved and they remain open. Otherwise, the game waits briefly so the player can memorize the location of the icons, and then closes the curtains again.

Add the following code inside the MainWindow component to signal to the Rust code when the user clicks on a tile.

    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") },

This change adds a way for the MainWindow to call to the Rust code that it should check if a player has solved a pair of tiles. The Rust code needs an additional property to toggle to disable further tile interaction, to prevent the player from opening more tiles than allowed. No cheating allowed!

The last change to the code is to act when the MemoryTile signals that a player clicked it.

Add the following handler in the MainWindow for loop clicked handler:

        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, you can now add a handler to the check_if_pair_solved callback, that checks if a player opened two tiles. If they match, the code sets the solved property to true in the model. If they don't match, start a timer that closes the tiles after one second. While the timer is running, disable every tile so a player can't click anything during this time.

Add this code before the main_window.run().unwrap(); 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);
                });
            }
        }
    });

The code uses a Weak pointer of the main_window. This is important because capturing a copy of the main_window itself within the callback handler would result in 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 code opens a window that allows a player to play the game by the rules.

Ideas For The Reader

The game is visually bare. Here are some ideas on how you could make further changes to enhance it:

  • The tiles could have rounded corners, to look less sharp. Use the border-radius property of Rectangle 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

The tutorial so far used cargo run to build and run the code as a native application. Native applications are the primary target of the Slint framework, but it also supports WebAssembly for demonstration purposes. This section uses the standard rust tool wasm-bindgen and wasm-pack to run the game in the browser. Read the wasm-bindgen documentation for more about using wasm and rust.

Install wasm-pack using cargo:

cargo install wasm-pack

Edit the Cargo.toml file to add the dependencies.

[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { version = "0.2" }
getrandom = { version = "0.2.2", features = ["js"] }

'cfg(target_arch = "wasm32")' ensures that these dependencies are only active when compiling for the wasm32 architecture. Note that the rand dependency is now duplicated, to enable the "wasm-bindgen" feature.

While you are editing the Cargo.toml, make one last change. To turn the binary into a library by adding the following:

[lib]
path = "src/main.rs"
crate-type = ["cdylib"]

This is because wasm-pack requires Rust to generate a "cdylib".

You also need to change 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() {
    //...
}

Compile the program with wasm-pack build --release --target web. This creates a pkg directory containing several files, including a .js file named after the program name that you need to import into an HTML file.

Create a minimal index.html in the top level of the project that declares a <canvas> element for rendering and loads the generated wasm file. The Slint runtime expects the <canvas> element to have the id id = "canvas". (Replace memory.js with 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 you can't load the index.html. Instead, you need to serve it through a web server. For example, using Python, by running:

python3 -m http.server

Now you can access the game at http://localhost:8000.

Conclusion

This tutorial showed you how to combine built-in Slint elements with Rust code to build a game. There is much more to Slint, such as layouts, widgets, or styling.

We recommend the following links to continue:

  • Examples: The Slint repository has several 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 the Rust crate that allows you to dynamically load Slint files.