Creating the Battler

In this lesson, we’re going to set the foundations of the battler’s scene and class. A battler is any character or monster that participates to an encounter. It’s likely going to be the most complex scene in a role-playing game’s combat system.

To avoid complex code with our battler, we’re going to use a programming principle that’s fundamental in Godot: composition over inheritance1. The battler is going to be an aggregate2 of resources and nodes.

We’re going to split the logic into several classes so each has a clear responsibility and we can keep scripts relatively short.

Giving each class a specific responsibility helps with code maintenance in the long run, especially when working with a team. It’s known as the Single Responsibility Principle (SRP), a core principle in object-oriented programming.

A precise responsibility can lead to either large or small classes, depending on the context. For example, our Battler class will represent a battler as a whole, and in a complete game project, it may grow in size throughout development. You can learn more in our video Programming Principles: Single Responsibility in Godot.

We’re going to work towards the following split for the battler over the following lessons:

The Battler class itself represents the battler as a whole. It will serve as a container for several nodes and resources, giving other systems access to the stats, animation, and other data using a mix of signals, methods, and properties.

It will also take care of applying actions, taking hits, and more operations that are too small to justify splitting them into dedicated classes.

To get started, create a new scene with only a Node2D named Battler. Save it as Battler.tscn and attach a script to the node.

Using exported properties for behavior

To add behavior to the battler, we’re mostly going to use exported variables, allowing you to instantiate the node and configure it in the editor. Let’s start by defining some placeholder properties. We’ll code the corresponding BattlerStatsBattlerAI, and ActionData classes that define the battler’s behavior and abilities in other lessons.

extends Node2D
class_name Battler

# Resource that manages both the base and final stats for this battler.
export var stats: Resource
# If the battler has an `ai_scene`, we will instantiate it and let the AI make decisions.
# If not, the player controls this battler. The system should allow for ally AIs.
export var ai_scene: PackedScene
# Each action's data stored in this array represents an action the battler can perform.
# These can be anything: attacks, healing spells, etc.
export var actions: Array

When prototyping, I often start with properties like these as they help me build a map of the classes and systems I will have to code. For instance, in an RPG game like this one, I know that the battler needs stats, actions, and could be AI-controlled.

We need a way to define a player-controlled party to distinguish them from enemies. In this demo, I’ve chosen to use a property on battlers for that. Again, the reason is a designer in the team just has to click a checkbox to define a starting party member, or we can do so in the code.

# If `true`, this battler is part of the player's party and it targets enemy units
export var is_party_member := false

In a complete game, you wouldn’t need to export this property. Instead, you would likely store party members in an array and instantiate battlers from it.

Knowing when the battler is ready to act

Before we add a class to sequence turns, we need to know when battlers are ready to act. To do so, we define a _readiness value that goes up every frame until it reaches 100.0.

Below, we also introduce a time scale. That value, set by the future turn queue, allows us to slow down or stop the increase of _readiness on a battler’s turn.

# The turn queue will change this property when another battler is acting.
var time_scale := 1.0 setget set_time_scale
# When this value reaches `100.0`, the battler is ready to take their turn.
var _readiness := 0.0 setget _set_readiness

Both values rely on a setter function. The leading underscore of _readiness and _set_readiness is a convention meaning the variable is pseudo-private: you are only meant to use it inside the Battler.gd script.

Let’s update the readiness and define the setter functions next. We need to define two signals that we’ll emit respectively:

  1. When _readiness is greater or equal to 100.0, which means the battler is ready to act.
  2. When _readiness changes, to notify the user interface.
# Emitted when the battler is ready to take a turn.
signal ready_to_act
# Emitted when the battler's `_readiness` changes.
signal readiness_changed(new_value)

Here are _process() and the setter functions.

func _process(delta: float) -> void:
    # Increments the `_readiness`. Note stats.speed isn't defined yet.
    # You can also write this self._readiness += ...
    _set_readiness(_readiness + stats.speed * delta * time_scale)


