FBSim: football-playing AI agents in Rust


I took a two week vacation in early November. Somehow I decided to spend it learning a bit more about Rust and Reinforcement Learning (RL), a sub-field of AI that I haven’t explored much before. We won’t be talking about RL this post, though. That’s for a future blogpost.

All of that lead to me writing FBSim1, which I struggled to describe as “a game-like football simulation environment for trying out AI ideas, written in Rust“. As a game-dev framework, I used amethyst. Rust, amethyst and FBSim are all open source.

The idea is the following: you write functions defining how each of your players are to behave according to their role (forward, left, right, defender and goalie)2 and according to the environment, which includes information about the position of every teammate, every opponent, the ball and the net. FBSim does the rest for you.

In Rust terms, all you need to do in order to make your own AI for the game and see it play against other AIs is to implement the Engine trait –a trait is like a protocol in swift, an interface in Go, or an Abstract Base Class in python: essentially, a set of methods–, then you register your engine and change the configuration to make the players use it.

We’ll be implementing a simple AI for FBSim from scratch later on in the blogpost (beginners to Rust are welcome too), but first let me show you how the game looks like.

These are two very simple stock AIs playing each other.

BasicWingWait detroying Basic on a game

Exciting? No? Well… uhh… it’s a bit more exciting if you write the AI yourself.

Let’s go ahead with the implementation.

Get (to) the sources

First, let’s download the source code, and checkout the tag for the code that’s compatible with this blogpost.

git clone git@github.com:IanTayler/fbsim.git
cd fbsim
git checkout blogpost-1b # APIs may change in the future!

Get Rust (if necessary)

If you have Rust and cargo installed, you’re ready to go ahead. If not, then you should check out rustup. Once you’ve installed rustup, and used it to install the compiler (I’m using 1.47.0) and cargo, you’re good to go.

Note: FBSim uses const fn with a match statement, so you will need a relatively new version of the Rust compiler. If you’re getting errors mentioning const fn and match, then you probably need to update your compiler to a newer version.

Build FBSim

Linux

In order for amethyst to work, you should first install some system dependencies, as detailed here. Install the dependencies according to your distribution and then you can proceed to run the following.

source ./env.sh # necessary due to a rendy bug with vulkan
cargo run

MacOS

If you’re under macOS, you first need to change the Cargo.toml to use metal instead of vulkan.

Your final Cargo.toml file should look like this:

[package]
name = "fbsim"
version = "0.1.0"
authors = ["Ian Tayler <iangtayler@gmail.com>"]
edition = "2018"
readme = "README.md"
repository = "https://github.com/IanTayler/fbsim/"
license = "MIT"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies.amethyst]
version = "0.15.3"
features = ["metal"]

[dependencies.serde]
version = "1.0.104"

[dependencies]
rand = "0.7.0"
rand_distr = "0.3.0"

Now, you can save the file and run:

cargo run

I didn’t test this on a mac, so these instructions are based on speculation. Let me know if it doesn’t work for you.

Note: It was brought to my attention that some people were having the following issue mentioning gfx-backend-metal and xcrun on macOS. If that’s you, follow the linked issue to get a likely solution.

Windows

I think the simple cargo run should work by itself under windows. Let me know if that’s not the case!

Checking that it works

If it works, you should see something like the following screenshot. The players should be moving about. Some of them could be standing still. That’s normal.

What an FBSim Engine is

What we’re going to do now is to implement a new Engine and try it out against one of the stock ones.

Implementing an Engine amounts to implementing a bunch of functions that take as input a representation of the state of the game, and output a description of how the agent should behave next frame.

In essence, we’ll be telling our players what to do based on what they can see.

Engine input

As input we’ll get an EngineData struct. Here is how it’s defined.

/// Input for engine functions.
pub struct EngineData<'a> {
    pub ball_position: Vector2<f32>,
    pub own_position: Vector2<f32>,
    pub own: &'a player::Player,
    pub own_type: &'a player::PlayerType,
    pub own_net_position: Vector2<f32>,
    pub opponent_net_position: Vector2<f32>,
    pub teammates_position: Vec<(PlayerType, Vector2<f32>)>,
    pub opponents_position: Vec<(PlayerType, Vector2<f32>)>,
}

