Goal: runs feed into something permanent. Gold is kept even when you lose and spent on permanent stat upgrades. Objectives unlock new characters, weapons, passives and passive slots. Harder difficulties unlock as you improve.
This phase is an outline. By the time you get here your game will have its own shape, and you may want different rules. Each step explains the approach and gives the key code; new files are shown in full, while changes to existing files show only the important part. Skip the “Find this / Replace with” hand-holding and fit things in where they make sense.
Checklist
gold_multGoldCoin pickup, just like XpOrb.Profile and saved.File: res://scripts/props/pickups/gold_coin.gd
class_name GoldCoin
extends Pickup
var amount: int = 1
func collect(player: Player) -> void:
game_state.add_gold(roundi(amount * player.gold_mult))
Make gold_coin.tscn exactly like xp_orb.tscn (layer 4, Monitoring off), but yellow.
Enemy drops. Add to EnemyData:
@export var gold_chance: float = 0.05
@export var gold_amount: int = 1
In Enemy._drop_loot(), after the XP orb:
if is_elite or randf() < data.gold_chance:
var coin: GoldCoin = GOLD_COIN_SCENE.instantiate()
coin.amount = data.gold_amount * (10 if is_elite else 1)
_spawn_here(coin)
(Add const GOLD_COIN_SCENE: PackedScene = preload("res://scenes/props/pickups/gold_coin.tscn") at the top.)
Gold per level-up. In game_state.add_xp(), inside the while loop, next to leveled_up.emit(level):
add_gold(GOLD_PER_LEVEL)
with const GOLD_PER_LEVEL: int = 10 at the top of game_state.gd.
Keeping it. Add to Profile:
var gold: int = 0
Save and load it in save_profile() and load_profile() the same way as unlocked_passive_slots:
config.set_value("meta", "gold", gold)
gold = config.get_value("meta", "gold", 0)
Then bank the run’s gold in RunEndScreen.show_result(). A flag stops it being added twice if something calls show_result() again:
var _banked: bool = false
func show_result(won: bool) -> void:
if not _banked:
_banked = true
Profile.gold += game_state.gold
Profile.save_profile()
title_label.text = "Victory!" if won else "Defeated"
visible = true
get_tree().paused = true
Why in Profile and not game_state? game_state is wiped every run by reset_run(). Profile is what’s saved to disk, which is the whole point of permanent progress.
A menu in the lobby lists stat upgrades (Damage, Armor, Max Health, Move Speed, and so on). Each has a few ranks, each rank costs more gold, and the bonus applies from the start of every run.
MetaUpgradeData resource describes one upgrade: which stat, how much per rank, max rank, and cost.Profile stores how many ranks of each you’ve bought.Player.recalculate_stats() applies each owned upgrade as one source. (Ranks of the same upgrade add up; a different source multiplies, just like Swarm.)File: res://scripts/resources/meta_upgrade_data.gd
class_name MetaUpgradeData
extends GameData
@export var stat: PassiveData.Stat = PassiveData.Stat.DAMAGE_MULT
# Bonus per rank; same fraction/plain-number rule as passives.
@export var amount_per_rank: float = 0.1
@export var max_rank: int = 5
@export var base_cost: int = 100
@export var cost_per_rank: int = 100
func cost_for_rank(current_rank: int) -> int:
return base_cost + cost_per_rank * current_rank
In Profile:
# Upgrade id -> ranks bought.
var upgrade_ranks: Dictionary = {}
func get_rank(upgrade: MetaUpgradeData) -> int:
return upgrade_ranks.get(upgrade.id, 0)
func buy_upgrade(upgrade: MetaUpgradeData) -> bool:
var rank: int = get_rank(upgrade)
if rank >= upgrade.max_rank:
return false
var cost: int = upgrade.cost_for_rank(rank)
if gold < cost:
return false
gold -= cost
upgrade_ranks[upgrade.id] = rank + 1
save_profile()
return true
Save and load upgrade_ranks with config.set_value("meta", "upgrade_ranks", upgrade_ranks) and config.get_value("meta", "upgrade_ranks", {}). ConfigFile can store dictionaries directly.
List the upgrades in the Upgrades autoload:
var meta_upgrades: Array[MetaUpgradeData] = [
preload("res://resources/meta_upgrades/damage.tres"),
preload("res://resources/meta_upgrades/max_hp.tres"),
# ...
]
Apply them in Player.recalculate_stats(), after the character bonuses:
for upgrade: MetaUpgradeData in Upgrades.meta_upgrades:
var rank: int = Profile.get_rank(upgrade)
if rank > 0:
_apply_bonus(upgrade.stat, upgrade.amount_per_rank * rank)
The shop screen. A Control scene (say scenes/ui/upgrade_shop.tscn) with a gold label, a list, and a Back button. This script builds one button per upgrade:
File: res://scripts/ui/upgrade_shop.gd
extends Control
@onready var gold_label: Label = $Center/VBox/Gold
@onready var list: VBoxContainer = $Center/VBox/List
func _ready() -> void:
_refresh()
func _refresh() -> void:
gold_label.text = "%d gold" % Profile.gold
for child: Node in list.get_children():
child.queue_free()
for upgrade: MetaUpgradeData in Upgrades.meta_upgrades:
var rank: int = Profile.get_rank(upgrade)
var button: Button = Button.new()
if rank >= upgrade.max_rank:
button.text = "%s (MAX)" % upgrade.display_name
button.disabled = true
else:
var cost: int = upgrade.cost_for_rank(rank)
button.text = "%s (%d/%d) - %d gold" % [upgrade.display_name, rank, upgrade.max_rank, cost]
button.disabled = Profile.gold < cost
button.pressed.connect(_on_buy.bind(upgrade))
list.add_child(button)
func _on_buy(upgrade: MetaUpgradeData) -> void:
if Profile.buy_upgrade(upgrade):
_refresh()
Add an Upgrades button to the main menu that calls get_tree().change_scene_to_file("res://scenes/ui/upgrade_shop.tscn").
Objectives like “select 3 passives in one game” or “beat the boss on Hard” reward new characters, weapons, passives, upgrades and extra passive slots (from 3 up to 6). Everything persists across modes.
GameData gets an unlocked_by_default flag. Characters, weapons and passives all extend GameData, so they all get it at once.Profile keeps a list of unlocked ids and completed objective ids.ObjectiveData resource describes one objective and its reward.In GameData:
@export var unlocked_by_default: bool = true
In Profile:
var unlocked_ids: Array[StringName] = []
var completed_objectives: Array[StringName] = []
func is_unlocked(data: GameData) -> bool:
return data.unlocked_by_default or unlocked_ids.has(data.id)
Save them with config.set_value(...). When loading, use assign(), because config.get_value() returns a plain (untyped) Array, which can’t be stored straight into a typed Array[StringName]:
unlocked_ids.assign(config.get_value("unlocks", "ids", []))
completed_objectives.assign(config.get_value("unlocks", "objectives", []))
File: res://scripts/resources/objective_data.gd
class_name ObjectiveData
extends GameData
enum Kind { WIN_RUN, REACH_LEVEL, KILL_COUNT, PASSIVES_IN_ONE_RUN }
@export var kind: Kind = Kind.WIN_RUN
@export var target: int = 1
# Id of the character, weapon or passive this unlocks. Leave empty for none.
@export var reward_unlock_id: StringName = &""
@export var reward_passive_slot: bool = false
@export var reward_gold: int = 0
func is_met(won: bool, player: Player) -> bool:
match kind:
Kind.WIN_RUN:
return won
Kind.REACH_LEVEL:
return game_state.level >= target
Kind.KILL_COUNT:
return game_state.kills >= target
Kind.PASSIVES_IN_ONE_RUN:
return player.passives.size() >= target
return false
Checking at the end of a run (in Profile):
func complete_objectives(won: bool, player: Player) -> Array[ObjectiveData]:
var newly_done: Array[ObjectiveData] = []
for objective: ObjectiveData in Upgrades.objectives:
if completed_objectives.has(objective.id) or not objective.is_met(won, player):
continue
completed_objectives.append(objective.id)
if objective.reward_unlock_id != &"":
unlocked_ids.append(objective.reward_unlock_id)
if objective.reward_passive_slot:
unlocked_passive_slots = mini(unlocked_passive_slots + 1, MAX_PASSIVE_SLOTS)
gold += objective.reward_gold
newly_done.append(objective)
save_profile()
return newly_done
Call it from RunEndScreen.show_result() (give the screen a player reference from main.gd, the same way the level-up screen gets one) and list newly_done on the end screen: “Objective complete: Pick 3 passives in one run (+1 passive slot)”. That moment is a big part of what makes this kind of game rewarding.
Example objectives (Swarm-style):
| Objective | Kind | Target | Reward |
|---|---|---|---|
| Pick 3 passives in one run | PASSIVES_IN_ONE_RUN | 3 | +1 passive slot |
| Reach level 20 | REACH_LEVEL | 20 | Unlock weapon_orbiting_blades |
| Defeat the boss | WIN_RUN | 1 | Unlock a new character |
| Kill 1000 enemies in one run | KILL_COUNT | 1000 | 500 gold |
Anywhere a list of possible things is shown, skip the locked ones. That’s a one-line check in each loop.
In UpgradeOffers, in both “new weapon” and “new passive” loops:
for data: PassiveData in Upgrades.passives:
if not Profile.is_unlocked(data):
continue
...
In character_select.gd, either skip locked characters or show them disabled with a hint:
for character: CharacterData in Characters.list:
var button: Button = character_button.instantiate()
button.text = character.display_name
button.disabled = not Profile.is_unlocked(character)
...
Swarm gives every account Jinx and Seraphine by default and unlocks the rest through objectives. Set unlocked_by_default to match whatever you want new players to start with.
Story, then Hard, then Extreme. Each tier raises enemy health and damage and gives more gold. Finishing everything on one tier unlocks the next.
A DifficultyData resource, chosen at character select, stored on game_state for the run, and read by enemies and gold pickups.
File: res://scripts/resources/difficulty_data.gd
class_name DifficultyData
extends GameData
@export var enemy_hp_mult: float = 1.0
@export var enemy_damage_mult: float = 1.0
@export var gold_mult: float = 1.0
In game_state: add var difficulty: DifficultyData, and set it in start_run() (add a parameter). Default to a “Story” resource so nothing breaks.
In Enemy._ready():
max_hp = data.max_hp * game_state.difficulty.enemy_hp_mult
damage_mult = game_state.difficulty.enemy_damage_mult
In game_state.add_gold():
gold += roundi(amount * difficulty.gold_mult)
Unlocking tiers: mark Hard and Extreme as unlocked_by_default = false, and give them an objective each (“Defeat the boss on Story” with reward_unlock_id = &"difficulty_hard"). To check which difficulty a run was on, add a DIFFICULTY_WIN kind to ObjectiveData with a difficulty_id field.
Co-op note: in Swarm, more players also means more enemies. If you add co-op, multiply spawn counts by the number of players in the
EnemySpawner.
unlocked_by_default = false never appears in level-ups until its objective is done.Next: Phase 9: Extras