Feature design for player upgrades (roguelike build customization). Iterate here as we implement.
- Players collect upgrades during a run that modify their abilities and weapons.
- Two upgrade types:
- Trigger upgrades: React to game events (enemy kill, weapon break, dash used, etc.) and execute effects (heal, spawn surface, throw item from crowd, etc.).
- Stat modifier upgrades: Permanently modify weapon stats (durability, damage, range) or player abilities (dash distance, cooldown).
- Game designers can create new upgrades easily using the editor (scene-based, similar to items).
- Upgrades stack — the same upgrade can be acquired multiple times with cumulative effect.
- Weapon modifiers apply to future spawned weapons only (not currently held).
┌──────────────────────────────────────────────────────────────────┐
│ UpgradeManager │
│ (Autoload singleton - tracks acquired upgrades, listens for │
│ global events, applies weapon modifiers to new weapons) │
└─────────────────┬────────────────────────────────────────────────┘
│
┌─────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌────────┐ ┌──────────┐ ┌───────────────────────┐
│EventBus│ │ Player │ │ ItemRegistry │
│(signals)│ │(abilities)│ │(weapon spawn hook) │
└────────┘ └──────────┘ └───────────────────────┘
-
UpgradeManager (autoload) — Central manager that:
- Tracks all acquired upgrades (dictionary of upgrade_id → count)
- Connects to EventBus for global triggers (enemy_killed, weapon_thrown, etc.)
- Executes trigger-based upgrade effects when events fire
- Provides
apply_weapon_modifiers(weapon)for stat modifiers - Exposes API for granting/removing upgrades (for console commands, UI, chests)
-
UpgradeRegistry (autoload) — Auto-discovers upgrade scenes in
res://scenes/upgrades/:- Mirrors ItemRegistry pattern
- Builds category cache for filtering (by type, rarity, tags)
- Provides
get_random_upgrades(count, filter)for pick-from-3 UI
-
EventBus extensions — New signals for upgrade triggers:
enemy_killed(enemy: Enemy, killer: Node3D)— when enemy diesenemy_spawned(enemy: Enemy)— when enemy spawns in roomweapon_thrown(weapon: Node3D, direction: Vector3, from_crowd: bool)weapon_broken(weapon: Node3D)— when weapon durability reaches 0weapon_spawned(weapon: Node3D)— when weapon enters world (spawn or crowd throw)crowd_throw_requested()— when crowd should throw an itemplayer_dashed(direction: Vector3)— when player dashes aplayer_teleported(from: Vector3, to: Vector3)— when player teleportsitem_picked_up(item: Node3D, picker: Node3D)— when item picked up
-
Upgrade base classes — Scene components attached to upgrade scenes:
BaseUpgrade— Metadata (name, description, icon, tags)TriggerUpgrade— Listens for specific event, has chance/cooldown, executes child effectsWeaponModifierUpgrade— Defines stat modifications for weapons matching filter
Upgrades are scenes in res://scenes/upgrades/ with this structure:
UpgradeName.tscn
├── BaseUpgrade (root node with metadata)
│ ├── @export var upgrade_name: String
│ ├── @export var description: String
│ ├── @export var icon: Texture2D
│ ├── @export var tags: Array[UpgradeCategory]
│ └── @export var max_stacks: int = -1 # -1 = unlimited
│
├── TriggerUpgrade (optional - for event-driven upgrades)
│ ├── @export var trigger: UpgradeTrigger.Type # enum
│ ├── @export var chance: float = 1.0 # 0-1 probability
│ ├── @export var cooldown: float = 0.0 # seconds between activations
│ └── Child effect nodes...
│
└── WeaponModifierUpgrade (optional - for stat modifiers)
├── @export var weapon_filter: Array[Categories.Category]
├── @export var stat_modifiers: Array[StatModifier]
└── (StatModifier: property name, delta value, multiply vs add)
enum Type {
# Combat triggers
ENEMY_KILLED, # Any enemy killed
ENEMY_SPAWNED, # Enemy appears in room
DAMAGE_DEALT, # Player deals damage
DAMAGE_TAKEN, # Player takes damage
# Weapon triggers
WEAPON_THROWN, # Player throws a weapon
WEAPON_BROKEN, # Weapon durability reaches 0
WEAPON_ATTACK, # Weapon attacks (melee swing, ranged shot)
CROWD_THROW, # Crowd throws an item
# Movement triggers
PLAYER_DASHED, # Player uses dash
PLAYER_TELEPORTED, # Player uses teleport
# Item triggers
ITEM_PICKED_UP, # Player picks up any item
ITEM_DROPPED, # Player drops an item
# Room triggers
ROOM_ENTERED, # Player enters new room
ROOM_CLEARED, # All enemies in room defeated
}Trigger upgrades execute child effect nodes when activated. These extend existing effect system:
# scripts/upgrades/effects/upgrade_effect.gd
class_name UpgradeEffect extends Node
## Base class for upgrade effects
## Receives trigger context (enemy, weapon, position, etc.)
func execute(context: Dictionary) -> void:
push_warning("UpgradeEffect.execute() not implemented")| Effect class | Description | Context used |
|---|---|---|
HealPlayerEffect |
Heal player by flat or % amount | — |
DamageAreaEffect |
Deal damage to enemies in radius | position |
SpawnSurfaceUpgradeEffect |
Spawn oil/ice/water at position | position |
SpawnProjectileEffect |
Fire projectile at nearest enemy | position, direction |
RepairWeaponEffect |
Restore durability to held weapon | — |
CrowdThrowEffect |
Make crowd throw an item | item_filter |
SpawnItemEffect |
Spawn specific item at position | position |
ModifyStatTemporaryEffect |
Temporary stat buff | duration |
ExplodeEffect |
Spawn explosion at position | position |
WeaponModifierUpgrade defines stat changes applied to weapons matching a filter.
# scripts/upgrades/stat_modifier.gd
class_name StatModifier extends Resource
enum Operation { ADD, MULTIPLY }
enum Stat {
DAMAGE,
DURABILITY,
MAX_DURABILITY,
ATTACK_COOLDOWN,
SWING_RANGE, # Melee only
SWING_ANGLE, # Melee only
KNOCKBACK_FORCE, # Melee only
PROJECTILE_SPEED, # Ranged only
SPREAD, # Ranged only
MAX_AMMO, # Ranged only
}
@export var stat: Stat
@export var operation: Operation = Operation.ADD
@export var value: float = 0.0- When weapon spawns, emit
EventBus.weapon_spawned(weapon) - UpgradeManager receives signal, calls
apply_weapon_modifiers(weapon) - For each acquired
WeaponModifierUpgrade:- Check if weapon matches
weapon_filter(via SpawnableBehaviour tags) - Apply each
StatModifierto weapon'sWeaponBehaviourproperties - Multiply by upgrade stack count
- Check if weapon matches
# In UpgradeManager
func apply_weapon_modifiers(weapon: Node3D) -> void:
var spawnable = weapon.get_node_or_null("SpawnableBehaviour")
if not spawnable:
return
var weapon_behaviour = weapon.get_node_or_null("WeaponBehaviour")
# Also check for MeleeWeaponBehaviour, RangedWeaponBehaviour
for upgrade_id in _acquired_upgrades:
var upgrade_scene = UpgradeRegistry.get_upgrade(upgrade_id)
var modifier = _get_weapon_modifier(upgrade_scene)
if not modifier:
continue
# Check filter match
if not spawnable.has_any_category(modifier.weapon_filter):
continue
# Apply modifiers × stack count
var stacks = _acquired_upgrades[upgrade_id]
for stat_mod in modifier.stat_modifiers:
_apply_stat_modifier(weapon_behaviour, stat_mod, stacks)# scripts/autoloads/upgrade_manager.gd
extends Node
signal upgrade_acquired(upgrade_id: String, new_count: int)
signal upgrade_removed(upgrade_id: String, new_count: int)
signal upgrade_triggered(upgrade_id: String, trigger_type: UpgradeTrigger.Type)
## Grant an upgrade to the player (stacks if already owned)
func grant_upgrade(upgrade_id: String, count: int = 1) -> void
## Remove upgrade stacks (returns actual amount removed)
func remove_upgrade(upgrade_id: String, count: int = 1) -> int
## Get current stack count for upgrade
func get_upgrade_count(upgrade_id: String) -> int
## Check if player has upgrade
func has_upgrade(upgrade_id: String) -> bool
## Get all acquired upgrades as Dictionary[upgrade_id, count]
func get_all_upgrades() -> Dictionary
## Clear all upgrades (for new run)
func reset_upgrades() -> void
## Apply weapon stat modifiers to a newly spawned weapon
func apply_weapon_modifiers(weapon: Node3D) -> void# scripts/autoloads/upgrade_registry.gd
extends Node
## Get upgrade scene by ID (filename without extension)
func get_upgrade(upgrade_id: String) -> PackedScene
## Get all registered upgrade IDs
func get_all_upgrade_ids() -> Array[String]
## Get random upgrades matching filter (for pick-from-3)
func get_random_upgrades(count: int, filter: Array[UpgradeCategory] = []) -> Array[String]
## Get upgrades matching all specified categories
func get_upgrades_matching_all(categories: Array[UpgradeCategory]) -> Array[String]
## Get upgrades matching any specified category
func get_upgrades_matching_any(categories: Array[UpgradeCategory]) -> Array[String]# scripts/core/upgrade_categories.gd
class_name UpgradeCategories
enum Category {
# Rarity
COMMON = 0,
UNCOMMON = 1,
RARE = 2,
EPIC = 3,
# Type
TRIGGER = 10, # Event-driven effect
WEAPON_MOD = 11, # Weapon stat modifier
ABILITY_MOD = 12, # Player ability modifier
# Trigger subtypes
ON_KILL = 20,
ON_DAMAGE = 21,
ON_THROW = 22,
ON_MOVEMENT = 23,
# Weapon subtypes
AFFECTS_MELEE = 30,
AFFECTS_RANGED = 31,
AFFECTS_ALL_WEAPONS = 32,
}For testing upgrades before UI is implemented:
/upgrade list — List all registered upgrades
/upgrade grant <id> — Grant upgrade to player
/upgrade grant <id> <n> — Grant n stacks of upgrade
/upgrade remove <id> — Remove one stack of upgrade
/upgrade clear — Remove all upgrades
/upgrade status — Show all acquired upgrades with counts
Modify weapon spawn flow to apply upgrades:
# In room spawn logic or ItemSpawnPoint
func _spawn_item(item_scene: PackedScene) -> Node3D:
var item = item_scene.instantiate()
_room.add_child(item)
# Notify UpgradeManager to apply modifiers
EventBus.weapon_spawned.emit(item)
return itemAdd signal emissions to existing code:
| Location | Signal to emit |
|---|---|
Enemy.die() |
EventBus.enemy_killed.emit(self, killer) |
EnemySpawner._spawn_enemy() |
EventBus.enemy_spawned.emit(enemy) |
ThrowableBehaviour.throw() |
EventBus.weapon_thrown.emit(item, direction, from_crowd) |
WeaponBehaviour.destroy() |
EventBus.weapon_broken.emit(item) |
DashComponent.activate() |
EventBus.player_dashed.emit(direction) |
TeleportComponent.execute_teleport() |
EventBus.player_teleported.emit(from, to) |
PickupableBehaviour.try_pick_up() |
EventBus.item_picked_up.emit(item, picker) |
For upgrades like "+1 dash charge" or "+1m teleport range":
# In UpgradeManager
func _apply_ability_modifier(upgrade: Node) -> void:
var player = get_tree().get_first_node_in_group("player")
if not player:
return
# Example: dash charge upgrade
var dash = player.get_node_or_null("DashComponent")
if dash and upgrade.affects_dash:
dash.add_charge() # Already implemented!HealOnKill.tscn
├── BaseUpgrade
│ ├── upgrade_name = "Vampiric Strike"
│ ├── description = "10% chance to heal 5 HP when killing an enemy"
│ └── tags = [COMMON, TRIGGER, ON_KILL]
│
└── TriggerUpgrade
├── trigger = ENEMY_KILLED
├── chance = 0.1
├── cooldown = 0.0
│
└── HealPlayerEffect
└── heal_amount = 5.0
MeleeDurability.tscn
├── BaseUpgrade
│ ├── upgrade_name = "Sturdy Grip"
│ ├── description = "Melee weapons have +5 durability"
│ └── tags = [COMMON, WEAPON_MOD, AFFECTS_MELEE]
│
└── WeaponModifierUpgrade
├── weapon_filter = [Categories.MELEE]
└── stat_modifiers = [
StatModifier(MAX_DURABILITY, ADD, 5.0),
StatModifier(DURABILITY, ADD, 5.0) # Start with bonus
]
IceTeleport.tscn
├── BaseUpgrade
│ ├── upgrade_name = "Frostblink"
│ ├── description = "Leave an ice surface when you teleport"
│ └── tags = [UNCOMMON, TRIGGER, ON_MOVEMENT]
│
└── TriggerUpgrade
├── trigger = PLAYER_TELEPORTED
├── chance = 1.0
├── cooldown = 0.0
│
└── SpawnSurfaceUpgradeEffect
├── surface_scene = preload("res://scenes/surfaces/IceSurface.tscn")
└── use_context_position = true # Spawn at teleport origin
CrossbowDamage.tscn
├── BaseUpgrade
│ ├── upgrade_name = "Sharpened Bolts"
│ ├── description = "Crossbows deal +3 damage"
│ └── tags = [UNCOMMON, WEAPON_MOD, AFFECTS_RANGED]
│
└── WeaponModifierUpgrade
├── weapon_filter = [Categories.CROSSBOW] # Specific item tag
└── stat_modifiers = [
StatModifier(DAMAGE, ADD, 3.0)
]
CrowdThrowOnBreak.tscn
├── BaseUpgrade
│ ├── upgrade_name = "Encore"
│ ├── description = "When a weapon breaks, 50% chance the crowd throws a new one"
│ └── tags = [RARE, TRIGGER, ON_THROW]
│
└── TriggerUpgrade
├── trigger = WEAPON_BROKEN
├── chance = 0.5
├── cooldown = 1.0 # Prevent spam
│
└── CrowdThrowEffect
└── item_filter = [Categories.WEAPON]
- Create
UpgradeCategoriesenum (scripts/core/upgrade_categories.gd) - Create
StatModifierresource (scripts/upgrades/stat_modifier.gd) - Create
BaseUpgradescript (scripts/upgrades/base_upgrade.gd) - Create
UpgradeTriggerenum and script (scripts/upgrades/upgrade_trigger.gd) - Create
TriggerUpgradescript (scripts/upgrades/trigger_upgrade.gd) - Create
WeaponModifierUpgradescript (scripts/upgrades/weapon_modifier_upgrade.gd) - Create
UpgradeEffectbase class (scripts/upgrades/effects/upgrade_effect.gd)
- Create
UpgradeRegistryautoload (scripts/autoloads/upgrade_registry.gd) - Create
UpgradeManagerautoload (scripts/autoloads/upgrade_manager.gd) - Add new signals to
EventBus - Register autoloads in
project.godot
- Add
enemy_killedemission toEnemy.die() - Add
enemy_spawnedemission toEnemySpawner._spawn_enemy_at() - Add
weapon_spawnedemission toItemSpawningManager.spawn_item() - Add
weapon_brokenemission toWeaponBehaviour.destroy() - Add
weapon_thrownemission toThrowableBehaviour.throw() - Add
player_dashedemission toDashComponent.activate() - Add
player_teleportedemission toTeleportComponent._execute_teleport() - Add
item_picked_upemission toPickupableBehaviour.try_pick_up()
- Add
/upgradecommands to DebugConsole
- Create example upgrade effects (HealPlayerEffect, SpawnSurfaceUpgradeEffect, DamageAreaEffect)
- Create 4 example upgrade scenes:
- HealOnKill (trigger: enemy killed, effect: heal)
- MeleeDurability (weapon modifier: +5 durability to melee)
- IceTeleport (trigger: teleport, effect: spawn ice)
- DashDamage (trigger: dash, effect: area damage)
- Test via console commands
- Upgrade selection UI (pick-from-3)
- Upgrade presentation triggers (room clear, chest, etc.)
- Upgrade icons and visual polish
- Meta-progression (unlock new upgrades across runs)
- Should trigger upgrades have a visual/audio feedback when they proc?
- Should stacking increase chance (1 stack = 10%, 2 stacks = 20%) or just effect magnitude?
- How to handle upgrades that affect "currently held weapon" vs "all weapons"?
- Should there be a cap on total upgrades per run?
Use this section when we change scope or make implementation choices.
- 2026-02-02: Initial design. Scene-based upgrades, two types (trigger + weapon modifier), manual console granting for v1.
- 2026-02-02: Phases 1-3 complete. Core infrastructure, autoloads, and event emissions implemented.
- 2026-02-02: Phases 4-5 complete. Debug commands and 4 example upgrades implemented.