Custom Spell System
APG internship — Godot 4 — GDScript / C#
view on github →
Overview
Built during my internship at Adventure Party Games, this is a visual spell design system for a Godot 4 game. The goal was to let designers construct spells entirely through a node graph editor without writing any new code for each spell — spells are compiled into data at edit time and cast as 3D projectiles at runtime.
The editor is accessed in-game with TAB. Spells are fired with the left mouse button.
The system is composed of seven node categories that can be combined freely.
Node Types
| element | Assigns color and identity to the spell: Fire, Ice, Lightning, or Arcane. |
| shape | Defines the hitbox geometry: Orb, Beam, AOE, Cone, Wall, Explode, Projectile, or Gravity Projectile. |
| path | Controls movement behavior: Line of Sight, Homing, Boomerang, Curve, Zig-Zag, or Upwards. |
| casting | Determines firing pattern: Burst, Continuous, Charge Up, and self-targeting variants of each. |
| modifier | Adjusts numeric parameters such as damage, speed, and size. |
| effect | Applies buffs or debuffs to the caster or target. |
| trigger | Activates child spells on events: On Hit, On Kill, On End, or On Timer — enabling chained and compound spells, e.g. a projectile that explodes into homing orbs on impact. |
| spell_ref | Embeds a previously saved spell by reference, allowing complex spells to be reused as components inside other spells. |
Implementation
When the designer saves a spell, the graph is compiled into a flat sequential array of dictionaries — one dict per node, in traversal order. No code is generated; the runtime reads the array and dispatches behavior entirely from data. This is what allows new spells to be authored without touching any C# or GDScript.
Trigger nodes are the exception: at compile time, _split_at_triggers() walks
the array, detects trigger entries, and splices each trigger’s child spell in-place as
a nested sub-array. The runtime re-enters this sub-array on the trigger event, which is what
gives chained spells (e.g. projectile → on-hit → homing orbs) their recursive
structure without any special-casing in the cast loop. SpellFactory also
recursively inlines any spell_ref entries before assembling the object, so
references to saved spells are fully transparent to the cast loop.
Spell casting is not tied to mouse input directly. Pressing the button starts the wand
animation; the animation hits a keyframe that emits a spell_cast() signal,
which the spell system receives to actually spawn the projectile. This keeps cast timing
locked to the animation rather than raw input.
Path scripts (homing, boomerang, zig-zag, etc.) capture their initial forward and right
vectors lazily on the first physics tick rather than in _ready(). This is
necessary because global_transform is assigned by the factory after
add_child — if paths read it in _ready() they would always see
the identity transform.
The project is 79.8% GDScript and 19.3% C# — the node graph editor and spell data are GDScript; performance-sensitive runtime logic (projectile physics, collision) is C#.
Shapes & Effects
Each shape is an Area3D child of the spell node. Collision routing varies per
shape: the projectile shape performs a CCD sweep ray each physics frame to catch collisions
at high speeds that would otherwise tunnel through thin geometry. The gravity well shape
pulls nearby bodies toward it each tick using apply_central_force for
RigidBodies and direct velocity manipulation for CharacterBodies, with a short spawn grace
period to avoid immediately affecting the caster. The beam shape uses a dot-product
projection to find the exact point along the beam axis where a hit occurred — needed so
OnHit triggers spawn at the correct world position rather than at the beam’s origin.
The effect system separates target (the entity being affected) from caster (always the player). Effects carry a duration; SlowMo specifically flags itself as real-time so its cleanup timer ignores the dilated time scale it creates. One-shot effects (teleport, throw) skip the timer entirely and free themselves immediately after applying. The trail modifier uses an object pool of 10 orbs — repositioned and reset via Tween rather than freed — to avoid per-frame allocation pressure.
↑ back to top