Phase 1 Code Review — Adversarial Findings #
Scope: full codebase as-is before any UI phase work.
Audience: high-level explanations for the author + actionable agent instructions.
Findings ranked: Bugs → Dead/Unused Code → Architecture → Testing Gaps → Simplification
1. Out-of-Bounds Array Access in update_next_sphere_hud #
Severity: Bug / Panic
What's happening: TIER_COLORS is a 9-element array (indices 0–8). In
hud.rs:341, the index is clamped with .min(9) instead of .min(8).
// hud.rs line 341
let idx = (queue.next as usize).saturating_sub(1).min(9); // ← should be .min(8)
let color = TIER_COLORS[idx]; // panics if idx == 9
If queue.next is ever 11 or higher (which can't happen today since dispensed
tiers cap at 5, but becomes possible the moment you expand the dispenser),
TIER_COLORS[9] panics at runtime. Compare to material_for_tier in
visuals.rs:74 which correctly uses .min(8). One copy got the right number;
the other didn't.
Agent fix: Change hud.rs:341 to .min(8).
2. InsideLauncher Is Never Inserted — Component Is Dead Code #
Severity: Bug (silent logic gap) + dead code
What's happening: InsideLauncher is defined, tested, and used as a query
filter in three separate systems (check_loss_condition, detect_collisions,
update_launcher_aiming), but it is never inserted on any entity in
gameplay code. The filters are therefore no-ops. This was presumably meant to
mark the physics sphere while it's being aimed at the launcher line, but the
design was changed to use a separate visual-only LauncherPreview entity
instead — and InsideLauncher was never removed.
Concrete consequence: the exclusion filter in check_loss_condition has no
effect. Any sphere spawned at launcher_z would start triggering the loss
timer immediately (the 0.5s grace period rescues it in practice, but only
accidentally).
Agent fix:
- Remove the
InsideLauncherstruct fromgame_state.rs. - Remove all
Without<InsideLauncher>filter clauses from query tuples incheck_loss_condition,detect_collisions, andupdate_launcher_aiming. - Remove the launcher-exclusion test
test_loss_condition_launcher_exclusion(it tests a component that no longer exists).
3. despawn_recursive_custom Is Duplicated Across Two Modules #
Severity: Code smell / maintenance hazard
What's happening: Identical private functions are copy-pasted verbatim in
game_state.rs:207–218 and hud.rs:467–478. Any bug fix or behavioral change
must be applied twice.
// Exact same code in both files:
fn despawn_recursive_custom(commands: &mut Commands, entity: Entity, children_query: &Query<&Children>) {
if let Ok(children) = children_query.get(entity) {
for child in children.iter() {
despawn_recursive_custom(commands, child, children_query);
}
}
commands.entity(entity).despawn();
}
Bevy's EntityCommands::despawn() in current Bevy 0.18 despawns only the
entity itself — children are orphaned unless removed separately. The custom
recursive walk is legitimate. But it should live in one place.
Agent fix:
- Create
src/utils.rs(or add a public function to an existing module) withpub fn despawn_recursive(commands: &mut Commands, entity: Entity, children_query: &Query<&Children>). - Replace both call sites with the shared function.
- Add
mod utils;tomain.rs.
4. material_for_tier Takes a _colorblind Parameter It Never Uses #
Severity: Unused code / misleading API
What's happening: The function signature is:
// visuals.rs:70
pub fn material_for_tier(tier: u8, _colorblind: bool, tier_mats: &TierMaterials) -> Handle<StandardMaterial> {
The _colorblind argument is silently ignored (the _ prefix is a Rust
convention meaning "intentionally unused"). Every call site passes false or
cb but nothing changes. The colorblind experience today is handled entirely
through 2D text label visibility, not material switching.
Keeping this parameter creates a false promise: callers think they can request a colorblind material variant; they can't.
Agent fix:
- Remove the
_colorblind: boolparameter frommaterial_for_tier. - Update the two call sites:
on_sphere_added(visuals.rs:243) andupdate_preview_material(visuals.rs:294). - Document this decision with a comment if colorblind material variants are planned for a future phase.
5. ColorblindMode Is Registered Twice #
Severity: Minor redundancy
What's happening:
// main.rs:30
.insert_resource(game_state::ColorblindMode::default())
// visuals.rs VisualPlugin::build:
.init_resource::<ColorblindMode>()
init_resource is a no-op when the resource already exists, so the
VisualPlugin call silently does nothing. This is harmless but confusing: a
reader of VisualPlugin would assume it owns the resource, and a reader of
main.rs would assume the same.
Agent fix: Remove the init_resource::<ColorblindMode>() line from
VisualPlugin::build in visuals.rs. Ownership of global game resources
belongs in main.rs.
6. update_launcher_preview_visuals Is Outside the InGame State Guard #
Severity: Inconsistency / fragility
What's happening: In LauncherPlugin::build, three systems are correctly
gated on AppState::InGame:
(update_launcher_aiming, check_launcher_obstructions, handle_launch_input)
.chain()
.run_if(in_state(AppState::InGame)),
update_launcher_preview_visuals, // ← runs in ALL states
update_launcher_preview_visuals compensates by manually checking the state
inside its body and hiding itself when not in-game. This works, but it means
the system runs every frame even during menus and game-over screens, and it
reads four queries + a resource every frame to do nothing. It also means the
logical gate is expressed in two different places.
Agent fix: Move update_launcher_preview_visuals inside the .chain()
with the other systems and add it to the .run_if(in_state(AppState::InGame))
guard. Remove the manual is_in_game check inside the function body.
7. animate_merged_spawns Duplicates Its Own Loop Body #
Severity: Simplification
What's happening: The function contains two nearly-identical loops — one
over visual_query (mesh transforms) and one over label_query (text
transforms) — that compute the same t/scale value from the same
MergeCooldown timer:
// Repeated twice, once per query type:
if let Ok(cooldown) = cooldown_query.get(parent) {
let t = (elapsed / duration).clamp(0.0, 1.0);
let scale = 0.8 + t * 0.2;
transform.scale = Vec3::splat(scale);
} else {
if transform.scale != Vec3::ONE { transform.scale = Vec3::ONE; }
}
animate_fulfilling_spheres has the same structure.
Agent fix: Extract a helper:
fn compute_merge_scale(cooldown: &MergeCooldown) -> f32 {
let t = (cooldown.timer.elapsed_secs() / cooldown.timer.duration().as_secs_f32()).clamp(0.0, 1.0);
0.8 + t * 0.2
}
Then unify both query loops in animate_merged_spawns by looking up the
scale once per parent entity and applying it to both the mesh child and the
label. Same refactor for animate_fulfilling_spheres.
8. O(N²) Occlusion Check in update_labels_screen_position #
Severity: Performance / future scalability
What's happening: For every label (one per sphere), the function does a full linear scan over all other spheres to determine if the label is occluded:
// visuals.rs:346
for (other_entity, other_transform, other_sphere) in sphere_query.iter() { ... }
With N spheres on the board, this is O(N²) work per frame. At the current scale (max ~15–20 spheres before game-over), this is not a performance problem today. But it's the single most expensive Update system and will be the first thing to profile badly when sphere counts grow.
The analytical ray–sphere approach is elegant; the O(N²) structure is not.
Agent fix (Phase 1 — cleanup without rearchitecting):
- Cache the sphere positions in a
Vecbefore the outer loop to avoid repeated ECS iteration overhead. - Add a comment marking this as the known hotspot.
Agent fix (Phase 2 — correct approach):
- Replace the occlusion check with a simple depth-sort: label the sphere whose screen-space depth (NDC z) is smallest (closest to camera); skip labels behind others. This can be done in one O(N log N) sort pass.
COMMENT: I'm not sure abouot this one, because of the risk of a sphere that's large blocking multiple smaller ones.
9. check_order_fulfillment Weight Formula Is Opaque #
Severity: Readability / maintenance hazard
What's happening: The tier selection logic works correctly, but the construction of tier-7's probability as an inline expression is hard to audit:
// game_state.rs:177
} else if roll < w5 + w6 + (100 - w5 - w6 - w8) {
7
100 - w5 - w6 - w8 is tier 7's weight (the remainder), but it's never
named, and the three-level if/else chain obscures the boundary math. The
comment block above it accurately describes the intent — but anyone editing
the weights must mentally re-derive that none of the four weights sum past 100.
Additionally, (60 as u32) is an unnecessary cast; 60u32 is idiomatic Rust.
Agent fix:
let w8 = (score.completed_orders * 10).min(40);
let w6 = 60u32.saturating_sub(score.completed_orders * 10).max(15);
let w5 = 5u32;
let w7 = 100 - w5 - w6 - w8; // ← named remainder
let roll = rand::random_range(0..100u32);
active_order.target_tier = if roll < w5 {
5
} else if roll < w5 + w6 {
6
} else if roll < w5 + w6 + w7 {
7
} else {
8
};
10. Visibility Guard Pattern Repeated ~15 Times #
Severity: Simplification
What's happening: Throughout visuals.rs and launcher.rs, the same
guarded-set pattern appears repeatedly:
if *visibility != Visibility::Hidden {
*visibility = Visibility::Hidden;
}
This idiom exists to avoid triggering change detection when the value isn't actually changing (a valid Bevy optimization). But writing it inline every time creates visual clutter and is easy to write wrong (e.g., checking Hidden but setting Visible by accident).
Agent fix: Add two helper functions (e.g., in utils.rs):
pub fn set_visibility(vis: &mut Visibility, target: Visibility) {
if *vis != target { *vis = target; }
}
Then replace all inline guard patterns with set_visibility(&mut visibility, Visibility::Hidden).
11. Testing Gaps #
11a. test_weighted_dispenser_distribution Tests a Tier That Cannot Exist #
core_math.rs:79 asserts tier >= 1 && tier <= 5, but get_random_dispensed_tier
only returns 1–4 (_ => 4). Tier 5 is unreachable. The bounds check in the
test is too wide and will silently accept a broken version of the function that
returns 5.
Agent fix: Change the assert to tier >= 1 && tier <= 4.
11b. No Test for spill_spheres #
spill_spheres in physics.rs is the system that unlocks Y-translation and
rotation when a sphere crosses launcher_z. It has no unit test. This is one
of the trickier systems (depends on the Rapier context being present) but the
axis-unlocking behavior is testable at the component level without a full
physics simulation.
Agent test sketch:
- Spawn a sphere with full
LockedAxesat z = 0, then teleport it toz > launcher_z. Run one update. AssertLockedAxesis now empty (or no longer present).
11c. No Test for Merge-Block During Active Cooldown #
handle_launch_input blocks firing when !merge_cooldowns.is_empty(). The
existing test_launch_cooldown_blocking tests the timer-based cooldown but
not the merge-cooldown gate. A sphere mid-merge should prevent a launch even
if the cooldown timer has expired.
Agent test sketch:
- Set up an app with
handle_launch_input, advance time past 0.8s cooldown. - Spawn an entity with
MergeCooldown. - Press left-click. Assert no sphere is spawned.
11d. No Test for check_order_fulfillment Timer Reset Behavior #
The fulfillment timer is stored in ActiveFulfillment and reset via
fulfillment.timer.reset() when a new match is found. There is no test that
verifies re-triggering: if the fulfilling entity is despawned externally (e.g.,
via a merge) while fulfillment is in progress, fulfillment.entity still
holds a stale handle and commands.entity(entity).despawn() will silently no-op
in Bevy (despawning an already-despawned entity). The score update will still
fire. This should be guarded.
Agent fix: Before calling commands.entity(entity).despawn() in
check_order_fulfillment, check sphere_query.get(entity).is_ok() (or use
commands.get_entity(entity)) and skip the score update if the entity no
longer exists.
12. Fulfilling Spheres Bypass Distance-Merge Detection But Not Collision Detection #
Severity: Edge-case logic gap
What's happening: check_distance_merges excludes Fulfilling spheres
from its proximity scan (the Without<Fulfilling> filter). But detect_collisions
does not exclude Fulfilling spheres — only spheres Without<InsideLauncher>
and Without<MergeCooldown>. If a free sphere collides with a fulfilling
sphere of the same tier during the 1.2s fulfillment window, detect_collisions
will fire a MergeEvent for the fulfilling entity.
resolve_merges will then try to despawn the fulfilling sphere via
DespawnDelay, which happens after check_order_fulfillment's despawn also
runs. Double-despawn in Bevy is a no-op, but the score is incremented once
(by the fulfillment timer) and once more (by the merge points), and a new
upgraded sphere is spawned from a sphere that was supposed to disappear as an
order completion.
Agent fix: Add Without<crate::game_state::Fulfilling> to the
detect_collisions sphere query filter, matching the existing guard in
check_distance_merges.
13. Architecture: Visuals Are Tangled Into Physics Spawn #
Severity: Architecture (future-phase hazard)
What's happening: spawn_sphere_entity in physics.rs adds
Visibility::default() to the physics entity:
// physics.rs:84
Visibility::default(),
This is a rendering concern living in the physics module. It works because
Bevy requires Visibility for hierarchy propagation, but it's the thin end of
a wedge that will make headless testing harder (all physics tests must either
carry or ignore rendering components).
More structurally: the on_sphere_added observer in visuals.rs is the
correct decoupling point — visuals react to physics events. But
spawn_sphere_entity reaching into rendering concepts (via Visibility)
blurs that line.
Agent fix for Phase 1:
- Remove
Visibility::default()fromspawn_sphere_entity. - Add it to the
on_sphere_addedobserver viacommands.entity(entity).insert(Visibility::default())if Bevy requires it for 2D overlay label positioning. - Audit whether it's actually needed (labels use a separate root entity; the
sphere mesh is a child spawned by the observer, so the parent may not need
Visibilityat all).
14. Win Condition Silently Swallowed if State Resource Is Absent #
Severity: Logic gap / silent failure
What's happening: resolve_merges in physics.rs declares its state
resource as optional:
// physics.rs:165
mut next_state: Option<ResMut<NextState<crate::game_state::AppState>>>,
This means if AppState is not registered in the app (which is the case in
every physics unit test that uses PhysicsPlugin directly), triggering the
Tier 9 win condition does nothing — no panic, no warning, just a silent
if let Some(ref mut ns) = next_state { ... } branch that is never taken.
The Option was likely added to make tests easier: PhysicsPlugin can be
added to a test app without also wiring up the full state machine. But it
trades test convenience for a production guarantee. If AppState were ever
accidentally unregistered, or if a future refactor moves physics into a
context where the state resource isn't present, the win condition would vanish
without any compile-time or runtime signal.
Agent fix:
- Make the resource required: change
Option<ResMut<NextState<AppState>>>toResMut<NextState<crate::game_state::AppState>>in the system signature. - In the tests that use
PhysicsPluginand exercise merges (test_collision_midpoint_merge,test_double_merge_safety,test_merge_momentum_conservation), add:
This is a few lines per test and keeps the production path honest.app.add_plugins(bevy::state::app::StatesPlugin); app.init_state::<crate::game_state::AppState>();
15. hud.rs Has No Tests #
Severity: Testing gap
The HUD module (hud.rs) contains all UI layout, button interaction handlers,
and score/order display logic. It currently has zero tests. The HUD is mostly
presentational, but two behaviors are worth testing without a full rendering
stack:
in_game_over_or_win_staterun condition — ensure it correctly admitsGameOverandWinbut rejectsMainMenuandInGame.update_score_hud/update_order_hudtext change guards — ensure text isn't rewritten every frame when the value hasn't changed (the!=guard exists; it should be tested).
Summary Table #
| # | File | Issue | Kind |
|---|---|---|---|
| 1 | hud.rs:341 |
TIER_COLORS indexed with .min(9) instead of .min(8) — potential panic |
Bug |
| 2 | game_state.rs / queries |
InsideLauncher never inserted; all exclusion filters are no-ops |
Bug / Dead code |
| 3 | game_state.rs:207, hud.rs:467 |
despawn_recursive_custom copy-pasted verbatim |
Duplication |
| 4 | visuals.rs:70 |
_colorblind parameter never used in material_for_tier |
Dead code |
| 5 | main.rs:30, visuals.rs |
ColorblindMode registered twice |
Redundancy |
| 6 | launcher.rs plugin |
update_launcher_preview_visuals outside InGame guard |
Inconsistency |
| 7 | visuals.rs:519 |
Merge animation loop body duplicated for mesh vs. label | Duplication |
| 8 | visuals.rs:346 |
O(N²) occlusion check in label projection | Performance |
| 9 | game_state.rs:168–181 |
Order weight formula uses unnamed 100-w5-w6-w8 inline |
Readability |
| 10 | visuals.rs, launcher.rs |
if *vis != X { *vis = X; } pattern repeated ~15 times |
Simplification |
| 11a | core_math.rs:79 |
Test asserts tier ≤ 5 but function only returns 1–4 | Test bug |
| 11b | physics.rs |
spill_spheres has no test |
Test gap |
| 11c | launcher.rs |
Merge-cooldown launch block has no test | Test gap |
| 11d | game_state.rs:149–154 |
Stale fulfillment entity not guarded before despawn | Logic gap |
| 12 | physics.rs:101 |
detect_collisions doesn't exclude Fulfilling spheres |
Bug |
| 13 | physics.rs:84 |
Visibility::default() in physics spawn function |
Architecture |
| 14 | physics.rs:165 |
Win condition uses Option<ResMut<NextState>> — silently swallowed if resource absent |
Logic gap |
| 15 | hud.rs |
Zero tests | Test gap |