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
OwnedWeaponweapon.gd a class_nameweapon_1 to signature_weaponprofile.gdWeaponData (drop the “level 6” idea)OwnedWeaponRemove res://scripts/characters/player/owned_weapon.gd.
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.
In the Godot editor:
scripts/characters/player/.owned_weapon.gd and choose Delete, then confirm.owned_weapon.gd.uid is still there afterwards, delete it as well.Nothing else in the project uses OwnedWeapon, so nothing else needs changing.
OwnedPassivestays. Passives have no node in the scene (they are just numbers), so a small data class is the right fit for them.
weapon.gd a class_nameAdd class_name Weapon to the top of weapon.gd.
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.
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.
weapon_1 to signature_weaponRename the weapon_1 property on CharacterData and the matching variable on Player.
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.
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:
res://resources/characters/player/gunner.tres to open it in the Inspector.<empty>.res://resources/weapons/weapon_gunner_default.tres from the FileSystem dock onto that slot.profile.gdReplace the two := lines with explicit types, and use the existing constant instead of a hard-coded 6.
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.
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()
WeaponDataRemove synergy_passive_id and the max_level(has_synergy) “level 6” logic. max_level() now just returns how many levels the weapon has.
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).
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)]
This is the most important step in the phase, so it gets the longest explanation.
cdr uses Ability Haste maths.projectile becomes an int.pickup_range becomes a sensible distance in metres._apply_passive() is renamed _apply_bonus(), because passives won’t be the only source of bonuses for long.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%.
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 |
Because of this split, the numbers you type into a PassiveData resource mean different things depending on the stat:
0.1 means +10%.10 CDR means 10 CDR, 1 projectile means one more projectile, 150 max HP means 150 HP.Your existing damage.tres (0.1, 0.2, 0.3, 0.4, 0.5) already follows this.
cdrThere 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.
projectile becomes an intYou 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.
pickup_range becomes 3.0In 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.
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]
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. |
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)