If you’re new to Rust. You can ignore the 'a. It’s a lifetime parameter. The rest should look somewhat familiar if you’ve used languages like C, C++, Go, etc. All the pubs just mean we want the fields and the struct to be publicly exported. Let’s look at the fields we actually care about.

ball_position: Vector2<f32>

This will have the absolute position of the ball –(0, 0) is the lower left of the screen–. You can access each coordinate by using Vector2‘s x and y fields. Vector2s also have special methods defined and most operations (+, -, *) behave like you’d expect if you’ve worked with numpy and similar libraries.

The actual Vector2 type is defined in the nalgebra crate3. The <f32> part just means the values in each coordinate are 32bit floats.

own_position: Vector2<f32>,

Same as above, but this will have the currently-running player’s position.

own_net_position: Vector2<f32>,
opponent_net_position: Vector2<f32>,

You get the idea, right? Positions of the centers of both nets. opponent_net is where you are trying to score. These are also always absolute positions, so they will be the same value every time.

teammates_position: Vec<(PlayerType, Vector2<f32>)>,
opponents_position: Vec<(PlayerType, Vector2<f32>)>,

Here we have a vector (Vec, similar to C++’s vectors, python’s lists, etc.) having the positions of all your 4 teammates (in the case of teammates_position) or the positions of your 5 opponents (in the case of opponents_position); along with the respective role of each of those opponents or teammates –each element is a pair with role first and position later–. We will not be using these two fields for the rest of this post, but if you plan on implementing a good engine, you probably should.

Engine output

/// Return type of engine functions.
pub struct EngineTransition {
    /// Velocity vector (pixels per second). Magnitude will
    /// be cropped to player speed!
    pub velocity: Vector2<f32>,
    /// Which action to activate (if any).
    pub action: Option<player::ActionType>,
}

This is what our functions will return. We define our player’s velocity in the x and y axis, and we set the player’s action, if any.

The action field will be either Some(ActionType::Kick) if we want our player to kick the ball if it collides with them next frame and None if we want the player to gently push the ball forward.4 There are no other actions in this version of FBSim.

Engine trait (i.e. methods)

pub trait Engine {
    fn goalie(&mut self, engine_data: EngineData) -> EngineTransition;
    fn forward(&mut self, engine_data: EngineData) -> EngineTransition;
    fn left(&mut self, engine_data: EngineData) -> EngineTransition;
    fn defender(&mut self, engine_data: EngineData) -> EngineTransition;
    fn right(&mut self, engine_data: EngineData) -> EngineTransition;
    fn dispatch(&mut self, engine_data: EngineData) -> EngineTransition {
        match engine_data.own_type {
            PlayerType::Goalie => self.goalie(engine_data),
            PlayerType::Defender => self.defender(engine_data),
            PlayerType::Forward => self.forward(engine_data),
            PlayerType::Left => self.left(engine_data),
            PlayerType::Right => self.right(engine_data),
        }
    }   
}

What this says is we need to implement methods called goalie, defender, left, right and forward. Each will govern how each of our roles play. The dispatch method is already implemented by default, and it’s unlikely you’ll want to change it.

The only difference between roles is the position they start on in the field after each goal and the fact that goalies have a bigger hitbox.

SimpleEngine trait

When convenient, you can use SimpleEngine, which only asks that you implement an engine_func method and implements all the Engine methods for you.

This can be useful when you will use exactly the same code for all your roles. You just write that code once in engine_func and SimpleEngine uses it for goalie, left, right, etc. indistinctly.

pub trait SimpleEngine {
    fn engine_func(&mut self, engine_data: EngineData) -> EngineTransition;
}

This is what we’ll be using. But you’ll likely want to have different methods for your different roles if you implement something more complex.

Implementation

Minimal Engine implementation

We’ll create a file src/engines/myengine.rs. That’s where our entire engine will live.

We’ll first create the silliest engine possible: we’ll always stay put and not do any actions. Later we’ll make a less silly engine.

