GameGuide

Phase 9: Extras (outline)

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


9.1 Breakable pods

How Swarm does it

Pods are scattered around the map. Shoot one and it breaks, dropping one random thing:

The one change to existing code: “anything damageable”

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) use Targeting.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.

The pod

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.

The drops

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.


9.2 Augments

How Swarm does it

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.

Approach

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.

Key code

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.

One catch: percentage max health

Glass Cannon’s “-30% max health” needs a percentage max-health stat, but MAX_HP is flat. Add a new stat:

  1. Add MAX_HP_MULT to the end of PassiveData.Stat, to STAT_NAMES, and to is_percent().
  2. Add var max_hp_mult: float, reset it to 1.0 in recalculate_stats(), and multiply it in _apply_bonus().
  3. At the end of the bonus loops in 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.

Offering augments

The level-up screen needs no layout changes; it already handles any list of offers.


9.3 Boons and map events

How Swarm does it

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:

Boons are timed bonuses

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.

The capture zone

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:

Scheduling events

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.

Megastructures

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

Test it

  1. Shoot a pod: it breaks and drops one of the four pickups. The vacuum pulls in every orb on the map; the sea mine clears the screen.
  2. Grab an augment like Glass Cannon. Max health on the HUD drops by 30% and enemies die faster.
  3. Wait for a capture zone, then stand in it. The percentage climbs, and on capture you get a boon (everything fires much faster for 15 s) or an augment pick.

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.