Skip to content

Intro To The Action System

DesirePathGames edited this page Jun 13, 2026 · 4 revisions

What Are Actions And Why Do We Use Them?

Actions are reusable parameterized scripts that act as the bread and butter of the framework, performing behavior against the player or their cards, enemies, or even aspects of the game itself such as playing music. By providing a common interface through which everything in the game happens, it creates an easy way of defining mechanics in an atomic, consistent, and extensible fashion. It also emphasizes a data-first driven design philosophy, focusing on configuration of behavior rather than hardcoding behavior, which is good for you the developer, as well as anyone looking to mod your game.

Put simply, they're the building blocks you use to actually make stuff.

Okay But What is An Action?

The anatomy of an action is simple, just a JSON object with a single String file path to a script that inherits from the BaseAction abstract class (usually a hard coded constant stored in Scripts autoload) and a dictionary containing that action's parameters. It'll look like this:

{"path_to_action_script": {"parameter_1": value, ...}}

For instance, here's the card_basic_block's action, simply giving the player some block when the card is played:

actions basic layout

Do note that it's one action per JSON object. DO NOT try to do this:

{"path_to_action_script_1": {"parameter_1": value, ...},"path_to_action_script_2": {"parameter_1": value, ...}}

This is because dictionaries do not guarantee order of key-values, and in fact godot alpha sorts keys when saving to json. You actions will run in alphabetical order as a result.

Instead, these are often stored in arrays of JSON objects, usually referred to as an action payload. These action payloads are assigned to lots of different parts of the code, and most action payloads will be automatically handled by the framework when their behavior is needed. Ex: If you play a card, the card_play_actions payload is invoked. These tend to be pretty self explanatory.

If you want to see what all the various action payload types are, a really fast way to see them all is to CTRL+ SHIFT + F project search "_actions: Array[Dictionary] ="

actions in framework

Tada! As you can see most of it is card related, but there's quite a few other places where actions are used such as status effects, artifacts, event dialogue, and consumables.

Also very important to remember (more on that in ActionHandler section below), action payloads are stored in a LIFO: Last In First Out. This means that if you have a card that you want to attack and then block, you define it as [block action, attack action] so the attack happens first.

Action Value Hierarchy, CardPlayRequest, and Value Aliasing

Okay so you understand what an action looks like and why they're used, now let's talk about how they're actually configured and how data is passed around. As it turns out, the above example with the block card is only one tiny piece. Maybe take a closer look at it again...

too close

NOT THAT CLOSE

actions basic layout

Two things of note are that there's no block amount specified. Is it 1 block? 100 block? How does the game know? Second, there's that weird "target_override" parameter. This is a good time to talk about how actions actually get their parameters and the implications behind where those parameters are stored.

The Action Hierarchy

So it's not enough to simply configure actions with values. Where those values are stored actually matters a great deal. As it turns out the way actions find values is stored in a hierarchy.

This hierarchy is: Shadowed Values -> Action Values -> CardPlayRequest Values -> Card Values -> Player Values

Shadowed values can be tricky (more on them later) and player values aren't terribly important, so let's just talk about Action, CardPlayRequest, and Card values.

Action and Card Values

Action values are by far the simplest. They're whatever parameters you include in the action itself. These are specific to that action and that action alone. They also override the below values. Thus the common target_override parameter you'll see put in a lot of actions. More on action targets below.

Card values are stored in CardData.card_values. These are modifiable such that whenever you play/discard/etc a card that they'll be carried over. Eg you modify the "damage" card value of an attack card, it plays stronger attacks. Also pretty simple. Okay now lets combine the card and action values together...

CardPlayRequest: Not Always Cards or Requests

CardPlayRequest is the glue that holds a lot of the action system together. Originally early in development these objects were simply used to queue up user card plays, thus the name. It's still used for that, but as it turns out, there's a lot of metadata one needs to store when playing a card! In addition to that, the move to actions away from cards and into everything necessitates some serious scope creep!

So despite the name, it's more of an action data container used to glue an entire action payload together. But ActionPayloadDataContainerButAlsoCardStuff doesn't really roll off the tongue, so the original name stuck.

Whenever any action payload is generated, a single CardPlayRequest is generated (See ActionGenerator below) and then passed in to each generated action. Cards themselves are actually completely optional for this, but if a card is involved the card_values portion is duplicated and then passed in to the CardPlayRequest and the CardData reference for that card is stored as well. This allows actions to grab card_values, but also allows you to mutate that shared data in a way that doesn't affect the card itself. Technically, the vast vast majority of time the value hierarchy won't ever touch a card's card_values due to this duplication, but it will still look if it can't find it for some reason in the CardPlayRequest.

So let's go back to our earlier block card example, but zoom out this time.

actions basic layout zoomed out

