Compare commits

..

4 Commits

Author SHA1 Message Date
f1b85862da docs: correct the rebuild-cost yardstick in ADR-0004 and the spec
Measured while building #16: at 10 000 enemies the grid rebuild costs
0.057 ms against 0.021 ms for move_enemies — 2.7x more, not less. ADR-0004
§3 counted arithmetic and missed what dominates: move_enemies is a linear
table walk, the rebuild writes into ~7000 scattered buckets.

The decision does not change. It never rested on the comparison but on the
absolute bound of 0.10 ms, and 0.057 ms is 0.34 % of the frame budget; an
incremental update would pay the same scattered write on every cell
crossing. Revision trigger 1 and the acceptance criterion of spec feature 3
are restated on that bound, which is what the runnable check now asserts.

Also records the second deviation the build produced: a cell holds
(Entity, Vec2) rather than Entity, because soft separation writes Transform
while reading its neighbours' positions.
2026-08-11 09:03:43 +02:00
0f93a939c0 feat: add enemies, spatial grid and soft separation
Enemies spawn on the arena rim, walk radially at the tower and stop at the
attack range. The spawner is cap-driven — it tops the field up to the target
population instead of placing N enemies on a schedule — so the target
population is the binding quantity the difficulty curve will stand on.

The spatial grid of ADR-0004 lands with them, because it now has its first
consumer and #17 needs it unchanged for target selection and projectile
collision. One full rebuild per frame is the only pass over the enemy set;
every consumer asks the grid.

Soft separation is a clamped positional offset, not a force: overlap stays
allowed, and capping the offset below one movement step is what keeps the
tower ring from pulsing.

Closes #16.
2026-08-11 09:03:36 +02:00
afeb09bdcd chore: bring skeleton branch up to date with master 2026-08-10 16:12:08 +02:00
bcc6d333d4 feat: add app skeleton with balance module, arena and tower 2026-08-10 15:50:58 +02:00
9 changed files with 1040 additions and 9 deletions

View File

