Custom Spell System

APG internship — Godot 4 — GDScript / C#

view on github → Custom Spell System screenshot

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