Skip to content

Write a Custom Action

An action is a small GDScript file that tells Hengo two things: which inputs to show in the editor, and which code to emit into the generated .gd. The whole library in the Actions reference is written against the same contract, and that contract is open to your project.

This page walks the full range, from a one-line command to loops, signal listeners and virtual overrides. Every recipe here mirrors a file that ships with the plugin, so you can always open the original next to it.

res://hengo/macros/ # your actions, per project
combat/
apply_damage.gd
res://addons/hengo/actions/ # the actions that ship with Hengo, same contract
flow/ math/ node2d/ ...
  • The first-level folder is the category. macros/combat/apply_damage.gd lands in a Combat group in the action search. A file dropped straight into macros/ is uncategorized and shows up under My Macros.
  • res://hengo/macros/ is created for you the first time Hengo loads.
  • Deeper nesting is allowed, it keeps the first-level category.
  • Hengo scans both folders when a collection loads, so reopen the collection (or the plugin) after adding a file.
res://hengo/macros/debug/log_message.gd
@tool
extends HenScriptMacroBase
func get_id() -> StringName:
return &'log_message'
func get_display_name() -> String:
return 'Log Message'
func get_description() -> String:
return 'Prints a value to the output console.'
func get_icon() -> String:
return 'terminal'
func get_inputs() -> Array[Dictionary]:
return [
{
name = 'Message',
type = 'Variant',
id = &'message',
doc = 'The value to print.',
default_value = 'hello'
}
]
func get_flow_inputs() -> Array[Dictionary]:
return [
{name = 'Start', id = &'enter'},
{name = 'Every Frame', id = &'update'},
{name = 'End', id = &'exit'}
]
func get_flow_enter() -> String:
return 'print({{message}})'
func get_flow_update() -> String:
return 'print({{message}})'
func get_flow_exit() -> String:
return 'print({{message}})'

Four rules hold for every action:

  • @tool is mandatory. The file is instanced by the editor, without it nothing loads.
  • Extend HenScriptMacroBase (or one of its subclasses, see signals).
  • get_id() is the only required override. It must be unique and permanent: saved actions reference the macro by this id, so renaming the file is safe but renaming the id is not.
  • Skip class_name. It is optional and it pollutes Godot’s global class list. The plugin’s own test fixtures leave it out for that reason.

Drop the file, reopen the collection, and Log Message shows up in the action search under Debug (see Actions on a state).

get_flow_<phase>() returns a template string. Hengo substitutes it in a fixed order:

  1. {{out:<id>}} output lines, see producers
  2. {{<input_id>}} for every input the action declares
  3. {{<branch_id>}} for every flow output, see branching
  4. {{VCNODE_ID}} with this action’s unique id
  5. {{loop_body}} last, see loops

Two names are always available inside a body:

  • _ref is the node the generated script runs on. Anything belonging to the object goes through it: _ref.position, _ref.get_node("Sprite2D").
  • delta exists only in the update and physics phases. An enter or exit body that uses it will not compile, so an action built around delta should only declare those two flow inputs.

Multi-line bodies are plain strings with \n and \t. A body injected into an indented spot keeps its indentation, so an if block nests correctly.

func _body() -> String:
return 'var speed_{{VCNODE_ID}}: float = {{speed}}\n' \
+ 'if speed_{{VCNODE_ID}} > 0.0:\n' \
+ '\t_ref.position.x += speed_{{VCNODE_ID}} * delta'

An input is a dictionary. name, type and id are required, the rest is optional.

KeyMeaning
nameLabel shown in the editor.
typeDeclared type, a Variant type (float, String, Vector2…) or a class (Node, Area2D). Drives which editor widget and which bindings are offered.
idStringName used as the {{placeholder}}.
docOne line shown in the action’s hover tooltip.
default_valueValue the slot starts with.
optionalA required-but-empty slot skips the action, an optional one is simply left out.
rawEmit the value verbatim instead of quoting it as a literal.
optionsFixed set of choices, rendered as a picker. Pair it with raw.
lvalueThe slot is an assignment target, see writing into a variable.
bind_onlyReadable, but a literal makes no sense for it (a node, a raycast). Any bound source works, node paths included.
type_fromFollow another slot’s bound type instead of the declared one.

