Imported from lownpho/mages (
.claude/skills/add-spell/SKILL.md). Install upstream withnpx skills add lownpho/mages --skill add-spell. Copyright stays with the author.
Adding a new spell
Spells are data plus, sometimes, one effect scene. Most shipped spells have no script at
all: they are a BulletSpellResource .tres pointing at the shared bullet_spell.tscn.
The casting machinery never changes. SpellCaster (characters/player/spells/spell_caster.gd)
handles per-spell cooldowns, the cast_time wind-up, channels, and over-time effects; it
instantiates the spell's effect scene, calls setup(spell, host), and adds it to the tree root.
That call is the entire contract. Everything the spell does lives in the effect scene.
The caster is not the player's. SpellCaster is mounted on any caster — the player,
every enemy, every summoned minion — and characters/creature/creature_caster.tscn is just that
same script on a Node2D. PlayerCastInput is the only player-specific piece: it maps
cast1..cast4 (LMB/MMB/RMB/Space, L1/L2/R1/R2 on pad) onto the spell row of GlobalInventory
and calls cast(). Creature behaviours call the same cast() on their own timing. Keep it that
way (see Faction-agnostic effects).
Read the doc comments in spell_caster.gd, cast_context.gd, spell_resource.gd,
bullet_spell_resource.gd, items/bullets/base_bullet.gd and
items/bullets/behaviours/bullet_behaviour.gd — they are the architecture source of truth.
design/docs/spells.md is the design catalogue: the prose is hand-written in
design/data/spells.yaml, every number in it is extracted from the .tres files, so its stats
are never stale.
The model
A spell is two or three things:
-
The resource — a
.tresper tier.SpellResourceextendsItemResource(so spells ride the pickup → bag → spell-row pipeline untouched, and can carryskill_modifier/speed_modifier/max_health_modifier/defence_modifieras passive grants). It holds what every spell shares:effect_scene,cooldown,cast_time(0 = instant; > 0 roots the player in the FSMCaststate),channeled,weakness,blurb. There is no mana — cooldowns and commitment are the whole cost model.Subclass it only when the spell has stats of its own. Shipped subclasses:
BulletSpellResource,HealResource,NopeResource,BwoomResource,BlinkResource,ChargeDashResource,SummonResource,ThwompResource,WhumfResource,MineResource. -
The effect scene — root script implements
setup(spell: SpellResource, caster: Node2D), builds aCastContextfrom the caster, and positions itself. Three optional extras the caster looks for:- a
finishedsignal → the effect is over-time: the cooldown starts when it ends, the spell can't recast while it's live, and a newer exclusive castinterrupt()s it. interrupt()→ cut it short; it goes on cooldown exactly as if it had run out.channel_released()→ required on anychanneledspell; fired on button release, on thecast_timecap (0 = uncapped), or when a behaviour ends the channel.
- a
-
The caster — already exists. The common case touches it not at all.
Tiers and side tiers
Tiers are separate .tres files with bigger numbers sharing one effect scene:
pew1.tres, pew2.tres, pew3.tres. Cooldowns are keyed by the resource, so re-slotting
can't dodge them and tiers are genuinely distinct spells. A side tier is a third-tier
variant carrying a weakness (pew3_insect.tres, blam3_insect.tres): it hits creatures whose
CreatureResource.kinds overlap for double. Side tiers are the reward for going and getting
them — never put a weakness on a base tier.
Modularity — extend the machinery, never special-case a spell
This is the load-bearing principle, and it overrides "don't touch the machinery" whenever they
conflict. A new spell sometimes needs a capability the shared code doesn't have yet. When that
happens, add the capability as a generic, reusable mechanism the machinery interprets — a
flag on SpellResource, a hook on the caster, a new BulletBehaviour, a uniform rule in
SpellCaster — never an if spell == bwoom branch. The test: another spell, including an
enemy-cast one, must be able to reuse it by setting data, with zero new code. If your change
names a specific spell, you did it wrong.
Worked examples, all still in the code:
- "Redirect damage to something other than health" (Nope) → a
damage_absorberhook on the caster: the hurt path filters incoming damage throughabsorber.absorb(damage) -> remainderbefore touching health. Nope's effect registers itself for the channel's duration. Any future damage-interceptor reuses the same hook — the hurt path never learns a spell name. - "Hold as long as you like" →
cast_time == 0on achanneledspell reinterpreted as uncapped in the caster: a general meaning of an existing field, not a new flag. - "Cooldown starts when the cast ends" → one uniform rule (
_resolve_cooldown) covering all three cast shapes — instant, wind-up, channel — plus any effect that exposesfinished. - "One burst at a time" →
_cancel_bursts()interrupts any live over-time effect when a new one starts, for every spell at once. Instants deliberately stack on top of a firing burst.
Three reuse rules baked into the system, all worth preserving:
- Don't reinvent
BaseBullet— and don't add fields toBulletResource. A projectile spell is aBulletSpellResource: aFirePattern+ an inlineBulletResource+ adamageScalingProfile, pointing at the sharedbullet_spell.tscn. No per-spell script. Anything beyond flying straight is a composableBulletBehaviourin the bullet'sbehavioursarray — the shipped set ishoming_behaviour,chain_behaviour,bounce_behaviour,blast_payload(the on-expire AoE that makes a fireball a fireball),spore_payload,spore_detonator. A genuinely new trait is a newBulletBehaviourresource, never a new field onBulletResourceand never a one-off effect script. Behaviours are shared across bullets, so they hold config only — per-bullet counters go inBaseBullet.runtime, keyed by the behaviour. Then weapons, spells and enemies get the trait for free. - Faction-agnostic effects. Keep effect scenes free of "the caster is the player". That is
what
CastContextis for: it samples origin, aim direction (never a cursor — a stick must drive it),skill/speed/defence,bullet_layer,target_groups,pierce,damageandweaknessfrom the caster once, in the one place that reads them.ctx.spawn_bullet(...)stamps all of it.tests/test_any_caster.tscnexists to prove a sproutling can cast the player's heal and fireball; don't break it. (Player-only utility like Blink is a deliberate exception.) - Reuse the damage and feedback channels. Damage always lands through
Hurtbox+get_damage()— never a new damage path. Hit/flash feedback goes throughgui/flatten.gdshader, never a sprite scale or a colour tween (off-grid / off-palette).
When you extend the machinery, update its doc comment in the same change, so the next spell author sees the new knob.
Damage delivery — pick the right pattern
All damage lands through the target's Hurtbox (an Area2D that accepts bodies and areas
carrying get_damage()). Layer + mask + bullets-group membership select the behaviour.
Layers are named in GameConstants: LAYER_PLAYER_BULLETS 256, LAYER_ENEMY_BULLETS 512,
LAYER_SPELL_BARRIER 1024.
| Pattern | How | Example |
|---|---|---|
| Bullet burst (the default) | BulletSpellResource + bullet_spell.tscn. max_shots 1 = a single projectile. |
pew, blam, snipe, ring, zaap |
| Piercing | pierce on the BulletResource (or the caster's buff) — leaves the bullets group, so the hurtbox damages but can't despawn it |
zoing |
| Chaining / ricochet / homing | a BulletBehaviour in behaviours |
chain_behaviour, bounce_behaviour, homing_behaviour |
| AoE on impact | blast_payload behaviour — spawns a one-shot DamageZone of radius_tiles at expiry, with optional frames. blast_only suppresses contact damage so a direct and a splash hit match |
fireball (pure data — no script) |
| Standalone AoE zone | components/damage_zone.gd — an area whose get_damage() hits a Hurtbox once on entry |
explosions, spore clouds |
| Channel / interceptor | channeled = true, effect implements channel_released(); register on a caster hook |
nope, bwoom |
| Summon | SummonResource (minion_scenes, count, spawn_pattern, minion_health, minion_lifetime, minion_spell, minion_sheet) + summon_spawner.tscn |
halp, bzzz, jimmy, poot, blops |
| Self / utility | no collision at all; act on the caster and emit the matching GlobalEvent signal |
heal, blink |
Damage is always ScalingProfile.compute(skill, speed, defence) —
base_damage + skill*skill_scaling + speed*speed_scaling + defence*defence_scaling, rounded.
Speed scaling reads bonus speed only (above base_speed), so an unequipped caster
contributes 0. Stats are authored in tiles; convert with GameConstants.PX_PER_TILE, never
hardcode 8.
Reference spells (copy from these)
pew/,blam/,ring/,snipe/,zaap/,zoing/— pure data. Four.tresand nothing else. Reach for this first for any projectile spell.fireball/— also pure data: a bullet whosebehaviourscarry ablast_payloadwith per-tier explosionSpriteFrames. Proof that "cast time + projectile + AoE" needs no script.heal/— the minimal scripted spell: a ~20-line effect that computes aScalingProfileoffCastContext, modifies the caster, emits the event, frees itself. Start here for self/utility.nope/— the channel template: registers itself ascaster.damage_absorber, implementschannel_released(), breaks early when its pool runs dry.halp/,jimmy/— summons: no effect script, aSummonResourceand a minion scene whose FSM is built from the same creature behaviour library the enemies use (seeadd-enemy).oop/,ploop/— mines: a bullet with no speed goes off where it was spawned.
Procedure
-
Design. Take the intent from
design/docs/spells.md. Numbers the design doesn't specify (projectile speed, timing juice) become@exports with sensible defaults. -
Folder. Everything in
game/characters/player/spells/<spell>/: optional<spell>_resource.gd+ effect scene.gd/.tscn, and the tier files<spell>1.tres,<spell>2.tres,<spell>3.tres(a spell may ship only the tiers it has). -
Icons & sprites. Spell icons live in
characters/player/spells/spells.png, an 8×8-cell grid; projectile sprites usually come fromitems/bullets/bullets.png(also 8×8 cells). Inspect the sheet before guessing regions (thepixel-artskill'sinspect_sheet.pydumps it) — rows are themed triplets, one cell per tier. If icons come later, leaveiconunset (slot and ground pickup render empty — say so) and use a placeholder bullet cell. -
Resources. Copy a reference tier
.tresand adapt. IconAtlasTextureregion isRect2(col*8, row*8, 8, 8). Keep one effect scene shared by all tiers. -
Visual feedback rules. The game is palette-locked (Zughy 32,
globals/palette.gd). Never alpha-blend, nevermodulatewith a non-white colour, never tween colours — tween coverage or position instead. Flat-colour flashes go throughgui/flatten.gdshader. -
Put it in the world. There is no item registry — every item in the game is a spell, and spells reach the player two ways: a
LootDropentry on some enemy'sCreatureResource(see therebalance-enemiesskill for which enemy should carry what), or the starter hand ingame/scenes/world.gd(roll_starter_hand). Don't add pickups toworld.tscnby hand. -
Document it — mandatory. Add the spell to
design/data/spells.yaml(id= the folder name,name,category,description, optionalper_tier) and rebuild. Never write a number there — cooldown, cast time, damage, tiers and projectile stats are all extracted from the.tres. The build fails if the yaml names a spell that doesn't ship, or a shipped spell is missing from the yaml. -
Name the tier files right — the grimoire reads them. Every
<spell>.tres,<spell><n>.tresand<spell><n>_<kind>.tresin the folder is a grimoire entry of its own (a silhouette until it is picked up), with nothing to register. Keep any other file in the folder off that naming (poot_shot.tres, notpoot4.tres), or the book shows it as a spell.tests/test_grimoire.tscnchecks every entry loads as a spell with an icon and an effect scene.design/tools/.venv/bin/python design/tools/build.py # --check to verify without writingUse that venv python: the system
python3has no PyYAML/Jinja2.
Godot gotchas (each of these has bitten before)
- Generate uids with Godot, not by hand. Hand-rolled uid strings get silently reassigned by
the editor later, churning every reference. Generate real ones up front:
godot --headless -s <script>where the script printsResourceUID.id_to_text(ResourceUID.create_id()), and put them in the.tscn/.tresheaders and cross-references. - Scripts get their uid from the importer. After writing
.gdfiles, rungodot --headless --importfromgame/(it fails with "no main scene" from the repo root). Then read the generated.gd.uidsidecars and patch the[ext_resource type="Script" …]lines to include them. - Never spawn siblings during
_readywith a directadd_child. An effect that spawns projectiles in_readyruns while the tree is still adding the effect itself — directget_tree().root.add_child(p)fails with "parent busy setting up children" and the spell silently does nothing. That is exactly whatCastContext.spawn_bullet(..., deferred: true)is for; a burst tick in_physics_processcan add synchronously. - Deferred adds rename nodes. Deferred-added duplicates become
@CharacterBody2D@N— find spawned nodes byget_script(), never by name. - The live editor re-saves scenes/resources from memory and can clobber on-disk edits. After editing on disk, reload in the editor before saving anything.
- Timers, not delta counters, for lifetimes/staggers/legs — one-shot child
Timers die with the node. A projectile that can outlive its purpose needs a fallback lifetime. - A bullet with
speed_tiles = 0orrange_tiles = 0never flies: it hides and expires where it spawned. That's a feature (mines), and a trap if unintended. - Zero-output headless timeout = a GDScript parse error, not slowness — warnings are errors, including unused params and loop vars.
Validate
There is a test suite; add to it rather than writing throwaways. Each prints
ALL PASS / FAILED: <n>:
godot --headless --path game res://tests/test_spell_damage.tscn # damage reaches the bullets
godot --headless --path game res://tests/test_bullet_spell.tscn # burst cadence, aim modes
godot --headless --path game res://tests/test_any_caster.tscn # an enemy can cast it
godot --headless --path game res://tests/test_loadout.tscn # slotting/cooldown plumbing
test_spell_damage uses distinct non-zero skill/speed/defence so a dropped scaling term
shows up, and tests/support/stub_caster.gd is there for driving an effect without a real
player. Pick tiers and stats so expected damage is exact and assert numbers, not "took some
damage". Pace by wall-clock, not frames — headless runs uncapped FPS, so frames == 60 is
not one second; use Time.get_ticks_msec(). Assert cleanup too: after lifetimes elapse, zero
effect/projectile nodes remain.
A generic machinery extension (a SpellCaster or caster hook) must be tested through a real
caster scene, run as a scene (godot --headless --path game res://tests/x.tscn), not
godot -s — -s loads before the autoloads (GlobalInventory, GlobalEvent) register.
Then feel it in the World (godot --path game res://scenes/world.tscn, which writes the Run
save): Tab pauses and opens the debug panel, whose Combat tab equips items and places enemies
to cast at, F3 shows dealt/taken tallies, and the console's reload (`) re-reads every
slotted item from disk so you can tune numbers in a text editor without restarting. Sprites,
timing juice and palette can only be judged there.