Writing an Entelect Challenge bot in Rust
A Rust command line app with JSON parsing

02 May 2018

Every year, one of the software development houses in South African called Entelect hosts an AI programming challenge.

It usually follows more or less the same formula. There is a two player game of some sort. Competitors, rather than playing the game themselves, need to write some software to play the game for them. The bots then go into a big tournament where they play the game against each other, and the winner’s author gets some prizes.

This year, the challenge is a lane-based tower defence game, reminiscent of Plants vs Zombies (although they’re trying very hard to give it a more serious theme). The official description of the game rules is on the competition’s website (although fair warning, at times you also need to take a look at clarifications in the competition’s forum).

Each player has an area that’s their base, and they build things there. Your base is always the one on the left, with your opponent’s base to the right. If you build an attack building, it will shoot missiles to the right, at your opponent. If they build a defence building, it can block a bunch of missiles. Building things uses energy, so you can build an energy building to produce more energy for you.

Entelect has provided a few sample bots in various programming languages, and has offered to support other languages if you submit your own sample bot. My favourite programming language is Rust, so today I’m writing a sample bot in Rust.

This article is a literate program, so I’ll be compiling the article itself into the sample bot. It also means that this post will be much more code heavy than usual, since it is the full code listing of my sample bot.

What does a sample bot need to do?

The requirements for a sample bot are up on the competition’s GitHub. The bot needs to do the bare minimum to show that it can read in the current game state, decide what to do based on that game state, and respond to the game engine in the correct format.

Our dependencies

For my bot, I’m going to pull some dependencies from the Cargo ecosystem.

The game state is provided in JSON format, so I’m going to include a library called Serde to make deserialization of the JSON a bit less painful.

The sample bot requirement also call for some random building, so I’m including the Rand crate.

Here is my Cargo.toml.

[package]
name = "entelect_challenge_rust_sample"
version = "1.0.0"

[dependencies]
serde_derive = "1.0.43"
serde = "1.0.43"
serde_json = "1.0.16"

rand = "0.4.2"

And, of course, to use these you also need to add the external crate declaration into your Rust code. Unless otherwise noted, these Rust code blocks are all being concatenated one after the other into a main.rs file.

extern crate serde;
extern crate serde_json;

#[macro_use]
extern crate serde_derive;

extern crate rand;
use rand::{thread_rng, Rng};

Error handling

In terms of error handling, I’m doing the semi-lazy thing and not implementing my own error types. I’m just going to convert everything to a Box<Error> and report their error messages on the standard error output if it gets to the top. At least I’m not just panicking whenever an error comes up.

use std::error::Error;

What are the buildings?

There are three types of buildings at this point, but there are probably going to be more of them before the finals. There’s a defence building, that just sort of sits there but has a lot of health. There’s an attack tower, which shoots missiles. Finally, there’s an energy building which generates extra energy so you can build more buildings.

When you’re building, the command needs you to refer to the tower by its ID, so it seems like a good idea to have that as part of the enum as well.

#[repr(u8)]
#[derive(Debug, Clone, Copy)]
enum Building {
    Defense = 0,
    Attack = 1,
    Energy = 2,
}

impl std::fmt::Display for Building {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "{}", *self as u8)
    }
}

Input and Output

The game state is passed to your bot as a JSON file with a special name. Similar competitions that I’ve entered before would have passed this data in on the standard input, so I thought this was a bit of an unusual choice. Still, it works out the same in the end. I just need to put down a constant for where this file is.

const STATE_PATH: &str = "state.json";

Similarly, once you’ve decided what you want to do, you need to write it to a special file.

const COMMAND_PATH: &str = "command.txt";

The commands you write to the file needs to be in a particular format as well, so I’m going to capture that format in a struct.

#[derive(Debug, Clone, Copy)]
struct Command {
    x: u32,
    y: u32,
    building: Building,
}

impl std::fmt::Display for Command {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "{},{},{}", self.x, self.y, self.building)
    }
}