@@ -68,6 +68,13 @@ System, das einen Durchlauf über dieselbe Gegnermenge macht, kostet höchstens
sein) und der Turm steht fest in der Mitte — es gibt keinen Grund für eine Hashmap. Der Zellindex sein) und der Turm steht fest in der Mitte — es gibt keinen Grund für eine Hashmap. Der Zellindex
ist zwei Divisionen und zwei `floor`. Gegner außerhalb des Gitters werden in die Randzelle geklemmt. ist zwei Divisionen und zwei `floor`. Gegner außerhalb des Gitters werden in die Randzelle geklemmt.
**Nachtrag vom 2026-08-11 (#16): je Zelle ein `Vec<(Entity, Vec2)>`, nicht `Vec<Entity>`.** Die
Position wird neben der `Entity` mitgeführt. Der Grund ist die weiche Separation aus §5: sie schreibt
`Transform` und liest dabei die `Transform`s ihrer Nachbarn. Mit bloßen `Entity`s müsste sie dieselbe
Gegner-Query gleichzeitig lesend und schreibend halten — in Bevy nicht ausleihbar, und die Auswege
(`ParamSet`, ein zweiter Puffer-Durchlauf) kosten mehr als das eine `Vec2` je Bucket-Eintrag. Die
Entscheidung ändert sich dadurch nicht; die Ablage tut es.
Das Grid ist **kein** `Component` an der Turm-Entity: Es beschreibt nicht den Turm, sondern die Das Grid ist **kein** `Component` an der Turm-Entity: Es beschreibt nicht den Turm, sondern die
Welt, und es überlebt keinen Run-Wechsel als Zustand — es wird ohnehin jeden Frame neu gebaut. Welt, und es überlebt keinen Run-Wechsel als Zustand — es wird ohnehin jeden Frame neu gebaut.
Damit bleibt die Ablageregel aus ADR-0002 §6 unberührt. Damit bleibt die Ablageregel aus ADR-0002 §6 unberührt.
@@ -118,6 +125,38 @@ schiebt eine `Entity` in einen `Vec`.
Die 10 ns sind eine großzügige Obergrenze, weil sie den teureren Nachbarn als Maßstab nimmt. Die 10 ns sind eine großzügige Obergrenze, weil sie den teureren Nachbarn als Maßstab nimmt.
#### Nachtrag vom 2026-08-11 (nachgemessen in #16): der Maßstab war falsch, die Schranke hält
Die Rechnung oben ist beim Bau des Grids nachgemessen worden — Release-Profil, `HARD_ENEMY_CAP`
Gegner, Median über 20 Läufe je System (`the_rebuild_stays_cheaper_than_move_enemies_at_the_hard_cap`
in `src/spatial.rs`):
| System | gemessen bei 10 000 Gegnern |
|---|---|
| `move_enemies` | **0,021 ms** |
| `rebuild_spatial_grid` | **0,057 ms** |
**Der Rebuild ist rund 2,7× teurer als `move_enemies`, nicht billiger.** Der Absatz oben zählt
Arithmetik — zwei Divisionen und zwei `floor` gegen eine Normalisierung mit Wurzel — und übersieht
die Kosten, die hier dominieren: `move_enemies` ist ein **linearer** Lauf über eine Tabelle, der
Rebuild schreibt in ~7 000 verstreute `Vec`s und zahlt Cache-Misses, die keine FLOP-Zählung sieht.
Der `Vec`-Push, den der Absatz als „Rest der Ersparnis" abtut, **ist** der Kostenpunkt.
Was das ändert und was nicht:
- **Die Entscheidung bleibt.** Sie stand nie auf dem Vergleich, sondern auf der absoluten Schranke:
0,057 ms sind **0,34 % des Frame-Budgets** und liegen unter den hier veranschlagten ≤0,10 ms. Die
Logik gesamt bleibt bei ≤0,18 ms.
- **Das inkrementelle Update wird dadurch nicht attraktiver.** Es würde denselben verstreuten
Schreibzugriff bezahlen, sobald ein Gegner die Zelle wechselt — und bei radialer Konvergenz
wechseln ständig welche —, plus Rückindex und Entfernen aus der Mitte eines `Vec`. Der Preis, der
hier wehtut, ist genau der, den es nicht einspart.
- **Der Maßstab „billiger als `move_enemies`" ist gestrichen.** Er war eine Bequemlichkeit, kein
Argument. Revisions-Auslöser 1 unten ist entsprechend auf die absolute Schranke umgestellt.
Zu messen ist erneut nach dem Wechsel auf echte Texturen und mit dem HUD — dieselbe Auflage, unter
der die Zahlen aus #4 stehen.
**Was ein inkrementelles Update dagegen einspart, ist nach oben durch dieselben ≤0,10 ms begrenzt — **Was ein inkrementelles Update dagegen einspart, ist nach oben durch dieselben ≤0,10 ms begrenzt —
und es spart nicht einmal die, denn den Zellindex muss es genauso je Gegner ausrechnen, um einen und es spart nicht einmal die, denn den Zellindex muss es genauso je Gegner ausrechnen, um einen
Zellwechsel überhaupt zu erkennen.** Der Rest der Ersparnis ist der `Vec`-Push. Dagegen steht: ein Zellwechsel überhaupt zu erkennen.** Der Rest der Ersparnis ist der `Vec`-Push. Dagegen steht: ein
@@ -280,10 +319,13 @@ und die Auslöse-Bedingungen, wo sie sichtbarer wäre.
Diese Entscheidung wird neu getroffen, sobald **eines** der folgenden Ereignisse eintritt: Diese Entscheidung wird neu getroffen, sobald **eines** der folgenden Ereignisse eintritt:
1. **Der Rebuild ist nicht mehr der billigere Durchlauf.** Eine Messung mit dem Aufbau aus 1. **Der Rebuild sprengt seine absolute Schranke.** Eine Messung bei `HARD_ENEMY_CAP` zeigt
`examples/perf_spike.rs` bei `HARD_ENEMY_CAP` zeigt `rebuild_spatial_grid` teurer als `rebuild_spatial_grid` über **0,10 ms**. Die Entscheidung gegen das inkrementelle Update steht und
`move_enemies` im selben Frame. Die Entscheidung gegen das inkrementelle Update steht und fällt fällt mit dieser Schranke; ist sie gerissen, wird sie neu getroffen — nicht vorher.
mit dieser Annahme; ist sie widerlegt, wird sie neu getroffen — nicht vorher. *Fassung bis 2026-08-11: „teurer als `move_enemies` im selben Frame". Dieser Maßstab ist in #16
widerlegt worden (Nachtrag zu §3) — der Rebuild war von Anfang an teurer, ohne dass die
Entscheidung dadurch fällig wäre. Er ist durch die Schranke ersetzt, auf der das Argument
tatsächlich steht.*
2. **Radien-Spreizung.** Ein Konsument braucht einen Abfrageradius kleiner als eine halbe Zelle oder 2. **Radien-Spreizung.** Ein Konsument braucht einen Abfrageradius kleiner als eine halbe Zelle oder
größer als ‹20› Zellkanten. Dann trägt **eine** Auflösung nicht mehr für alle Abfragen, und zwei größer als ‹20› Zellkanten. Dann trägt **eine** Auflösung nicht mehr für alle Abfragen, und zwei
Auflösungen oder eine hierarchische Struktur sind zu prüfen. Auflösungen oder eine hierarchische Struktur sind zu prüfen.

View File

@@ -141,9 +141,11 @@ Auslöse-System und die Projektil-Kollision bedient.
Sprung-zu-Welle auf die höchste im Debug-Panel erreichbare Welle. Sprung-zu-Welle auf die höchste im Debug-Panel erreichbare Welle.
- Bei 10 000 gleichzeitigen Gegnern bleibt die Frametime unter 16,67 ms (Nachmessung mit dem Aufbau - Bei 10 000 gleichzeitigen Gegnern bleibt die Frametime unter 16,67 ms (Nachmessung mit dem Aufbau
aus `examples/perf_spike.rs`, nach dem Wechsel auf echte Sprites zu wiederholen). aus `examples/perf_spike.rs`, nach dem Wechsel auf echte Sprites zu wiederholen).
- Der Rebuild des `SpatialGrid` kostet bei 10 000 Gegnern **nicht mehr als `move_enemies` im selben - Der Rebuild des `SpatialGrid` kostet bei 10 000 Gegnern **höchstens 0,10 ms** (ADR-0004 §3 — die
Frame** (ADR-0004 §3 — die Entscheidung gegen ein inkrementelles Update steht auf dieser Annahme; Entscheidung gegen ein inkrementelles Update steht auf dieser Schranke; reißt sie, ist die ADR
reißt sie, ist die ADR fällig, nicht die Zahl). fällig, nicht die Zahl). *Bis 2026-08-11 stand hier „nicht mehr als `move_enemies` im selben
Frame"; die Messung in #16 hat diesen Maßstab widerlegt — gemessen 0,057 ms gegen 0,021 ms —, ohne
die Schranke zu reißen. Siehe den Nachtrag zu ADR-0004 §3.*
- Kein Gegner bewegt sich in einem Frame vom Turm **weg**, solange ihn keine Fähigkeit dorthin - Kein Gegner bewegt sich in einem Frame vom Turm **weg**, solange ihn keine Fähigkeit dorthin
drückt — die Separationsklemmung macht das konstruktiv unmöglich, und der Turmring pulsiert drückt — die Separationsklemmung macht das konstruktiv unmöglich, und der Turmring pulsiert
sichtbar nicht. sichtbar nicht.

41
src/arena.rs Normal file
View File

@@ -0,0 +1,41 @@
//! The arena: the disc the tower stands on and enemies walk in across.
//!
//! Programmer art, no asset files. The arena is scenery and outlives a run, so it is spawned
//! once at startup and is *not* bound to the tower entity (ADR-0002 §6).
use bevy::prelude::*;
use crate::balance::Balance;
/// Draw order. The arena is the backdrop everything else sits on.
pub const ARENA_Z: f32 = 0.0;
const FLOOR_COLOR: Color = Color::srgb(0.10, 0.11, 0.14);
const RIM_COLOR: Color = Color::srgb(0.22, 0.24, 0.30);
/// How much wider than the arena the rim ring is drawn, so it reads as an outline.
const RIM_WIDTH: f32 = 6.0;
/// Spawns the arena floor, sized from `Res<Balance>` so a changed radius moves the rim
/// without any further code change.
pub fn spawn_arena(
mut commands: Commands,
balance: Res<Balance>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
let radius = balance.arena.radius;
// ponytail: two circle meshes instead of a ring mesh — the outer one is just a slightly
// larger disc behind the floor. No asset files, and the visible edge is the actual spawn
// rim rather than a decorative square.
commands.spawn((
Mesh2d(meshes.add(Circle::new(radius + RIM_WIDTH))),
MeshMaterial2d(materials.add(RIM_COLOR)),
Transform::from_xyz(0.0, 0.0, ARENA_Z),
));
commands.spawn((
Mesh2d(meshes.add(Circle::new(radius))),
MeshMaterial2d(materials.add(FLOOR_COLOR)),
Transform::from_xyz(0.0, 0.0, ARENA_Z + 0.1),
));
}

138
src/balance/mod.rs Normal file
View File

@@ -0,0 +1,138 @@
//! Balancing data (ADR-0001).
//!
//! This module holds **only data and data structures**. No gameplay logic, no Bevy systems.
//! Gameplay reads `Res<Balance>` and never sees the constants in [`values`].
//!
//! The numbers themselves live in [`values`] and are all placeholders; this file only gives
//! them their shape. `Balance` is deliberately built from plain fields, numbers and vectors —
//! no `Handle`, no `Entity` — so it could be serialized without a redesign if the revision
//! triggers of ADR-0001 ever fire. A `serde` derive is explicitly *not* added now.
mod values;
use bevy::prelude::*;
/// The numeric type of every stat and balancing value (ADR-0002 §4).
///
/// An alias, not a newtype. `f32` carries seven significant digits, which is enough for the
/// slice; ADR-0002 revision trigger 1 says when this is reconsidered.
pub type Stat = f32;
/// All balancing data of the game, in one place.
///
/// Built by exactly one function, [`Balance::from_constants`]. Nothing else constructs a
/// `Balance`, which is what makes that function the seam an asset loading path would replace.
#[derive(Resource, Debug, Clone)]
pub struct Balance {
pub arena: ArenaBalance,
pub tower: TowerBalance,
pub enemy: EnemyBalance,
pub combat: CombatBalance,
pub meta: MetaBalance,
pub caps: Caps,
}
/// The playing field.
#[derive(Debug, Clone)]
pub struct ArenaBalance {
/// Distance from the tower in the centre to the rim enemies spawn on, in world units.
pub radius: Stat,
}
/// Base values of the tower, before any modifier from ADR-0002 is applied.
#[derive(Debug, Clone)]
pub struct TowerBalance {
/// Damage of a single shot.
pub damage: Stat,
/// Seconds between two shots — an interval, not a rate (ADR-0002 §3).
pub attack_interval: Stat,
/// Tower health. The tower dying is what ends a run.
pub health: Stat,
}
/// Base values of the single enemy archetype the skeleton knows.
#[derive(Debug, Clone)]
pub struct EnemyBalance {
/// Movement speed in world units per second.
pub speed: Stat,
/// Health at wave 1.
pub health: Stat,
/// Distance from the tower centre at which an enemy stops walking.
pub attack_range: Stat,
/// Enemies closer than this push each other apart (ADR-0004 §5).
pub separation_radius: Stat,
/// ‹k›: how many neighbours one enemy takes into account (ADR-0004 §5).
pub separation_neighbours: usize,
/// ‹f› < 1: the separation offset is clamped to `f x (speed x dt)` (ADR-0004 §5).
pub separation_clamp: Stat,
/// Living enemies the spawner fills the field up to. Becomes `target_population(wave)`.
pub target_population: usize,
/// `HARD_ENEMY_CAP`: the measured performance limit, never exceeded by anything.
pub hard_cap: usize,
}
/// Values that describe how things touch each other.
#[derive(Debug, Clone)]
pub struct CombatBalance {
/// A projectile within this distance of an enemy counts as a hit, in world units.
pub hit_radius: Stat,
}
/// Meta progression income — the currency that survives a run.
#[derive(Debug, Clone)]
pub struct MetaBalance {
/// Meta currency granted per killed enemy.
pub gain_per_kill: Stat,
}
/// Upper bounds for the percentage-point sums of the stat pipeline (ADR-0002 §2).
///
/// Empty on purpose. ADR-0002 §2 requires every clamp limit to be a balancing value living
/// here — one per clamped stat — with no global clamp and no default in code. The skeleton
/// has none of the stats that carry a cap (`crit_chance`, `defense_percent`,
/// `cooldown_reduction`, `wave_cooldown_reduction`, `free_upgrade_chance`), so there is
/// nothing to put in yet.
///
// ponytail: an empty struct rather than five caps nothing reads. It exists so the place is
// named and reachable as `balance.caps.*`; the fields land with the stats that need them in
// the thickening phase, and the compiler will point at this struct when they do.
#[derive(Debug, Clone, Default)]
pub struct Caps {}
impl Balance {
/// Builds `Balance` from the constants in [`values`].
///
/// **The one and only constructor** (ADR-0001, decision point 3). Everything that wants
/// balancing data goes through the resource this returns; nothing else assembles a
/// `Balance`. That makes this function the single seam a later asset loading path — or a
/// debug panel that mutates `Balance` in the running process — attaches to.
pub fn from_constants() -> Self {
Self {
arena: ArenaBalance {
radius: values::ARENA_RADIUS,
},
tower: TowerBalance {
damage: values::TOWER_DAMAGE,
attack_interval: values::TOWER_ATTACK_INTERVAL,
health: values::TOWER_HEALTH,
},
enemy: EnemyBalance {
speed: values::ENEMY_SPEED,
health: values::ENEMY_HEALTH,
attack_range: values::ENEMY_ATTACK_RANGE,
separation_radius: values::ENEMY_SEPARATION_RADIUS,
separation_neighbours: values::ENEMY_SEPARATION_NEIGHBOURS,
separation_clamp: values::ENEMY_SEPARATION_CLAMP,
target_population: values::ENEMY_TARGET_POPULATION,
hard_cap: values::HARD_ENEMY_CAP,
},
combat: CombatBalance {
hit_radius: values::HIT_RADIUS,
},
meta: MetaBalance {
gain_per_kill: values::META_GAIN_PER_KILL,
},
caps: Caps::default(),
}
}
}

84
src/balance/values.rs Normal file
View File

@@ -0,0 +1,84 @@
//! The raw balancing numbers (ADR-0001).
//!
//! **Every value in this file is a placeholder.** None of them has been tuned, and four of
//! the numbers this file will eventually hold are not derivable from any source at all
//! (see issue #8) — they can only be found by playing. Treat a value here as "a number that
//! lets the skeleton walk", never as a design decision.
//!
//! This is the only file in the crate that holds balancing numbers. Gameplay code never
//! sees these constants; it reads `Res<Balance>` (ADR-0001, decision point 4).
use super::Stat;
/// PLACEHOLDER. Distance from the tower to the rim enemies spawn on, in world units.
/// Taken from `examples/perf_spike.rs`, where it was chosen so the whole arena fits into a
/// 1920x1080 window without frustum culling.
pub(super) const ARENA_RADIUS: Stat = 500.0;
/// PLACEHOLDER. Enemy movement speed in world units per second.
pub(super) const ENEMY_SPEED: Stat = 50.0;
/// PLACEHOLDER. Enemy health of the one archetype the skeleton knows. The real curve is
/// per-wave and exponential (spec, "Feststehende Zahlen"); this is the wave-1 base it will
/// later be derived from.
pub(super) const ENEMY_HEALTH: Stat = 10.0;
/// PLACEHOLDER. Distance from the tower centre at which an enemy stops walking.
///
/// In the skeleton this only ends the radial approach — the ring it produces is what makes
/// the separation of ADR-0004 observable at all. #18 turns standing here into attacking.
pub(super) const ENEMY_ATTACK_RANGE: Stat = 28.0;
/// PLACEHOLDER. Enemies closer than this push each other apart (ADR-0004 §5).
///
/// This is also the value the grid's cell size follows (ADR-0004 §2), because the separation
/// query is the one that runs once per enemy rather than once per frame.
pub(super) const ENEMY_SEPARATION_RADIUS: Stat = 14.0;
/// PLACEHOLDER. ‹k› from ADR-0004 §5: how many neighbours one enemy takes into account.
/// Caps cost and force in one number.
pub(super) const ENEMY_SEPARATION_NEIGHBOURS: usize = 8;
/// PLACEHOLDER. ‹f› from ADR-0004 §5, and it must stay **below 1**: the separation offset is
/// clamped to `f x (speed x dt)`, so an enemy can never be displaced further than it walks.
/// That is what makes reversing the radial approach constructively impossible.
pub(super) const ENEMY_SEPARATION_CLAMP: Stat = 0.5;
/// PLACEHOLDER. Living enemies the spawner keeps the field filled to.
///
/// A constant, because the skeleton has no waves. The spec calls for `target_population(wave)`
/// — a function of the wave counter and itself part of the difficulty curve. The wave ticket
/// replaces this field with that function; the spawner above it does not change, because it is
/// cap-driven ("fill up to the target") rather than plan-driven.
pub(super) const ENEMY_TARGET_POPULATION: usize = 400;
/// The hard upper bound on simultaneous enemies — **not a placeholder**.
///
/// Measured in #4: the frame budget breaks at ~70 000 untextured sprites, which leaves this
/// 7x headroom. It is a performance limit, never a design tool: no effect raises it, and
/// nothing exceeds it. What effects raise is the target population above.
pub(super) const HARD_ENEMY_CAP: usize = 10_000;
/// PLACEHOLDER. Damage of a single tower shot. ADR-0002 §3: `Damage`, mode `Direct`, base > 0.
pub(super) const TOWER_DAMAGE: Stat = 5.0;
/// PLACEHOLDER. Seconds between two tower shots.
///
/// This is an **interval, not a rate** — ADR-0002 §3 is explicit that the stat is called
/// `AttackInterval` and not `AttackSpeed`, so that "attack speed x1.25" becomes a divisor on
/// a duration instead of a second accumulator. The skeleton keeps the same unit.
pub(super) const TOWER_ATTACK_INTERVAL: Stat = 0.5;
/// PLACEHOLDER. Tower health. ADR-0002 §3: `MaxHealth`, mode `Direct`, base > 0.
pub(super) const TOWER_HEALTH: Stat = 100.0;
/// PLACEHOLDER. A projectile within this distance of an enemy counts as a hit, in world
/// units. Taken from `examples/perf_spike.rs`.
pub(super) const HIT_RADIUS: Stat = 10.0;
/// PLACEHOLDER. Meta currency granted per killed enemy.
///
/// The skeleton pays out a flat amount per kill. The spec ("Feststehende Zahlen") calls for
/// drop-based meta income — a chance per normal enemy, guaranteed per boss, amount scaling
/// with the wave. That shape needs bosses and waves, neither of which exists yet.
pub(super) const META_GAIN_PER_KILL: Stat = 1.0;

283
src/enemy.rs Normal file
View File

@@ -0,0 +1,283 @@
//! Enemies: they appear on the arena rim, walk radially at the tower, and push each other
//! apart softly once they get close (spec feature 3, ADR-0004).
//!
//! One archetype. The three to four from the calibration, the boss, the wave tick and the
//! attack on the tower all belong to later tickets; what is here is the part the walking
//! skeleton needs to show a field of enemies converging on the tower.
use bevy::prelude::*;
use core::f32::consts::TAU;
use crate::AppState;
use crate::arena::ARENA_Z;
use crate::balance::{Balance, Stat};
use crate::spatial::SpatialGrid;
/// Marks an enemy. The only thing that identifies the enemy set to the grid rebuild.
#[derive(Component)]
pub struct Enemy;
/// Remaining hit points. Nothing reduces it yet — #17 brings the projectiles that do.
///
/// It is carried from the start anyway, so that the enemy archetype does not change shape the
/// moment shooting lands. `expect` rather than `allow`: the compiler asks for this line back
/// once #17 has a reader.
#[expect(dead_code, reason = "read by the projectile hit in #17")]
#[derive(Component)]
pub struct Health(pub Stat);
/// Edge length of the enemy sprite. Programmer art, not a balancing value — no rule reads it.
const ENEMY_SIZE: f32 = 8.0;
const ENEMY_COLOR: Color = Color::srgb(0.85, 0.25, 0.25);
/// Fills the field up to the target population.
///
/// **Cap-driven, not plan-driven** (cap analysis, issue #1): it asks how many are alive and
/// tops up, rather than placing N enemies on a schedule. That is what makes the target
/// population the binding quantity — an effect promising "more enemies" raises it, and a spawn
/// multiplier would be exactly nothing.
pub fn maintain_population(
mut commands: Commands,
balance: Res<Balance>,
enemies: Query<(), With<Enemy>>,
// ponytail: a counter through a hash instead of the `rand` crate — deterministic, so a run
// replays identically, and the dependency list stays at Bevy alone. `Local` rather than a
// `Resource`: nothing outside this system ever reads it.
mut seed: Local<u32>,
) {
// The hard cap binds here and nowhere else — it is the single place a population number
// turns into entities, so clamping it here means nothing can exceed it.
let target = balance.enemy.target_population.min(balance.enemy.hard_cap);
let alive = enemies.iter().len();
for _ in alive..target {
*seed = seed.wrapping_add(1);
let position = Vec2::from_angle(hash01(*seed) * TAU) * balance.arena.radius;
commands.spawn((
Enemy,
Health(balance.enemy.health),
// Run-bound, like the tower (ADR-0002 §6): the end of a run takes them with it and
// there is no cleanup list to forget.
DespawnOnExit(AppState::Run),
Sprite::from_color(ENEMY_COLOR, Vec2::splat(ENEMY_SIZE)),
Transform::from_xyz(position.x, position.y, ARENA_Z + 0.5),
));
}
}
/// Walks every enemy straight at the tower and stops it at the attack range.
///
/// Runs **before** the grid rebuild (ADR-0004 §6), so the grid describes the positions the
/// consumers actually see.
pub fn move_enemies(
time: Res<Time>,
balance: Res<Balance>,
mut enemies: Query<&mut Transform, With<Enemy>>,
) {
let step_length = balance.enemy.speed * time.delta_secs();
let stop_at = balance.enemy.attack_range;
for mut transform in &mut enemies {
let position = transform.translation.truncate();
let distance = position.length();
let remaining = distance - stop_at;
if remaining <= 0.0 {
continue;
}
// `min(remaining)` keeps an enemy from stepping through the ring and out the far side
// at low frame rates. Division is safe: `distance > stop_at > 0`.
let step = position / distance * -step_length.min(remaining);
transform.translation.x += step.x;
transform.translation.y += step.y;
}
}
/// Soft separation: a clamped positional offset, not a force (ADR-0004 §5).
///
/// This is **not** collision resolution — overlap stays allowed. The goal is a readable ring,
/// because the target population is the quantity the whole difficulty curve stands on and it
/// cannot be read off a pile of coincident sprites. Forbidding penetration instead would build
/// the queue at the tower ring that no amount of DPS dissolves.
///
/// The clamp is what keeps the ring from pulsing. Density peaks at the tower ring, and a
/// neighbourhood there is isotropic tangentially but not radially — nobody stands behind an
/// enemy, towards the tower, to push back — so the residual vector points *outwards*. Capping
/// the offset below one movement step makes reversing the approach constructively impossible.
pub fn separate_enemies(
time: Res<Time>,
balance: Res<Balance>,
grid: Res<SpatialGrid>,
mut enemies: Query<(Entity, &mut Transform), With<Enemy>>,
) {
let enemy = &balance.enemy;
let max_offset = enemy.separation_clamp * enemy.speed * time.delta_secs();
let radius = enemy.separation_radius;
if max_offset <= 0.0 || radius <= 0.0 {
return;
}
for (entity, mut transform) in &mut enemies {
let position = transform.translation.truncate();
let mut push = Vec2::ZERO;
let mut counted = 0usize;
grid.for_each_in_radius(position, radius, |other, other_position| {
// The neighbour budget caps cost and force in one number — the same rule shape as
// the target limit per ability trigger, rather than two separate ones.
if other == entity || counted >= enemy.separation_neighbours {
return;
}
let away = position - other_position;
let distance = away.length();
let direction = if distance > f32::EPSILON {
away / distance
} else {
// ponytail: coincident enemies get a per-entity fixed direction instead of a
// random one. Two enemies on the same pixel then push apart rather than in
// lockstep, and it stays deterministic.
Vec2::from_angle(hash01(entity.to_bits() as u32) * TAU)
};
push += direction * (1.0 - distance / radius);
counted += 1;
});
if counted == 0 {
continue;
}
let offset = push.clamp_length_max(max_offset);
transform.translation.x += offset.x;
transform.translation.y += offset.y;
}
}
/// Cheap deterministic hash into `[0, 1)`.
fn hash01(n: u32) -> f32 {
let mut x = n.wrapping_mul(0x9E37_79B9).wrapping_add(0x1234_5678);
x ^= x >> 16;
x = x.wrapping_mul(0x85EB_CA6B);
x ^= x >> 13;
(x >> 8) as f32 / (1u32 << 24) as f32
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spatial::rebuild_spatial_grid;
use bevy::state::app::StatesPlugin;
use core::time::Duration;
/// A frame at 60 fps. The app runs without `TimePlugin` so the test drives `Time` itself —
/// with real time, six hundred `update()` calls would advance the simulation by a few
/// milliseconds and no enemy would ever reach the ring.
const FRAME: Duration = Duration::from_micros(16_667);
/// The gameplay half of the app without a window, in the frame order of ADR-0004 §6.
fn headless_app() -> App {
let balance = Balance::from_constants();
let mut app = App::new();
app.add_plugins(StatesPlugin)
.insert_resource(Time::<()>::default())
.insert_resource(SpatialGrid::from_balance(&balance))
.insert_resource(balance)
.init_state::<AppState>()
.add_systems(
Update,
(
maintain_population,
move_enemies,
rebuild_spatial_grid,
separate_enemies,
)
.chain(),
);
app
}
fn step(app: &mut App) {
app.world_mut().resource_mut::<Time>().advance_by(FRAME);
app.update();
}
/// Distances to the tower, keyed by entity and sorted — query iteration order is not part
/// of Bevy's contract, and comparing two frames positionally would silently compare
/// different enemies.
fn distances(app: &mut App) -> Vec<(Entity, f32)> {
let mut query = app
.world_mut()
.query_filtered::<(Entity, &Transform), With<Enemy>>();
let mut out: Vec<(Entity, f32)> = query
.iter(app.world())
.map(|(e, t)| (e, t.translation.truncate().length()))
.collect();
out.sort_by_key(|(e, _)| e.to_bits());
out
}
#[test]
fn the_spawner_fills_up_to_the_target_and_then_stops() {
let mut app = headless_app();
let target = app.world().resource::<Balance>().enemy.target_population;
step(&mut app);
assert_eq!(distances(&mut app).len(), target, "first fill");
step(&mut app);
assert_eq!(
distances(&mut app).len(),
target,
"a filled field must not keep spawning"
);
}
/// The claim ADR-0004 §5 rests on, and the acceptance criterion of spec feature 3: the
/// tower ring must not pulse, and the field must still converge instead of stalling in its
/// own push-back.
///
/// The property is *not* "distance never grows". An enemy standing at the attack range
/// takes no step inwards that frame, so there is nothing for the clamp to be smaller than
/// and the separation can move it outwards. What the clamp guarantees is that it can never
/// move it further than one movement step — the overshoot a pulse would need.
#[test]
fn separation_never_reverses_the_radial_approach() {
let mut app = headless_app();
step(&mut app);
let (spawn_radius, budget) = {
let balance = app.world().resource::<Balance>();
(
balance.arena.radius,
balance.enemy.speed * FRAME.as_secs_f32(),
)
};
let first = distances(&mut app);
let mut previous = first.clone();
// Long enough for the front rank to reach the ring, which is where density peaks and
// the residual separation vector points outwards.
for frame in 0..600 {
step(&mut app);
let current = distances(&mut app);
assert_eq!(current.len(), previous.len(), "population changed mid-test");
for (&(entity, before), &(same, after)) in previous.iter().zip(&current) {
assert_eq!(entity, same, "enemy sets diverged");
assert!(
after - before <= budget,
"frame {frame}: {entity} was pushed {} outwards, more than the {budget} it \
walks in a frame",
after - before
);
assert!(
after <= spawn_radius,
"frame {frame}: {entity} was pushed past the spawn rim, to {after}"
);
}
previous = current;
}
let mean = |v: &[(Entity, f32)]| v.iter().map(|(_, d)| d).sum::<f32>() / v.len() as f32;
assert!(
mean(&previous) < mean(&first),
"the field did not converge: separation ate the radial approach"
);
}
}

View File

@@ -1,3 +1,118 @@
fn main() { //! InfiniTower — walking skeleton.
println!("Hello, world!"); //!
//! Module layout follows ADR-0001: [`balance`] holds data only and knows nothing about
//! gameplay; gameplay modules ([`arena`], [`tower`]) read `Res<Balance>` and never touch a
//! balancing constant directly.
mod arena;
/// Some balancing values still exist before the systems that read them: #17 (shooting) and
/// #18 (tower death, meta payout) consume the rest. `expect` rather than `allow`, so the
/// compiler asks for this line back once the last field has found its reader.
#[expect(dead_code, reason = "read by the gameplay tickets #17-#18")]
mod balance;
mod enemy;
mod spatial;
mod tower;
use bevy::camera::ScalingMode;
use bevy::prelude::*;
use balance::Balance;
use spatial::SpatialGrid;
/// The large-scale structure of the program.
///
/// `Run` is a single playthrough from wave 1 to the tower's death; `Meta` is everything
/// between two runs (workshop, cards). The transition between them is where run start and run
/// end will hang — it exists from the first commit so that #18 does not have to thread it
/// through every system afterwards.
#[derive(States, Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
pub enum AppState {
/// A run is in progress.
// ponytail: `Run` is the default so `cargo run` lands in a run and the tower is on screen
// without a keypress. There is no meta screen to show yet. #18 brings the real run
// start/end and should flip the default to `Meta` — a one-line change here.
#[default]
Run,
/// Between two runs.
Meta,
}
/// How much of the view is padding around the arena. Keeps the spawn rim off the screen edge.
const VIEW_MARGIN: f32 = 1.08;
const BACKGROUND: Color = Color::srgb(0.05, 0.05, 0.07);
fn main() {
// The single source of every balancing number (ADR-0001). The grid is sized from it, so
// it has to exist before the app is assembled.
let balance = Balance::from_constants();
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "InfiniTower".into(),
..default()
}),
..default()
}))
.insert_resource(ClearColor(BACKGROUND))
.insert_resource(SpatialGrid::from_balance(&balance))
.insert_resource(balance)
.init_state::<AppState>()
.add_systems(Startup, (spawn_camera, arena::spawn_arena))
.add_systems(OnEnter(AppState::Run), tower::spawn_tower)
.add_systems(Update, toggle_run_state)
// The frame order of ADR-0004 §6, stated rather than inherited from the registration
// order: move, then rebuild the grid, then every consumer reading it.
.add_systems(
Update,
(
enemy::maintain_population,
enemy::move_enemies,
spatial::rebuild_spatial_grid,
enemy::separate_enemies,
)
.chain()
.run_if(in_state(AppState::Run)),
)
.run();
}
/// Orthographic camera, fixed on the centre of the arena.
///
/// The projection is anchored to the arena radius rather than to the window size, so the whole
/// spawn periphery stays visible at any window size — that is the acceptance criterion of
/// spec feature 1, and tying it to `Res<Balance>` means a changed radius keeps it true.
fn spawn_camera(mut commands: Commands, balance: Res<Balance>) {
let span = balance.arena.radius * 2.0 * VIEW_MARGIN;
commands.spawn((
Camera2d,
Projection::Orthographic(OrthographicProjection {
scaling_mode: ScalingMode::AutoMin {
min_width: span,
min_height: span,
},
..OrthographicProjection::default_2d()
}),
));
}
/// Space toggles between `Run` and `Meta`.
///
// ponytail: a debug keybinding, not a game mechanic — it is the cheapest way to exercise the
// transition by hand while the skeleton has neither a tower death (#18) nor a meta screen.
// It goes away with the real run end.
fn toggle_run_state(
keys: Res<ButtonInput<KeyCode>>,
state: Res<State<AppState>>,
mut next_state: ResMut<NextState<AppState>>,
) {
if !keys.just_pressed(KeyCode::Space) {
return;
}
next_state.set(match state.get() {
AppState::Run => AppState::Meta,
AppState::Meta => AppState::Run,
});
} }

