Home · Architecture · Combat · Extensions · Known gaps · Source index
Spatial queries, geometry, and RNG
Snapshot/read and command/write model
Live. Every enemy, projectile, and death pass builds an owned EnemySnapshot vector and rebuilds the world's grid. A snapshot row contains ID, position, HP, and radius. Query results return copyable EnemyRef values containing ID, position, and HP.
live World.enemies
-> EnemySnapshot::collect
-> Grid::rebuild (cells hold snapshot indices)
-> SpatialQuery reads immutable snapshot
-> behavior queues commands by EntityId
-> apply commands against live World
This separates broad read access from deferred writes and avoids borrowing unrelated live enemies inside a behavior. A new snapshot and grid rebuild occur for each run_system, not once per frame.
Risk. The snapshot is pass-local, not transactionally live. A target can be killed by an earlier queued command while later commands or projectiles still refer to its snapshot ID. Application looks up the live target, but it does not reject an enemy already at zero HP.
Sources: src/spatial.rs, src/systems.rs, src/world.rs.
Uniform grid
Live. The grid is created with logical width and height 800 and cell size 10, producing 80 × 80 cells. Every cell stores u32 indices into the current snapshot rather than entity IDs.
| Operation | Behavior |
|---|---|
coords(pos) |
Integer cast of pos / cell_size |
rebuild |
Clears cell vectors, clamps each enemy cell to grid bounds, inserts snapshot index |
candidates_in_box |
Clamps query bounds and yields indices from overlapping cells |
| Exact filtering | Performed by each query after broad-phase candidates |
Cell vector capacities survive clear, but the snapshot vector itself is newly collected each pass.
Risk. Negative float-to-integer casts truncate toward zero before clamping. Out-of-range enemies are bucketed into edge cells, so the grid represents positions outside the logical arena as if they were on its boundary for broad-phase purposes; exact distance checks still use the original position.
Source: src/spatial.rs.
Query semantics
Nearest enemy
Live as a utility, not used by the tower. nearest_enemy(from, exclude) widens a square search by one cell at a time. It returns only after the best candidate lies within the current guaranteed circular radius or the grid-wide reach is exhausted. It excludes exactly one entity ID.
The tower does not use this method; it scans world.enemies directly.
Enemies in radius
Unwired through pools. enemies_in_radius(center, r) gathers cells overlapping the circle's bounding box and retains enemies whose centers satisfy distance² ≤ r². It ignores enemy body radius.
Projectile collision
Live. first_hit(pos, projectile_radius) searches a box padded by one cell, keeps body-overlap candidates satisfying:
distance²(enemy.pos, projectile.pos) <= (projectile.radius + enemy.radius)²
and returns the nearest overlapping enemy ID.
The one-cell padding assumes enemy radius is no larger than cell size. Current blobs satisfy this exactly: radius 10, cell size 10.
Sources: src/spatial.rs, src/systems.rs.
Geometry helpers
| Helper | State | Formula/behavior |
|---|---|---|
normal_off_enemy |
Live | Normalize projectile.pos - enemy.pos |
steer |
Unwired | Normalize current/wanted vectors, linear blend clamped to [0,1], renormalize at original speed |
reflect_dir |
Unwired | v - 2(v·n)n |
| Arena wall normal | Live | Adds inward axis normals for crossed boundaries, then normalizes for corners |
Risk. normal_off_enemy normalizes a zero vector if projectile and enemy centers coincide. steer has the same issue for zero current velocity, zero desired direction, or a blended cancellation.
Wall dispatch runs when no enemy was hit. With no current behavior returning reflection or pierce for walls, the projectile is marked consumed. Consumed wall hits do not call on_expire and do not use ExpireReason::Consumed.
Sources: src/geometry.rs, src/world.rs, src/behavior/projectile.rs.
Coordinate-system mismatch
Risk. Simulation bounds and rendered arena offsets are inconsistent.
rendered window: 900 x 900
drawn arena: x=50..850, y=50..850
logical Arena bounds: x=0..800, y=0..800
grid bounds: x=0..800, y=0..800
tower/seek center: (450, 450)
scheduled spawn: (0, 400)
drawn left marker: (50, 450)
Consequences:
- The tower and movement target use the drawn arena center
(450,450), not logical center(400,400). - A scheduled blob starts 50 pixels left and 50 pixels above the drawn left marker.
- The initial spawn is outside the visible arena border.
- Projectiles test walls at 0 and 800, while the visible border is at 50 and 850.
- The grid clamps around 0–800 while enemies seek toward 450,450; that center happens to remain in bounds but is offset from the logical center.
- Drawn top/right/bottom/left markers are visual only and are not wave spawn inputs.
Sources: src/world.rs, src/systems.rs, src/tower.rs, src/enemies/movement.rs.
Deterministic RNG
Live. World seeds its RNG with 1. The constructor XORs the seed with a fixed constant. next_u32 uses xorshift operations; f32 uses 24 output bits to produce [0,1), and range_f32(min,max) linearly maps that value.
Each run_system calls world.rng.fork():
parent state += golden-ratio constant
mixed parent bits seed child stream
behavior pass consumes child only
next pass forks parent again
The child's final state is not merged into the parent. Therefore, how many random values one behavior consumes does not change the next pass's parent-derived stream, but adding/removing a pass or event-delivery wave does.
Current random consumers:
| Consumer | Stream |
|---|---|
| Blob split angle jitter and distance | Child RNG of death sweep |
| Weapon crate offers | Parent world RNG directly |
range_f32 |
Unwired |
Risk. run_system forks even when a pass has no random work, and every event delivery wave forks again. Changes to pass/event counts perturb later crate offers and split streams even if the new code never rolls a random value.
Risk. Deterministic RNG does not make all behavior order deterministic: tower loadout starts from HashMap iteration. Current unequal behavior priorities provide a stable poison-before-impact ordering, but equal-priority additions would need an explicit tie-break.
Sources: src/rng.rs, src/systems.rs, src/world.rs, src/enemies/behaviors.rs.
Verification gaps
There are no unit or integration tests for nearest-neighbor completeness, collision padding, out-of-bounds bucketing, zero-vector normalization, RNG sequences/fork isolation, split placement, or coordinate conversion. See IncompleteSystems.
Home · Architecture · Combat · Extensions · Known gaps · Source index