Oh look. The card_values defines 10 block. So the action will see that it doesn't have a block value, check the CardPlayRequest containing the copied card_values, and see that there's 10 block.

And what happens if we add another block action?

double blocking

Both actions will generate 10 block each. If the block value changes, it will affect both of those actions. And if we add a third action but include 7 block inside the action itself...

triple blocking

You'll wind up with 27 block. And the third block action will always give 7, regardless of card_values.

But wait, what if I want to block for two separate+ values, and have both of those values modifiable in the card itself? For instance, block for 10 then block for 6, with an upgrade on the card doing 10(12) + 6(8). Well that's where action value aliasing comes in.

Value Aliasing

If you want a card with two of the same action but different values and you want them both to store those values in the card itself for modification, you're going to run into a naming conflict as dictionaries of course have unique keys. This is where the custom_key_names action value comes in handy.

The format for this is "custom_key_names":{"original_name_1": "custom_name_1",...}

Back to our block card example. We can now mess with the middle block action to make it pull from card_values using a block value named other_block

custom key names

The usual place you'll see this is in cards that apply multiple status effects, where the status effect id will be aliased.

Action Targets and Target Overrides

A really common thing you'll do when playing the game is to play a card or use a consumable and be prompted to select a target enemy. This is controlled via the CardData.card_requires_target and ConsumableData.consumable_requires_target flags.

Once a target is selected (or not selected), either the given target or null will be passed to the CardPlayRequest. Each action will also gain a list of targets via BaseAction.targets, which in the case of manual user input is the same thing.

From there, before the action is performed it will call get_adjusted_action_targets(). It will either use the selected targets supplied to it (default), or use a target override to select different targets via the BaseAction.TARGET_OVERRIDES enum. It will then run through that list of targets, filtering out the ones that are dead so as not to target them (this too can be overridden with "force_dead_targets": true)

After it has a list of valid targets, the action is executed against them. For a lot of actions that don't allow selection, you'll use a target override of some kind, usually the player (self buffs) or all enemies (AoE attacks). For the blocking example, if you did not provide an override it would simply not generate any block to the player because the target is null and thus not considered valid.

Time Delay and is_instant_action()

Each action, once performed, has a delay where the game will wait after execution in order to ensure that all the actions aren't be executed in one frame. It'd be really silly after all to attack and enemy with a 5 hit attack and all the attacks happen at the exact same time.

In addition, some actions are always considered instant. They do not have a time_delay. This is achieved by overriding the is_instant_action() method in your action's script to return true.

The ActionHandler

This singleton manages all actions being performed at once, ensuring they are executed in order with no possibility of multiple actions happening simultaneously. Whenever actions are added to an empty handler, it will "lock" and automatically begin processing the actions, with subsequent action pushes adding on to the current processing rather than interrupting or happening in parallel.

The Action Stack and Current Queue

The complete structure of actions is stored as a stack of queues of actions. There is also a current queue, which contains a queue of actions that are being processed. Once this queue finishes, the next queue is popped from the stack. This continues until the entire stack is empty.

Action Stack And Queue

When the entire stack and current queue are emptied, it will emit the actions_finished signal, allowing for other parts of the framework, primarily the UI, to asynchronously await on them.

Remember LIFO

Do note that the default behavior of populating action payloads onto the stack is to populate each action as its own separate queue. This means that if you have a list of actions [A, B, C, D] they will be populated onto the stack as [Queue(A), Queue([B]), Queue([C]), Queue([D])], which will be executed as D, C, B, A because stacks are Last-In-First-Out.

So if we go back to the block card again...

custom key names

First we get 7 block, then 15, then 10, with each block action getting its own queue.

The ActionGenerator

In order to make generating actions much less of a headache, a factory singleton, ActionGenerator was created. This contains a generic method for turning JSON based action payloads into a list of BaseAction objects you can push onto the ActionHandler. It also contains a lot of factory methods used for generating actions used by the UI or general game mechanics and automatically pushing them to the stack. One example is resetting your energy at the start of your turn.

Common Actions

ActionAttackGenerator

ActionDrawGenerator

Whenever

Meta Actions

Meta actions are simply actions that affect or generate other actions. This includes if/else trees, "looping", and modifying action values.

ActionValidator is your standard if statement. Supplying it validators can check for various conditions such as the state of the player. enemies, or the currently played card. If all validators pass it will generate a child payload of actions from passed_action_data, otherwise it will use failed_action_data.

For for-loop logic, there is ActionVariableActionGenerator, which simply clones an action payload a given number of times.

ActionVariableCostModifer will take a card's input energy costs and then multiply child action values by that amount. This is the primary meta action for getting X cost cards to work, and can be combined with ActionVariableActionGenerator to repeat an action X number of times by nesting them.

Clone this wiki locally