241
src/spatial.rs Normal file
View File

@@ -0,0 +1,241 @@
//! The one neighbourhood query in the game (ADR-0004).
//!
//! A uniform grid over the arena, rebuilt from scratch every frame. It exists because four
//! systems want the same question answered — soft separation, target selection, the shared
//! range scan and projectile collision — and each of them would otherwise walk the whole enemy
//! set to answer it.
//!
//! The binding rule that comes with it (ADR-0004 §6): **apart from [`rebuild_spatial_grid`],
//! no system iterates the enemy query to find neighbours.** Consumers ask the grid.
use bevy::prelude::*;
use crate::balance::Balance;
use crate::enemy::Enemy;
/// A dense uniform grid over the arena, one bucket per cell.
///
/// A `Resource`, not a component on the tower: it describes the world rather than the tower,
/// and it carries no state across frames, so the placement rule of ADR-0002 §6 is untouched.
/// It changes every single frame, which makes `is_changed()` on it worthless as a signal — it
/// must never become an input to the `EffectiveStats` recomputation (ADR-0004, consequences).
#[derive(Resource)]
pub struct SpatialGrid {
/// Edge length of one cell. Follows the separation radius, *not* the tower range
/// (ADR-0004 §2) — the small query is the one that runs 10 000 times per frame.
cell_size: f32,
/// Cells per side. The grid is square and centred on the tower.
dim: usize,
/// Half the edge length of the covered square, in world units.
half_extent: f32,
/// The buckets, row-major. Positions are stored next to the entity: the separation system
/// writes `Transform` while reading its neighbours' positions, and a grid that held bare
/// `Entity` would force it to borrow the enemy query mutably and immutably at once.
cells: Vec<Vec<(Entity, Vec2)>>,
}
impl SpatialGrid {
/// Sizes the grid from `Balance`, so a changed arena radius or separation radius reshapes
/// it without any further code change.
pub fn from_balance(balance: &Balance) -> Self {
let cell_size = balance
.enemy
.separation_radius
.max(balance.combat.hit_radius);
let half_extent = balance.arena.radius;
let dim = ((half_extent * 2.0 / cell_size).ceil() as usize).max(1);
Self {
cell_size,
dim,
half_extent,
cells: vec![Vec::new(); dim * dim],
}
}
/// Cell coordinates of a world position. Positions outside the grid clamp into the edge
/// cell (ADR-0004 §1) — the spawn rim sits exactly on the boundary.
fn cell_of(&self, pos: Vec2) -> (usize, usize) {
let last = (self.dim - 1) as isize;
let index = |v: f32| {
(((v + self.half_extent) / self.cell_size).floor() as isize).clamp(0, last) as usize
};
(index(pos.x), index(pos.y))
}
/// Empties every bucket while keeping its allocation. There is no allocation per frame.
fn clear(&mut self) {
for cell in &mut self.cells {
cell.clear();
}
}
fn insert(&mut self, entity: Entity, pos: Vec2) {
let (x, y) = self.cell_of(pos);
self.cells[y * self.dim + x].push((entity, pos));
}
/// Visits every entity within `radius` of `centre`, narrow phase included — the callback
/// only ever sees real hits, never block candidates.
///
/// This is the query all four consumers of ADR-0004 share. The caller's own entity is
/// visited too if it is in the grid; whoever cares filters it out.
pub fn for_each_in_radius(
&self,
centre: Vec2,
radius: f32,
mut visit: impl FnMut(Entity, Vec2),
) {
let (min_x, min_y) = self.cell_of(centre - Vec2::splat(radius));
let (max_x, max_y) = self.cell_of(centre + Vec2::splat(radius));
let radius_sq = radius * radius;
for y in min_y..=max_y {
for x in min_x..=max_x {
for &(entity, pos) in &self.cells[y * self.dim + x] {
if centre.distance_squared(pos) <= radius_sq {
visit(entity, pos);
}
}
}
}
}
}
/// Rebuilds the whole grid from the enemy positions of this frame.
///
/// Full rebuild rather than an incremental update: it costs 0.057 ms at the hard cap, a third
/// of a percent of the frame, and an incremental one would pay the same scattered bucket write
/// on every cell crossing while adding a per-entity back-index and a class of bugs (ADR-0004
/// §3 with its 2026-08-11 addendum). The same trade ADR-0002 §6 made for the accumulators.
///
/// **The one and only pass over the enemy set.** Ordered after `move_enemies` and before every
/// consumer (ADR-0004 §6).
pub fn rebuild_spatial_grid(
mut grid: ResMut<SpatialGrid>,
enemies: Query<(Entity, &Transform), With<Enemy>>,
) {
grid.clear();
for (entity, transform) in &enemies {
grid.insert(entity, transform.translation.truncate());
}
}
#[cfg(test)]
mod tests {
use super::*;
use core::time::Duration;
use std::time::Instant;
/// The bound ADR-0004 §3 rests on: the rebuild costs at most this at `HARD_ENEMY_CAP`.
const BUDGET_MS: f64 = 0.10;
fn grid() -> SpatialGrid {
SpatialGrid::from_balance(&Balance::from_constants())
}
/// Real entity ids, taken from a throwaway world — there is no supported way to conjure an
/// `Entity` out of a number that survives a Bevy upgrade.
fn ids(count: usize) -> Vec<Entity> {
let mut world = World::new();
(0..count).map(|_| world.spawn_empty().id()).collect()
}
/// The narrow phase is part of the query, so a candidate from the same cell block that is
/// out of range must not reach the callback. Without this the separation would push
/// against neighbours it never actually had.
#[test]
fn the_query_returns_neighbours_in_range_and_nothing_else() {
let mut grid = grid();
let cell_size = grid.cell_size;
let e = ids(3);
grid.insert(e[0], Vec2::new(3.0, 0.0));
// Inside the cell block the query walks, but outside the query radius.
grid.insert(e[1], Vec2::new(cell_size * 1.2, 0.0));
grid.insert(e[2], Vec2::new(200.0, 200.0));
let mut found = Vec::new();
grid.for_each_in_radius(Vec2::ZERO, 10.0, |entity, _| found.push(entity));
assert_eq!(found, vec![e[0]], "query returned {found:?}");
}
/// The spawn rim sits exactly on the grid boundary, and a separation offset may push an
/// enemy past it. Both must land in a cell instead of panicking on an index.
#[test]
fn positions_on_and_beyond_the_rim_clamp_into_the_edge_cell() {
let grid = grid();
let radius = Balance::from_constants().arena.radius;
let last = grid.dim - 1;
assert_eq!(grid.cell_of(Vec2::splat(radius)), (last, last));
assert_eq!(grid.cell_of(Vec2::splat(-radius * 3.0)), (0, 0));
}
/// Revision trigger 1 of ADR-0004, as a runnable check: the decision against an incremental
/// update rests on the rebuild staying under 0.10 ms at the hard cap. If this fails, the
/// ADR is due — not the number.
///
/// It prints `move_enemies` next to it because the ADR originally used that as its yardstick.
/// The measurement in #16 refuted the yardstick (0.057 ms against 0.021 ms — the scattered
/// bucket writes cost more than the arithmetic the ADR counted) without touching the bound.
/// Printed, not asserted, for exactly that reason.
///
/// Ignored by default because it is a measurement, not a unit test:
/// cargo test --release -- --ignored --nocapture
#[test]
#[ignore = "perf measurement; run with: cargo test --release -- --ignored --nocapture"]
fn the_rebuild_stays_within_its_budget_at_the_hard_cap() {
use crate::enemy::{Enemy, move_enemies};
let balance = Balance::from_constants();
let count = balance.enemy.hard_cap;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.insert_resource(SpatialGrid::from_balance(&balance))
.insert_resource(balance.clone());
// Spread over the whole disc, all of them outside the attack range, so `move_enemies`
// does its full work for every single one.
for i in 0..count {
let angle = (i as f32 / count as f32) * core::f32::consts::TAU;
let radius = balance.arena.radius * (0.2 + 0.8 * (i % 97) as f32 / 97.0);
let pos = Vec2::from_angle(angle) * radius;
app.world_mut()
.spawn((Enemy, Transform::from_xyz(pos.x, pos.y, 0.0)));
}
let world = app.world_mut();
world
.resource_mut::<Time>()
.advance_by(Duration::from_micros(16_667));
let move_id = world.register_system(move_enemies);
let rebuild_id = world.register_system(rebuild_spatial_grid);
let median = |world: &mut World, id| {
let mut samples: Vec<f64> = (0..24)
.map(|_| {
let t0 = Instant::now();
world.run_system(id).expect("system ran");
t0.elapsed().as_secs_f64() * 1e3
})
.skip(4) // discard the cold runs
.collect();
samples.sort_by(f64::total_cmp);
samples[samples.len() / 2]
};
let move_ms = median(world, move_id);
let rebuild_ms = median(world, rebuild_id);
println!(
"{count} enemies: rebuild_spatial_grid {rebuild_ms:.3} ms (budget {BUDGET_MS:.3} ms) \
| move_enemies {move_ms:.3} ms"
);
assert!(
rebuild_ms <= BUDGET_MS,
"ADR-0004 revision trigger 1 fired: rebuild {rebuild_ms:.3} ms exceeds the \
{BUDGET_MS:.3} ms the decision rests on, at {count} enemies"
);
}
}

