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 Targets And The Action Hierarchy

Okay so you understand what an action looks like and why they're used, now let's talk about how they're actually configured. 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 seperate 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

A common place you'll see this is in cards that apply multiple status effects. Usually the status effect id will be aliased.

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.

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.

Clone this wiki locally