At edit time a slot can hold a literal, a variable or property binding, a free-text expression, or another action fed inline. Codegen resolves them in that priority: inline action, then expression, then binding, then literal. Your body sees the result, so it never has to care which one the user picked.

{
name = 'Axis',
type = 'String',
id = &'axis',
doc = 'Which axis to move along.',
raw = true,
options = ['x', 'y'],
default_value = 'x'
}

options shows a dropdown instead of a text field. Keep raw = true and make default_value the first option, otherwise the widget and the emitted code disagree. This is how actions/input/check_key.gd turns 50 key constants into one action.

When the value emitted is not what a human should read, add option_labels with one label per option. And when the useful values are whatever the project happens to hold rather than a fixed list, use picker instead: input_action, audio_bus, scene_path or group. Those are collected when the slot opens, so they follow the project instead of being frozen at load.

get_flow_inputs() declares which lifecycle phases the action supports, and each one needs a matching get_flow_<phase>():

Flow input idCell in the editorRunsBody method
enterStartonce, when the state becomes activeget_flow_enter()
updateEvery Frameevery idle frame, delta availableget_flow_update()
physicsPhysicsevery physics tick, delta availableget_flow_physics()
exitEndonce, when leaving the stateget_flow_exit()

The ids are what Hengo matches on, and the name you write is not what the user reads: the cells and the phase buttons are always labelled Start / Every Frame / Physics / End. Write the name to match so your file does not lie to the next reader.

Declare only what genuinely works. An action whose body needs delta declares update and physics only. get_default_phase() picks where a freshly added action lands, empty means the first declared phase.

Two phases are gated further than what you declare: Every Frame is also offered to an action with no flow inputs at all, because its body comes from an override, and End is only offered when every branch the action declares is optional, since a transition on End re-enters the state forever.

A producer writes a value into a variable the user chooses. Declare the output, return its right side from get_output_<id>(), and put {{out:<id>}} on its own line in the body.

a producer, modelled on actions/math/angle_difference.gd
func get_inputs() -> Array[Dictionary]:
return [
{name = 'Value', type = 'float', id = &'value', doc = 'The number to snap.', default_value = 0.0},
{name = 'Step', type = 'float', id = &'step', doc = 'Size of the grid to snap to.', default_value = 1.0}
]
func get_outputs() -> Array[Dictionary]:
return [
{name = 'Result', type = 'float', id = &'result', doc = 'The snapped value.'}
]
func get_output_result() -> String:
return 'snappedf({{value}}, {{step}})'
func get_flow_update() -> String:
return '{{out:result}}'
  • When the user stores the output, the line becomes _ref.my_var = snappedf(...).
  • When nobody stores it and nothing wires it, the whole line disappears. If the body has nothing else left, the action is skipped with no output stored, which is the correct outcome: it would compute nothing.
  • A step downstream reading the value through a wire counts as storing it.
  • Several outputs are fine, actions/physics3d/raycast_check.gd declares three.
  • get_unstored_body() is the escape hatch for an action that must still run when nobody takes its value. A pure producer leaves it empty and simply vanishes.

A pure producer can also be used inline, plugged straight into another action’s input without a variable in between. Hengo allows that only when the action has outputs, no branches, no loop body, no persistent state, and every phase body is nothing but {{out:...}} lines. Keep a producer pure and you get inlining for free.

Every flow output is a branch the user points at a state or sub-state.

modelled on actions/flow/if_condition.gd
func get_flow_outputs() -> Array[Dictionary]:
return [
{name = 'True', id = &'true', doc = 'Where to go when the value is zero.'},
{name = 'False', id = &'false', doc = 'Where to go when it is not.'}
]
func _body() -> String:
return 'if is_zero_approx({{value}}):\n\t{{true}}\nelse:\n\t{{false}}'
  • {{true}} becomes whatever the user hung on that branch, its steps and its transition, or pass when the branch is left unset, so the block always compiles.
  • The user can also point a branch at a state in another script, Hengo emits the call on that instance.
  • A transition cannot run on End. Changing state from inside exit() re-enters it forever, so Hengo refuses it. Steps on a branch are fine there, it is the transition that is banned.
  • An action whose branches are all unset is skipped, not silently blanked.

