This is a lightly edited transcript of the above Youtube video. It's the fourth part of a series on game development where I'll be building an entire game step by step and sharing the process in public.
Thanks for tuning in to this fourth installment in the Making Cyberglads series.
After the previous episode we have something that's starting to look like a game, but that doesn't offer a challenge to the player.
We have an arena with a Player and an NPC character. The Player character can be controlled by the player; it can move and attack the NPC. When the NPC takes damage, an animated head-up display registers its decreasing health until it has no hit points left. When that happens, the NPC character disappears and the game is finished.
Now as long as the NPC doesn't fight back this won't be much of a game, so let's change that today. I'm going to build a basic AI that will control the NPC character and make it move and attack the Player character.
The AI will be controlled in the _process() function that's executed at every frame. As long as the NPC is alive it will either move or attack. For the moment we want NPCs to move towards the player at all times in a straight line.
In NPC script add: func ai_get_direction(): return target.position - self.position func ai_move(): var direction = ai_get_direction() var motion = direction.normalized() * speed move_and_slide(motion)
These two functions will get the NPC moving in the Player character's direction when they are called. We're using the move_and_slide() function from the KinematicBody2D object just like we do for the Player.
We don't want it to be able to attack as soon as it becomes in range, otherwise it would be unplayable, so we'll set an AI think time attribute and set up a timer as soon as the game loads.
var ai_think_time = 0.7 var ai_think_time_timer = null In the _onready() function, add: setup_ai_think_time_timer() And then create the new function: func setup_ai_think_time_timer(): ai_think_time_timer = Timer.new() ai_think_time_timer.set_one_shot(true) ai_think_time_timer.set_wait_time(ai_think_time) ai_think_time_timer.connect("timeout", self, "on_ai_thinktime_timeout_complete") add_child(ai_think_time_timer)
The timer will be started when the NPC comes into range of the player. The _process() function will call a decide_to_attack() function that starts the timer. If both characters are still alive and in range by the time the timer finishes counting, the NPC will attack the player.
A character will only be allowed to attack another character if their target's state is 0, meaning that the target character is alive. We'll be checking the state value of characters often so let's add a getter on this attribute in the Character script.
In the Character script, add: func get_state(): return state
Now we can write our decide_to_attack() function in the NPC script, and the trigger for the actual attack() function when the AI think time timer completes.
Back in the NPC script, add: func decide_to_attack(): ai_think_time_timer.start() func on_ai_thinktime_timeout_complete(): if is_in_range && state == 0 && target.get_state() == 0: attack()
Finally let's write the _process() function that will orchestrate these NPC actions.
Add _process() function: func _process(delta): if state == 0 && target.get_state() == 0: if is_in_range && ai_think_time_timer.time_left == 0: decide_to_attack() else: ai_move()
Notice that while I'm adding an artificial think time before the AI can attack, there is no such think time for movement, so the NPC will start moving as soon as the game begins. We'll see how that plays out.
Let's playtest the game in its current state.
Here's a problem. The game crashes when the NPC keeps trying to attack the Player character after it has died. That's because I'm calling the get_state() function after I performed a queue_free() on the Player node. You can't request attribute values for a node that doesn't exist anymore.
To fix this I'm just going to apply the queue_free() function to the Character's CollisionShape2D node instead of the root node.
Change the Character script, add at the top: onready var collision_shape = $"CollisionShape2D" And change the take_damage() section, in the part where the character dies: collision_shape.queue_free()
It's enough for the Sprite to disappear and for the Character to stop registering collisions. We don't need to remove it entirely.
Let's test it again.
Now I can keep attacking the NPC after he's died and I get no error.
But now let's make things a little more interesting. In its current state the game consists of moving your character to the NPC and clicking repeatedly as fast as you can on the attack button to kill it before it kills you. Aside from any repetitive strain injuries that this might cause, it doesn't make for a particularly compelling game experience.
So what can we do to slow the game down somewhat? Well, the only friction in the game at the moment is in the think time, and by this I mean both the human think time for the player and the artificial think time that we introduced on the NPC side. I want to go a step further and introduce the notion of reflexes. I said earlier that each character would have a reflexes attribute that would determine how fast it would be able to attack. Let's put that into practice. I'm adding an attack_delay value in the Character's _ready() function, based on the character's reflexes. I'm also setting up another timer, this time an attack timer with a timeout equal to the attack_delay.
In the Character script add: var attack_timer = null var attack_delay = 999 var can_attack = true In the _onready() function, add: attack_delay = 5 - reflexes setup_attack_timer() And add setup_attack_timer() function: func setup_attack_timer(): attack_timer = Timer.new() attack_timer.set_one_shot(true) attack_timer.set_wait_time(attack_delay) attack_timer.connect("timeout", self, "on_attack_timeout_complete") add_child(attack_timer) func on_attack_timeout_complete(): can_attack = true
When the attack timer times out I'm setting a new attribute can_attack to true.
At the end of each call to the attack() function, I'm freezing attacks for this character until the attack timer times out.
At the end of the attack() function add: freeze_attacks() Add new function: func freeze_attacks(): can_attack = false attack_timer.start()
To finish this new feature, I need to check whether can_attack is set to true before attacking.
For the NPC this means changing both the _process() and the on_ai_thinktime_timeout_complete() functions.
In NPC script, adapt the _process() function: func _process(delta): if state == 0 && target.get_state() == 0: # check that the ai isn't already attacking, or that it isn't already thinking about it if is_in_range && can_attack && ai_think_time_timer.time_left == 0 && attack_timer.time_left == 0: decide_to_attack() else: ai_move() And also the on_ai_thinktime_timeout_complete() function: func on_ai_thinktime_timeout_complete(): # recheck whether the npc can still attack (maybe the target moved away during the npc think time, or # maybe there's nobody left to attack) if can_attack && is_in_range && state == 0 && target.get_state() == 0: attack()
This last function needed be updated because the context of the game might have changed during the AI think time. The Player may have moved out of the NPC's melee combat range or it may have died in the meantime.
For the player, the _input() function needs to be updated.
In the Player script, change the _input() function (add can_attack check): func _input(event): if event.is_action_released("attack") && state == 0 && is_in_range && can_attack: attack()
So now we have attack timers for both the player and the NPCs whose duration depends on the character's innate reflexes. You can't just blindly attack constantly anymore, you have to make sure to be in range when you can attack your opponent and then make sure you're out of range of their own attacks in between, so there's an element of timing. The gameplay is still very basic, but it should already be playable in its current state.
Let's test it.
So we have a working game now but the NPC is always the same so it could get boring quickly. Let's add some variety by introducing three NPC configurations that you can play against. For that I need to set the value of a variable somewhere that is going to change my NPC specs.
Change _onready() function in the NPC script: match global.npc_name: "Ernie": speed = 50 strength = 45 reflexes = 2 max_health = 15 max_energy = 15 "Bert": speed = 400 strength = 50 reflexes = 3.5 max_health = 18 max_energy = 25 "Kermit": speed = 200 strength = 80 reflexes = 2.5 max_health = 30 max_energy = 20 ._onready()
I've added a little pattern matching expression that will tell us which NPC configuration to use in the game. We have 3 different NPC's that I've called Ernie, Bert and Kermit, each with different attributes.
I've ranked them in order of increasing difficulty as opponents. We have:
- Ernie (slow and weak, like a lame donkey)
- Bert (fast but vulnerable like a cheetah)
- Kermit (average speed but very strong and resilient, like a monster toad)
The name of the NPC to be loaded in the game will be decided in the autoloaded global script that we introduced in the previous episode.
Paste the following code in the global script: extends Node var npc_name = "Ernie" func get_npc_max_health(): match npc_name: "Ernie": return 15 "Bert": return 18 "Kermit": return 40 func get_player_max_health(): return 18
The default value for the npc name will be Ernie. In other words we'll default to the easiest NPC opponent.
I've changed the get_npc_max_health() functions here and made the NPC's max health dependant on the npc name. Remember that we need to do this here rather than in the NPC script because we need this value to load the HUD before the Characters are loaded into the scene (check out my previous video for details about that).
Let's test the game to see how challenging it is. We already know that Ernie isn't much of a challenge, but he'll follow me around and he'll get me if I'm too slow.
Let's change the default npc to Bert.
In the global script, change default value of npc name: var npc_name = "Bert"
And now let's run the game.
Suddenly it's a lot more difficult because Bert is significantly faster than Ernie, both in terms of movement speed but also of reflexes.
In the next episode I'll let the player decide which NPC they want to fight.
Building a basic AI was surprisingly easy. Sometimes tasks appear overwhelming but they're actually not all that difficult, whereas tweaking your user interface or trying to integrate artwork just right can be a lot more time consuming.
Of course we've only scratched the surface in terms of what a combat AI could do and we'll revisit this simplistic first iteration many times in the future.
Here are some resources if you want to dig deeper already into building an AI using Godot engine. You can find all the links in the video description below.
Gonkee has a tutorial on building an AI that follows you around in a platformer, and he integrates line of sight constraints.
Pigdev made a tutorial that illustrates steering behaviors to make NPC movement and direction changes more fluid.
UmaiPixel built a platformer tutorial where he makes extensive use of raycasting to decide NPC behavior.
And finally KidsCanCode has a great multipart tutorial for which he built an entire top-down tank game to illustrate how AI and pathfinding works in Godot engine.
But if you want to take a step back and get a global perspective on game AI separate from any engine-specific considerations, I recommend a great Youtube video by TheHappieCat. Her game-related channel is just fantastic.
In the next episode I'll create a splash screen that will let the player choose which NPC they want to fight.
That's it for Making Cyberglads 4. You can check out the game by downloading it from the cyberglads.com website.
If you do you can already pick one of the three opponents. Let me know if you're able to beat them all! Kermit is even tougher than Bert.