Bots and AI

In this tutorial we'll add bots and a simple AI system to our 2D platformer. In the end we'll get something like this:

attack

Bot Prefab

Let's start by creating a prefab for our bots. Prefab is a separate scene, that can be instantiated at any time in some other scene. It allows us to make reusable and well isolated parts of the game. At first, we need a sprite sheet for the bot, we'll use this one. It contains attack, hit, death, walk, idle animations. In this tutorial we'll use only walk and attack animations, other animations will be used in the next tutorial. The sprite sheet looks like this - 13x5 sprites where every sprite is 64x64px:

skeleton

Save this image in the data/characters folder as skeleton.png. Open the editor and create a new scene, right-click on the __ROOT__ scene node and click on Replace With -> Physics 2D -> Rigid Body. Rename this node to Skeleton and then create a Rectangle child node by right-clicking on the Skeleton node and doing Create Child -> 2D -> Rectangle, select the new rectangle node and set its scale to 2.0, 2.0, 1.0 (default scale of 1.0 is too small and the skeleton will be half of the height of our player). Now let's apply a texture to the rectangle, find skeleton.png in the asset browser, select it, set its properties like on the screenshot below - all filtration modes to Nearest (to make its pixels sharp, not blurry) and wrapping to Clamp To Edge (to prevent potential seams on the edges). Find the Material property in the inspector and open the material editor, drag the skeleton.png texture from the asset browser to diffuseTexture property in the material editor. Set the UV Rect -> Size property to 0.077; 0.2 to select a single sprite from the sprite sheet, and you should see something similar to this:

skeleton prefab

If you look closely at the world viewer, you should notice a small warning sign near the rigid body - the editor tells us that we've forgotten to add a collider to the rigid body. Let's fix this by right-clicking on the rigid body, then select Create Child -> Physics 2D -> Collider. Select the collider and set its shape to Capsule in the properties like so:

capsule

We're almost finished with our prefab, the last step is to configure properties of the rigid body. Currently, we have a simple rigid body, that will rotate freely during collisions and will also "sleep" on inactivity, which will prevent the body from moving. Let's fix this by selecting the rigid body in the inspector and disable rotational movement and prevent it from sleeping:

rigid body

The "skeleton" of our skeleton (pun intended) prefab is finished, and now we can start writing some code.

Script

Now on to the code part, run the following command in the root folder of your game: fyrox-template script --name=bot and add the mod bot; line at the beginning of lib.rs of the game package. The code for the script will look something like this:

#![allow(unused)]
fn main() {
#[derive(Visit, Reflect, Default, Debug, Clone, TypeUuidProvider, ComponentProvider)]
#[type_uuid(id = "d2786d36-a0af-4e67-916a-438af62f818b")]
#[visit(optional)]
pub struct Bot {
    // Add fields here.
}

impl ScriptTrait for Bot {
    fn on_init(&mut self, context: &mut ScriptContext) {
        // Put initialization logic here.
    }

    fn on_start(&mut self, context: &mut ScriptContext) {
        // There should be a logic that depends on other scripts in scene.
        // It is called right after **all** scripts were initialized.
    }

    fn on_deinit(&mut self, context: &mut ScriptDeinitContext) {
        // Put de-initialization logic here.
    }

    fn on_os_event(&mut self, event: &Event<()>, context: &mut ScriptContext) {
        // Respond to OS events here.
    }

    fn on_update(&mut self, context: &mut ScriptContext) {
        // Put object logic here.
    }
}
}

We need only on_update method, and the rest methods can be removed. Register the script by adding script_constructors.add::<Bot>("Bot"); line near the script_constructors.add::<Player>("Player"); line in lib.rs (as we did in the previous part of the tutorial). We also need to import all required types for the bot, replace all the imports at the beginning of the bot.rs with the following:

#![allow(unused)]
fn main() {
use crate::Game;
use fyrox::{
    core::{
        algebra::{Vector2, Vector3},
        pool::Handle,
        reflect::prelude::*,
        type_traits::prelude::*,
        variable::InheritableVariable,
        visitor::prelude::*,
    },
    graph::{BaseSceneGraph, SceneGraph},
    scene::{
        animation::spritesheet::SpriteSheetAnimation,
        dim2::{
            collider::Collider, physics::RayCastOptions, rectangle::Rectangle, rigidbody::RigidBody,
        },
        node::Node,
        rigidbody::RigidBodyType,
    },
    script::{ScriptContext, ScriptTrait},
};
}

