GameGuide

Phase 0: Cleanup and stat groundwork

Goal: fix the small problems in the existing weapon code and set up the stat system the rest of the guide depends on. The game won’t look any different at the end of this phase, but every later phase builds on it.

Checklist


0.1 Delete OwnedWeapon

What

Remove res://scripts/characters/player/owned_weapon.gd.

Why

Right now a weapon’s level could live in two places: OwnedWeapon.level and the level variable in weapon.gd. When the same fact is stored twice, sooner or later one copy gets updated and the other doesn’t, and you get bugs that are hard to track down. Programmers call this having “two sources of truth”.

We decided the Weapon node owns the level. Weapons have to exist in the scene anyway (they fire bullets from somewhere), so it makes sense for the node to hold its own data and level.

There is also a bug in OwnedWeapon.bonus(): it reads data.values, but WeaponData has no values field. It was copied from OwnedPassive. Deleting the file removes the bug too.

How

In the Godot editor:

  1. In the FileSystem dock, open scripts/characters/player/.
  2. Right-click owned_weapon.gd and choose Delete, then confirm.
  3. If owned_weapon.gd.uid is still there afterwards, delete it as well.

Nothing else in the project uses OwnedWeapon, so nothing else needs changing.

OwnedPassive stays. Passives have no node in the scene (they are just numbers), so a small data class is the right fit for them.


0.2 Give weapon.gd a class_name

What

Add class_name Weapon to the top of weapon.gd.

Why

class_name registers the script as a global type. Other scripts can then write:

var weapons: Array[Weapon] = []
if node is Weapon:

Without it you would need to preload the script everywhere you want to use it as a type. Phase 1 turns this file into the base class every weapon builds on, so it needs a name.

How

File: res://scripts/characters/player/weapons/weapon.gd

Find this:

extends Node3D

Replace with:

class_name Weapon
extends Node3D

Phase 1 rewrites the rest of this file, so leave everything else as it is for now.


0.3 Rename weapon_1 to signature_weapon

What

Rename the weapon_1 property on CharacterData and the matching variable on Player.

Why

weapon_1 suggests there will be a weapon_2, weapon_3, and so on, each set up the same way. In Swarm the first weapon is special:

The other four weapons are picked up during the run, so they don’t belong on CharacterData at all. Naming the field signature_weapon makes that clear to anyone reading the code, including you in six months.

How

File: res://scripts/resources/character_data.gd

Find this:

@export_group("Loadout")
@export var weapon_1: WeaponData

Replace with:

@export_group("Loadout")
@export var signature_weapon: WeaponData

File: res://scripts/characters/player/player.gd

Find this:

# --- Add weapon slots ---
var weapon_1: WeaponData

Replace with:

# --- Add weapon slots ---
var signature_weapon: WeaponData

Find this:

func _apply_character(character: CharacterData) -> void:
	weapon_1 = character.weapon_1

Replace with:

func _apply_character(character: CharacterData) -> void:
	signature_weapon = character.signature_weapon

gunner.tres saved the weapon under the old name (weapon_1 = ...). After the rename, Godot no longer recognises that line and the Gunner ends up with no weapon. Re-link it:

In the Godot editor:

  1. Double-click res://resources/characters/player/gunner.tres to open it in the Inspector.
  2. Expand Loadout. Signature Weapon will say <empty>.
  3. Drag res://resources/weapons/weapon_gunner_default.tres from the FileSystem dock onto that slot.
  4. Press Ctrl+S to save the resource.

0.4 Use explicit types in profile.gd

What

Replace the two := lines with explicit types, and use the existing constant instead of a hard-coded 6.

Why

The rest of the project spells out every type, so this keeps it consistent. Using MAX_PASSIVE_SLOTS instead of 6 means that if you ever change the maximum, you only change it in one place.

How

File: res://scripts/autoload/profile.gd

Find this:

func load_profile() -> void:
	var config := ConfigFile.new()
	if config.load(SAVE_PATH) != OK:
		return
	unlocked_passive_slots = clampi(config.get_value("unlocks", "passive_slots", 3), 1, 6)

func save_profile() -> void:
	var config := ConfigFile.new()

Replace with:

func load_profile() -> void:
	var config: ConfigFile = ConfigFile.new()
	if config.load(SAVE_PATH) != OK:
		return
	unlocked_passive_slots = clampi(config.get_value("unlocks", "passive_slots", 3), 1, MAX_PASSIVE_SLOTS)

func save_profile() -> void:
	var config: ConfigFile = ConfigFile.new()

0.5 Simplify WeaponData

What

Remove synergy_passive_id and the max_level(has_synergy) “level 6” logic. max_level() now just returns how many levels the weapon has.

Why