# We will later need to propagate the time scale to status effects, which is why we use a
# setter function.
func set_time_scale(value) -> void:
    time_scale = value


# Setter for the `_readiness` variable. Emits signals when the value changes and when the battler
# is ready to act.
func _set_readiness(value: float) -> void:
    _readiness = value
    emit_signal("readiness_changed", _readiness)

    if _readiness >= 100.0:
        emit_signal("ready_to_act")
        # When the battler is ready to act, we pause the process loop. Doing so prevents _process from triggering another call to this function.
        set_process(false)

To wrap up this lesson, let’s add a property to pause the battler from a parent node.

var is_active: bool = true setget set_is_active

# ...

func set_is_active(value) -> void:
    is_active = value
    set_process(is_active)

We can use the property when a special event happens during an encounter. For instance, if you want to include dialogues or cutscenes at the start or during an encounter.

Selecting the battler

In the game, we can target battlers. To do so, let’s add two properties. One allows us to mark a battler as being selected, which we will use to play an animation. The other allows us to mark a target as selectable or not. You may use it to remove an ally or enemy from the encounter, temporarily.

# Emitted when modifying `is_selected`. The user interface will react to this for player-controlled battlers.
signal selection_toggled(value)

# If `true`, the battler is selected, which makes it move forward.
var is_selected: bool = false setget set_is_selected
# If `false`, the battler cannot be targeted by any action.
var is_selectable: bool = true setget set_is_selectable


func set_is_selected(value) -> void:
    # This defensive check helps us ensure we don't attempt to change `is_selected` if the battler isn't selectable.
    if value:
        assert(is_selectable)

    is_selected = value
    emit_signal("selection_toggled", is_selected)


func set_is_selectable(value) -> void:
    is_selectable = value
    if not is_selectable:
        set_is_selected(false)

There’s one last function we will use in the turn queue.

# Returns `true` if the battler is controlled by the player.
func is_player_controlled() -> bool:
    return ai_scene == null

The code so far

Here is how Battler.gd should look so far.

# Character or monster that's participating in combat.
# Any battler can be given an AI and turn into a computer-controlled ally or a foe.
extends Node2D
class_name Battler

signal ready_to_act
signal readiness_changed(new_value)
signal selection_toggled(value)

export var stats: Resource
export var ai_scene: PackedScene
export var actions: Array
export var is_party_member := false

var time_scale := 1.0 setget set_time_scale
var is_active: bool = true setget set_is_active
var is_selected: bool = false setget set_is_selected
var is_selectable: bool = true setget set_is_selectable

var _readiness := 0.0 setget _set_readiness


func _process(delta: float) -> void:
    _set_readiness(_readiness + stats.speed * delta * time_scale)


func is_player_controlled() -> bool:
    return ai_scene == null


func set_time_scale(value) -> void:
    time_scale = value


func set_is_active(value) -> void:
    is_active = value
    set_process(is_active)


func set_is_selected(value) -> void:
    if value:
        assert(is_selectable)

    is_selected = value
    emit_signal("selection_toggled", is_selected)


func set_is_selectable(value) -> void:
    is_selectable = value
    if not is_selectable:
        set_is_selected(false)


func _set_readiness(value: float) -> void:
    _readiness = value
    emit_signal("readiness_changed", _readiness)
    if _readiness >= 100.0:
        emit_signal("ready_to_act")
        set_process(false)

  1. Composition over inheritance is a principle in Object-Oriented Programming (OOP) that states that to make an object flexible and reuse code, we should favor instancing other classes that implement desired behavior over inheriting functionality from a parent class.↩︎

  2. Aggregation is a particular kind of composition where an object’s children or the objects it references can function independently from it. Godot’s scene system is built for aggregation: you can run any scene in isolation in the editor, by pressing F6.↩︎

Community Discussion