Goal: the extra systems that make Swarm runs feel varied: breakable pods with random drops, augments (run-long modifiers with a trade-off), temporary boons, and map events you capture by standing in a zone.
This phase is an outline, like Phase 8. Each section explains the idea, how it fits into what you already have, and the key code. Pick the ones you want; none depend on each other.
Checklist
Pods are scattered around the map. Shoot one and it breaks, dropping one random thing:
Until now bullets and blades only hurt Enemys (if not (body is Enemy)). A pod isn’t an enemy: it shouldn’t chase you, count as a kill, or attract auto-aim. But bullets should still hit it.
The simplest fix is to ask “can this thing take damage?” instead of “is it an enemy?”. In projectile.gd and orbit_blade.gd, change the hit check to:
if not body.has_method("take_damage"):
return
body.take_damage(Weapon.roll_crit(damage, crit_chance))
This is called duck typing (“if it has take_damage(), I can damage it”). Godot is happy to call a method on a Node3D variable as long as the actual object has it. Knockback in orbit_blade.gd still needs an Enemy, so keep an if body is Enemy: check around the apply_knockback() part.
Area weapons (
Pulse Aura,Explosion) useTargeting.enemies_in_radius(), which only looks at the"enemies"group, so they won’t hit pods. That’s fine: pods are meant to be shot.
File: res://scripts/props/pod.gd
class_name Pod
extends StaticBody3D
const DROPS: Array[PackedScene] = [
preload("res://scenes/props/pickups/gold_coin.tscn"),
preload("res://scenes/props/pickups/heal_pack.tscn"),
preload("res://scenes/props/pickups/vacuum.tscn"),
preload("res://scenes/props/pickups/sea_mine.tscn"),
]
@export var max_hp: float = 100.0
var _hp: float = 0.0
func _ready() -> void:
_hp = max_hp
func take_damage(amount: float) -> void:
if _hp <= 0.0:
return
_hp -= amount
if _hp <= 0.0:
_break()
func _break() -> void:
var drop: Node3D = DROPS.pick_random().instantiate()
drop.position = global_position
get_tree().current_scene.add_child.call_deferred(drop)
queue_free()
Scene: a StaticBody3D with a box shape and mesh, on layer 3 (so bullets and enemies collide with it) and not in the "enemies" group. Scatter a few in flat_land.tscn, or write a small PodSpawner that places them at random points and respawns them over time.
All of them extend Pickup and only override collect(). Make each scene like xp_orb.tscn (layer 4, so the pickup radius pulls them in), with its own colour.
class_name HealPack
extends Pickup
const HEAL_FRACTION: float = 0.2
func collect(_player: Player) -> void:
for node: Node in get_tree().get_nodes_in_group("players"):
var target: Player = node as Player
target.heal(target.max_hp * HEAL_FRACTION)
class_name Vacuum
extends Pickup
func collect(player: Player) -> void:
for node: Node in get_tree().get_nodes_in_group("pickups"):
if node is XpOrb or node is GoldCoin:
(node as Pickup).attract(player)
class_name SeaMine
extends Pickup
const EXPLOSION_SCENE: PackedScene = preload("res://scenes/combat/explosion.tscn")
func collect(player: Player) -> void:
var explosion: Explosion = EXPLOSION_SCENE.instantiate()
explosion.radius = 12.0
explosion.damage = 500.0
explosion.position = player.global_position
get_tree().current_scene.add_child(explosion)
The "pickups" group that Pickup joins back in Phase 3 is what makes the vacuum a few lines long. The vacuum only pulls XP and gold, so it doesn’t drag Access Cards (which you’re meant to walk to) or other heal packs.
Augments are rewards from special events. Each one changes the rules for the rest of the run, usually with a trade-off:
Some are pure stat changes; others add a behaviour.
StatBonus resource (one stat and one amount; the amount can be negative).AugmentData holds a list of StatBonuses and, optionally, an effect_scene for augments that need behaviour (built like an Ability from Phase 7).recalculate_stats() applies each augment as one source.AUGMENT.Why does multiplicative stacking help here? A “-30%” downside becomes × 0.7. It can never push a stat below zero, no matter what else you have. With additive stacking, enough downsides could make damage negative.
File: res://scripts/resources/stat_bonus.gd
class_name StatBonus
extends Resource
@export var stat: PassiveData.Stat = PassiveData.Stat.DAMAGE_MULT
# Same fraction/plain-number rule as passives. Negative values are downsides.
@export var amount: float = 0.0
File: res://scripts/resources/augment_data.gd
class_name AugmentData
extends GameData
@export_multiline var description: String = ""
@export var bonuses: Array[StatBonus] = []
# Optional: a node (like an Ability) for augments that do more than change stats.
@export var effect_scene: PackedScene
In Player:
var augments: Array[AugmentData] = []
func add_augment(data: AugmentData) -> void:
augments.append(data)
if data.effect_scene:
_spawn_ability(data.effect_scene)
recalculate_stats()
loadout_changed.emit()
and in recalculate_stats(), next to the passives loop:
for augment: AugmentData in augments:
for bonus: StatBonus in augment.bonuses:
_apply_bonus(bonus.stat, bonus.amount)
Strictly, each augment should be one source, so two percentage bonuses for the same stat within one augment should add before multiplying. That only matters if a single augment lists the same stat twice, so this simpler loop is fine in practice.
Glass Cannon’s “-30% max health” needs a percentage max-health stat, but MAX_HP is flat. Add a new stat:
MAX_HP_MULT to the end of PassiveData.Stat, to STAT_NAMES, and to is_percent().var max_hp_mult: float, reset it to 1.0 in recalculate_stats(), and multiply it in _apply_bonus().recalculate_stats() (before the current_hp line), apply it once: max_hp *= max_hp_mult.Why at the end? Order matters when flat and percentage bonuses touch the same stat. “+150, then ×0.7” gives a different answer from “×0.7, then +150”. Collecting the multiplier separately and applying it once after all the flat bonuses gives the same result no matter what order your sources are in.
AUGMENT to LevelUpScreen.Reason and UpgradeOffer.Kind.UpgradeOffers.build_augments(player), which picks 3 random AugmentData from an Upgrades.augments list that the player doesn’t already have.UpgradeOffer.apply() calls player.add_augment(augment_data).game_state.augment_offered signal, emitted by a map event (9.3).The level-up screen needs no layout changes; it already handles any list of offers.
At random times, a spire appears somewhere on the map. Standing in its circle captures it; more players capture faster. Depending on the spire you get:
You already built these in Phase 7. A boon is just a bigger, shorter one applied to every player:
func _grant_boon() -> void:
for node: Node in get_tree().get_nodes_in_group("players"):
var player: Player = node as Player
player.add_timed_bonus(PassiveData.Stat.CDR, 200.0, 15.0)
player.add_timed_bonus(PassiveData.Stat.AREA_SIZE_MULT, 1.0, 15.0)
Swarm also has a permanent upgrade for boon duration. If you want one, add a BOON_DURATION_MULT stat the same way as MAX_HP_MULT above and multiply the 15.0 by it.
File: res://scripts/props/capture_zone.gd
class_name CaptureZone
extends Area3D
signal captured
@export var capture_time: float = 5.0
# Disappears if nobody captures it in time.
@export var lifetime: float = 45.0
@onready var progress_label: Label3D = $ProgressLabel
var _progress: float = 0.0
func _physics_process(delta: float) -> void:
lifetime -= delta
if lifetime <= 0.0:
queue_free()
return
var players_inside: int = 0
for body: Node3D in get_overlapping_bodies():
if body is Player:
players_inside += 1
_progress += delta * players_inside
progress_label.text = "%d%%" % roundi(_progress / capture_time * 100.0)
if _progress >= capture_time:
captured.emit()
queue_free()
Scene: an Area3D with a large cylinder shape (radius about 3), mask 2 (player), a flat ring mesh (like the Pulse Aura ring), and a billboard Label3D named ProgressLabel.
How it works:
get_overlapping_bodies() asks the area “who’s inside me right now?”. Because the mask is only layer 2, it only ever sees players.delta × players inside, so two players capture twice as fast, as in Swarm.captured and decides the reward.A small EventSpawner node in the level, similar to EnemySpawner:
extends Node
const ZONE_SCENE: PackedScene = preload("res://scenes/props/capture_zone.tscn")
@export var min_gap: float = 60.0
@export var max_gap: float = 120.0
@export var spawn_distance: float = 20.0
var _next_event_time: float = 0.0
func _ready() -> void:
_next_event_time = randf_range(min_gap, max_gap)
func _physics_process(_delta: float) -> void:
if game_state.run_time < _next_event_time:
return
_next_event_time = game_state.run_time + randf_range(min_gap, max_gap)
var player: Player = Targeting.nearest_player(get_tree(), Vector3.ZERO)
if player == null:
return
var angle: float = randf() * TAU
var zone: CaptureZone = ZONE_SCENE.instantiate()
zone.position = player.global_position + Vector3(cos(angle), 0.0, sin(angle)) * spawn_distance
zone.captured.connect(_on_zone_captured)
get_tree().current_scene.add_child(zone)
func _on_zone_captured() -> void:
match randi_range(0, 1):
0:
_grant_boon()
1:
game_state.augment_offered.emit()
(Put _grant_boon() from above in the same script.)
Spawning the zone 20 m from the player means it’s on screen but not under your feet. You have to choose to go for it, which is the interesting decision.
Each Swarm map has one big interactive structure. All of them build on things you now have:
| Idea | Built from |
|---|---|
| Healing fountain: stand on it to charge, heals everyone 50%, long recharge | A CaptureZone that doesn’t free itself; it disables for a cooldown instead |
| Ion cannon: carry fuel cells to it, then it fires lasers for 30 s | A Pickup the player carries, a zone to drop it in, then a timer that spawns Explosions at random enemies |
| Freeze bomb: stand on it to stun every enemy nearby | Targeting.enemies_in_radius() plus a stun(seconds) function on Enemy that zeroes move while a timer runs |
| Rising flood during the boss | An Area3D that slowly moves across the map and calls take_damage() on players inside it |
That’s the full Swarm loop. From here it’s content and tuning: more characters, weapons, enemies, maps and events, all using the systems you’ve built.