Home · Architecture · Combat · Extensions · Known gaps · Source index
Combat pipeline
This page describes the executable combat path in the current Rust source. Labels mean:
| Label | Meaning |
|---|---|
| Live | Called by the main loop and mutates or renders current play |
| Partial | Some implementation exists, but important behavior is absent |
| Unwired | Defined but not reached by the current runtime |
| Risk | Live behavior with a correctness or maintenance hazard |
Frame order
Live. Macroquad clamps simulation dt to 0.05 seconds, reads unclamped wall-clock time only for the crate UI, and steps the world even when a weapon crate is open.
main loop
get_frame_time().min(0.05)
step_world
now += dt
tick_wave
tick_enemies
tick_tower
tick_projectiles
death_sweep
animation_system
draw_simulation
weapons_crate UI, if open
apply one UI action, if any
next_frame
Sources: src/main.rs, src/systems.rs.
Deferred command boundary
Live. Enemy, projectile, status, and death behavior passes use run_system. Each pass:
fork world RNG
-> collect EnemySnapshot
-> rebuild Grid
-> run hooks against snapshot/query
-> append Commands
-> apply_commands to live World in insertion order
-> deliver generated GameEvents in waves
The command buffer is the write side of behavior execution. It supports projectile, pool, enemy, and animation spawning; damage; status application; despawn; resource grants; events; and enemy replication. Application is deliberately non-reentrant: newly caused deaths are not dispatched recursively inside apply_commands.
| Command | Current application |
|---|---|
SpawnProjectile |
Allocates a fresh entity ID, then pushes |
SpawnPool |
Pushes a pool without an entity ID |
SpawnEnemy |
Allocates a fresh entity ID, then pushes |
Damage |
Resolves DamageMap, applies resistance, records tower DPS, may emit EnemyKilled |
ApplyStatus |
Appends a boxed status to the target |
Despawn |
Removes matching enemies and projectiles |
GrantResource(Wp) |
Adds player XP; Sp and Tp do nothing |
Emit |
Queues a game event |
SpawnAnimation |
Allocates an ID and pushes |
Replicate(Enemy) |
Clones the live enemy's mutated behaviors into a fresh registry template |
Sources: src/command.rs, src/world.rs, src/context.rs.
Projectile hit flow
Live. The tower aims at the nearest vector-distance enemy, creates one projectile at the tower position when its cooldown expires, and materializes a fresh behavior list from the tower loadout. Behaviors are sorted by their own priority.
projectile behavior tick
-> integrate position
-> SpatialQuery::first_hit
yes: compute enemy surface normal
run every on_hit_enemy hook
fold reflect/pierce responses
queue one Damage command from HitContext.damage
queue each accumulated status
no: test arena wall
run every on_hit_wall hook
-> test lifetime and run on_expire(Lifetime)
-> retain unless marked consumed/expired
-> apply queued commands after all projectiles
dispatch_enemy_hit runs every behavior even when an earlier behavior requests reflection or piercing. The last non-None reflection wins; any behavior requesting pierce keeps the projectile unless a reflection is present. With no reflect and no pierce, the projectile is consumed. Damage and statuses are still queued for that hit.
Risk. The collision snapshot is fixed for the entire projectile pass. Multiple projectiles can select the same enemy before queued damage is applied. Commands are then applied in order against the live enemy, including one that is already at or below zero HP.
Sources: src/systems.rs, src/behavior/projectile.rs, src/spatial.rs, src/geometry.rs.
DamageMap
Live. A hit carries a map keyed by typed DamageID. Each kind has independent accumulators:
kind damage = max(0, flat * max(0, 1 + sum(increased)) * product(max(0, 1 + more)))
hit total = sum(all kind damage)
TowerBaseWeapon contributes physical flat damage equal to its tier and 1% * (tier - 1) increased physical damage. At application, the enemy handles each kind separately:
damage_done = computed_kind_damage * (1 - enemy_resistance_for_kind)
enemy.hp -= damage_done
Risk. Resistance is not clamped. A resistance above 1 makes damage_done negative and therefore heals; negative resistance amplifies damage. Overkill is not clamped to remaining HP, so meters record the full resolved amount.
Partial. DamageBehavior factories for Physical and Poison are registered, but combat never calls DamageBehavior::compute; live resolution is DamageEffects::compute plus the enemy resistance map.
Sources: src/context.rs, src/enemy.rs, src/weapons/impact.rs, src/behavior/damage.rs, src/registry.rs.
Poison damage over time
Live. Every Poison Coating hit appends an independent PeriodDamage status. Tier t configures total-rate amount 3 + 1.5t, interval 1 second, and lifetime 5 seconds. Despite the interval name, damage is continuous every simulation frame:
frame DoT = (amount / interval) * dt
duration += dt
queue Damage(target, poison map, captured projectile source, Dot)
expire status when duration > lifetime
The projectile source is captured when the status is created, so tower-applied poison remains attributed to Source::Tower during the generic enemy tick, whose context source is None. This feeds DoT DPS and kill attribution.
Risk. Expiration uses duration > lifetime after queuing damage, so a status also queues damage on the first frame that moves it beyond five seconds. Repeated hits stack statuses without merging, refreshing, or a cap.
Sources: src/weapons/poison.rs, src/behavior/status.rs, src/types.rs.
Death and event delivery
Live. A Damage command that leaves an enemy dead immediately appends GameEvent::EnemyKilled(Corpse). Event delivery handles that event by spawning a 2.5-second death animation and granting the corpse XP. Events may generate commands and further events for up to eight waves.
Later in the same frame, death_sweep finds every non-alive enemy, dispatches its live on_death behaviors, and emits another EnemyKilled(Corpse). After that pass it removes dead enemies and may open a crate for queued level-ups.
Damage kills enemy
-> EnemyKilled event #1
-> animation + XP
...
death_sweep sees same dead enemy
-> on_death (for example split)
-> EnemyKilled event #2
-> animation + XP
-> remove enemy
Risk: duplicate kill rewards. A normal command-buffer kill emits at least two kill events: one in apply_commands and one in death_sweep. Multiple queued damage commands against the already-dead target can each return Killed and emit additional events. Each event grants full XP and creates a death animation. There is no “death handled” flag or event deduplication.
The event cascade silently stops after eight waves; remaining events are dropped without the suggested log or assertion.
Sources: src/world.rs, src/systems.rs, src/enemy.rs.
Expiry and hooks
| Path | Status |
|---|---|
Projectile on_tick |
Live |
Projectile on_hit_enemy / on_hit_wall |
Live |
Projectile on_expire(Lifetime) |
Live, with current lifetime fixed at 1000 seconds |
ExpireReason::OutOfBounds / Consumed |
Unwired |
Projectile on_fire |
Unwired; no dispatcher is called |
Tower behavior hooks and tower.mods |
Unwired |
| Pool behavior pass | Unwired; see IncompleteSystems |
See also EnemiesAndWaves, TowersWeaponsProgression, and SpatialGeometryRng.
Home · Architecture · Combat · Extensions · Known gaps · Source index