Home · Architecture · Combat · Extensions · Known gaps · Source index
Enemies and waves
Wave scheduler
Live. World begins with wave_timer = 0, so the first call to tick_wave spawns immediately. Every frame subtracts dt; a spawn occurs when the timer is non-positive and the current enemy vector has fewer than eight entries.
| Setting | Implemented value |
|---|---|
| Interval after a successful spawn | 2.5 s |
| Soft concurrent cap checked by scheduler | 8 enemies |
| Registered wave enemy | blob |
| Spawn position | (0, arena.height * 0.5) = (0, 400) |
| Tier expression | round((10 + world.now) / 10) |
Only one enemy can be spawned per call; missed intervals are not caught up. If the cap blocks a spawn, the timer remains non-positive. The next frame after the count falls below eight therefore spawns immediately and resets the timer.
Risk. The cap covers only scheduled wave spawns. Split replication directly pushes children and can raise the enemy count above eight.
Risk. Every successful spawn prints the floating-point tier and simulation time to stdout.
Sources: src/systems.rs, src/world.rs, src/registry.rs.
Blob construction and scaling
Live. Blob is the only registered enemy kind. EnemyRegistry::make_kind creates the registry template and then stamps its real typed EnemyID.
hp = round(1.0 * 1.6^tier)
xp = round(1.0 * 1.3^tier)
radius = 10
velocity = (0, 0)
resistances = empty
statuses = empty
behaviors = [SplitOnDeath(3), SeekCenter]
The enemy stores both current and maximum HP, tier, XP, death-animation kind, killer, behaviors, and a function pointer used to draw it. Empty resistance entries mean zero resistance to every registered damage kind.
Blob color is (0.3, hp_fraction * 0.2, hp_fraction * 0.2, 1.0), where HP fraction is clamped to [0, 1]. Damage darkens the green and blue channels; there is no separate health bar or tier label.
Sources: src/enemies/blob.rs, src/enemy.rs, src/registry.rs.
Movement and center arrival
Live. Enemy behaviors run before statuses and movement integration. SeekCenter:
- Checks
distance_squared(enemy.pos, tower_location) <= enemy.radius. - If true, emits
GameEvent::DespawnEnemy(id). - Sets velocity to the normalized direction toward
(CENTER_X, CENTER_Y)at 60 units/s. - After behavior/status dispatch, the enemy is moved by
velocity * dt.
The emitted event is delivered after the enemy pass and becomes a Despawn command in a second event wave. Reaching the tower grants no XP and applies no player/tower damage.
Risk. The arrival comparison mixes squared distance with an unsquared radius. With radius 10, it triggers within sqrt(10), not within 10 units.
Risk. The behavior normalizes center - pos even after deciding the enemy has arrived. At the exact center, normalizing a zero vector may produce invalid components depending on Macroquad/glam behavior, though deferred despawn follows.
Partial. ZigZag exists but is byte-for-byte equivalent in effect to SeekCenter and is never attached to a registered enemy.
Sources: src/enemies/movement.rs, src/systems.rs, src/world.rs.
Blob splitting
Live. Every newly constructed blob receives SplitOnDeath::create(3), setting both generations and count to 3 with a spread of 22. On death the live behavior mutates before it queues replication:
if generations == 0 or count == 0: stop
generations -= 1
count -= 1
repeat count times:
angle = evenly spaced ring angle
jitter = deterministic random value in [-0.3, +0.3) radians
radius = deterministic random value in [11, 22)
queue Replicate(original enemy id, death position + offset)
Replication looks up the still-live dead parent, reads its kind, tier, and already-mutated cloned behavior list, creates a fresh full-stat registry template, replaces that template's behaviors with the clones, sets the child position, allocates an ID, and pushes it.
The resulting family is:
original: count 3 --death--> 2 children carrying count 2
each child: count 2 --death--> 1 child carrying count 1
each grandchild: count 1 --death--> no child
total entities in one family: 1 + 2 + 2 = 5
Children retain the parent's tier and therefore receive full tier-scaled HP and XP. They do not inherit current HP, statuses, resistance mutations, velocity, killer, or position other than the explicit split location. They do inherit cloned enemy behavior state.
Risk. Every descendant grants full XP, amplified further by the duplicate EnemyKilled path described in CombatPipeline.
Risk. Replication bypasses the scheduler's MAX_ENEMIES check and the World::spawn_enemy helper, although it does allocate a fresh ID itself.
Sources: src/enemies/behaviors.rs, src/world.rs, src/enemies/blob.rs.
Enemy tick and death lifecycle
snapshot all enemies
rebuild spatial grid
for each live vector entry:
dispatch enemy on_tick hooks
dispatch every status on_tick; remove statuses returning true
integrate velocity
apply queued commands
deliver events
...
after projectiles: death_sweep
dispatch on_death for every hp <= 0 entry
emit EnemyKilled
remove dead entries
open crate if level-ups are pending
Risk. A status queues damage rather than applying it immediately, so an enemy that will die from this frame's DoT still completes behavior dispatch and movement before command application.
Risk. death_sweep is the only guard around on_death, and dead enemies remain in the vector until that sweep. Multiple damage commands can emit multiple kill events before the one on-death dispatch. See CombatPipeline.
Registry and lifecycle inventory
| Item | State | Notes |
|---|---|---|
| Generic typed enemy registry | Live | Name interning, dense typed IDs, display, priority, factory |
blob definition |
Live | Sole registered kind |
| Enemy behavior priority method | Partial | Defined, but enemy behaviors are not sorted before dispatch |
| Corpse snapshot | Live | Carries id, pos, kind, tier, XP, animation, killer, radius, overkill HP |
| Enemy damage to tower/player | Unwired | Center arrival only despawns |
| Alternative lanes/spawn points | Unwired | Four markers are drawn, but waves use one numeric spawn |
ENEMY_SPEED constant |
Unwired | Movement hardcodes 60.0 separately |
ENEMY_MAX_HP constant |
Unwired | Blob computes its own tier HP |
Coordinate warning
The wave, arena collision, grid, and renderer do not use one coordinate convention. A wave starts at (0, 400), while the drawn arena begins at (50, 50) and its left spawn marker is (50, 450). The tower is at (450, 450). Full details are in SpatialGeometryRng.
Home · Architecture · Combat · Extensions · Known gaps · Source index