A branch declared optional = true is a shortcut the action offers, not the reason it runs. An action with only optional branches is never skipped for having none wired, and it can sit on End.

Ask is_flow_connected() in the body and emit the if only when somebody wired it, so a user who does not care pays nothing:

func get_flow_outputs() -> Array[Dictionary]:
return [
{name = 'Hit', id = &'hit', doc = 'Where to go when it connected.', optional = true}
]
func _body() -> String:
if not is_flow_connected(&'hit'):
return '_ref.strike()'
return 'if _ref.strike():\n\t{{hit}}'
actions/flow/repeat.gd
func get_has_body() -> bool:
return true
func _body() -> String:
return 'for __i_{{VCNODE_ID}} in {{times}}:\n\t{{out:index}}\n\t{{loop_body}}'

get_has_body() gives the action a nested chain in the editor, hanging off the card with its own Add action node at the end, and {{loop_body}} is where those actions land. An empty loop emits pass, so the for is never left dangling.

Two related rules:

  • get_needs_loop() marks an action that only makes sense inside a loop (break, continue). At the top level it is refused.
  • An action that keeps state, declares a virtual override, or hooks enter/exit cannot be nested inside a loop, its declarations live at the state or script level and a per-iteration hook makes no sense.

Nested actions run at the loop’s phase, whatever phase they were dropped on.

get_script_base() injects declarations into the state class, so a value survives across frames. Pair it with get_flow_reset(), which runs at the top of the state’s enter() no matter which phase the action itself is on.

actions/flow/cooldown.gd
func get_script_base() -> String:
return 'var cooldown_{{VCNODE_ID}}: float = 0.0'
func get_flow_reset() -> String:
return 'cooldown_{{VCNODE_ID}} = 0.0'
func _body() -> String:
return 'cooldown_{{VCNODE_ID}} = maxf(cooldown_{{VCNODE_ID}} - delta, 0.0)\n' \
+ 'if cooldown_{{VCNODE_ID}} <= 0.0:\n' \
+ '\tcooldown_{{VCNODE_ID}} = {{seconds}}\n' \
+ '\t{{ready}}\n' \
+ 'else:\n' \
+ '\t{{cooling}}'

State objects are built once, so without a reset a counter would survive re-entry into the state. get_flow_teardown() is the mirror, it runs in exit() and is where an action undoes what it armed.

An input marked lvalue = true is the left side of an assignment, so it must be bound to a variable or a property. An expression, an inline action or an empty slot all skip the action with a clear reason instead of emitting 0 = 5.

actions/variable/set_value.gd
func get_inputs() -> Array[Dictionary]:
return [
{
name = 'Target',
type = 'Variant',
id = &'target',
doc = 'The variable or property to write to.',
lvalue = true,
default_value = null
},
{
name = 'Value',
type = 'Variant',
id = &'value',
doc = 'The value to store.',
type_from = &'target',
default_value = 0
}
]
func get_flow_update() -> String:
return '{{target}} = {{value}}'

type_from = &'target' makes the Value slot adopt whatever type Target is bound to, so typing 45 into it stores an int and not the string "45". Use it on any Variant slot that has to match another one. It can also follow an output id, which is how a producer’s inputs adopt the type of the variable its result is stored in.

Signal actions extend HenActionSignalBase, which already arms the connection on enter, drops it on exit, and raises a flag the phase body branches on. Declaring one is three methods:

res://hengo/macros/event/on_tree_exited.gd
@tool
extends HenActionSignalBase
func get_id() -> StringName:
return &'on_tree_exited'
func get_display_name() -> String:
return 'On Tree Exited'
func get_icon() -> String:
return 'door-open'
func get_inputs() -> Array[Dictionary]:
return [_emitter_input()]
func get_signal_code() -> String:
return "'tree_exited'"

_emitter_input() is the shared bind_only node slot, and _store_input('Store Body') adds an optional lvalue slot for signals that carry an argument (declare get_arg_count() to match, Godot refuses a callable expecting more arguments than the signal sends). See actions/event/on_body_entered.gd.