85
src/tower.rs Normal file
View File

@@ -0,0 +1,85 @@
//! The tower: the one player-owned unit, fixed in the centre of the arena.
//!
//! The tower entity is spawned on entering [`AppState::Run`] and dies on leaving it
//! (ADR-0002 §6). Everything run-bound — `RunUpgrades`, `EffectiveStats`, ability components —
//! will hang off this entity, so that the end of a run cleans itself up and no reset list
//! exists that a later feature can forget to extend.
use bevy::prelude::*;
use crate::AppState;
use crate::arena::ARENA_Z;
/// Marks the tower entity. There is exactly one while a run is in progress, and none outside.
#[derive(Component)]
pub struct Tower;
/// Edge length of the tower sprite in world units. Programmer art, not a balancing value —
/// nothing in the game reads the tower's visual size.
const TOWER_SIZE: f32 = 32.0;
const TOWER_COLOR: Color = Color::srgb(0.90, 0.85, 0.25);
/// Spawns the tower. Registered on `OnEnter(AppState::Run)`.
pub fn spawn_tower(mut commands: Commands) {
commands.spawn((
Tower,
// The despawn half of ADR-0002 §6. Bevy removes the entity on the transition out of
// `Run`, so there is no cleanup system that could be forgotten or ordered wrongly.
DespawnOnExit(AppState::Run),
Sprite::from_color(TOWER_COLOR, Vec2::splat(TOWER_SIZE)),
Transform::from_xyz(0.0, 0.0, ARENA_Z + 1.0),
));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::balance::Balance;
use bevy::state::app::StatesPlugin;
/// The gameplay half of the app without a window: enough to drive state transitions.
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, StatesPlugin))
.insert_resource(Balance::from_constants())
.init_state::<AppState>()
.add_systems(OnEnter(AppState::Run), spawn_tower);
app.update();
app
}
fn go_to(app: &mut App, state: AppState) {
app.world_mut()
.resource_mut::<NextState<AppState>>()
.set(state);
app.update();
}
fn tower_count(app: &mut App) -> usize {
let mut query = app.world_mut().query_filtered::<Entity, With<Tower>>();
query.iter(app.world()).count()
}
/// The acceptance criterion of #14: a Meta -> Run -> Meta round trip leaves nothing
/// behind. Counting *all* entities, not just towers, is what makes this catch a future
/// run-bound entity that someone spawns without tying it to the run.
#[test]
fn meta_run_meta_leaves_nothing_behind() {
let mut app = headless_app();
go_to(&mut app, AppState::Meta);
let between_runs = app.world().entities().len();
assert_eq!(tower_count(&mut app), 0, "no tower exists between runs");
go_to(&mut app, AppState::Run);
assert_eq!(tower_count(&mut app), 1, "entering Run spawns exactly one tower");
go_to(&mut app, AppState::Meta);
assert_eq!(tower_count(&mut app), 0, "leaving Run despawns the tower");
assert_eq!(
app.world().entities().len(),
between_runs,
"a Meta -> Run -> Meta round trip left entities behind"
);
}
}