Home · Architecture · Combat · Extensions · Known gaps · Source index
Towers, weapons, and progression
Tower runtime
Live. The sole tower is created at (450, 450) with zero cooldown and a three-second damage-meter window. Every frame it aims at the enemy with minimum center-to-center distance. When cooldown is non-positive and the enemy vector is nonempty, it:
- Resets cooldown to
1 / 4.2seconds. - Builds a velocity of 300 units/s along the current angle.
- Allocates a projectile ID.
- Materializes every equipped
(WeaponID, tier)throughWeaponRegistry. - Sorts the resulting behaviors by
ProjectileBehavior::priority. - Pushes one radius-5 projectile with source
Towerand lifetime 1000 seconds.
Risk. Target selection does not filter hp <= 0, so an enemy killed earlier in the frame but not yet swept can still influence aim and permit firing.
Risk. The cooldown emits at most one shot per frame and resets rather than carrying negative remainder. Low frame rates therefore lose shots instead of catching up.
Sources: src/tower.rs, src/systems.rs, src/world.rs.
Persistent loadout, ephemeral behaviors
Live. Tower.weapon_levels is a HashMap<WeaponID, u32>. It is the persistent loadout; boxed projectile behaviors are newly constructed for every shot.
crate pick / initial equip
-> weapon_levels[id] += 1
next shot
-> for each (id, tier)
WeaponRegistry::make(id, tier)
-> sort behavior instances by behavior priority
-> attach to projectile
This means a crate upgrade affects the next shot with no rebuild operation. The initial tower already equips both registered weapons at tier 1.
| Weapon | Initial state | Behavior priority | Implemented tier effect |
|---|---|---|---|
| Impact | Tier 1 | IMPACT = 100 |
Physical flat = tier; increased = 1% × (tier − 1) |
| Poison Coating | Tier 1 | DEFAULT = 0 |
Poison amount = 3 + 1.5 × tier over nominal 5 s |
Poison runs before Impact because of priority, but it only appends a status while Impact appends damage, so current results do not depend on their relative execution beyond that guarantee.
Risk. Loadout iteration begins in a standard HashMap, whose order is not a replay-stable content ordering. Sorting fixes current cross-priority order, but equal-priority weapons inherit that arbitrary starting order.
Sources: src/tower.rs, src/registry.rs, src/weapons/impact.rs, src/weapons/poison.rs.
Weapon and content registries
Live. The generic registry interns string names into dense, type-distinct IDs and stores display name, integer priority, factory, and an all list. Separate marker types prevent mixing weapon, enemy, damage, and status IDs at compile time.
| Registry | Seeded entries | Current use |
|---|---|---|
| Weapon | impact, poison_coating |
Loadout creation and crate display/offers |
| Enemy | blob |
Wave and split construction |
| Damage | physical, poison |
IDs supplied to weapons/statuses; factories themselves unused |
| Status | poison |
Constructed and stored, but live poison bypasses it |
Partial. Registry definition priority is readable through Registry::priority, but firing sorts the created behavior's priority instead. The status registry's poison creates a fixed Source::None status and is not requested by current gameplay.
Risk. Re-registering a name overwrites its definition but appends the same typed ID to all again. There is no duplicate guard. Crate rolling assumes the weapon catalog is nonempty; current seeding satisfies that assumption.
Sources: src/registry.rs, src/world.rs.
XP and levels
Live. Only ResourceKind::Wp is connected, and it is treated as player XP. Required XP is:
required(level) = round(10.0 * 1.6^level)
The player starts at level 0, XP 0, next requirement 10, and no pending picks. Adding XP loops across as many thresholds as the award crosses; each threshold increments both level and level_ups.
| Resource | State |
|---|---|
Wp |
Live: calls Player::add_xp |
Sp |
Unwired: command arm is empty |
Tp |
Unwired: command arm is empty |
XP comes from EnemyKilled(Corpse) event handling. The duplicate event risk can multiply awards; see CombatPipeline.
Sources: src/world.rs, src/types.rs.
Weapon crate state machine
Live. After death_sweep, if picks are pending, the player did not manually close a crate, and the screen is Playing, the world rolls three offers and opens the crate.
Offers are sampled independently with replacement:
offer[i] = all_weapons[parent_rng.next_u32() % all_weapons.len()]
With two registered weapons and three cards, duplicates are expected and valid.
| Input | While crate open |
|---|---|
| Left / Right | Move cursor within cards 0–2 |
| Enter | Pick selected weapon |
| Space | Close without consuming a level-up |
Picking calls tower.equip(id), consumes one pending level, and resets player status to None. If more picks remain, dismiss_weapon_crate immediately rolls another crate; otherwise it returns to Playing.
Closing sets Screen::Playing and PlayerStatus::ManuallyClosed without consuming the pending pick. Automatic reopening is then suppressed. Pressing Space during normal play calls open_weapon_crate; if a pick remains, this reopens it. A successful later pick resets status.
Live but surprising. Space during normal play cannot create a free crate because open_weapon_crate returns when no level-up is pending.
Risk. Simulation is not paused while the crate is displayed. main calls step_world for every screen state before drawing and handling the crate, despite the “two-clock/pause” comments in context code. Enemies, projectiles, waves, deaths, and level gains continue behind the overlay.
Sources: src/main.rs, src/systems.rs, src/world.rs.
DPS meters
Live. Tower-attributed Damage commands record their resolved amount by delivery type:
| Meter | Receives |
|---|---|
| Hit | DamageDelivery::Hit |
| DoT | DamageDelivery::Dot |
| Total | Hit DPS + DoT DPS |
Each meter stores (world.now, amount) samples. On every new record it removes samples older than three seconds, then reports sum / 3. Rendering displays all three values.
Risk. Old samples are pruned only when another damage record arrives. If combat stops, displayed DPS can remain nonzero indefinitely.
Risk. The denominator is always three seconds, including the first seconds of play, and damage is not capped to remaining enemy HP. These are valid implementation choices but should not be read as instantaneous DPS or effective HP removed.
Sources: src/tower.rs, src/world.rs, src/systems.rs.
Tower and fire extension hooks
| Hook/storage | State | Evidence |
|---|---|---|
ProjectileBehavior::on_fire |
Unwired | Defined, no dispatcher/call site |
FireContext |
Unwired | Defined with origin, aim, target; never constructed |
TowerBehavior hit/tick/expire hooks |
Unwired | Trait exists; no dispatch functions |
Tower.mods |
Unwired | Initialized empty and never read by systems |
| Multishot/fan shot shaping | Unwired | Tower directly pushes exactly one projectile |
The projectile hooks after launch are live, but the pre-flight and tower-modification layers are scaffolding only. See IncompleteSystems.
Home · Architecture · Combat · Extensions · Known gaps · Source index