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.
Where actions live
Section titled “Where actions live”res://hengo/macros/ # your actions, per project combat/ apply_damage.gdres://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.gdlands in a Combat group in the action search. A file dropped straight intomacros/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.
The skeleton
Section titled “The skeleton”@toolextends 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:
@toolis 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).
How a body becomes code
Section titled “How a body becomes code”get_flow_<phase>() returns a template string. Hengo substitutes it in a fixed order:
{{out:<id>}}output lines, see producers{{<input_id>}}for every input the action declares{{<branch_id>}}for every flow output, see branching{{VCNODE_ID}}with this action’s unique id{{loop_body}}last, see loops
Two names are always available inside a body:
_refis the node the generated script runs on. Anything belonging to the object goes through it:_ref.position,_ref.get_node("Sprite2D").deltaexists only in theupdateandphysicsphases. Anenterorexitbody that uses it will not compile, so an action built arounddeltashould 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'1. Declare inputs
Section titled “1. Declare inputs”An input is a dictionary. name, type and id are required, the rest is optional.
| Key | Meaning |
|---|---|
name | Label shown in the editor. |
type | Declared type, a Variant type (float, String, Vector2…) or a class (Node, Area2D). Drives which editor widget and which bindings are offered. |
id | StringName used as the {{placeholder}}. |
doc | One line shown in the action’s hover tooltip. |
default_value | Value the slot starts with. |
optional | A required-but-empty slot skips the action, an optional one is simply left out. |
raw | Emit the value verbatim instead of quoting it as a literal. |
options | Fixed set of choices, rendered as a picker. Pair it with raw. |
lvalue | The slot is an assignment target, see writing into a variable. |
bind_only | Readable, but a literal makes no sense for it (a node, a raycast). Any bound source works, node paths included. |
type_from | Follow 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.
Pickers
Section titled “Pickers”{ 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.
2. Pick the phases
Section titled “2. Pick the phases”get_flow_inputs() declares which lifecycle phases the action supports, and each one needs a
matching get_flow_<phase>():
| Flow input id | Cell in the editor | Runs | Body method |
|---|---|---|---|
enter | Start | once, when the state becomes active | get_flow_enter() |
update | Every Frame | every idle frame, delta available | get_flow_update() |
physics | Physics | every physics tick, delta available | get_flow_physics() |
exit | End | once, when leaving the state | get_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.
3. Produce a value
Section titled “3. Produce a value”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.
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.gddeclares 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.
4. Branch to another state
Section titled “4. Branch to another state”Every flow output is a branch the user points at a state or sub-state.
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, orpasswhen 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 insideexit()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.
Optional branches
Section titled “Optional branches”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}}'5. Hold a loop body
Section titled “5. Hold a loop body”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.
6. Keep state between frames
Section titled “6. Keep state between frames”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.
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.
7. Write into a variable
Section titled “7. Write into a variable”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.
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.
8. Listen to a signal
Section titled “8. Listen to a signal”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:
@toolextends 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.
9. React to the node type
Section titled “9. React to the node type”get_target_classes() gates the action: it only appears for scripts whose base class inherits from
one of them. Empty means every class.
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.
10. Override a virtual method
Section titled “10. Override a virtual method”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.
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.
11. Refuse to emit
Section titled “11. Refuse to emit”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.
Presentation
Section titled “Presentation”get_display_name(), the name in the search and on the row. Empty falls back to the file name capitalized,apply_damage.gdbecomes “Apply Damage”.get_icon(), a Lucide icon name that exists inaddons/hengo/assets/new_icons/(no extension), for examplegit-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.
Naming
Section titled “Naming”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.
Documentation
Section titled “Documentation”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.docon 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
optionsalready lists its choices, do not repeat them in thedoc.
Try it out
Section titled “Try it out”- Save the file under
res://hengo/macros/<category>/. - Reopen the collection so the scan picks it up.
- Click a cell on a state’s entry node on the canvas, and search for your action.
- Compile All and read the result in the generated
.gdunderres://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:
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.