td

EnemiesAndWaves
Login

EnemiesAndWaves

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:

  1. Checks distance_squared(enemy.pos, tower_location) <= enemy.radius.
  2. If true, emits GameEvent::DespawnEnemy(id).
  3. Sets velocity to the normalized direction toward (CENTER_X, CENTER_Y) at 60 units/s.
  4. 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