The current design treats an evolution as a hidden 6th level that can show up in normal level-ups. We decided to do it the Swarm way instead: evolutions only come from Access Cards (Phase 5 and 6). An evolution is a one-off transformation, not “another level”, so it gets its own fields in Phase 5.

Until then, a weapon simply has as many levels as you put in its levels array (5 in this guide).

How

File: res://scripts/resources/weapon_data.gd

Replace the whole file with:

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] = []

func max_level() -> int:
	return levels.size()

func level_stats(level: int) -> WeaponLevel:
	return levels[clampi(level - 1, 0, levels.size() - 1)]

0.6 Rework stat stacking and cooldown maths

This is the most important step in the phase, so it gets the longest explanation.

What

  1. Percentage stats (damage, area, duration, and so on) multiply between sources instead of adding.
  2. Flat stats (defence, CDR, crit chance, regen, projectile count, max HP) keep adding.
  3. cdr uses Ability Haste maths.
  4. projectile becomes an int.
  5. pickup_range becomes a sensible distance in metres.
  6. _apply_passive() is renamed _apply_bonus(), because passives won’t be the only source of bonuses for long.

Why: what a “source” is

A source is one thing that gives you a bonus: the Damage passive, a permanent Damage upgrade bought with gold, a temporary buff from your innate, an augment. Each call to _apply_bonus() counts as one source.

Within one source, the levels don’t stack on top of each other. Your PassiveData.values already stores the total bonus at each level ([0.1, 0.2, 0.3, 0.4, 0.5]), so level 3 of Damage is simply +30%.

Why: additive vs multiplicative

Say you have a +50% Damage passive and a +20% Damage permanent upgrade.

Stacking Maths Final damage multiplier
Additive (old) 1 + 0.5 + 0.2 1.70×
Multiplicative (new) 1.5 × 1.2 1.80×

Multiplying makes each new source feel worth the same no matter how much you already have, which is how Swarm works. It also handles downsides cleanly: a “-30% damage” augment becomes × 0.7, which can never push damage below zero.

Flat stats keep adding because multiplying them doesn’t make sense. Two “+1 projectile” bonuses should give +2 projectiles, not some odd product.

Multiplies between sources (percentage) Adds (flat)
Damage, Move Speed, Pickup Range, XP, Gold, Area Size, Duration Max HP, Defence, CDR, HP Regen, Projectile Count, Crit Chance

A rule for writing passive resources

Because of this split, the numbers you type into a PassiveData resource mean different things depending on the stat:

Your existing damage.tres (0.1, 0.2, 0.3, 0.4, 0.5) already follows this.

Why: Ability Haste maths for cdr

There are two common ways to make cooldowns shorter:

Method Formula At 50 At 100 At 200
Percentage CDR base × (1 - cdr) 50% of base 0, instant negative
Ability Haste base / (1 + cdr / 100) 67% of base 50% of base 33% of base

With percentage CDR, stacking enough of it takes cooldowns to zero and breaks the game. With Haste maths, every point is worth the same amount of “extra casts per minute”, but the cooldown never actually reaches zero. We keep the name cdr because it’s what you’re used to, but the maths is Haste. Phase 1 puts the formula into the weapon code.

Why: projectile becomes an int

You can’t fire 1.5 bullets. Using int makes that impossible by design. Since PassiveData.values is an array of floats, we convert with roundi() when applying the bonus.

Why: pickup_range becomes 3.0

In Godot 3D, 1 unit = 1 metre. Your player capsule is 1 m wide and the camera shows roughly 60 m across, so a pickup range of 100 would vacuum up the whole screen. 3.0 m is a good start. Phase 3 uses it.

How

File: res://scripts/characters/player/player.gd

Find this:

var damage_mult: float
var cdr: float

Replace with:

var damage_mult: float
# Haste-style: cooldown = base / (1 + cdr / 100), so it never reaches zero.
var cdr: float

Find this:

var projectile: float

Replace with:

var projectile: int

Find the whole recalculate_stats() and _apply_passive() functions and replace both with:

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())
	# 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()

# 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

Also give the loop in add_passive() an explicit type.

Find this:

func add_passive(data: PassiveData) -> bool:
	for passive in passives:

Replace with:

func add_passive(data: PassiveData) -> bool:
	for passive: OwnedPassive in passives:

Finally, add the “fractions vs plain numbers” rule as a comment where you will see it while making passives.

File: res://scripts/resources/passive_data.gd

Find this:

# Total bonus at each level, not a per-level delta.
@export var values: Array[float] = [0.0, 0.0, 0.0, 0.0, 0.0]

Replace with:

# Total bonus at each level, not a per-level delta.
# Percentage stats use fractions (0.1 = +10%); flat stats use plain numbers.
@export var values: Array[float] = [0.0, 0.0, 0.0, 0.0, 0.0]