Reading the file and parsing the JSON

I want a function that will go off, read the state file, deserialize it, and give me a strongly typed model to work with. That’s what this function does.

Specifying the schema ended up being a rather large chunk of code, so it’s in its own file called state.rs.

use std::fs::File;
use std::io::prelude::*;
use serde_json;
use std::error::Error;

pub fn read_state_from_file(filename: &str) -> Result<State, Box<Error>> {
    let mut file = File::open(filename)?;
    let mut content = String::new();
    file.read_to_string(&mut content)?;
    let state = serde_json::from_str(content.as_ref())?;
    Ok(state)
}

I found an example of the game’s state JSON file. Unfortunately, there wasn’t a specifically documented schema, but I could figure out more or less what it seemed to be expecting.

I’m using Serde, and its ability to derive the deserializer for JSON to Rust structs. One of the really fantastic features here is that it can do automatic name transformations from the camelCase used in JSON typically to the snake_case convention used in Rust.

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct State {
    pub game_details: GameDetails,
    pub players: Vec<Player>,
    pub game_map: Vec<Vec<GameCell>>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameDetails {
    pub round: u32,
    pub map_width: u32,
    pub map_height: u32,
    pub building_prices: BuildingPrices
}

#[derive(Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub struct BuildingPrices {
    pub energy: u32,
    pub defense: u32,
    pub attack: u32
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Player {
    pub player_type: char,
    pub energy: u32,
    pub health: u32,
    pub hits_taken: u32,
    pub score: u32
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCell {
    pub x: u32,
    pub y: u32,
    pub buildings: Vec<BuildingState>,
    pub missiles: Vec<MissileState>,
    pub cell_owner: char
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildingState {
    pub health: u32,
    pub construction_time_left: i32,
    pub price: u32,
    pub weapon_damage: u32,
    pub weapon_speed: u32,
    pub weapon_cooldown_time_left: u32,
    pub weapon_cooldown_period: u32,
    pub destroy_multiplier: u32,
    pub construction_score: u32,
    pub energy_generated_per_turn: u32,
    pub building_type: String,
    pub x: u32,
    pub y: u32,
    pub player_type: char
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MissileState {
    pub damage: u32,
    pub speed: u32,
    pub x: u32,
    pub y: u32,
    pub player_type: char
}

Since it’s in its own file, we also need to declare it back in our main.rs file.

mod state;

A simple AI strategy

The simple strategy required for the sample bot has two cases that need to be handled.

  1. If a row is under attack, build a defence building in it.
  2. If you have enough energy to build all of the buildings, build something randomly.

To put this strategy in place, it helps if we have some extra functions in place to work at a higher level of abstraction.

Can we afford it?

The first question is if we have enough energy to build these buildings.

Firstly, I’m writing a function to find out what my current energy is. This is more complicated than you’d expect, because all of the players in the game are in an array. You first need to find the right one.

fn current_energy(state: &state::State) -> u32 {
    state.players.iter()
        .filter(|p| p.player_type == 'A')
        .map(|p| p.energy)
        .next()
        .unwrap_or(0)
}

Then we can figure out which buildings we can afford.

fn can_afford_all_buildings(state: &state::State) -> bool {
    can_afford_attack_buildings(state) &&
        can_afford_defence_buildings(state) &&
        can_afford_energy_buildings(state)
}

fn can_afford_attack_buildings(state: &state::State) -> bool {
    current_energy(state) >= state.game_details.building_prices.attack
}
fn can_afford_defence_buildings(state: &state::State) -> bool {
    current_energy(state) >= state.game_details.building_prices.defense
}
fn can_afford_energy_buildings(state: &state::State) -> bool {
    current_energy(state) >= state.game_details.building_prices.energy
}

Are we under attack?

To see if we’re in the first case, we need to know if any of the rows are undefended and under attack.

fn is_under_attack(state: &state::State, y: u32) -> bool {
    let attack = state.game_map[y as usize].iter()
        .any(|cell| cell.buildings.iter()
             .any(|building| building.player_type == 'B' &&
                  building.building_type == "ATTACK"));
    let defences = state.game_map[y as usize].iter()
        .any(|cell| cell.buildings.iter()
             .any(|building| building.player_type == 'A' &&
                  building.building_type == "DEFENSE"));
    attack && !defences
}

Is anything already here?

The last thing that we need, before we bring it all together, is some functions to figure out where we can build.

fn is_occupied(state: &state::State, x: u32, y: u32) -> bool {
    !state.game_map[y as usize][x as usize].buildings.is_empty()
}

fn unoccupied_in_row(state: &state::State, y: u32) -> Vec<u32> {
    (0..state.game_details.map_width/2)
        .filter(|&x| !is_occupied(&state, x, y))
        .collect()
}

fn unoccupied_cells(state: &state::State) -> Vec<(u32, u32)> {
    (0..state.game_details.map_width/2)
        .flat_map(|x| (0..state.game_details.map_height)
                  .map(|y| (x, y))
                  .collect::<Vec<(u32, u32)>>())
        .filter(|&(x, y)| !is_occupied(&state, x, y))
        .collect()
}

Using all of that to choose our move

We can now throw those together to implement the rules we wanted. Something that I found particularly useful is that Rand has a function where you pass in a list and get it to randomly choose one.

fn choose_move(state: &state::State) -> Option<Command> {
    let mut rng = thread_rng();

    if can_afford_defence_buildings(state) {
        for y in 0..state.game_details.map_height {
            if is_under_attack(state, y) {
                let x_options = unoccupied_in_row(state, y);
                if let Some(&x) = rng.choose(&x_options) {
                    return Some(Command {
                        x: x,
                        y: y,
                        building: Building::Defense
                    });
                }
            }
        }
    }

    if can_afford_all_buildings(state) {
        let options = unoccupied_cells(state);
        let option = rng.choose(&options);
        let buildings = [Building::Attack, Building::Defense, Building::Energy];
        let building = rng.choose(&buildings);
        match (option, building) {
            (Some(&(x, y)), Some(&building)) => Some(Command {
                x: x,
                y: y,
                building: building
            }),
            _ => None
        }
    }
    else {
        None
    }
}

Responding with the command

Most similar competitions that I’ve done assumed that you’d write the command to the standard output. The Entelect challenge expects you to write it to a file. We already set up the formatting of the command, so here we just need to write it out.

The rules don’t say how to do nothing, but the engine complains if you don’t create the command file. If my bot doesn’t have anything to do, it will still create the file, but it won’t write anything to it.

use std::fs::File;
use std::io::prelude::*;

fn write_command(filename: &str, command: Option<Command>) -> Result<(), Box<Error> > {
    let mut file = File::create(filename)?;
    if let Some(command) = command {
        write!(file, "{}", command)?;
    }

    Ok(())
}

Bringing it all together

After defining all of that stuff, our main function is actually fairly simple. We read the state file, figure out the command we want to give, then write it to the file. This is also where we need to handle any errors that bubbled up by writing a message to the terminal.

use std::process;

fn main() {
    let state = match state::read_state_from_file(STATE_PATH) {
        Ok(state) => state,
        Err(error) => {
            eprintln!("Failed to read the {} file. {}", STATE_PATH, error);
            process::exit(1);
        }
    };
    let command = choose_move(&state);
  
    match write_command(COMMAND_PATH, command) {
        Ok(()) => {}
        Err(error) => {
            eprintln!("Failed to write the {} file. {}", COMMAND_PATH, error);
            process::exit(1);
        }
    }
}

Where to from here?

The next step for me is to submit this as a pull request to the challenge repo, along with a read-me explaining how to get the Rust tool-chain. Hopefully, it’ll be accepted, they’ll add support for calling Rust to their game runner, and then participants will be able to write bots using Rust! Wish me luck!