We need to store a handle to the sprite in our script, add the following field in the Bot struct:

#![allow(unused)]
fn main() {
    rectangle: InheritableVariable<Handle<Node>>,
}

Open the skeleton prefab and assign the script to the root rigid body. Set the rectangle field to Sprite (2D) and save the prefab. Great, now let's begin writing the actual AI code of the bot.

Patrol

By default, when there's no target nearby the bot will patrol in available bounds. Basically, it will walk from one "wall" to another. Add the following fields to the Bot script:

#![allow(unused)]
fn main() {
    speed: InheritableVariable<f32>,
    direction: f32,
    front_obstacle_sensor: InheritableVariable<Handle<Node>>,
    back_obstacle_sensor: InheritableVariable<Handle<Node>>,
}

speed field will define overall movement speed of the bot and direction will be used to alternate movement direction along X axis. Open the skeleton prefab and set the speed to 1.2 and the direction to -1.0. Add the movement handling code somewhere in the impl Bot:

#![allow(unused)]
fn main() {
    fn do_move(&mut self, ctx: &mut ScriptContext) {
        let Some(rigid_body) = ctx.scene.graph.try_get_mut_of_type::<RigidBody>(ctx.handle) else {
            return;
        };

        let y_vel = rigid_body.lin_vel().y;

        rigid_body.set_lin_vel(Vector2::new(-*self.speed * self.direction, y_vel));

        // Also, inverse the sprite along the X axis.
        let Some(rectangle) = ctx.scene.graph.try_get_mut(*self.rectangle) else {
            return;
        };

        rectangle.local_transform_mut().set_scale(Vector3::new(
            2.0 * self.direction.signum(),
            2.0,
            1.0,
        ));
    }
}

This code is quite straightforward - at first, we're doing a checked borrow of the node that contains the script. It must be of dim2::RigidBody type. Then we're setting horizontal speed of body using speed and direction variables we've added earlier. As the last step we're changing horizontal scale of the sprite using sign of the current direction. This way we're flipping the sprite in the current direction. Now we need to call do_move method in on_update like so:

#![allow(unused)]
fn main() {
        self.do_move(ctx);
}

Open the main scene (scene.rgs by default) and find the skeleton prefab in the asset browser, drag'n'drop it in the scene and adjust its position to get something like this:

skeleton on scene

Run the game, and you should see the skeleton moving away from the player to the right. Cool, but the bot will be stuck immediately when it hits a wall, so we also need a way of detecting obstacles along the way, so the bot could "understand" when it should change movement direction. We'll use sensor collider for this purpose. Open the skeleton prefab and create two new 2D colliders under the root Skeleton node, adjust their sizes to be something similar to the following screenshot:

obstacle sensor

It is very important to have Is Sensor property checked on both colliders, we don't need the collider to participate in actual collision detection - it will be used only in intersection checks with the environment. Do not forget to assign handles of both FrontObstacleSensor and BackObstacleSensor to the respective fields in the Bot script instance on the root rigid body.

Now onto the movement algorithm, it is quite simple: move the bot horizontally in the current direction until one of the obstacle sensors intersects with an obstacle. In this case all we need to do is to switch the current direction to opposite (from 1.0 to -1.0 and vice versa). This way the bot will patrol arbitrary level parts quite easily and reliably and there's no need to manually place any way points.

Obstacles checking algorithms is quite simple, add the following code in the impl Bot:

#![allow(unused)]
fn main() {
    fn has_obstacles(&mut self, ctx: &mut ScriptContext) -> bool {
        let graph = &ctx.scene.graph;

        // Select the sensor using current walking direction.
        let sensor_handle = if self.direction < 0.0 {
            *self.back_obstacle_sensor
        } else {
            *self.front_obstacle_sensor
        };

        // Check if it intersects something.
        let Some(obstacle_sensor) = graph.try_get_of_type::<Collider>(sensor_handle) else {
            return false;
        };

        for intersection in obstacle_sensor
            .intersects(&ctx.scene.graph.physics2d)
            .filter(|i| i.has_any_active_contact)
        {
            for collider_handle in [intersection.collider1, intersection.collider2] {
                let Some(other_collider) = graph.try_get_of_type::<Collider>(collider_handle)
                else {
                    continue;
                };

                let Some(rigid_body) = graph.try_get_of_type::<RigidBody>(other_collider.parent())
                else {
                    continue;
                };

                if rigid_body.body_type() == RigidBodyType::Static {
                    return true;
                }
            }
        }

        false
    }
}

