A basic application

So you’ve got one of the examples running, but what now? Let’s go through a simple application for the BBC:microbit to understand it better.

The application is a simple game: As a cursor traverses the LED matrix, use the button to toggle the 'set' state of each individual LED. Once all LEDs are lit, you win!

Main

The full example can be found here.

Rust Nightly

The first thing you’ll notice is a few declarations stating that the application requires some nightly features:

#![no_std]
#![no_main]
#![macro_use]
#![feature(generic_associated_types)]
#![feature(type_alias_impl_trait)]

Dealing with errors

Then, what follows are some declarations on how to deal with panics and faults. During development, a good practice is to rely on defmt-rtt and panic-probe to print diagnostics to the terminal:

use defmt_rtt as _;
use panic_probe as _;

Defining actors

Lets define the basic game first. We’ll model the game as an actor named Game, and it will drive the entire game logic and interact with the LED matrix and the 'A' button on the micro:bit board.

Luckily, Drogue Device already have actors for buttons and a LED matrix driver, so we only need to create the Game actor.

Lets start with the types:

/// to light a red. You win when the whole board is lit.
struct Game {
    matrix: LedMatrix,
}

#[derive(Clone)]
pub enum GameMessage {
    Toggle,
}

impl TryFrom<ButtonEvent> for GameMessage {
    type Error = ();
    fn try_from(event: ButtonEvent) -> Result<Self, Self::Error> {
        match event {
            ButtonEvent::Released => Ok(GameMessage::Toggle),
            _ => Err(()),
        }
    }
}

impl Game {
    pub fn new(matrix: LedMatrix) -> Self {
        Self { matrix }
    }
}

The Game type holds on to the led matrix. In addition, we define the GameMessage message that our actor will expect to be notified of whenever the button is pressed. The Address type is a handle that you can use to send messages to an Actor. Our Game actor will be reachable using an Address<GameCommand> instance, which you get when mounting an actor. The predefined Button actor can work with any handle that implements the TryFrom<ButtonEvent> trait, so we’ll add an implemented for our GameMessage type.

We can now have our Game type implement the Actor trait:

#[actor]
impl Actor for Game {
    type Message<'m> = GameMessage;

    async fn on_mount<M>(&mut self, _: Address<GameMessage>, mut inbox: M)
    where
        M: Inbox<GameMessage> + 'm,
    {

The Message of an Actor defines the messages that our actor expects to handle.

Why do you have to define the actor attribute? Rust does not yet support async methods in traits, and needs some assistance in knowing what to do. This is only temporary until Rust supports basic async traits, so for now we’ll have to live with it.

We can now define the main actor logic in the on_mount implementation.

The on_mount entry point is called only once, and should contain the Actor main loop. The actor is handed an address to itself. This can be useful if you need to register the Actor with other actors or types. The Address type can be cloned, so it’s easy to pass around.

The Inbox parameter is a handle that the actor can use to wait for incoming messages.

Game logic

Having the boilerplate out of the way, we can now focus on our application. The game logic can be described as follows:

Game logic
  1. Define a set of coordinates in a 5x5 matrix

  2. Initialize cursor at (0, 0)

  3. Initialize coordinates as not being visited

  4. While not all coordinates visited

    1. Lit the LED at the cursor coordinate

    2. Wait for a GameMessage to arrive within a timeout

    3. If a GameMessage::Toggle arrived, toggle the visited status at the cursor coordinate

    4. Unlit the LED at the cursor coordinate, if not visited

    5. Update cursor

The game logic is implemented here:

defmt::info!("Starting game! Press the 'A' button to lit the LED at the cursor.");
let speed = Duration::from_millis(200);

let mut coordinates: [[bool; 5]; 5] = [[false; 5]; 5];
let mut cursor = 0;
let (mut x, mut y) = (0, 0);
let mut done = false;

let mut render = Ticker::every(Duration::from_millis(5));
while !done {
    self.matrix.on(x, y);
    // Race timeout and button press
    let timeout = Timer::after(speed);
    let event = inbox.next();
    pin_mut!(timeout);
    pin_mut!(event);

    let mut logic = select(timeout, event);
    loop {
        let tick = render.next();
        pin_mut!(tick);
        match select(tick, &mut logic).await {
            Either::Left((_, _)) => {
                self.matrix.render();
            }
            Either::Right((f, _)) => match f {
                Either::Left(_) => {
                    break;
                }
                Either::Right(_) => {
                    // Set/unset
                    coordinates[y][x] = !coordinates[y][x];
                    break;
                }
            },
        }
    }

    // Unlit only if we're not set
    if !coordinates[y][x] {
        self.matrix.off(x, y)
    }

    // Check if game is done
    done = true;
    for x in 0..5 {
        for y in 0..5 {
            if !coordinates[y][x] {
                done = false;
                break;
            }
        }
    }

    x = cursor % 5;
    y = (cursor / 5) % 5;
    cursor += 1;
    self.matrix.render();
}

The LedMatrix has an on and off method to toggle the LED at a given coordinate. In order to render the current LedMatrix state, we need to call a render function at the desired refresh interval. The "outer" loop does just that until one of the 'game logic' futures are completing.

The inbox.next() is an async funtion that completes when an event have been received (i.e. button has been pressed).

The Timer type can be used to asynchronously wait for an event. The futures select function can be used to "race" two async functions for completion.

Therefore, the outer select races the rending loop and the inbox.next() and timer, whereas they are raced against eachother. If there is an event or timeout, we update the game state.

Application main

With the game logic defined, we can now wire our three actors together in the application main:

#[embassy::main]
async fn main(spawner: embassy::executor::Spawner, p: Peripherals) {
    // Using a board support package to simplify setup
    let board = Microbit::new(p);

    // An actor for the game logic
    let game = spawn_actor!(spawner, GAME, Game, Game::new(board.display));

    // Actor for button 'A'
    spawn_actor!(spawner, BUTTON_A, Button<PinButtonA, GameMessage>, Button::new(board.btn_a, game));
}

The main entry point is defined using the #[embassy::main] macro. The entry point is also required to take a Spawner and a Peripherals argument.

The Spawner is the way the main application spawns tasks and actors. The Peripherals type holds all peripherals that the application may use.

For some boards, Drogue Device provide a Board Support Package (BSP) that helps locating peripherals for a given board. You can also access all peripherals using the Peripherals type. The board simply helps setting them up and giving them a name that maps to your particular board. In our case, we can use the Microbit board.

Finally, we spawn an instance for each of the actors in our system: LED_MATRIX, GAME, and BUTTON_A.

An Address handle of the actor is returned when spawned, and can be used as a parameter to other actors, or directly in the main function.

Summary

We’ve gone through a basic application written for the BBC micro:bit. We’ve created our own Actor instance to drive the game logic, and wired it together with two other actors already defined by Drogue Device, which was simple due to the composability of actors.