// src/engines/myengine.rs
//
// First import everything we need.
// "crate" are imports relative to our own code.
// So crate::components is under src/components, for example.
use crate::{        
    components::player::ActionType,
    engines::{EngineData, EngineTransition, SimpleEngine},
};                      
                    
// We don't need any fields. We just define an empty struct.
pub struct MyEngine;    

// We use `impl` to implement the `SimpleEngine` trait.
impl SimpleEngine for MyEngine {
    fn engine_func(&mut self, engine_data: EngineData) -> EngineTransition {
        // In Rust, the last expression in the function is the return value!
        // Here, we return an EngineTransition, as the trait defines.
        // Remember not to add a semicolon (;) for the return expression
        // as that evaluates to an empty value ()!
        EngineTransition {
            // Stay put! Moving is dangerous!!!! (´・_・`)
            velocity: math::Vector2::new(0.0, 0.0),
            // Don't act! Actions have consequences!!!!! (´・_・`)
            action: None,
        }
    }   
} 

Exporting our engine

Now we need to register our new engine in the EngineRegistry. For that, we’ll need to export our MyEngine struct and import it in the registry.

We export our new file using pub mod in the module’s mod.rs file.

// src/engines/mod.rs
pub use self::engine::{Engine, EngineData, EngineTransition, SimpleEngine};

pub mod basic;
pub mod engine;
pub mod myengine;

Adding our Engine to the registry

Now we add it to the registry in src/resources/engine_registry.rs. First step is importing it.

// src/resources/engine_registry.rs
use crate::engines::{          
    basic::{Basic, BasicWingWait},  
    myengine::MyEngine,        
    Engine,
};
use std::collections::BTreeMap;

And then, later down the same file, we add an insert in the definition of EngineRegistry::default.

// src/resources/engine_registry.rs, line 41
impl Default for EngineRegistry {
    fn default() -> Self {
        let mut registry =
            EngineRegistry::new(BTreeMap::<String, Box<dyn Engine + Send + Sync>>::new());
        registry.insert("basic".to_string(), Box::new(Basic::new()));
        registry.insert(
            "basic_wing_wait".to_string(),
            Box::new(BasicWingWait::new()),
        );
        registry.insert("myengine".to_string(), Box::new(MyEngine {}));
        registry
    }
}

Here we added an entry to a map from a String to a Box<dyn Engine>.5,6

Two things require explanation for people less familiar with Rust: Box and to_string. If you already know about them, skip to the next section.

We need to use a Box around our Engine due to the way Rust handles dynamic dispatch to structs. Box allocates our struct in the heap and allows us to handle structs of different sizes in the same data structure.

The reason why we need to call the to_string method on "myengine" is because string literals in Rust are not really of type String but rather of type &str. This is an optimization that avoids heap-allocation of literals, but the fact that they’re of a different type means we have to call to_string() on them if we need a heap-allocated String.

Changing configuration to use myengine

Currently, the best way to use a different engine is to change the configuration in assets/player.ron. This will change the engine used by the upper-side team. The lower-side team is governed by the configuration in assets/enemy.ron.

I’ll add a command-line parameter for this in the future.

For now, what you need to do is change the value in EngineRunner to "myengine".

// assets/player.ron line 123
extras: PlayerData(
    player: (
        speed: 48.0,
        kick_strength: 256.0,
        push_strength: 96.0,
        side: UpperSide,
    ),
    robot: Robot(logic_module: EngineRunner("myengine")),
),

Running our small engine

All that’s left to do is to run cargo run again. You may need to do source ./env.sh again if you’re running linux and you closed your first terminal.

You should see the upper-side players stay put while the lower-side team does its best to score.

Improving the engine

Okay. That wasn’t much. Let’s go ahead and write an engine that at least tries to play the game. We’ll modify MyEngine directly so that we don’t need to worry about registering another engine.

Here’s the code:

// We use `impl` to implement the `SimpleEngine` trait.
impl SimpleEngine for MyEngine {
    fn engine_func(&mut self, engine_data: EngineData) -> EngineTransition {
        // The idea behind this engine is simple:
        // 1. If we're close to the ball, run towards it and kick it!
        // 2. If we're far away from the ball, run to your own net! Defense!
        //
        // First compute what's the difference in position with the ball.
        let difference_with_ball = engine_data.ball_position - engine_data.own_position;
        // Vector2 from nalgebra implements `norm()` which computes the euclidean norm.
        if difference_with_ball.norm() > 100.0 {
            // We're far away from the ball. Find in which direction is your net!
            let difference_with_own_net = engine_data.own_net_position - engine_data.own_position;
            // Now run in that direction as fast as you can!
            EngineTransition {
                velocity: difference_with_own_net * engine_data.own.speed,
                action: None,
            }
        } else {
            // We're close to the ball!
            // ATTAAAAACKKKK!!!!!
            EngineTransition {
                velocity: difference_with_ball * engine_data.own.speed,
                action: Some(ActionType::Kick),
            }
        }
    }
}

I didn’t explain what engine_data.own was before. It just holds a bunch of constant data about the player: their speed, how hard they can kick the ball, etc.

For those of you unfamiliar with Rust, the reason why this method works and returns the right value from inside the if ... else ... is because if-else itself is an expression, and that expression evaluates either to the last expression of the if block in case the condition is true, or the last expression in the else block, if the condition evaluates to false. In other words, if-else behaves like the ternary operator A ? B : C does in other imperative languages.

If you save those changes and run cargo run you should see a bunch of players desperately running toward their own goal and kicking the ball away as hard as they can when it gets close to them.

The code for MyEngine, including the changes to the registry and the configuration can be found in the branch blogpost-1-implemented of the github repo for FBSim.

If you want to keep improving MyEngine, with a little bit of work you should be able to beat basic and basic_wing_wait without having to do anything fancy. You can look at their implementation under src/engines/basic.rs for inspiration. Remember to change assets/enemy.ron to use basic_wing_wait instead of basic if you want to play against it. Think of basic as level 1 and basic_wing_wait as level 2.

Future steps for FBSim

This was a fun project during my two-week vacation. Nothing serious. If I keep working on it, this is what I’d like to do.7

  • Adding support for writing engines in scripting languages. Was thinking of python and lua. Might try to throw in mun just for fun.
  • Training Reinforcement Learning agents for FBSim. Might do it in Rust directly or wait until I implement python scripting depending on the state of the Rust ecosystem for ML/RL.
  • Adding support for self-managed tournaments between a group of engines.
  • Maybe more game-like features to lower the entry barrier?

Big thanks to my friends Elías Masquil and Joaquín Alori who shared very helpful comments about earlier versions of this blogpost and FBSim.

Footnotes

  1. I do know “FBSim” is the worst name ever. Don’t judge me.
  2. Raise your hand if the mention of a goalie was the first time you realised I was talking about soccer. If you need to localize the game to North America, I think changing the sprites and the background colour to make it look like ice hockey shouldn’t be too difficult.
  3. When using amethyst, you should probably not import nalgebra directly as you can get most symbols by importing amethyst::core::math, which re-exports a lot of stuff from nalgebra.
  4. If you’ve never heard of an Option type, it’s a type that encodes a value that may not exist, in a similar way of how you’d use None in python, sentinel values in C, and other contraptions in other languages. The difference is you need to also mark the case where the value does exist as Some(value), and not just value. This way, the compiler can check whether you’re making the mistake of treating a value that may not exist as if it always existed. It’s a cool little idea that’s most common in functional programming languages, but has been popular with imperative languages as well recently.
  5. It’s actually Box<dyn Engine + Sync + Send> but let’s not go into unnecessary detail.
  6. You can ignore the dyn if you don’t know what that is. It’s just telling Rust that we don’t know at compile time the exact type of the objects in the boxes: we only know about a trait they implement.
  7. Not counting the small changes like adding a command-line argument for setting which engine to use, etc.

2 responses to “FBSim: football-playing AI agents in Rust”

  1. This is a really cool post, thanks! I’m also really interested in AI in Rust, with an eye towards reinforcement learning.

Leave a comment

Create a website or blog at WordPress.com