GameGuide

Phase 8: Progress between runs (outline)

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


8.1 Gold

How Swarm does it

Approach

  1. A GoldCoin pickup, just like XpOrb.
  2. Enemies sometimes drop coins; elites always do.
  3. Each level-up gives a little gold.
  4. When the run ends, the run’s gold is added to the permanent total in Profile and saved.

Key code

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.


8.2 Permanent upgrades shop

How Swarm does it

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.

Approach

Key code

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").


8.3 Unlocks and objectives

How Swarm does it

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.

Approach

  1. GameData gets an unlocked_by_default flag. Characters, weapons and passives all extend GameData, so they all get it at once.
  2. Profile keeps a list of unlocked ids and completed objective ids.
  3. An ObjectiveData resource describes one objective and its reward.
  4. When a run ends, check every unfinished objective and hand out rewards.

Key code

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

8.4 Only offer unlocked things

Approach

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.


8.5 Difficulty tiers

How Swarm does it

Story, then Hard, then Extreme. Each tier raises enemy health and damage and gives more gold. Finishing everything on one tier unlocks the next.

Approach

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.


Test it

  1. Play a run, collect some gold, and die. Restart the game completely; your gold total is still there.
  2. Buy a Damage upgrade in the shop and start a run. Enemies die noticeably faster from the first second.
  3. Pick 3 passives in a run and finish it. The end screen announces the objective, and the next run lets you hold 4 passives.
  4. A weapon marked unlocked_by_default = false never appears in level-ups until its objective is done.

Next: Phase 9: Extras