Writing one from scratch is the same pattern by hand: get_script_base() for the flag and the callback, get_flow_reset() to connect, get_flow_teardown() to disconnect.

get_target_classes() gates the action: it only appears for scripts whose base class inherits from one of them. Empty means every class.

actions/render/change_color.gd
func get_target_classes() -> Array[StringName]:
return [&'CanvasItem', &'Node3D']
func _get_body() -> String:
if targets(&'Light3D'):
return '(_ref as Light3D).light_color = {{color}}'
if targets(&'Polygon2D'):
return '(_ref as Polygon2D).color = {{color}}'
return '_ref.modulate = {{color}}'

targets(&'Class') is true when the script’s base class inherits from that class, which lets one file serve 2D and 3D with different code.

Some behaviour cannot be expressed in a phase body: a key press is an event, not a per-frame check. get_function_overrides() contributes lines to a virtual method on the node, and get_script_scope() declares the variables that method reads.

actions/input/check_key.gd
func get_script_scope() -> String:
return 'var key_hit_{{VCNODE_ID}}: bool = false'
func get_function_overrides() -> Array[Dictionary]:
return [
{
name = '_input',
params = [ {name = 'event', type = 'InputEvent'} ],
body = 'if event is InputEventKey and event.keycode == ' + str(value_of(&'key', 'KEY_SPACE')) + ':\n' \
+ '\tkey_hit_{{VCNODE_ID}} = true'
}
]

The phase body then reads the flag through _ref., because those declarations sit at script scope and the body runs on the state class.

Two more context helpers are primed before any body getter runs:

  • value_of(&'id', fallback) returns the literal a slot holds.
  • is_bound(&'id') tells whether a slot is bound to a variable, property or node path, which lets a body drop a line nobody can receive.

If an action can detect that it is misconfigured, say so instead of emitting code that quietly does nothing:

func get_validation_error() -> String:
if not is_bound(&'target'):
return 'target must be bound to a node'
return ''

A non-empty reason skips the action with a loud # hengo: marker in the generated file and a warning in the Output. See Why an action did not emit.

  • get_display_name(), the name in the search and on the row. Empty falls back to the file name capitalized, apply_damage.gd becomes “Apply Damage”.
  • get_icon(), a Lucide icon name that exists in addons/hengo/assets/new_icons/ (no extension), for example git-branch.
  • get_color(), a hex string that tints the row.
  • Both fall back to the category defaults, so an action in a known folder already looks right.

One concept gets one name. Never suffix the node dimension into the display name: it is “Set Scale”, not “Set Scale 3D”, the 2D and 3D split is get_target_classes()’s job, and two actions with the same name gated to different classes never appear together. A suffix that names a data type the user picks on purpose is fine (“Make Vector2”). Before writing a new action, check whether one already covers the concept and extend that one instead.

The description and the doc lines are what the editor shows on hover, so they are the manual for whoever uses the action:

  • get_description(), one or two sentences on what the action does.
  • doc on every input, and on outputs and flow outputs when it helps, one line each.
  • English, present tense, impersonal. No [ or ] in the text, they collide with the tooltip markup.
  • An input with options already lists its choices, do not repeat them in the doc.
  1. Save the file under res://hengo/macros/<category>/.
  2. Reopen the collection so the scan picks it up.
  3. Click a cell on a state’s entry node on the canvas, and search for your action.
  4. Compile All and read the result in the generated .gd under res://hengo/scripts/. That output is the truth about what your template emitted.

Faster than compiling, once the file exists, is to ask the CLI what it emits without opening the editor at all:

Terminal window
godot --headless --path . -s tools/hengo_cli.gd -- --preview <your_action_id>

It prints the inputs, the outputs with their real right-hand side, the branches, and the body per phase and per target class, which is the part a targets() dispatch makes easy to get wrong. 5. If the action was skipped you get a # hengo: ... unresolved: <reason> comment in the code and a warning in Godot’s Output, look the reason up in Why an action did not emit.

Full method-by-method contract: Action API.