At first, it selects the sensor using the current movement direction, then it fetches all intersection events from it and checks if there's at least one static rigid body intersected. Remember, that we've set static rigid bodies for our level tiles. As the final step, add the following code to the on_update:

#![allow(unused)]
fn main() {
        if self.has_obstacles(ctx) {
            self.direction = -self.direction;
        }
}

This code is very simple - if there's an obstacle, then change movement direction to opposite. Now run the game and the bot should change its direction when it detects an obstacle before it. It should look like this:

obstacle checks

There's no animations yet, but the basic movement works ok. We'll add animations later in this tutorial.

Ground Checks

At this moment, our bot can move, but it can easily fall off the ledge into "abyss" and die. Let's prevent that by adding ground check, that will be used to switch movement direction also. How will we check for ground presence anyway? We'll do this using simple ray casting. At first, add the following fields to the bot script:

#![allow(unused)]
fn main() {
    ground_probe: InheritableVariable<Handle<Node>>,
    ground_probe_distance: InheritableVariable<f32>,
    ground_probe_timeout: f32,
}

ground_probe field will be used to store a handle of a point scene node, that will be used as a starting point for ray casting. ground_probe_distance field is used to define maximum distance, after which ray casting considered failed. Now add the following code in the impl Bot:

#![allow(unused)]
fn main() {
impl Bot {
    fn has_ground_in_front(&self, ctx: &ScriptContext) -> bool {
        // Do ground check using ray casting from the ground probe position down at some distance.
        let Some(ground_probe) = ctx.scene.graph.try_get(*self.ground_probe) else {
            return false;
        };

        let ground_probe_position = ground_probe.global_position().xy();

        let mut intersections = Vec::new();
        ctx.scene.graph.physics2d.cast_ray(
            RayCastOptions {
                ray_origin: ground_probe_position.into(),
                // Cast the ray
                ray_direction: Vector2::new(0.0, -*self.ground_probe_distance),
                max_len: *self.ground_probe_distance,
                groups: Default::default(),
                // Make sure the closest intersection will be first in the list of intersections.
                sort_results: true,
            },
            &mut intersections,
        );

        for intersection in intersections {
            let Some(collider) = ctx.scene.graph.try_get(intersection.collider) else {
                continue;
            };

            let Some(rigid_body) = ctx
                .scene
                .graph
                .try_get_of_type::<RigidBody>(collider.parent())
            else {
                continue;
            };

            if rigid_body.body_type() == RigidBodyType::Static
                && intersection
                    .position
                    .coords
                    .metric_distance(&ground_probe_position)
                    <= *self.ground_probe_distance
            {
                return true;
            }
        }

        false
    }
}

Open the skeleton prefab and create the ground probe like so:

ground probe

Do not forget to assign its handle to the bot script as well. Add the final piece of code to on_update:

#![allow(unused)]
fn main() {
        self.ground_probe_timeout -= ctx.dt;
        if self.ground_probe_timeout <= 0.0 {
            if !self.has_ground_in_front(ctx) {
                self.direction = -self.direction;
            }
            self.ground_probe_timeout = 0.3;
        }
}

Open the editor and add another skeleton somewhere, where it can easily fall off the ledge. Run the game and the skeleton should avoid such place and walk back and forth on a platform.

Targets

When the bot is patrolling, it will search for a target to attack. Bots will be able to attack only the player, so we just need to check if the player is in front of a bot and close enough to it. We need a way to get player's handle, we could just iterate over the scene and search for it at every frame, but that's inefficient and there's a better way. All we need to do is to slightly modify the plugin and the player script. Add the following field to the plugin:

#![allow(unused)]
fn main() {
    player: Handle<Node>,
}

Now we need to set this handle somehow, the ideal place for it is on_start method of the Player script:

#![allow(unused)]
fn main() {
    fn on_start(&mut self, ctx: &mut ScriptContext) {
        ctx.plugins.get_mut::<Game>().player = ctx.handle;
    }
}

Great, now when the player script is created and initialized, it will register itself in the plugin. Now we can use this handle in the bot's target searching routine. Add the following code to the impl Bot:

#![allow(unused)]
fn main() {
    fn search_target(&mut self, ctx: &mut ScriptContext) {
        let game = ctx.plugins.get::<Game>();

        let self_position = ctx.scene.graph[ctx.handle].global_position();

        let Some(player) = ctx.scene.graph.try_get(game.player) else {
            return;
        };

        let player_position = player.global_position();

        let signed_distance = player_position.x - self_position.x;
        if signed_distance.abs() < 3.0 && signed_distance.signum() != self.direction.signum() {
            self.target = game.player;
        }
    }
}

This code is very straightforward - at first, we're fetching a reference to the plugin (in which we've just stored player's handle). Then we're getting self position of the bot and player's position. Finally, to check if the bot can "see" the player we're calculating horizontal distance between the player and the bot, checking its absolute value to be less than some sensible threshold and also checking the sign of the distance. If the sign of the distance is opposite to the sign of the direction, then the bot can see the player. As the last step, call this method in the on_update method:

#![allow(unused)]
fn main() {
    fn on_update(&mut self, ctx: &mut ScriptContext) {
        self.search_target(ctx);
}

If there's a target, then the bot will follow it and try to attack when it is close enough. To implement this, all we need to do is to alternate movement direction according to a target position. Add the following code in on_update, but after any other direction modifications - target following will have priority over any other actions.

#![allow(unused)]
fn main() {
        if self.target.is_some() {
            let target_position = ctx.scene.graph[self.target].global_position();
            let self_position = ctx.scene.graph[ctx.handle].global_position();
            self.direction = (self_position.x - target_position.x).signum();

            // Stand still while attacking.
            if target_position.metric_distance(&self_position) > 1.1 {
                self.speed.set_value_and_mark_modified(1.2);
            } else {
                self.speed.set_value_and_mark_modified(0.0);
            }
        }
}

Animations

Our bot can patrol, search and follow targets, but all of this is not properly visualized since we're not using any animations for such actions. Let's fix this, add the following fields to the Bot structure:

#![allow(unused)]
fn main() {
    animations: Vec<SpriteSheetAnimation>,
    current_animation: InheritableVariable<u32>,
}

As with the player from the previous tutorial, we'll use sprite sheet animations. Open the bot prefab and select the rigid body, add five animations and fill every slot. For example, attack animation will look like this:

attack animation

If you have any issues with this, see previous part of the tutorial to learn how to use sprite sheet animations editor. Remember, that we have 5 animations and their indices are the following: 0 - attack, 1 - death, 2 - walk, 3 - idle, 4 - hit reaction. Now on to the animation switching. We need to handle just two animations for now - walking and attacking. Add the following code somewhere in the on_update:

#![allow(unused)]
fn main() {
        if self.direction != 0.0 {
            self.current_animation.set_value_and_mark_modified(2);
        }
        if self.target.is_some() {
            let target_position = ctx.scene.graph[self.target].global_position();
            let self_position = ctx.scene.graph[ctx.handle].global_position();
            if target_position.metric_distance(&self_position) < 1.1 {
                self.current_animation.set_value_and_mark_modified(0);
            }
        }
}

Here we just switch current animation index. If the bot is moving, then movement animation is selected (with index 2) and if there's a target (and it is close enough), then the attack animation is selected (with index 0). The last step is to apply the animation to the bot's sprite. Add the following code at the end of on_update:

#![allow(unused)]
fn main() {
        if let Some(current_animation) = self.animations.get_mut(*self.current_animation as usize) {
            current_animation.update(ctx.dt);

            if let Some(sprite) = ctx
                .scene
                .graph
                .try_get_mut_of_type::<Rectangle>(*self.rectangle)
            {
                // Set new frame to the sprite.
                sprite
                    .material()
                    .data_ref()
                    .set_texture(&"diffuseTexture".into(), current_animation.texture())
                    .unwrap();
                sprite.set_uv_rect(
                    current_animation
                        .current_frame_uv_rect()
                        .unwrap_or_default(),
                );
            }
        }
}

Run the game and you should see something like this:

attack

You can create multiple instances of the skeleton and place them in different places on your level, to make the game more interesting. This tutorial teaches about technical details, not game design, so use your imagination and experiment with different approaches.

Conclusion

In this tutorial we've learned how to create basic AI, that can patrol an area, search for a target, follow and attack it. In the next tutorial we'll add damage system, ability to attack for the player and the bot and various items, such as healing potions.