Test it

  1. Press F5, go to character select, pick Gunner, and start.
  2. You should be able to move with WASD and the model should face the mouse, exactly like before.
  3. The Debugger > Errors tab at the bottom of the editor should be empty.

If something breaks:

Error Likely cause
Invalid access to property or key 'weapon_1' A weapon_1 reference was missed in step 0.3. Search the project with Ctrl+Shift+F.
Could not find type "OwnedWeapon" Something still mentions OwnedWeapon. Search for it and remove it.
Identifier "MAX_PASSIVE_SLOTS" not declared The constant in profile.gd was renamed or removed.

Full file listings

These are the complete files as they should look at the end of Phase 0.

File: res://scripts/characters/player/weapons/weapon.gd

class_name Weapon
extends Node3D

@export var data: WeaponData
var level: int = 1
var stats: WeaponLevel

func set_level(new_level: int) -> void:
	level = new_level
	stats = data.level_stats(level)

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] = []

func max_level() -> int:
	return levels.size()

func level_stats(level: int) -> WeaponLevel:
	return levels[clampi(level - 1, 0, levels.size() - 1)]

File: res://scripts/resources/character_data.gd

class_name CharacterData
extends GameData

@export var model_scene: PackedScene

# Bonuses are additive on top of player.gd's base stats, so they can only raise them.
@export_group("Stat Bonuses")
@export_range(0, 500) var bonus_max_hp: int = 0
@export_range(0, 25) var bonus_defence: int = 0

@export_group("Loadout")
@export var signature_weapon: WeaponData

File: res://scripts/resources/passive_data.gd

class_name PassiveData
extends GameData

enum Stat { MAX_HP, DEFENCE, DAMAGE_MULT, CDR, HP_REGEN, MOVE_SPEED, PROJECTILE,
	CRIT_CHANCE, PICKUP_RANGE, XP_MULT, GOLD_MULT, AREA_SIZE_MULT, DURATION_MULT }

@export var stat: Stat = Stat.MAX_HP

# Total bonus at each level, not a per-level delta.
# Percentage stats use fractions (0.1 = +10%); flat stats use plain numbers.
@export var values: Array[float] = [0.0, 0.0, 0.0, 0.0, 0.0]

File: res://scripts/autoload/profile.gd

extends Node

const SAVE_PATH: String = "user://profile.cfg"

var unlocked_passive_slots: int = 3
const MAX_PASSIVE_SLOTS: int = 6

func _ready() -> void:
	load_profile()

func load_profile() -> void:
	var config: ConfigFile = ConfigFile.new()
	if config.load(SAVE_PATH) != OK:
		return
	unlocked_passive_slots = clampi(config.get_value("unlocks", "passive_slots", 3), 1, MAX_PASSIVE_SLOTS)

func save_profile() -> void:
	var config: ConfigFile = ConfigFile.new()
	config.set_value("unlocks", "passive_slots", unlocked_passive_slots)
	config.save(SAVE_PATH)

File: res://scripts/characters/player/player.gd

extends CharacterBody3D
class_name Player

signal stats_changed
const MAX_PASSIVE_LEVEL: int = 5

@export var character_data: CharacterData
@onready var character_model: Node3D = $CharacterModel
@onready var camera: Camera3D = $Camera3D


# --- 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

# --- Create passive slots ---
var passive_slots: int = Profile.MAX_PASSIVE_SLOTS
var passives: Array[OwnedPassive] = []

# --- Add weapon slots ---
var signature_weapon: WeaponData

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	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:
	signature_weapon = character.signature_weapon
	if character.model_scene:
		character_model.add_child(character.model_scene.instantiate())

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())
	# 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()

# 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 add_passive(data: PassiveData) -> bool:
	for passive: OwnedPassive in passives:
		if passive.data.id == data.id:
			if passive.level >= MAX_PASSIVE_LEVEL:
				return false
			passive.level += 1
			recalculate_stats()
			return true
	if passives.size() >= passive_slots:
		return false
	passives.append(OwnedPassive.new(data))
	recalculate_stats()
	return true

func _physics_process(delta: float) -> void:
	# 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()
	_face_mouse()

func _face_mouse() -> void:
	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)
	var hit: Variant = Plane(Vector3.UP, global_position.y).intersects_ray(ray_origin, ray_dir)
	if hit == null:
		return
	var hit_point: Vector3 = hit
	var to_mouse: Vector3 = hit_point - global_position
	if to_mouse.length_squared() < 0.0001:
		return
	# The model's front is +X, so the angle is measured from +X.
	character_model.rotation.y = atan2(-to_mouse.z, to_mouse.x)

Next: Phase 1: The signature weapon fires