Goal: once a weapon is at max level and you own its matching passive, an Access Card pick can offer to evolve it into a stronger version with new behaviour. Holding Tab shows which passive evolves which weapon.
Access Cards themselves are dropped by elites in Phase 6. In this phase a debug key (F1) pretends you picked one up, so you can build and test evolutions first.
Checklist
WeaponDataThese come straight from Swarm:
The evolution passive is often not an obvious damage stat. Swarm pairs weapons with things like Move Speed or Health Regen, which pushes you to take passives you’d otherwise skip. That’s a big part of what makes builds interesting.
WeaponDataEach weapon resource gets an Evolution section: which passive unlocks it, the evolved name, description and icon, and a separate set of stats for the evolved version.
An evolution isn’t more of the same; it’s a transformation. Giving it its own name, icon and stats makes it feel like a reward, and it means the normal level-up offers never accidentally include it. Only the Access Card rule (5.3) does.
File: res://scripts/resources/weapon_data.gd
Find this:
@export var scales_with: Array[PassiveData.Stat] = []
Replace with:
@export var scales_with: Array[PassiveData.Stat] = []
@export_group("Evolution")
# Own at least one level of this passive (and max the weapon) to unlock the evolution.
@export var evolution_passive: PassiveData
@export var evolved_name: String = ""
@export_multiline var evolved_description: String = ""
@export var evolved_icon: Texture2D
@export var evolved_stats: WeaponLevel
@export_group("Evolution") folds everything below it into a collapsible Evolution section in the Inspector.
Why a PassiveData reference instead of an id string? You can drag the passive resource straight in, so there’s no way to make a typo. When we compare, we still compare ids (the same way get_passive() does), so two copies of the same resource still match.
evolved: whether this weapon has evolved.can_evolve(): checks rules 1 and 2.evolve(): flips the flag and swaps to the evolved stats.The weapon already knows its level and its data, and it has a reference to its player (to check the passive). Putting the check here means anything (the offer rules, the overlay, a future HUD glow) can just ask weapon.can_evolve().
File: res://scripts/characters/player/weapons/weapon.gd
Find this:
var player: Player
Replace with:
var player: Player
var evolved: bool = false
Find this:
func get_display_name() -> String:
return data.display_name
func get_icon() -> Texture2D:
return data.icon
Replace with:
func get_display_name() -> String:
if evolved and data.evolved_name != "":
return data.evolved_name
return data.display_name
func get_icon() -> Texture2D:
if evolved and data.evolved_icon:
return data.evolved_icon
return data.icon
func can_evolve() -> bool:
if evolved or data.evolution_passive == null or data.evolved_stats == null:
return false
return is_max_level() and player.get_passive(data.evolution_passive) != null
func evolve() -> void:
evolved = true
stats = data.evolved_stats
This is why get_display_name() and get_icon() were added back in Phase 1: the HUD and offers already use them, so they show the evolved name and icon automatically.
game_state for “an Access Card was collected”.EVOLUTION.UpgradeOffers.build_access_card(): builds a single choice, which may be an evolution.ACCESS_CARD.An Access Card pick looks and behaves almost exactly like a level-up: pause, show choices, apply one, unpause. The only differences are the title and how the choices are built. That’s exactly why Phase 3 put a reason in the queue instead of a plain counter.
File: res://scripts/autoload/game_state.gd
Find this:
signal gold_changed(total: int)
Replace with:
signal gold_changed(total: int)
signal access_card_collected
File: res://scripts/systems/upgrade_offer.gd
Find this:
enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD, NEW_WEAPON }
Replace with:
enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD, NEW_WEAPON, EVOLUTION }
Find this in apply():
Kind.NEW_WEAPON:
player.add_weapon(weapon_data)
Replace with:
Kind.NEW_WEAPON:
player.add_weapon(weapon_data)
Kind.EVOLUTION:
player.evolve_weapon(weapon)
File: res://scripts/characters/player/player.gd
Add this function below level_up_weapon():
func evolve_weapon(weapon: Weapon) -> void:
weapon.evolve()
loadout_changed.emit()
The player wraps weapon.evolve() so it can announce loadout_changed. The HUD only listens to the player, not to each weapon.
File: res://scripts/systems/upgrade_offers.gd
Find this:
class_name UpgradeOffers
extends RefCounted
Replace with:
class_name UpgradeOffers
extends RefCounted
# Chance an Access Card offers an evolution when one is available. 1.0 = always.
const EVOLUTION_CHANCE: float = 0.5
static func build_access_card(player: Player) -> Array[UpgradeOffer]:
var evolutions: Array[UpgradeOffer] = []
for weapon: Weapon in player.weapons:
if weapon.can_evolve():
evolutions.append(_evolution_offer(weapon))
if not evolutions.is_empty() and randf() < EVOLUTION_CHANCE:
evolutions.shuffle()
evolutions.resize(1)
return evolutions
return build_level_up(player, 1)
static func _evolution_offer(weapon: Weapon) -> UpgradeOffer:
var offer: UpgradeOffer = UpgradeOffer.new()
offer.kind = UpgradeOffer.Kind.EVOLUTION
offer.weapon = weapon
offer.title = "EVOLVE %s > %s" % [weapon.data.display_name, weapon.data.evolved_name]
offer.description = weapon.data.evolved_description
offer.icon = weapon.data.evolved_icon if weapon.data.evolved_icon else weapon.data.icon
return offer
can_evolve().randf() is a random number from 0 to 1, so < 0.5 succeeds half the time. On success, offer one evolution, picked at random if several are ready.build_level_up(player, 1)). If nothing at all is left, that function already returns the heal and gold fallbacks, which matches Swarm’s Access Card behaviour.Why a chance and not a guarantee? It’s Swarm’s design: Access Cards are exciting because they might be the one. If you find it frustrating, set EVOLUTION_CHANCE to 1.0.
File: res://scripts/ui/level_up_screen.gd
Find this:
enum Reason { LEVEL_UP }
Replace with:
enum Reason { LEVEL_UP, ACCESS_CARD }
Find this:
func _ready() -> void:
visible = false
game_state.leveled_up.connect(_on_leveled_up)
func _on_leveled_up(_new_level: int) -> void:
_enqueue(Reason.LEVEL_UP)
Replace with:
func _ready() -> void:
visible = false
game_state.leveled_up.connect(_on_leveled_up)
game_state.access_card_collected.connect(_on_access_card_collected)
func _on_leveled_up(_new_level: int) -> void:
_enqueue(Reason.LEVEL_UP)
func _on_access_card_collected() -> void:
_enqueue(Reason.ACCESS_CARD)
Find this in _show_next():
match reason:
Reason.LEVEL_UP:
title_label.text = "Level Up!"
offers = UpgradeOffers.build_level_up(player)
Replace with:
match reason:
Reason.LEVEL_UP:
title_label.text = "Level Up!"
offers = UpgradeOffers.build_level_up(player)
Reason.ACCESS_CARD:
title_label.text = "Access Card!"
offers = UpgradeOffers.build_access_card(player)
Nothing else in the screen changes: it already handles any number of choices.
File: res://scripts/systems/main.gd
Find this:
match key.keycode:
KEY_F2:
game_state.add_xp(game_state.xp_to_next - game_state.xp)
Replace with:
match key.keycode:
KEY_F1:
game_state.access_card_collected.emit()
KEY_F2:
game_state.add_xp(game_state.xp_to_next - game_state.xp)
Bigger numbers are fine, but they don’t feel like a new weapon. Swarm’s evolutions change something you can see: pierce everything, orbit forever, fire twice. Even a small rule change makes the evolution moment memorable.
The pattern is the same for every weapon: inside fire() (or wherever it matters), check if evolved: and do something different.
In the Godot editor: open weapon_gunner_default.tres and expand Evolution:
| Field | Value |
|---|---|
| Evolution Passive | res://resources/passives/cdr.tres |
| Evolved Name | Bullet Storm |
| Evolved Description | Fires twice as fast. Bullets pierce every enemy. |
| Evolved Icon | (leave empty for now, or a different placeholder) |
| Evolved Stats | New WeaponLevel: Damage 10, Cooldown 0.5, Projectiles 7, Pierce 0 |
Pierce is left at 0 in the stats because the code below overrides it for the evolved version.
File: res://scripts/characters/player/weapons/machine_gun.gd
Replace the whole file with:
extends Weapon
const BULLET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/bullet.tscn")
const BULLET_SPEED: float = 30.0
const BULLET_LIFETIME: float = 1.0
# Gap between neighbouring bullets in the fan.
const SPREAD_DEGREES: float = 6.0
# Effectively "pierce everything".
const EVOLVED_PIERCE: int = 999
const EVOLVED_BULLET_SCALE: float = 1.5
func fire() -> bool:
var count: int = get_projectile_count()
var start_angle: float = -SPREAD_DEGREES * (count - 1) / 2.0
for i: int in count:
var angle: float = deg_to_rad(start_angle + SPREAD_DEGREES * i)
var direction: Vector3 = player.aim_direction.rotated(Vector3.UP, angle)
var bullet: Projectile = spawn_projectile(BULLET_SCENE, direction, BULLET_SPEED, BULLET_LIFETIME)
if evolved:
bullet.pierce = EVOLVED_PIERCE
bullet.scale = Vector3.ONE * EVOLVED_BULLET_SCALE
return true
This is why spawn_projectile() returns the projectile: the evolved version can tweak a bullet after the standard setup.
Fill in each weapon resource’s Evolution section like this:
| Weapon | Evolution Passive | Evolved Name | Evolved Description |
|---|---|---|---|
| Seeker Shot | projectile.tres |
Hunter Swarm | Fires two waves of seekers at twice as many targets. |
| Orbiting Blades | duration.tres |
Endless Cyclone | Blades orbit forever. |
| Pulse Aura | max_hp.tres |
Heartbeat | Each pulse also deals 2% of your max health as damage. |
For Evolved Stats, copy each weapon’s level 5 numbers into a New WeaponLevel.
Hunter Swarm (Seeker Shot). File: res://scripts/characters/player/weapons/seeker_shot.gd
Find this:
var targets: Array[Enemy] = Targeting.nearest_enemies(get_tree(), global_position, get_projectile_count(), RANGE)
Replace with:
var target_count: int = get_projectile_count() * (2 if evolved else 1)
var targets: Array[Enemy] = Targeting.nearest_enemies(get_tree(), global_position, target_count, RANGE)
Endless Cyclone (Orbiting Blades). File: res://scripts/characters/player/weapons/orbiting_blades.gd
Find this:
if _time_left > 0.0:
_time_left -= delta
if _time_left <= 0.0:
_clear_blades()
Replace with:
if _time_left > 0.0 and not evolved:
_time_left -= delta
if _time_left <= 0.0:
_clear_blades()
Find this:
func get_cooldown() -> float:
return super() + get_duration()
Replace with:
func get_cooldown() -> float:
if evolved:
return super()
return super() + get_duration()
When evolved, blades are never removed by the timer. fire() still runs every cooldown, and since it starts with _clear_blades() and makes a fresh set, the blades always use your latest damage, count and area. The swap happens within a single frame, so you never see a gap.
Heartbeat (Pulse Aura). File: res://scripts/characters/player/weapons/pulse_aura.gd
Find this:
const FLASH_TIME: float = 0.15
Replace with:
const FLASH_TIME: float = 0.15
const EVOLVED_MAX_HP_DAMAGE: float = 0.02
Find this:
var radius: float = BASE_RADIUS * get_area_scale()
for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
enemy.take_damage(Weapon.roll_crit(get_damage(), player.crit_chance))
Replace with:
var radius: float = BASE_RADIUS * get_area_scale()
var damage: float = get_damage()
if evolved:
damage += player.max_hp * EVOLVED_MAX_HP_DAMAGE
for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
enemy.take_damage(Weapon.roll_crit(damage, player.crit_chance))
Heartbeat shows how a “non-combat” evolution passive (Max Health) can feed back into damage, the kind of build synergy Swarm is built around.
File: res://scripts/ui/hud.gd
Find this:
weapon_icons.add_child(_make_slot(weapon.get_icon(), "Lv %d" % weapon.level, tooltip))
Replace with:
var caption: String = "EVO" if weapon.evolved else ("Lv %d" % weapon.level)
weapon_icons.add_child(_make_slot(weapon.get_icon(), caption, tooltip))
Hold Tab to see every weapon’s evolution recipe and how close you are:
EVOLUTIONS
Machine Gun + Cooldown > Bullet Storm
Lv 5/5, passive owned - READY (collect an Access Card)
Seeker Shot + Projectiles > Hunter Swarm
not owned
Without this you’d have to remember every pairing. Swarm’s Tab screen does the same thing, and it’s what lets players plan builds instead of guessing.
Project > Project Settings > Input Map: add an action named show_build and bind it to Tab.
Godot also uses Tab to move keyboard focus between buttons. While the level-up screen is open, Tab will do both. That’s harmless, and handy, because you can check your recipes while choosing.
File: res://scripts/ui/build_overlay.gd
class_name BuildOverlay
extends CanvasLayer
@onready var label: Label = $Panel/Margin/Label
var player: Player
func _ready() -> void:
visible = false
func _process(_delta: float) -> void:
var held: bool = Input.is_action_pressed("show_build")
if held and not visible:
label.text = _build_text()
visible = held
func _build_text() -> String:
var lines: PackedStringArray = ["EVOLUTIONS", ""]
for data: WeaponData in _all_weapons():
if data.evolution_passive == null:
continue
lines.append("%s + %s > %s" % [data.display_name, data.evolution_passive.display_name, data.evolved_name])
lines.append(" " + _status(data))
return "\n".join(lines)
# Owned weapons first, then everything else in the pool.
func _all_weapons() -> Array[WeaponData]:
var result: Array[WeaponData] = []
for weapon: Weapon in player.weapons:
result.append(weapon.data)
for data: WeaponData in Upgrades.weapons:
if not result.has(data):
result.append(data)
return result
func _status(data: WeaponData) -> String:
var weapon: Weapon = player.get_weapon(data)
if weapon == null:
return "not owned"
if weapon.evolved:
return "EVOLVED"
var level_part: String = "Lv %d/%d" % [weapon.level, data.max_level()]
var has_passive: bool = player.get_passive(data.evolution_passive) != null
var passive_part: String = "passive owned" if has_passive else "passive missing"
if weapon.can_evolve():
return "%s, %s - READY (collect an Access Card)" % [level_part, passive_part]
return "%s, %s" % [level_part, passive_part]
Notes:
held and not visible), not every frame while it’s held.Input.is_action_pressed() (rather than an input event) works no matter what has focus, and keeps working while paused because this overlay uses Process Mode: Always.In the Godot editor:
BuildOverlay (CanvasLayer) script: build_overlay.gd, Layer 15, Process Mode Always
└─ Panel (PanelContainer) Anchors Preset: Center Left; Position x 16
└─ Margin (MarginContainer) all margins 16
└─ Label (Label)
Save as res://scenes/ui/build_overlay.tscn.
main.gdFile: res://scripts/systems/main.gd
Find this:
const RUN_END_SCENE: PackedScene = preload("res://scenes/ui/run_end_screen.tscn")
Replace with:
const RUN_END_SCENE: PackedScene = preload("res://scenes/ui/run_end_screen.tscn")
const BUILD_OVERLAY_SCENE: PackedScene = preload("res://scenes/ui/build_overlay.tscn")
Find this:
run_end_screen = RUN_END_SCENE.instantiate()
add_child(run_end_screen)
Replace with:
var build_overlay: BuildOverlay = BUILD_OVERLAY_SCENE.instantiate()
build_overlay.player = player
add_child(build_overlay)
run_end_screen = RUN_END_SCENE.instantiate()
add_child(run_end_screen)
Lv 1/5, passive missing.If something breaks:
| Problem | Likely cause |
|---|---|
| Never offered, even when READY | EVOLUTION_CHANCE is very low, or game_state.access_card_collected isn’t connected in the level-up screen. |
| Tab shows nothing | show_build isn’t in the Input Map, or BuildOverlay isn’t Process Mode: Always (it won’t work while paused). |
Invalid access to property 'cooldown' after evolving |
Evolved Stats is empty in the weapon resource. can_evolve() should prevent this; check it includes the evolved_stats == null test. |
| Endless Cyclone blades still disappear | The and not evolved check is missing in _physics_process(). |
File: res://scripts/resources/weapon_data.gd
class_name WeaponData
extends GameData
@export var weapon_scene: PackedScene
# One entry per level, so entry 0 is level 1.
@export var levels: Array[WeaponLevel] = []
# For tooltips only; keep in sync with which get_...() functions the weapon script calls.
@export var scales_with: Array[PassiveData.Stat] = []
@export_group("Evolution")
# Own at least one level of this passive (and max the weapon) to unlock the evolution.
@export var evolution_passive: PassiveData
@export var evolved_name: String = ""
@export_multiline var evolved_description: String = ""
@export var evolved_icon: Texture2D
@export var evolved_stats: WeaponLevel
func max_level() -> int:
return levels.size()
func level_stats(level: int) -> WeaponLevel:
return levels[clampi(level - 1, 0, levels.size() - 1)]
func scaling_text() -> String:
if scales_with.is_empty():
return ""
var names: PackedStringArray = []
for stat: PassiveData.Stat in scales_with:
names.append(PassiveData.STAT_NAMES[stat])
return "Scales with: " + ", ".join(names)
File: res://scripts/characters/player/weapons/weapon.gd
class_name Weapon
extends Node3D
const CRIT_MULTIPLIER: float = 2.0
# How far in front of the player projectiles appear.
const MUZZLE_OFFSET: float = 0.8
var data: WeaponData
var level: int = 1
var stats: WeaponLevel
var player: Player
var evolved: bool = false
var _cooldown_left: float = 0.0
# Called by the player before the weapon is added to the scene tree.
func setup(owning_player: Player, weapon_data: WeaponData) -> void:
player = owning_player
data = weapon_data
set_level(1)
func set_level(new_level: int) -> void:
level = new_level
stats = data.level_stats(level)
func is_max_level() -> bool:
return level >= data.max_level()
func get_display_name() -> String:
if evolved and data.evolved_name != "":
return data.evolved_name
return data.display_name
func get_icon() -> Texture2D:
if evolved and data.evolved_icon:
return data.evolved_icon
return data.icon
func can_evolve() -> bool:
if evolved or data.evolution_passive == null or data.evolved_stats == null:
return false
return is_max_level() and player.get_passive(data.evolution_passive) != null
func evolve() -> void:
evolved = true
stats = data.evolved_stats
func _physics_process(delta: float) -> void:
_cooldown_left -= delta
if _cooldown_left > 0.0:
return
if fire():
_cooldown_left = get_cooldown()
# Each weapon overrides this. Return false if there was nothing to fire at,
# so the weapon tries again next frame instead of waiting a full cooldown.
func fire() -> bool:
return false
# --- Stats after the player's bonuses ---
func get_cooldown() -> float:
return stats.cooldown / (1.0 + player.cdr / 100.0)
func get_damage() -> float:
return stats.damage * player.damage_mult
func get_projectile_count() -> int:
return maxi(1, stats.projectiles + player.projectile)
func get_area_scale() -> float:
return stats.area_scale * player.area_size_mult
func get_duration() -> float:
return stats.duration * player.duration_mult
# --- Helpers ---
func spawn_projectile(scene: PackedScene, direction: Vector3, speed: float, lifetime: float) -> Projectile:
var projectile: Projectile = scene.instantiate()
projectile.direction = direction.normalized()
projectile.speed = speed
projectile.lifetime = lifetime
projectile.damage = get_damage()
projectile.crit_chance = player.crit_chance
projectile.pierce = stats.pierce
get_tree().current_scene.add_child(projectile)
projectile.global_position = global_position + projectile.direction * MUZZLE_OFFSET
return projectile
static func roll_crit(damage: float, crit_chance: float) -> float:
if randf() * 100.0 < crit_chance:
return damage * CRIT_MULTIPLIER
return damage
File: res://scripts/systems/upgrade_offer.gd
class_name UpgradeOffer
extends RefCounted
enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD, NEW_WEAPON, EVOLUTION }
const HEAL_FRACTION: float = 0.25
const FALLBACK_GOLD: int = 25
var kind: Kind = Kind.HEAL
var title: String = ""
var description: String = ""
var icon: Texture2D
var passive_data: PassiveData
var weapon: Weapon
var weapon_data: WeaponData
func apply(player: Player) -> void:
match kind:
Kind.NEW_PASSIVE, Kind.PASSIVE_LEVEL:
player.add_passive(passive_data)
Kind.WEAPON_LEVEL:
player.level_up_weapon(weapon)
Kind.HEAL:
player.heal(player.max_hp * HEAL_FRACTION)
Kind.GOLD:
game_state.add_gold(FALLBACK_GOLD)
Kind.NEW_WEAPON:
player.add_weapon(weapon_data)
Kind.EVOLUTION:
player.evolve_weapon(weapon)
File: res://scripts/systems/upgrade_offers.gd
class_name UpgradeOffers
extends RefCounted
# Chance an Access Card offers an evolution when one is available. 1.0 = always.
const EVOLUTION_CHANCE: float = 0.5
static func build_access_card(player: Player) -> Array[UpgradeOffer]:
var evolutions: Array[UpgradeOffer] = []
for weapon: Weapon in player.weapons:
if weapon.can_evolve():
evolutions.append(_evolution_offer(weapon))
if not evolutions.is_empty() and randf() < EVOLUTION_CHANCE:
evolutions.shuffle()
evolutions.resize(1)
return evolutions
return build_level_up(player, 1)
static func _evolution_offer(weapon: Weapon) -> UpgradeOffer:
var offer: UpgradeOffer = UpgradeOffer.new()
offer.kind = UpgradeOffer.Kind.EVOLUTION
offer.weapon = weapon
offer.title = "EVOLVE %s > %s" % [weapon.data.display_name, weapon.data.evolved_name]
offer.description = weapon.data.evolved_description
offer.icon = weapon.data.evolved_icon if weapon.data.evolved_icon else weapon.data.icon
return offer
static func build_level_up(player: Player, count: int = 3) -> Array[UpgradeOffer]:
var candidates: Array[UpgradeOffer] = []
_add_weapon_offers(player, candidates)
_add_passive_offers(player, candidates)
if candidates.is_empty():
return _fallback_offers()
candidates.shuffle()
if candidates.size() > count:
candidates.resize(count)
return candidates
static func _add_weapon_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
for weapon: Weapon in player.weapons:
if weapon.is_max_level():
continue
var offer: UpgradeOffer = UpgradeOffer.new()
offer.kind = UpgradeOffer.Kind.WEAPON_LEVEL
offer.weapon = weapon
offer.title = "%s Lv %d" % [weapon.get_display_name(), weapon.level + 1]
offer.description = weapon.data.level_stats(weapon.level + 1).description
offer.icon = weapon.get_icon()
candidates.append(offer)
if player.weapons.size() >= Player.MAX_WEAPON_SLOTS:
return
for data: WeaponData in Upgrades.weapons:
if player.get_weapon(data) != null:
continue
var offer: UpgradeOffer = UpgradeOffer.new()
offer.kind = UpgradeOffer.Kind.NEW_WEAPON
offer.weapon_data = data
offer.title = "NEW %s" % data.display_name
offer.description = "%s\n%s" % [data.level_stats(1).description, data.scaling_text()]
offer.icon = data.icon
candidates.append(offer)
static func _add_passive_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
for passive: OwnedPassive in player.passives:
if passive.level < Player.MAX_PASSIVE_LEVEL:
candidates.append(_passive_offer(passive.data, passive.level + 1))
if player.passives.size() >= player.passive_slots:
return
for data: PassiveData in Upgrades.passives:
if player.get_passive(data) == null:
candidates.append(_passive_offer(data, 1))
static func _passive_offer(data: PassiveData, level: int) -> UpgradeOffer:
var offer: UpgradeOffer = UpgradeOffer.new()
offer.passive_data = data
offer.description = data.describe_level(level)
offer.icon = data.icon
if level == 1:
offer.kind = UpgradeOffer.Kind.NEW_PASSIVE
offer.title = "NEW %s" % data.display_name
else:
offer.kind = UpgradeOffer.Kind.PASSIVE_LEVEL
offer.title = "%s Lv %d" % [data.display_name, level]
return offer
static func _fallback_offers() -> Array[UpgradeOffer]:
var heal: UpgradeOffer = UpgradeOffer.new()
heal.kind = UpgradeOffer.Kind.HEAL
heal.title = "Patch Up"
heal.description = "Restore %d%% of your max health." % roundi(UpgradeOffer.HEAL_FRACTION * 100.0)
var gold: UpgradeOffer = UpgradeOffer.new()
gold.kind = UpgradeOffer.Kind.GOLD
gold.title = "Pocket Change"
gold.description = "Gain %d gold." % UpgradeOffer.FALLBACK_GOLD
var offers: Array[UpgradeOffer] = [heal, gold]
return offers
File: res://scripts/autoload/game_state.gd
extends Node
# --- Game state tracker ---
# This file will be used to keep track of the progress of any given round.
# It should not include any locked in variables on its own, but should be
# used to keep track of per-round data, like current XP, phase of the round,
# tally enemy kills.
signal xp_changed(current: float, required: float)
signal leveled_up(new_level: int)
signal gold_changed(total: int)
signal access_card_collected
const MAX_LEVEL: int = 99
var selected_character: CharacterData
var selected_level: PackedScene
var passive_slots: int = 3
# --- Per-run tracking ---
var run_time: float = 0.0
var level: int = 1
var xp: float = 0.0
var xp_to_next: float = 5.0
var gold: int = 0
func start_run(character: CharacterData, level_scene: PackedScene) -> void:
selected_character = character
selected_level = level_scene
passive_slots = Profile.unlocked_passive_slots
reset_run()
func reset_run() -> void:
run_time = 0.0
level = 1
xp = 0.0
xp_to_next = xp_required(1)
gold = 0
# XP needed to go from this level to the next. Tune freely.
func xp_required(from_level: int) -> float:
return 5.0 + 10.0 * (from_level - 1)
func add_xp(amount: float) -> void:
if level >= MAX_LEVEL:
return
xp += amount
while xp >= xp_to_next and level < MAX_LEVEL:
xp -= xp_to_next
level += 1
xp_to_next = xp_required(level)
leveled_up.emit(level)
xp_changed.emit(xp, xp_to_next)
func add_gold(amount: int) -> void:
gold += amount
gold_changed.emit(gold)
File: res://scripts/ui/level_up_screen.gd
class_name LevelUpScreen
extends CanvasLayer
enum Reason { LEVEL_UP, ACCESS_CARD }
const CHOICE_SIZE: Vector2 = Vector2(560, 96)
const ICON_SIZE: int = 64
@onready var title_label: Label = $Center/Panel/Margin/VBox/Title
@onready var choices_box: VBoxContainer = $Center/Panel/Margin/VBox/Choices
var player: Player
# Holds Reason values, one per pending pick.
var _queue: Array[int] = []
func _ready() -> void:
visible = false
game_state.leveled_up.connect(_on_leveled_up)
game_state.access_card_collected.connect(_on_access_card_collected)
func _on_leveled_up(_new_level: int) -> void:
_enqueue(Reason.LEVEL_UP)
func _on_access_card_collected() -> void:
_enqueue(Reason.ACCESS_CARD)
func _enqueue(reason: Reason) -> void:
_queue.append(reason)
if not visible:
_show_next()
func _show_next() -> void:
if _queue.is_empty():
visible = false
get_tree().paused = false
return
var reason: int = _queue.pop_front()
var offers: Array[UpgradeOffer] = []
match reason:
Reason.LEVEL_UP:
title_label.text = "Level Up!"
offers = UpgradeOffers.build_level_up(player)
Reason.ACCESS_CARD:
title_label.text = "Access Card!"
offers = UpgradeOffers.build_access_card(player)
_show_offers(offers)
visible = true
get_tree().paused = true
func _show_offers(offers: Array[UpgradeOffer]) -> void:
for child: Node in choices_box.get_children():
child.queue_free()
var first_button: Button = null
for offer: UpgradeOffer in offers:
var button: Button = Button.new()
button.text = "%s\n%s" % [offer.title, offer.description]
button.icon = offer.icon
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.custom_minimum_size = CHOICE_SIZE
button.add_theme_constant_override("icon_max_width", ICON_SIZE)
button.pressed.connect(_on_offer_chosen.bind(offer))
choices_box.add_child(button)
if first_button == null:
first_button = button
if first_button:
first_button.grab_focus()
func _on_offer_chosen(offer: UpgradeOffer) -> void:
offer.apply(player)
_show_next()
File: res://scripts/characters/player/weapons/machine_gun.gd
extends Weapon
const BULLET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/bullet.tscn")
const BULLET_SPEED: float = 30.0
const BULLET_LIFETIME: float = 1.0
# Gap between neighbouring bullets in the fan.
const SPREAD_DEGREES: float = 6.0
# Effectively "pierce everything".
const EVOLVED_PIERCE: int = 999
const EVOLVED_BULLET_SCALE: float = 1.5
func fire() -> bool:
var count: int = get_projectile_count()
var start_angle: float = -SPREAD_DEGREES * (count - 1) / 2.0
for i: int in count:
var angle: float = deg_to_rad(start_angle + SPREAD_DEGREES * i)
var direction: Vector3 = player.aim_direction.rotated(Vector3.UP, angle)
var bullet: Projectile = spawn_projectile(BULLET_SCENE, direction, BULLET_SPEED, BULLET_LIFETIME)
if evolved:
bullet.pierce = EVOLVED_PIERCE
bullet.scale = Vector3.ONE * EVOLVED_BULLET_SCALE
return true
File: res://scripts/characters/player/weapons/seeker_shot.gd
extends Weapon
const BULLET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/bullet.tscn")
const RANGE: float = 20.0
const BULLET_SPEED: float = 25.0
const BULLET_LIFETIME: float = 1.2
func fire() -> bool:
var target_count: int = get_projectile_count() * (2 if evolved else 1)
var targets: Array[Enemy] = Targeting.nearest_enemies(get_tree(), global_position, target_count, RANGE)
if targets.is_empty():
return false
for target: Enemy in targets:
var direction: Vector3 = target.global_position - global_position
direction.y = 0.0
spawn_projectile(BULLET_SCENE, direction, BULLET_SPEED, BULLET_LIFETIME)
return true
File: res://scripts/characters/player/weapons/orbiting_blades.gd
extends Weapon
const BLADE_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/orbit_blade.tscn")
const BASE_RADIUS: float = 3.0
# Radians per second; TAU would be one full turn per second.
const SPIN_SPEED: float = 3.0
const KNOCKBACK: float = 6.0
var _pivot: Node3D
var _time_left: float = 0.0
func _ready() -> void:
_pivot = Node3D.new()
add_child(_pivot)
func _physics_process(delta: float) -> void:
super(delta)
_pivot.rotate_y(SPIN_SPEED * delta)
if _time_left > 0.0 and not evolved:
_time_left -= delta
if _time_left <= 0.0:
_clear_blades()
func fire() -> bool:
_clear_blades()
var count: int = get_projectile_count()
var radius: float = BASE_RADIUS * get_area_scale()
for i: int in count:
var blade: OrbitBlade = BLADE_SCENE.instantiate()
blade.damage = get_damage()
blade.crit_chance = player.crit_chance
blade.knockback = KNOCKBACK
var angle: float = TAU * i / count
blade.position = Vector3(cos(angle), 0.0, sin(angle)) * radius
blade.rotation.y = -angle
blade.scale = Vector3.ONE * get_area_scale()
_pivot.add_child(blade)
_time_left = get_duration()
return true
# Blades are out for `duration`, then the normal cooldown starts.
func get_cooldown() -> float:
if evolved:
return super()
return super() + get_duration()
func _clear_blades() -> void:
for blade: Node in _pivot.get_children():
blade.queue_free()
File: res://scripts/characters/player/weapons/pulse_aura.gd
extends Weapon
const BASE_RADIUS: float = 4.0
const FLASH_TIME: float = 0.15
const EVOLVED_MAX_HP_DAMAGE: float = 0.02
@onready var ring: MeshInstance3D = $Ring
func _ready() -> void:
ring.visible = false
func fire() -> bool:
var radius: float = BASE_RADIUS * get_area_scale()
var damage: float = get_damage()
if evolved:
damage += player.max_hp * EVOLVED_MAX_HP_DAMAGE
for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
enemy.take_damage(Weapon.roll_crit(damage, player.crit_chance))
_flash_ring(radius)
return true
func _flash_ring(radius: float) -> void:
ring.scale = Vector3(radius, 1.0, radius)
ring.visible = true
var tween: Tween = create_tween()
tween.tween_interval(FLASH_TIME)
tween.tween_callback(ring.hide)
File: res://scripts/ui/hud.gd
class_name Hud
extends CanvasLayer
const SLOT_SIZE: Vector2 = Vector2(48, 48)
@onready var hp_bar: ProgressBar = $TopBar/HBox/HpBar
@onready var level_label: Label = $TopBar/HBox/LevelLabel
@onready var gold_label: Label = $TopBar/HBox/GoldLabel
@onready var timer_label: Label = $TopBar/HBox/TimerLabel
@onready var weapon_icons: HBoxContainer = $Loadout/WeaponIcons
@onready var passive_icons: HBoxContainer = $Loadout/PassiveIcons
@onready var xp_bar: ProgressBar = $XpBar
var player: Player
func setup(target_player: Player) -> void:
player = target_player
player.health_changed.connect(_on_health_changed)
player.loadout_changed.connect(_refresh_loadout)
game_state.xp_changed.connect(_on_xp_changed)
game_state.leveled_up.connect(_on_leveled_up)
game_state.gold_changed.connect(_on_gold_changed)
_on_health_changed(player.current_hp, player.max_hp)
_on_xp_changed(game_state.xp, game_state.xp_to_next)
_on_leveled_up(game_state.level)
_on_gold_changed(game_state.gold)
_refresh_loadout()
func _process(_delta: float) -> void:
var total_seconds: int = floori(game_state.run_time)
timer_label.text = "%02d:%02d" % [floori(total_seconds / 60.0), total_seconds % 60]
func _on_health_changed(current: float, maximum: float) -> void:
hp_bar.max_value = maximum
hp_bar.value = current
func _on_xp_changed(current: float, required: float) -> void:
xp_bar.max_value = required
xp_bar.value = current
func _on_leveled_up(new_level: int) -> void:
level_label.text = "Lv %d" % new_level
func _on_gold_changed(total: int) -> void:
gold_label.text = "%d gold" % total
func _refresh_loadout() -> void:
for child: Node in weapon_icons.get_children():
child.queue_free()
for child: Node in passive_icons.get_children():
child.queue_free()
for weapon: Weapon in player.weapons:
var tooltip: String = "%s\n%s\n%s" % [weapon.get_display_name(), weapon.stats.description, weapon.data.scaling_text()]
var caption: String = "EVO" if weapon.evolved else ("Lv %d" % weapon.level)
weapon_icons.add_child(_make_slot(weapon.get_icon(), caption, tooltip))
for passive: OwnedPassive in player.passives:
var tooltip: String = passive.data.describe_level(passive.level)
passive_icons.add_child(_make_slot(passive.data.icon, "Lv %d" % passive.level, tooltip))
func _make_slot(icon: Texture2D, caption: String, tooltip: String) -> Control:
var slot: VBoxContainer = VBoxContainer.new()
slot.tooltip_text = tooltip
var picture: TextureRect = TextureRect.new()
picture.texture = icon
picture.custom_minimum_size = SLOT_SIZE
picture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
picture.mouse_filter = Control.MOUSE_FILTER_PASS
var label: Label = Label.new()
label.text = caption
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
slot.add_child(picture)
slot.add_child(label)
return slot
File: res://scripts/ui/build_overlay.gd
class_name BuildOverlay
extends CanvasLayer
@onready var label: Label = $Panel/Margin/Label
var player: Player
func _ready() -> void:
visible = false
func _process(_delta: float) -> void:
var held: bool = Input.is_action_pressed("show_build")
if held and not visible:
label.text = _build_text()
visible = held
func _build_text() -> String:
var lines: PackedStringArray = ["EVOLUTIONS", ""]
for data: WeaponData in _all_weapons():
if data.evolution_passive == null:
continue
lines.append("%s + %s > %s" % [data.display_name, data.evolution_passive.display_name, data.evolved_name])
lines.append(" " + _status(data))
return "\n".join(lines)
# Owned weapons first, then everything else in the pool.
func _all_weapons() -> Array[WeaponData]:
var result: Array[WeaponData] = []
for weapon: Weapon in player.weapons:
result.append(weapon.data)
for data: WeaponData in Upgrades.weapons:
if not result.has(data):
result.append(data)
return result
func _status(data: WeaponData) -> String:
var weapon: Weapon = player.get_weapon(data)
if weapon == null:
return "not owned"
if weapon.evolved:
return "EVOLVED"
var level_part: String = "Lv %d/%d" % [weapon.level, data.max_level()]
var has_passive: bool = player.get_passive(data.evolution_passive) != null
var passive_part: String = "passive owned" if has_passive else "passive missing"
if weapon.can_evolve():
return "%s, %s - READY (collect an Access Card)" % [level_part, passive_part]
return "%s, %s" % [level_part, passive_part]
File: res://scripts/systems/main.gd
extends Node3D
const PLAYER_SCENE: PackedScene = preload("res://scenes/characters/player/player.tscn")
const HUD_SCENE: PackedScene = preload("res://scenes/ui/hud.tscn")
const LEVEL_UP_SCENE: PackedScene = preload("res://scenes/ui/level_up_screen.tscn")
const RUN_END_SCENE: PackedScene = preload("res://scenes/ui/run_end_screen.tscn")
const BUILD_OVERLAY_SCENE: PackedScene = preload("res://scenes/ui/build_overlay.tscn")
var player: Player
var run_end_screen: RunEndScreen
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
var level: Node3D = game_state.selected_level.instantiate()
add_child(level)
player = PLAYER_SCENE.instantiate()
# Floor surface is at y=0 and the capsule is 2 tall, centred on its origin.
player.position = Vector3(0, 1, 0)
add_child(player)
player.died.connect(_on_player_died)
var hud: Hud = HUD_SCENE.instantiate()
add_child(hud)
hud.setup(player)
var level_up_screen: LevelUpScreen = LEVEL_UP_SCENE.instantiate()
level_up_screen.player = player
add_child(level_up_screen)
var build_overlay: BuildOverlay = BUILD_OVERLAY_SCENE.instantiate()
build_overlay.player = player
add_child(build_overlay)
run_end_screen = RUN_END_SCENE.instantiate()
add_child(run_end_screen)
func _process(delta: float) -> void:
game_state.run_time += delta
func _unhandled_input(event: InputEvent) -> void:
# Debug shortcuts; ignored in exported release builds.
if not OS.is_debug_build():
return
var key: InputEventKey = event as InputEventKey
if key == null or not key.pressed or key.echo:
return
match key.keycode:
KEY_F1:
game_state.access_card_collected.emit()
KEY_F2:
game_state.add_xp(game_state.xp_to_next - game_state.xp)
func _on_player_died() -> void:
# Only end the run when nobody is left (matters for co-op).
if get_tree().get_node_count_in_group("players") == 0:
run_end_screen.show_result(false)
File: res://scripts/characters/player/player.gd
extends CharacterBody3D
class_name Player
signal stats_changed
signal health_changed(current: float, maximum: float)
signal died
signal loadout_changed
const MAX_PASSIVE_LEVEL: int = 5
# The signature weapon plus 4 picked up during the run.
const MAX_WEAPON_SLOTS: int = 5
const AUTO_AIM_RANGE: float = 25.0
@export var character_data: CharacterData
@onready var character_model: Node3D = $CharacterModel
@onready var camera: Camera3D = $Camera3D
@onready var weapon_holder: Node3D = $Weapons
@onready var pickup_area: Area3D = $PickupArea
@onready var pickup_shape: CollisionShape3D = $PickupArea/CollisionShape3D
# --- Set up stats ---
var max_hp: float
var defence: float
var damage_mult: float
# Haste-style: cooldown = base / (1 + cdr / 100), so it never reaches zero.
var cdr: float
var hp_regen: float
var move_speed: float
var projectile: int
var crit_chance: float
var pickup_range: float
var xp_mult: float
var gold_mult: float
var area_size_mult: float
var duration_mult: float
var current_hp: float = 0.0
var is_dead: bool = false
# --- Create passive slots ---
var passive_slots: int = Profile.MAX_PASSIVE_SLOTS
var passives: Array[OwnedPassive] = []
# --- Weapons ---
var weapons: Array[Weapon] = []
# --- Aiming ---
# The model's front is +X, so that's the starting aim.
var aim_direction: Vector3 = Vector3.RIGHT
var auto_aim: bool = false
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
add_to_group("players")
pickup_area.area_entered.connect(_on_pickup_area_entered)
passive_slots = game_state.passive_slots
if character_data == null:
character_data = game_state.selected_character
if character_data:
_apply_character(character_data)
recalculate_stats()
current_hp = max_hp
func _apply_character(character: CharacterData) -> void:
if character.model_scene:
character_model.add_child(character.model_scene.instantiate())
if character.signature_weapon:
_spawn_weapon(character.signature_weapon)
func _spawn_weapon(data: WeaponData) -> Weapon:
var weapon: Weapon = data.weapon_scene.instantiate()
weapon.setup(self, data)
weapon_holder.add_child(weapon)
weapons.append(weapon)
return weapon
func recalculate_stats() -> void:
var previous_max_hp: float = max_hp
max_hp = 1000.0
defence = 0.0
damage_mult = 1.0
cdr = 0.0
hp_regen = 10.0
move_speed = 7.0
projectile = 0
crit_chance = 5.0
pickup_range = 3.0
xp_mult = 1.0
gold_mult = 1.0
area_size_mult = 1.0
duration_mult = 1.0
if character_data:
max_hp += character_data.bonus_max_hp
defence += character_data.bonus_defence
for passive: OwnedPassive in passives:
_apply_bonus(passive.data.stat, passive.bonus())
(pickup_shape.shape as SphereShape3D).radius = pickup_range
# Raising max HP grants the difference rather than healing to full.
current_hp = minf(current_hp + maxf(0.0, max_hp - previous_max_hp), max_hp)
stats_changed.emit()
health_changed.emit(current_hp, max_hp)
# Each call is one "source". Percentage stats multiply between sources; flat stats add.
func _apply_bonus(stat: PassiveData.Stat, amount: float) -> void:
match stat:
PassiveData.Stat.MAX_HP:
max_hp += amount
PassiveData.Stat.DEFENCE:
defence += amount
PassiveData.Stat.DAMAGE_MULT:
damage_mult *= 1.0 + amount
PassiveData.Stat.CDR:
cdr += amount
PassiveData.Stat.HP_REGEN:
hp_regen += amount
PassiveData.Stat.MOVE_SPEED:
move_speed *= 1.0 + amount
PassiveData.Stat.PROJECTILE:
projectile += roundi(amount)
PassiveData.Stat.CRIT_CHANCE:
crit_chance += amount
PassiveData.Stat.PICKUP_RANGE:
pickup_range *= 1.0 + amount
PassiveData.Stat.XP_MULT:
xp_mult *= 1.0 + amount
PassiveData.Stat.GOLD_MULT:
gold_mult *= 1.0 + amount
PassiveData.Stat.AREA_SIZE_MULT:
area_size_mult *= 1.0 + amount
PassiveData.Stat.DURATION_MULT:
duration_mult *= 1.0 + amount
func get_passive(data: PassiveData) -> OwnedPassive:
for passive: OwnedPassive in passives:
if passive.data.id == data.id:
return passive
return null
func add_passive(data: PassiveData) -> bool:
var owned: OwnedPassive = get_passive(data)
if owned:
if owned.level >= MAX_PASSIVE_LEVEL:
return false
owned.level += 1
else:
if passives.size() >= passive_slots:
return false
passives.append(OwnedPassive.new(data))
recalculate_stats()
loadout_changed.emit()
return true
func get_weapon(data: WeaponData) -> Weapon:
for weapon: Weapon in weapons:
if weapon.data.id == data.id:
return weapon
return null
func add_weapon(data: WeaponData) -> bool:
var owned: Weapon = get_weapon(data)
if owned:
return level_up_weapon(owned)
if weapons.size() >= MAX_WEAPON_SLOTS:
return false
_spawn_weapon(data)
loadout_changed.emit()
return true
func level_up_weapon(weapon: Weapon) -> bool:
if weapon.is_max_level():
return false
weapon.set_level(weapon.level + 1)
loadout_changed.emit()
return true
func evolve_weapon(weapon: Weapon) -> void:
weapon.evolve()
loadout_changed.emit()
func heal(amount: float) -> void:
if is_dead:
return
current_hp = minf(current_hp + amount, max_hp)
health_changed.emit(current_hp, max_hp)
func _on_pickup_area_entered(area: Area3D) -> void:
if area is Pickup:
(area as Pickup).attract(self)
func _physics_process(delta: float) -> void:
_regenerate(delta)
# Gravity in case we fuck something up
if not is_on_floor():
velocity += get_gravity() * delta
var input_dir: Vector2 = Input.get_vector("leftward", "rightward", "upward", "downward")
var direction: Vector3 = Vector3(input_dir.x, 0.0, input_dir.y).normalized()
if direction:
velocity.x = direction.x * move_speed
velocity.z = direction.z * move_speed
else:
velocity.x = move_toward(velocity.x, 0, move_speed)
velocity.z = move_toward(velocity.z, 0, move_speed)
move_and_slide()
_update_aim()
func _regenerate(delta: float) -> void:
if current_hp >= max_hp:
return
current_hp = minf(current_hp + hp_regen * delta, max_hp)
health_changed.emit(current_hp, max_hp)
func take_damage(amount: float) -> void:
if is_dead:
return
# Like League armour: 100 defence means taking half damage.
current_hp -= amount * 100.0 / (100.0 + maxf(defence, 0.0))
health_changed.emit(current_hp, max_hp)
if current_hp <= 0.0:
_die()
func _die() -> void:
current_hp = 0.0
is_dead = true
remove_from_group("players")
set_physics_process(false)
weapon_holder.process_mode = Node.PROCESS_MODE_DISABLED
character_model.visible = false
died.emit()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("auto_aim"):
auto_aim = not auto_aim
func _update_aim() -> void:
var aim_point: Variant = _get_aim_point()
if aim_point == null:
return
var point: Vector3 = aim_point
var to_point: Vector3 = point - global_position
to_point.y = 0.0
if to_point.length_squared() < 0.0001:
return
aim_direction = to_point.normalized()
# The model's front is +X, so the angle is measured from +X.
character_model.rotation.y = atan2(-aim_direction.z, aim_direction.x)
# Returns a Vector3, or null when there's nothing to aim at.
func _get_aim_point() -> Variant:
if auto_aim:
var target: Node3D = Targeting.nearest_enemy(get_tree(), global_position, AUTO_AIM_RANGE)
if target == null:
return null
return target.global_position
var mouse_pos: Vector2 = get_viewport().get_mouse_position()
var ray_origin: Vector3 = camera.project_ray_origin(mouse_pos)
var ray_dir: Vector3 = camera.project_ray_normal(mouse_pos)
return Plane(Vector3.UP, global_position.y).intersects_ray(ray_origin, ray_dir)