Skip to content

029 collision particle feedback#195

Open
cleder wants to merge 5 commits into
developfrom
029-collision-particle-feedback
Open

029 collision particle feedback#195
cleder wants to merge 5 commits into
developfrom
029-collision-particle-feedback

Conversation

@cleder

@cleder cleder commented May 24, 2026

Copy link
Copy Markdown
Owner

Summary by Sourcery

Add an observer-driven collision particle feedback system that spawns short-lived spark effects for ball collisions with walls, paddle, and bricks, wired into existing collision handlers and gated by game state.

New Features:

  • Introduce a collision feedback subsystem that spawns particle-based visual effects for ball collisions with walls, paddle, and bricks.
  • Add a configurable feedback profile resource controlling particle count and effect lifetime within bounded ranges.
  • Define collision feedback signals and target classification enums to describe ball impact events.

Enhancements:

  • Wire collision feedback triggers into existing ball-wall, ball-brick, and paddle-ball collision systems without altering scoring or destruction logic.
  • Add lifecycle management to automatically update and despawn collision feedback effects and their particles over time.
  • Document collision feedback behavior, contracts, and implementation notes in the developer guide and dedicated spec files.

Documentation:

  • Extend the developer guide with collision feedback design notes, behavior constraints, and integration details.
  • Add a full feature specification, plan, data model, contracts, tasks, and quickstart docs for collision particle feedback.

Tests:

  • Add integration tests validating collision feedback spawning, positioning, lifetime and particle count bounds, pause suppression, burst behavior, style consistency, and hierarchy safety.

Chores:

  • Update Copilot/agent configuration to reference the new collision particle feedback feature specs and technologies.

Summary by CodeRabbit

  • New Features

    • Particle feedback effects for ball collisions with walls, paddle, and bricks; spawn at collision contact points with 8–16 particles and 0.20–0.35s lifetimes; suppressed while paused.
  • Documentation

    • Added full spec, plan, quickstart, data model, contracts, checklists, research, tasks, and developer notes for the collision particle feedback feature.
  • Tests

    • Added integration tests validating spawn, lifetime, counts, pause suppression, and behavior across collision types.

Review Change Stack

Copilot AI review requested due to automatic review settings May 24, 2026 20:38
@semanticdiff-com

semanticdiff-com Bot commented May 24, 2026

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  .cargo/config.toml Unsupported file format
  .github/agents/copilot-instructions.md Unsupported file format
  .github/copilot-instructions.md Unsupported file format
  .specify/feature.json  0% smaller
  docs/developer-guide.md Unsupported file format
  specs/029-collision-particle-feedback/checklists/requirements.md Unsupported file format
  specs/029-collision-particle-feedback/contracts/collision-feedback-observer.md Unsupported file format
  specs/029-collision-particle-feedback/data-model.md Unsupported file format
  specs/029-collision-particle-feedback/plan.md Unsupported file format
  specs/029-collision-particle-feedback/quickstart.md Unsupported file format
  specs/029-collision-particle-feedback/research.md Unsupported file format
  specs/029-collision-particle-feedback/spec.md Unsupported file format
  specs/029-collision-particle-feedback/tasks.md Unsupported file format
  src/lib.rs Unsupported file format
  src/signals.rs  0% smaller
  src/systems/collision_feedback.rs  0% smaller
  src/systems/mod.rs  0% smaller
  tests/collision_particle_feedback.rs  0% smaller

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@sourcery-ai

sourcery-ai Bot commented May 24, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Implements an observer-driven collision particle feedback system that spawns short-lived sparkly effects for ball collisions with walls, paddles, and bricks, wires it into existing collision paths, and adds tests, docs, and tooling metadata for the new feature.

Sequence diagram for collision feedback spawning and lifetime

sequenceDiagram
    participant BrickCollisionSystem
    participant WallCollisionSystem
    participant PaddleCollisionSystem
    participant Commands
    participant CollisionFeedbackObserver as spawn_collision_feedback_effect
    participant LifetimeSystem as update_feedback_effect_lifetimes

    BrickCollisionSystem->>Commands: trigger(CollisionFeedbackTriggered)
    WallCollisionSystem->>Commands: trigger(CollisionFeedbackTriggered)
    PaddleCollisionSystem->>Commands: trigger(CollisionFeedbackTriggered)

    Commands-->>CollisionFeedbackObserver: On<CollisionFeedbackTriggered>
    CollisionFeedbackObserver->>CollisionFeedbackObserver: feedback_allowed_for_state_opt
    alt [profile valid]
        CollisionFeedbackObserver->>CollisionFeedbackObserver: resolve_contact_point
        CollisionFeedbackObserver->>CollisionFeedbackObserver: offset_contact_toward_ball
        CollisionFeedbackObserver->>Commands: spawn(FeedbackEffectInstance)
        CollisionFeedbackObserver->>Commands: spawn(CollisionFeedbackParticle x N)
    end

    loop each frame
        LifetimeSystem->>LifetimeSystem: update_feedback_effect_lifetimes
        LifetimeSystem->>CollisionFeedbackParticle: move by velocity * dt
        LifetimeSystem->>FeedbackEffectInstance: increment elapsed_seconds
        alt [elapsed_seconds >= lifetime_seconds]
            LifetimeSystem->>Commands: despawn(FeedbackEffectInstance)
            LifetimeSystem->>Commands: despawn(CollisionFeedbackParticle with source_effect)
        end
    end
Loading

File-Level Changes

Change Details Files
Add collision feedback ECS subsystem with observer-based spawning and lifetime management for sparkly impact effects.
  • Introduce CollisionFeedbackTriggered event and CollisionFeedbackTargetKind enum for classifying collision targets and passing contact data.
  • Implement FeedbackProfile resource, FeedbackEffectInstance and CollisionFeedbackParticle components, plus helpers for game-state gating, contact resolution, and style variation.
  • Provide spawn_collision_feedback_effect observer to instantiate effects/particles and update_feedback_effect_lifetimes system to move/despawn them over time, including optional visual mesh/material setup.
src/signals.rs
src/systems/collision_feedback.rs
src/systems/mod.rs
Wire collision feedback triggers into existing collision-handling systems for walls, bricks, and paddle, with game-state-aware gating and updated queries.
  • Extend brick-ball collision system to compute brick and ball positions, resolve/offset contact points, emit feedback triggers for both destructible and indestructible bricks, and generalize indestructible-brick handling.
  • Update ball-wall collision detection to include transforms for both ball and wall, compute a midpoint-based contact point, and emit wall feedback triggers when allowed by game state.
  • Augment character controller collision handling to capture paddle/ball transforms, emit paddle feedback triggers on collisions, and thread GameState into relevant systems while tightening brick query ParamSet usage and comments.
src/lib.rs
Document, expose, and test collision feedback behavior and constraints, and update project tooling metadata for the new feature.
  • Export collision feedback types from systems::mod and describe behavior/guardrails in the developer guide, including observer usage, game-state gating, and lifetime/particle bounds.
  • Add a dedicated integration test suite validating spawning per target kind, contact point correctness, particle/lifetime bounds, pause suppression, style consistency, cleanup behavior, and hierarchy safety.
  • Register feature requirements and plan/spec artifacts under specs/029-collision-particle-feedback, and update Copilot/tooling instructions to reference the new spec and dependency set; slightly adjust cargo config linker flags.
src/systems/mod.rs
docs/developer-guide.md
tests/collision_particle_feedback.rs
specs/029-collision-particle-feedback/spec.md
specs/029-collision-particle-feedback/tasks.md
specs/029-collision-particle-feedback/plan.md
specs/029-collision-particle-feedback/quickstart.md
specs/029-collision-particle-feedback/data-model.md
specs/029-collision-particle-feedback/contracts/collision-feedback-observer.md
specs/029-collision-particle-feedback/research.md
specs/029-collision-particle-feedback/checklists/requirements.md
.github/agents/copilot-instructions.md
.github/copilot-instructions.md
.cargo/config.toml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@dosubot dosubot Bot added documentation 📚 Improvements or additions to documentation Game-play 👾🎲 rust 🦀 Pull requests that update rust code labels May 24, 2026
@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR adds a new collision particle-feedback feature: specification and plans, signal/event types, an ECS-based effect system (spawn + lifetime updater), integration into collision handlers, a comprehensive test suite, and supporting docs/config updates.

Changes

Collision Particle Feedback Feature

Layer / File(s) Summary
Specification and Planning Documents
specs/029-collision-particle-feedback/spec.md, specs/029-collision-particle-feedback/plan.md, specs/029-collision-particle-feedback/research.md, specs/029-collision-particle-feedback/quickstart.md, specs/029-collision-particle-feedback/tasks.md, specs/029-collision-particle-feedback/data-model.md, specs/029-collision-particle-feedback/contracts/collision-feedback-observer.md, specs/029-collision-particle-feedback/checklists/requirements.md
Complete feature specification and implementation plan: defines user scenarios, mandatory TDD acceptance criteria (effect spawning at contact points, 8–16 particles, 0.20–0.35s lifetime, pause suppression), technical design, phase-by-phase tasks, and validation checklist.
Signal Contracts and Data Types
src/signals.rs
Introduces CollisionFeedbackTargetKind enum and CollisionFeedbackTriggered event with ball entity, target entity, target kind, contact point (with fallback), and brick-destruction flag.
Collision Feedback Effect System
src/systems/collision_feedback.rs, src/systems/mod.rs
Implements CollisionFeedbackVisuals and FeedbackProfile resources, FeedbackEffectInstance and CollisionFeedbackParticle components, helpers for gating/contact resolution/offset, spawn_collision_feedback_effect observer, and update_feedback_effect_lifetimes system; re-exports types via systems module.
System Wiring and App Initialization
src/lib.rs (run initialization)
Registers FeedbackProfile and CollisionFeedbackVisuals, registers update_feedback_effect_lifetimes in Update schedule, and registers spawn_collision_feedback_effect in core observers.
Collision Handler Integration
src/lib.rs (mark_brick_on_ball_collision, detect_ball_wall_collisions, read_character_controller_collisions)
Refactors collision handlers to emit CollisionFeedbackTriggered when allowed by game state; includes ParamSet brick query changes, contact-point resolution/fallbacks, explicit indestructible skip logic, and paddle/ball transform usage for offsets.
Comprehensive Integration Tests
tests/collision_particle_feedback.rs
Adds Bevy ECS tests covering same-frame spawning, exact contact-point placement, contact helper behavior, particle motion, particle-count/lifetime bounds, pause suppression without replay, repeated-collision cleanup, style consistency/variation, burst-per-collision behavior, coverage across target kinds, and hierarchy-safety.
Project Configuration and Documentation
.cargo/config.toml, .specify/feature.json, .github/copilot-instructions.md, .github/agents/copilot-instructions.md, docs/developer-guide.md
Removes linker-specific rustflags, adds feature directory metadata, updates copilot instructions to reference the feature plan and active tech stack, and documents collision-feedback notes in developer guide.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • cleder/brkrs#122: Adds or modifies collision/event message types and observer wiring in src/signals.rs, overlapping signal definitions and consumption patterns.
  • cleder/brkrs#138: Also updates detect_ball_wall_collisions and wall-hit signaling paths that relate to this PR’s wall-feedback emissions.
  • cleder/brkrs#161: Touches brick collision handling in src/lib.rs, overlapping code paths that affect when feedback triggers are emitted.

Suggested labels

visual, Review effort 4/5

Poem

🐰 I hopped where ball and border meet,
and scattered sparks in bursts so sweet,
eight to sixteen, quick as a wink,
they shimmered, lived, then went extinct—
a rabbit's cheer for tiny bling!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title '029 collision particle feedback' directly corresponds to the main feature branch and implementation. It is specific enough to identify the core change (collision particle feedback system) while being appropriately concise.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 029-collision-particle-feedback

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/signals.rs
/// Collision target kind.
pub target_kind: CollisionFeedbackTargetKind,
/// Preferred collision contact point in world space.
pub contact_point: Vec3,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The contact_point: Vec3 field in CollisionFeedbackTriggered does not guarantee that the value is finite or valid. If upstream systems provide a NaN or infinite value, this could cause subtle bugs in physics or rendering consumers.

Recommendation:

  • Ensure that the producer of this event validates contact_point for finiteness before emitting the event.
  • Alternatively, document that consumers must check for validity and use fallback_contact_point if contact_point is invalid.
  • Consider providing a helper function to select a valid contact point, e.g.:
fn valid_contact_point(&self) -> Vec3 {
    if self.contact_point.is_finite() {
        self.contact_point
    } else if let Some(fallback) = self.fallback_contact_point {
        fallback
    } else {
        // Provide a sensible default or log a warning
        Vec3::ZERO
    }
}

Comment thread src/systems/collision_feedback.rs Outdated
Comment on lines +295 to +299
for (particle_entity, particle) in particles.p0().iter() {
if expired.contains(&particle.source_effect) {
commands.entity(particle_entity).despawn();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential inefficiency in particle cleanup:

The loop iterates over all particles and checks if their source_effect is in the expired list. This approach may become inefficient as the number of particles grows, especially if there are many effects and particles. Consider optimizing by maintaining a direct mapping from effect entities to their particles, or by batching the despawn operations to reduce iteration overhead.

Recommended solution:

  • Use a more efficient data structure (e.g., a HashSet for expired effects) to speed up the lookup.
  • Consider associating particles with their effect via a parent-child relationship and using Bevy's hierarchical despawn to clean up all associated particles when the effect is removed.

Comment thread src/systems/collision_feedback.rs Outdated
Comment on lines +225 to +233
let visuals = if let Some(visuals) = visuals {
Some(visuals.clone())
} else if let (Some(meshes), Some(materials)) = (meshes.as_mut(), materials.as_mut()) {
let created = create_collision_feedback_visuals(meshes.as_mut(), materials.as_mut());
commands.insert_resource(created.clone());
Some(created)
} else {
None
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing visuals handling in particle spawning:

When neither visuals nor asset resources (meshes, materials) are available, the code proceeds to spawn particles without any mesh or material, resulting in invisible or incomplete effects. This could lead to confusion or unintended behavior in the game.

Recommended solution:

  • Add a check to ensure that visuals are available before spawning particles. If visuals are missing, log a warning and skip particle spawning, or provide a fallback visual.

assert!(!effects.is_empty());

for effect in effects {
assert!((0.85..=1.15).contains(&effect.style_variant));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Floating-point comparisons in assertions (e.g., assert!((0.85..=1.15).contains(&effect.style_variant));) can lead to non-deterministic test failures due to rounding errors. Consider using an epsilon-based comparison, such as:

assert!((effect.style_variant - 1.0).abs() <= 0.15);

This approach is more robust against minor floating-point inaccuracies.

Comment on lines +266 to +279
fn burst_collisions_spawn_one_effect_per_collision_no_cap() {
let mut app = make_test_app();

for i in 0..25 {
trigger_collision(
&mut app,
CollisionFeedbackTargetKind::Brick,
Vec3::new(i as f32, 0.0, 0.0),
);
}
app.update();

assert_eq!(effect_instances(app.world_mut()).len(), 25);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test burst_collisions_spawn_one_effect_per_collision_no_cap assumes that the system will always spawn one effect per collision, even for a burst of 25 collisions. If the implementation introduces an internal cap or resource exhaustion, this test may fail or become flaky. Consider explicitly documenting or testing for the absence of such caps, or parameterizing the test to check for the maximum supported burst size.

@codacy-production

codacy-production Bot commented May 24, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 3 medium · 3 minor

Alerts:
⚠ 6 issues (≤ 0 issues of at least minor severity)

Results:
6 new issues

Category Results
BestPractice 2 medium
CodeStyle 3 minor
Complexity 1 medium

View in Codacy

🟢 Metrics 73 complexity · 10 duplication

Metric Results
Complexity 73
Duplication 10

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • In update_feedback_effect_lifetimes, the cleanup loop builds a Vec<Entity> of expired effects and then does expired.contains(&particle.source_effect) for every particle, which is O(n²); consider using a HashSet<Entity> or tagging particles for removal in a first pass to keep this linear even under heavy burst loads.
  • The collision-feedback spawning logic for bricks is duplicated between the main destructible-brick branch and the later brick_queries.p2 branch in mark_brick_on_ball_collision; extracting a small helper that computes contact/offset and triggers CollisionFeedbackTriggered would reduce repetition and make it easier to evolve the feedback behavior in one place.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `update_feedback_effect_lifetimes`, the cleanup loop builds a `Vec<Entity>` of expired effects and then does `expired.contains(&particle.source_effect)` for every particle, which is O(n²); consider using a `HashSet<Entity>` or tagging particles for removal in a first pass to keep this linear even under heavy burst loads.
- The collision-feedback spawning logic for bricks is duplicated between the main destructible-brick branch and the later `brick_queries.p2` branch in `mark_brick_on_ball_collision`; extracting a small helper that computes contact/offset and triggers `CollisionFeedbackTriggered` would reduce repetition and make it easier to evolve the feedback behavior in one place.

## Individual Comments

### Comment 1
<location path="src/lib.rs" line_range="828" />
<code_context>
                 }
-                let current_type = brick_type_ro.0;
+
+                if systems::collision_feedback::feedback_allowed_for_state_opt(&game_state) {
+                    let ball_pos = transforms
+                        .get(triggering_ball)
</code_context>
<issue_to_address>
**suggestion:** Collision feedback emission for bricks is duplicated between the main and fallback brick queries, which makes the logic harder to maintain.

The block that computes `ball_pos`/`midpoint`, resolves and offsets the contact point, and emits `CollisionFeedbackTriggered` is duplicated in both the main destructible-brick branch and the `brick_queries.p2()` fallback. The only behavioral difference is `brick_destroyed_on_impact` (`!is_multi_hit_brick(current_type)` vs `false`). Consider extracting a helper (e.g. `emit_brick_feedback(...)`) to centralize this logic and avoid the branches diverging when you adjust contact resolution or logging later.

Suggested implementation:

```rust
use bevy::prelude::*;

/// Centralized helper for computing ball and contact positions for brick collisions.
/// This avoids duplicating the midpoint/contact resolution logic across brick branches.
fn resolve_brick_collision_contact(
    transforms: &Query<&Transform>,
    triggering_ball: Entity,
    brick_pos: Vec3,
) -> (Vec3, Vec3) {
    let ball_pos = transforms
        .get(triggering_ball)
        .map(|t| t.translation)
        .unwrap_or(Vec3::ZERO);

    let midpoint = (ball_pos + brick_pos) * 0.5;
    let contact =
        systems::collision_feedback::resolve_contact_point(midpoint, brick_pos);
    let contact = systems::collision_feedback::offset_contact_toward_ball(
        contact,
        brick_pos,
        ball_pos,
    );

    (ball_pos, contact)
}

```

```rust
                if systems::collision_feedback::feedback_allowed_for_state_opt(&game_state) {
                    let (_ball_pos, contact) = resolve_brick_collision_contact(
                        &transforms,
                        triggering_ball,
                        brick_pos,
                    );
                    commands.trigger(crate::signals::CollisionFeedbackTriggered {
                        ball_entity: triggering_ball,

```

To fully implement the suggested refactor and remove duplication between the main destructible-brick branch and the `brick_queries.p2()` fallback:

1. Apply the same replacement to the fallback brick-handling branch:
   - Locate the duplicated block in the fallback branch that computes `ball_pos`, `midpoint`, `contact`, and then calls `commands.trigger(CollisionFeedbackTriggered { ... })`.
   - Replace the duplicated computation block with a call to `resolve_brick_collision_contact(&transforms, triggering_ball, brick_pos)` and reuse the `contact` return value exactly as in the main branch.

2. If the event payload explicitly needs `ball_pos` (for logging or additional fields in `CollisionFeedbackTriggered`), adjust the helper usage to:
   - Destructure both values as `let (ball_pos, contact) = resolve_brick_collision_contact(...);`
   - Use `ball_pos` wherever its currently being recomputed in both branches.

3. If you prefer to have the helper live in `systems::collision_feedback` instead of `src/lib.rs`, move `resolve_brick_collision_contact` into that module:
   - Change its signature to be `pub(crate)` or `pub` as appropriate.
   - Update the call sites to use `systems::collision_feedback::resolve_brick_collision_contact(...)` instead of the bare function name.
</issue_to_address>

### Comment 2
<location path="tests/collision_particle_feedback.rs" line_range="48-50" />
<code_context>
+    });
+}
+
+fn effect_instances(world: &mut World) -> Vec<FeedbackEffectInstance> {
+    let mut q = world.query::<&FeedbackEffectInstance>();
+    q.iter(world).copied().collect()
+}
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding direct unit tests for `resolve_contact_point` and `offset_contact_toward_ball` edge cases

Currently these helpers are only exercised indirectly via the observer. Please add focused tests (here or in a `collision_feedback_tests` module) that cover:

- `resolve_contact_point` with a non-finite `contact_point` and finite `fallback_contact_point`
- `resolve_contact_point` when both inputs are non-finite (fall back to `Vec3::ZERO`)
- `offset_contact_toward_ball` when the sourceball direction is zero-length or non-finite (returns original contact point)
- A normal `offset_contact_toward_ball` case asserting the 0.25-unit offset toward the ball

This will better protect these math-heavy helpers, which are reused across collision systems, during future refactors.

Suggested implementation:

```rust
use bevy::prelude::*;

use brkrs::game_state::GameState;
use brkrs::signals::{CollisionFeedbackTargetKind, CollisionFeedbackTriggered};
use brkrs::systems::collision_feedback::{
    spawn_collision_feedback_effect, update_feedback_effect_lifetimes, CollisionFeedbackParticle,
    FeedbackEffectInstance, FeedbackProfile, offset_contact_toward_ball, resolve_contact_point,
};

#[test]
fn resolve_contact_point_uses_fallback_when_primary_non_finite() {
    let non_finite = Vec3::new(f32::NAN, 1.0, 0.0);
    let fallback = Vec3::new(1.0, 2.0, 3.0);

    let resolved = resolve_contact_point(non_finite, Some(fallback));

    assert_eq!(resolved, fallback);
}

#[test]
fn resolve_contact_point_falls_back_to_zero_when_both_non_finite() {
    let non_finite = Vec3::splat(f32::NAN);

    let resolved = resolve_contact_point(non_finite, Some(non_finite));

    assert_eq!(resolved, Vec3::ZERO);
}

#[test]
fn offset_contact_toward_ball_returns_original_when_direction_zero_length() {
    let contact = Vec3::new(1.0, 0.0, 0.0);
    let source = Vec3::new(0.0, 0.0, 0.0);
    let ball = source; // zero-length source→ball direction

    let offset = offset_contact_toward_ball(contact, ball, source);

    assert_eq!(offset, contact);
}

#[test]
fn offset_contact_toward_ball_returns_original_when_direction_non_finite() {
    let contact = Vec3::new(1.0, 0.0, 0.0);
    let source = Vec3::new(f32::NAN, 0.0, 0.0);
    let ball = Vec3::new(0.0, 0.0, 0.0);

    let offset = offset_contact_toward_ball(contact, ball, source);

    assert_eq!(offset, contact);
}

#[test]
fn offset_contact_toward_ball_offsets_toward_ball_by_quarter_unit() {
    let contact = Vec3::new(0.0, 0.0, 0.0);
    let source = Vec3::new(0.0, 0.0, 0.0);
    let ball = Vec3::new(1.0, 0.0, 0.0);

    let offset = offset_contact_toward_ball(contact, ball, source);

    let expected = Vec3::new(0.25, 0.0, 0.0);
    assert!((offset - expected).length() <= 1e-4);
}

fn make_test_app() -> App {

```

These tests assume the following signatures and visibility:

- `pub fn resolve_contact_point(contact_point: Vec3, fallback_contact_point: Option<Vec3>) -> Vec3`
- `pub fn offset_contact_toward_ball(contact_point: Vec3, ball_translation: Vec3, source_translation: Vec3) -> Vec3`

If the signatures differ (e.g., parameter order, `Option` usage, or module path), adjust the test calls accordingly. If these helpers are not currently `pub` from `brkrs::systems::collision_feedback`, they will need to be made public (or re-exported in a public module) so they can be used from the `tests` crate.
</issue_to_address>

### Comment 3
<location path="tests/collision_particle_feedback.rs" line_range="161-170" />
<code_context>
+}
+
+#[test]
+fn pause_suppresses_spawns_and_resume_does_not_replay() {
+    let mut app = make_test_app();
+
+    app.world_mut()
+        .resource_mut::<NextState<GameState>>()
+        .set(GameState::Paused);
+    app.update();
+
+    trigger_collision(&mut app, CollisionFeedbackTargetKind::Wall, Vec3::ZERO);
+    app.update();
+
+    assert!(effect_instances(app.world_mut()).is_empty());
+
+    app.world_mut()
+        .resource_mut::<NextState<GameState>>()
+        .set(GameState::Playing);
+    app.update();
+
+    assert!(
+        effect_instances(app.world_mut()).is_empty(),
+        "Suppressed effects must not replay after resume"
+    );
+}
</code_context>
<issue_to_address>
**suggestion (testing):** Consider testing the behavior when the `GameState` resource is absent, since the observer explicitly supports that case

Right now we only verify behavior when `GameState` is present (`Paused` vs `Playing`). Since `feedback_allowed_for_state_opt` also treats a *missing* `State<GameState>` as "allowed", please add a test that builds a minimal `App` without `StatesPlugin`/`GameState`, triggers `spawn_collision_feedback_effect`, and asserts that an effect is spawned. That will guard the "no state == allowed" contract against regressions.

Suggested implementation:

```rust
    assert!(
        effect_instances(app.world_mut()).is_empty(),
        "Suppressed effects must not replay after resume"
    );
}

#[test]
fn collision_feedback_allowed_when_gamestate_absent() {
    let mut app = make_test_app();

    // Remove the `State<GameState>` resource to simulate an app without `GameState`
    app.world_mut().remove_resource::<State<GameState>>();

    trigger_collision(
        &mut app,
        CollisionFeedbackTargetKind::Wall,
        Vec3::ZERO,
    );
    app.update();

    assert!(
        !effect_instances(app.world_mut()).is_empty(),
        "Collision feedback effects should still spawn when GameState is absent"
    );
}

```

1. Ensure `State` is imported in this test module (e.g. `use bevy::prelude::State;`) if it is not already.
2. This test assumes `make_test_app`, `CollisionFeedbackTargetKind`, `Vec3`, and `effect_instances` are already in scope, as in the other tests in this file.
</issue_to_address>

### Comment 4
<location path="tests/collision_particle_feedback.rs" line_range="186-195" />
<code_context>
+}
+
+#[test]
+fn repeated_collisions_cleanup_all_effects_and_particles() {
+    let mut app = make_test_app();
+
+    for i in 0..12 {
+        trigger_collision(
+            &mut app,
+            CollisionFeedbackTargetKind::Brick,
+            Vec3::new(i as f32, 0.0, 0.0),
+        );
+    }
+    app.update();
+
+    assert_eq!(effect_instances(app.world_mut()).len(), 12);
+
+    advance_time(&mut app, 1.0);
+    app.update();
+
+    assert!(effect_instances(app.world_mut()).is_empty());
+    let mut q = app.world_mut().query::<&CollisionFeedbackParticle>();
+    assert_eq!(q.iter(app.world()).count(), 0);
+}
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add an assertion that particles actually move over time, not just that they are later despawned

This test covers cleanup, but `update_feedback_effect_lifetimes` is also responsible for applying per-frame motion. A separate test could:
- Trigger a single collision and call `app.update()` to record initial particle translations.
- Advance time by a small `delta_secs` (e.g. 0.1), call `app.update()` again, and assert that at least one particle’s translation changes in the expected direction.

That would guard against regressions where velocities stop being applied while keeping this test focused on despawning.

Suggested implementation:

```rust
use std::collections::HashMap;

use bevy::prelude::*;

```

```rust
fn repeated_collisions_cleanup_all_effects_and_particles() {
    let mut app = make_test_app();

    for i in 0..12 {
        trigger_collision(
            &mut app,
            CollisionFeedbackTargetKind::Brick,
            Vec3::new(i as f32, 0.0, 0.0),
        );
    }
    app.update();

    assert_eq!(effect_instances(app.world_mut()).len(), 12);

    advance_time(&mut app, 1.0);
    app.update();

    assert!(effect_instances(app.world_mut()).is_empty());
    let mut q = app.world_mut().query::<&CollisionFeedbackParticle>();
    assert_eq!(q.iter(app.world()).count(), 0);
}

#[test]
fn particles_move_over_time() {
    let mut app = make_test_app();

    // Trigger a single collision at the origin to spawn particles.
    trigger_collision(
        &mut app,
        CollisionFeedbackTargetKind::Brick,
        Vec3::ZERO,
    );
    app.update();

    // Capture initial positions and velocities per particle entity.
    let mut world = app.world_mut();
    let mut initial: HashMap<Entity, (Vec3, Vec3)> = HashMap::new();
    {
        let mut q = world.query::<(Entity, &Transform, &CollisionFeedbackParticle)>();
        for (entity, transform, particle) in q.iter(&world) {
            initial.insert(entity, (transform.translation, particle.velocity));
        }
    }

    assert!(
        !initial.is_empty(),
        "Expected at least one particle to be spawned by collision feedback"
    );

    // Advance time by a small delta and update the simulation.
    let delta_secs = 0.1;
    advance_time(&mut app, delta_secs);
    app.update();

    // Re-query and assert that at least one particle moved in the direction of its velocity.
    world = app.world_mut();
    let mut q = world.query::<(Entity, &Transform, &CollisionFeedbackParticle)>();

    let mut any_moved_in_expected_direction = false;
    for (entity, transform, _particle) in q.iter(&world) {
        if let Some((initial_pos, velocity)) = initial.get(&entity) {
            let displacement = transform.translation - *initial_pos;

            if displacement.length() > 0.0 && displacement.dot(*velocity) > 0.0 {
                any_moved_in_expected_direction = true;
                break;
            }
        }
    }

    assert!(
        any_moved_in_expected_direction,
        "Expected at least one particle to move in the direction of its velocity over time"
    );
}

```

This test assumes that `CollisionFeedbackParticle` has a public `velocity: Vec3` field and that particles are moved via `Transform.translation`. If your component uses different field names or a separate position component, adjust the query and the `(initial_pos, velocity)` extraction accordingly.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/lib.rs Outdated
Comment on lines +48 to +50
fn effect_instances(world: &mut World) -> Vec<FeedbackEffectInstance> {
let mut q = world.query::<&FeedbackEffectInstance>();
q.iter(world).copied().collect()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding direct unit tests for resolve_contact_point and offset_contact_toward_ball edge cases

Currently these helpers are only exercised indirectly via the observer. Please add focused tests (here or in a collision_feedback_tests module) that cover:

  • resolve_contact_point with a non-finite contact_point and finite fallback_contact_point
  • resolve_contact_point when both inputs are non-finite (fall back to Vec3::ZERO)
  • offset_contact_toward_ball when the source→ball direction is zero-length or non-finite (returns original contact point)
  • A normal offset_contact_toward_ball case asserting the 0.25-unit offset toward the ball

This will better protect these math-heavy helpers, which are reused across collision systems, during future refactors.

Suggested implementation:

use bevy::prelude::*;

use brkrs::game_state::GameState;
use brkrs::signals::{CollisionFeedbackTargetKind, CollisionFeedbackTriggered};
use brkrs::systems::collision_feedback::{
    spawn_collision_feedback_effect, update_feedback_effect_lifetimes, CollisionFeedbackParticle,
    FeedbackEffectInstance, FeedbackProfile, offset_contact_toward_ball, resolve_contact_point,
};

#[test]
fn resolve_contact_point_uses_fallback_when_primary_non_finite() {
    let non_finite = Vec3::new(f32::NAN, 1.0, 0.0);
    let fallback = Vec3::new(1.0, 2.0, 3.0);

    let resolved = resolve_contact_point(non_finite, Some(fallback));

    assert_eq!(resolved, fallback);
}

#[test]
fn resolve_contact_point_falls_back_to_zero_when_both_non_finite() {
    let non_finite = Vec3::splat(f32::NAN);

    let resolved = resolve_contact_point(non_finite, Some(non_finite));

    assert_eq!(resolved, Vec3::ZERO);
}

#[test]
fn offset_contact_toward_ball_returns_original_when_direction_zero_length() {
    let contact = Vec3::new(1.0, 0.0, 0.0);
    let source = Vec3::new(0.0, 0.0, 0.0);
    let ball = source; // zero-length source→ball direction

    let offset = offset_contact_toward_ball(contact, ball, source);

    assert_eq!(offset, contact);
}

#[test]
fn offset_contact_toward_ball_returns_original_when_direction_non_finite() {
    let contact = Vec3::new(1.0, 0.0, 0.0);
    let source = Vec3::new(f32::NAN, 0.0, 0.0);
    let ball = Vec3::new(0.0, 0.0, 0.0);

    let offset = offset_contact_toward_ball(contact, ball, source);

    assert_eq!(offset, contact);
}

#[test]
fn offset_contact_toward_ball_offsets_toward_ball_by_quarter_unit() {
    let contact = Vec3::new(0.0, 0.0, 0.0);
    let source = Vec3::new(0.0, 0.0, 0.0);
    let ball = Vec3::new(1.0, 0.0, 0.0);

    let offset = offset_contact_toward_ball(contact, ball, source);

    let expected = Vec3::new(0.25, 0.0, 0.0);
    assert!((offset - expected).length() <= 1e-4);
}

fn make_test_app() -> App {

These tests assume the following signatures and visibility:

  • pub fn resolve_contact_point(contact_point: Vec3, fallback_contact_point: Option<Vec3>) -> Vec3
  • pub fn offset_contact_toward_ball(contact_point: Vec3, ball_translation: Vec3, source_translation: Vec3) -> Vec3

If the signatures differ (e.g., parameter order, Option usage, or module path), adjust the test calls accordingly. If these helpers are not currently pub from brkrs::systems::collision_feedback, they will need to be made public (or re-exported in a public module) so they can be used from the tests crate.

Comment on lines +161 to +170
fn pause_suppresses_spawns_and_resume_does_not_replay() {
let mut app = make_test_app();

app.world_mut()
.resource_mut::<NextState<GameState>>()
.set(GameState::Paused);
app.update();

trigger_collision(&mut app, CollisionFeedbackTargetKind::Wall, Vec3::ZERO);
app.update();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider testing the behavior when the GameState resource is absent, since the observer explicitly supports that case

Right now we only verify behavior when GameState is present (Paused vs Playing). Since feedback_allowed_for_state_opt also treats a missing State<GameState> as "allowed", please add a test that builds a minimal App without StatesPlugin/GameState, triggers spawn_collision_feedback_effect, and asserts that an effect is spawned. That will guard the "no state == allowed" contract against regressions.

Suggested implementation:

    assert!(
        effect_instances(app.world_mut()).is_empty(),
        "Suppressed effects must not replay after resume"
    );
}

#[test]
fn collision_feedback_allowed_when_gamestate_absent() {
    let mut app = make_test_app();

    // Remove the `State<GameState>` resource to simulate an app without `GameState`
    app.world_mut().remove_resource::<State<GameState>>();

    trigger_collision(
        &mut app,
        CollisionFeedbackTargetKind::Wall,
        Vec3::ZERO,
    );
    app.update();

    assert!(
        !effect_instances(app.world_mut()).is_empty(),
        "Collision feedback effects should still spawn when GameState is absent"
    );
}
  1. Ensure State is imported in this test module (e.g. use bevy::prelude::State;) if it is not already.
  2. This test assumes make_test_app, CollisionFeedbackTargetKind, Vec3, and effect_instances are already in scope, as in the other tests in this file.

Comment on lines +186 to +195
fn repeated_collisions_cleanup_all_effects_and_particles() {
let mut app = make_test_app();

for i in 0..12 {
trigger_collision(
&mut app,
CollisionFeedbackTargetKind::Brick,
Vec3::new(i as f32, 0.0, 0.0),
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add an assertion that particles actually move over time, not just that they are later despawned

This test covers cleanup, but update_feedback_effect_lifetimes is also responsible for applying per-frame motion. A separate test could:

  • Trigger a single collision and call app.update() to record initial particle translations.
  • Advance time by a small delta_secs (e.g. 0.1), call app.update() again, and assert that at least one particle’s translation changes in the expected direction.

That would guard against regressions where velocities stop being applied while keeping this test focused on despawning.

Suggested implementation:

use std::collections::HashMap;

use bevy::prelude::*;
fn repeated_collisions_cleanup_all_effects_and_particles() {
    let mut app = make_test_app();

    for i in 0..12 {
        trigger_collision(
            &mut app,
            CollisionFeedbackTargetKind::Brick,
            Vec3::new(i as f32, 0.0, 0.0),
        );
    }
    app.update();

    assert_eq!(effect_instances(app.world_mut()).len(), 12);

    advance_time(&mut app, 1.0);
    app.update();

    assert!(effect_instances(app.world_mut()).is_empty());
    let mut q = app.world_mut().query::<&CollisionFeedbackParticle>();
    assert_eq!(q.iter(app.world()).count(), 0);
}

#[test]
fn particles_move_over_time() {
    let mut app = make_test_app();

    // Trigger a single collision at the origin to spawn particles.
    trigger_collision(
        &mut app,
        CollisionFeedbackTargetKind::Brick,
        Vec3::ZERO,
    );
    app.update();

    // Capture initial positions and velocities per particle entity.
    let mut world = app.world_mut();
    let mut initial: HashMap<Entity, (Vec3, Vec3)> = HashMap::new();
    {
        let mut q = world.query::<(Entity, &Transform, &CollisionFeedbackParticle)>();
        for (entity, transform, particle) in q.iter(&world) {
            initial.insert(entity, (transform.translation, particle.velocity));
        }
    }

    assert!(
        !initial.is_empty(),
        "Expected at least one particle to be spawned by collision feedback"
    );

    // Advance time by a small delta and update the simulation.
    let delta_secs = 0.1;
    advance_time(&mut app, delta_secs);
    app.update();

    // Re-query and assert that at least one particle moved in the direction of its velocity.
    world = app.world_mut();
    let mut q = world.query::<(Entity, &Transform, &CollisionFeedbackParticle)>();

    let mut any_moved_in_expected_direction = false;
    for (entity, transform, _particle) in q.iter(&world) {
        if let Some((initial_pos, velocity)) = initial.get(&entity) {
            let displacement = transform.translation - *initial_pos;

            if displacement.length() > 0.0 && displacement.dot(*velocity) > 0.0 {
                any_moved_in_expected_direction = true;
                break;
            }
        }
    }

    assert!(
        any_moved_in_expected_direction,
        "Expected at least one particle to move in the direction of its velocity over time"
    );
}

This test assumes that CollisionFeedbackParticle has a public velocity: Vec3 field and that particles are moved via Transform.translation. If your component uses different field names or a separate position component, adjust the query and the (initial_pos, velocity) extraction accordingly.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements immediate visual particle feedback for ball collisions with walls, paddles, and bricks using Bevy 0.17 observers. It introduces a new collision_feedback system, integrates event triggers into existing collision detection paths, and includes comprehensive specifications, documentation, and integration tests. Feedback focuses on improving the robustness and performance of the implementation: correcting resource cloning logic, relaxing restrictive validation on tunable parameters, and moving lazy asset initialization to a more idiomatic FromWorld pattern. Reviewers also recommended simplifying ParamSet usage, optimizing particle cleanup via entity hierarchies, and replacing the duplicated midpoint heuristic with accurate contact manifold data from the physics engine to satisfy specification requirements.

}

let event = trigger.event();
let profile = profile.map(|p| p.clone()).unwrap_or_default();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Bevy, Res<T> typically does not implement Clone. Calling .clone() on an Option<Res<FeedbackProfile>> might attempt to clone the resource wrapper itself rather than the underlying data, which could result in a compilation error. Could you clarify if you are using a specific Bevy version or a custom wrapper that implements Clone for Res? Otherwise, you should dereference the resource before cloning the data.

Suggested change
let profile = profile.map(|p| p.clone()).unwrap_or_default();
let profile = profile.as_deref().cloned().unwrap_or_default();
References
  1. When commenting on API usage without full dependency context, avoid making absolute claims about compilation errors. Instead, phrase suggestions as possibilities and ask for clarification on crate versions or custom wrappers.

Comment on lines +48 to +55
fn is_valid(&self) -> bool {
self.min_particles <= self.max_particles
&& self.min_lifetime_seconds <= self.max_lifetime_seconds
&& self.min_particles >= DEFAULT_MIN_PARTICLES
&& self.max_particles <= DEFAULT_MAX_PARTICLES
&& self.min_lifetime_seconds >= DEFAULT_MIN_LIFETIME
&& self.max_lifetime_seconds <= DEFAULT_MAX_LIFETIME
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The is_valid check is overly restrictive because it enforces that the "tunable" parameters must remain within the hardcoded default ranges. If a developer attempts to tune the FeedbackProfile resource to use, for example, 20 particles or a 0.5s lifetime, the system will silently fail to spawn any effects. The validation should instead focus on logical consistency (e.g., min <= max and non-negative values).

    fn is_valid(&self) -> bool {
        self.min_particles <= self.max_particles
            && self.min_lifetime_seconds <= self.max_lifetime_seconds
            && self.min_particles > 0
            && self.min_lifetime_seconds >= 0.0
    }

Comment thread src/systems/collision_feedback.rs Outdated
Comment on lines +225 to +233
let visuals = if let Some(visuals) = visuals {
Some(visuals.clone())
} else if let (Some(meshes), Some(materials)) = (meshes.as_mut(), materials.as_mut()) {
let created = create_collision_feedback_visuals(meshes.as_mut(), materials.as_mut());
commands.insert_resource(created.clone());
Some(created)
} else {
None
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Initializing the CollisionFeedbackVisuals resource lazily inside an observer is inefficient and prone to race conditions. Since commands.insert_resource is deferred until the end of the stage, multiple collisions occurring in the same frame will each trigger the mesh and material creation logic, resulting in redundant asset handles and multiple insertion commands. It is better to initialize this resource using app.init_resource::<CollisionFeedbackVisuals>() in the plugin setup, implementing FromWorld for CollisionFeedbackVisuals to handle the asset creation.

Comment thread src/systems/collision_feedback.rs Outdated
Comment on lines +268 to +271
mut particles: ParamSet<(
Query<(Entity, &CollisionFeedbackParticle)>,
Query<(&CollisionFeedbackParticle, &mut Transform)>,
)>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The use of ParamSet here is unnecessary. In Bevy, multiple queries can read the same component (&CollisionFeedbackParticle) simultaneously without conflict. ParamSet is only required when multiple queries access the same component and at least one of them is mutable (&mut). Removing the ParamSet will simplify the system signature and the subsequent code.

Comment thread src/systems/collision_feedback.rs Outdated
Comment on lines +295 to +299
for (particle_entity, particle) in particles.p0().iter() {
if expired.contains(&particle.source_effect) {
commands.entity(particle_entity).despawn();
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current cleanup logic iterates over all particles in the world and performs a lookup in the expired list for each one whenever any effect expires. This results in O(N_particles x N_expired) complexity, which can become a performance bottleneck during intense gameplay. A more efficient and idiomatic Bevy approach would be to make the particles children of the effect entity using commands.entity(effect_entity).add_child(particle_entity) and then using commands.entity(effect_entity).despawn_recursive(). This leverages Bevy's built-in hierarchy management for automatic cleanup.

Comment thread src/lib.rs Outdated
Comment on lines +833 to +838
let midpoint = (ball_pos + brick_pos) * 0.5;
let contact =
systems::collision_feedback::resolve_contact_point(midpoint, brick_pos);
let contact = systems::collision_feedback::offset_contact_toward_ball(
contact, brick_pos, ball_pos,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for calculating the collision contact point using a midpoint heuristic is duplicated across mark_brick_on_ball_collision, detect_ball_wall_collisions, and read_character_controller_collisions. Furthermore, the midpoint between entity centers is an inaccurate representation of the "exact contact point" required by the specification (FR-004, FR-015), especially for collisions with large walls. Consider centralizing this logic into a helper function and, if possible, leveraging RapierContext to retrieve the actual contact manifold from the physics engine for better visual accuracy.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an observer-driven collision particle feedback subsystem that spawns short-lived “sparkly” burst effects for ball impacts against walls, paddle, and bricks, with configurable bounds and integration coverage via acceptance-style tests.

Changes:

  • Introduces CollisionFeedbackTriggered (+ target-kind enum) and a new systems::collision_feedback module to spawn/advance/cleanup effect instances and particles.
  • Hooks collision feedback triggers into existing collision entry points in src/lib.rs (brick, wall, paddle paths), gated by GameState::Playing.
  • Adds a comprehensive integration test suite and feature specification/plan docs for the new behavior.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/collision_particle_feedback.rs New integration tests covering spawn timing, contact point, bounds, pause suppression, cleanup, and hierarchy safety.
src/systems/mod.rs Exposes the new collision feedback module and re-exports key types.
src/systems/collision_feedback.rs New observer + lifetime/particle systems and supporting helpers/resources for collision VFX.
src/signals.rs Adds CollisionFeedbackTargetKind and CollisionFeedbackTriggered observer event contract.
src/lib.rs Wires resource init + systems/observers; emits collision feedback triggers from existing collision paths.
docs/developer-guide.md Documents collision feedback behavior contracts and guardrails.
.cargo/config.toml Removes explicit LLD linker rustflag override.
.github/copilot-instructions.md Points SPECKIT guidance at the new feature plan path.
.github/agents/copilot-instructions.md Updates agent instructions with the feature’s tech stack line-items.
specs/029-collision-particle-feedback/* Adds feature spec, plan, research, tasks, contracts, checklist, and quickstart docs.
.specify/feature.json Registers the feature directory for tooling.

Comment thread tests/collision_particle_feedback.rs Outdated
trigger_collision(&mut app, CollisionFeedbackTargetKind::Paddle, Vec3::ZERO);
app.update();

let effects = effect_instances(app.world());
Comment thread src/systems/collision_feedback.rs Outdated
Comment on lines +225 to +233
let visuals = if let Some(visuals) = visuals {
Some(visuals.clone())
} else if let (Some(meshes), Some(materials)) = (meshes.as_mut(), materials.as_mut()) {
let created = create_collision_feedback_visuals(meshes.as_mut(), materials.as_mut());
commands.insert_resource(created.clone());
Some(created)
} else {
None
};
Comment thread src/systems/collision_feedback.rs Outdated
Comment on lines +279 to +298
let mut expired = Vec::new();
for (entity, mut effect) in &mut effects {
effect.elapsed_seconds += dt;
if effect.elapsed_seconds >= effect.lifetime_seconds {
expired.push(entity);
}
}

if expired.is_empty() {
return;
}

for effect in &expired {
commands.entity(*effect).despawn();
}

for (particle_entity, particle) in particles.p0().iter() {
if expired.contains(&particle.source_effect) {
commands.entity(particle_entity).despawn();
}

@llamapreview llamapreview Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Code Review by LlamaPReview

🎯 TL;DR & Recommendation

Recommendation: Request Changes

This PR successfully adds collision particle feedback but introduces two critical issues: a potential behavioral change for indestructible bricks that may break level designs, and a heuristic contact point that violates the spec's accuracy requirement. Build failure risk exists due to a possible rand API mismatch.

📄 Documentation Diagram

This diagram documents the refactored collision particle feedback flow from collision event to effect cleanup.

sequenceDiagram
    participant CollisionEvent as Collision Event
    participant CollisionSys as Collision System
    participant FeedbackObserver as Feedback Observer
    participant EffectInstance as Effect Instance
    participant Particle as Particles
    participant UpdateLifecycle as Lifecycle System

    CollisionEvent->>CollisionSys: CollisionEvent::Started
    CollisionSys->>CollisionSys: Classify collision (Wall/Paddle/Brick)
    CollisionSys->>CollisionSys: Compute contact point (heuristic)
    CollisionSys->>FeedbackObserver: Trigger CollisionFeedbackTriggered
    note over FeedbackObserver: PR #35;195: new observer
    FeedbackObserver->>FeedbackObserver: Check game state (pause suppression)
    FeedbackObserver->>EffectInstance: Spawn FeedbackEffectInstance
    FeedbackObserver->>Particle: Spawn N particles (8-16) at contact point
    EffectInstance->>UpdateLifecycle: Elapsed time updated
    UpdateLifecycle->>EffectInstance: Check lifetime expired
    UpdateLifecycle->>EffectInstance: Despawn effect
    UpdateLifecycle->>Particle: Despawn child particles
Loading

🌟 Strengths

  • Comprehensive test coverage validates burst, pause, and lifetime behavior.
  • Clear observer-driven architecture integrates well with existing collision systems.
Priority File Category Impact Summary (≤12 words) Anchors
P1 src/lib.rs Bug Silent destruction rule change for bricks ≥ ID threshold constant:INDESTRUCTIBLE_BRICK, path:src/level_format.rs
P1 src/lib.rs Bug Contact point uses heuristic, not exact collision manifold method:resolve_contact_point, method:offset_contact_toward_ball
P2 src/systems/collision_feedback.rs Build rand API may not compile if project uses rand 0.8
P2 src/lib.rs Maintainability ParamSet with three queries duplicates logic, increases risk

🔍 Notable Themes

  • Accuracy vs. Simplicity Trade-off: The contact point heuristic prioritizes simplicity over physics accuracy, risking visual misalignment. A dedicated utility to extract contact data from Rapier would uphold the spec but may require additional work.
  • Version Dependency Risk: The PR assumes a rand version (0.9+) without updating Cargo.toml, creating a potential build blocker. Dependencies should be explicitly pinned.

📈 Risk Diagram

This diagram illustrates the behavioral risks associated with the indestructible brick threshold change and the heuristic contact point computation.

sequenceDiagram
    participant Level as Level Config
    participant CollisionLogic as Collision System
    participant Brick as Brick Entity
    participant Feedback as Feedback Effect

    Note over Level: Brick type IDs defined in level files
    Level->>CollisionLogic: Read brick type (e.g., 90, 91, 92)
    CollisionLogic->>Brick: Check destruction rule
    note over CollisionLogic: R1(P1): Threshold `>= INDESTRUCTIBLE_BRICK` silently makes <br/>all types >=90 indestructible – breaks existing levels
    alt Brick type >= INDESTRUCTIBLE_BRICK (e.g., 90)
        CollisionLogic->>Brick: Skip destruction (new behavior)
    else Brick type == HAZARD_BRICK_91 (old behavior)
        CollisionLogic->>Brick: Mark for destruction (changed)
    end

    CollisionLogic->>Feedback: Compute contact point
    note over CollisionLogic,Feedback: R2(P1): Contact point uses midpoint+offset heuristic,<br/>not exact physics manifold – violates FR-015
    activate Feedback
        Feedback->>Feedback: Spawn effect at heuristic point
    deactivate Feedback
Loading
⚠️ **Unanchored Suggestions (Manual Review Recommended)**

The following suggestions could not be precisely anchored to a specific line in the diff. This can happen if the code is outside the changed lines, has been significantly refactored, or if the suggestion is a general observation. Please review them carefully in the context of the full file.


📁 File: src/lib.rs

The mark_brick_on_ball_collision function now uses a ParamSet with three distinct queries. The third query (index 2) duplicates the first query’s structure minus the With<CountsTowardsCompletion> filter. This pattern duplicates query definitions and increases the risk of inconsistency (e.g., changing the first query but forgetting the third). The addition was necessary to support feedback for bricks that do not count towards completion (like indestructible bricks). However, the design could be simplified by using a single query with a With<Brick> filter and a separate check for CountsTowardsCompletion at runtime via a Has component check (Bevy 0.17 supports Has<C>() in queries). That would avoid the ParamSet growth and reduce cognitive load. The current implementation also accesses brick_queries.p2() in the else if branch, which introduces subtle control flow dependencies.

Suggestion:

Consolidate to a single query with a `With<Brick>` filter and use `Has<CountsTowardsCompletion>` on the entity to distinguish behavior. For example:
```rust
let brick_query = Query<(Entity, &BrickTypeId, Option<&GlobalTransform>, Option<&Transform>), (With<Brick>, Without<MarkedForDespawn>)>;
// ...
let is_counting = world.get::<CountsTowardsCompletion>(entity).is_some();

**Related Code:**
```rust
mut brick_queries: ParamSet<(
        Query<...>,
        Query<...>,
        Query<...>,
    )>,


💡 Have feedback? We'd love to hear it in our GitHub Discussions.
✨ This review was generated by LlamaPReview Advanced, which is free for all open-source projects. Learn more.

Comment thread src/lib.rs
Comment on lines +860 to +862
// Skip indestructible bricks (all types >= 90).
// These bricks still emit feedback on hit, but are not destroyed by the ball.
if current_type >= crate::level_format::INDESTRUCTIBLE_BRICK {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 | Confidence: Medium

The PR replaces a single-type check (current_type == HAZARD_BRICK_91) with a threshold check (current_type >= INDESTRUCTIBLE_BRICK). This changes the destruction logic for any brick type with an ID >= INDESTRUCTIBLE_BRICK. The constant INDESTRUCTIBLE_BRICK is not defined in the PR’s changed files; its definition in src/level_format.rs must be verified. If the constant value is 90 (as the comment suggests), then all brick types 90+ become indestructible by ball collision. This may be intentional to align with the spec’s “indestructible bricks should still emit feedback” requirement, but it is a silent behavioral change for any existing bricks in that range that were previously destructible. The spec does not call for mass-indestructibility; it only mentions hazard bricks. This change should be explicitly documented and its constant value confirmed. Without verification, this could break level designs that rely on destructibility of bricks with IDs >= 90.

Comment thread src/lib.rs Outdated
Comment on lines +833 to +838
let midpoint = (ball_pos + brick_pos) * 0.5;
let contact =
systems::collision_feedback::resolve_contact_point(midpoint, brick_pos);
let contact = systems::collision_feedback::offset_contact_toward_ball(
contact, brick_pos, ball_pos,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 | Confidence: Medium

FR-015 and the spec require the feedback effect to spawn at the exact collision contact point. The implementation computes the contact point as the midpoint between ball center and brick center, then offsets it toward the ball by a fixed 0.25 units. This is a heuristic that does not use the actual contact manifold from Rapier collisions. While the Rapier CollisionEvent does not directly expose contact points in a straightforward way, the use of this simplistic approximation means the effect may appear visibly offset from the true impact location, especially for edge collisions or thick walls. The research.md (Decision 3) acknowledges a “deterministic fallback path,” but the implemented fallback uses entity centers, not a physical contact point. This degrades the accuracy promised by the spec and could be visually jarring. The integration tests only verify that the effect is placed at the contact_point provided in the trigger, which comes from the same heuristic, so the tests pass but do not validate correctness against actual physics.

Comment on lines +191 to +193
let mut rng = rand::rng();
let particle_count = profile.sample_particle_count(&mut rng);
let lifetime_seconds = profile.sample_lifetime_seconds(&mut rng);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 | Confidence: High

The code uses the API rand::rng(), RngExt, and random_range, which are only available in rand 0.9+. The plan.md lists “rand 0.x” as a dependency, but the PR does not update Cargo.toml to specify the required version. If the project currently uses rand 0.8 (which uses thread_rng() and gen_range()), this code will fail to compile. The same file also uses use rand::RngExt; which is a sub-crate trait (likely a version-specific import). A deterministic build failure cannot be proven without the project’s Cargo.toml, but the API mismatch is clear. The project must either upgrade to rand 0.9 or provide a version gate.

Code Suggestion:

If the project stays on rand 0.8, replace `rand::rng()` with `rand::thread_rng()`, `RngExt` with `Rng` (or remove `use rand::RngExt;` and use `Rng::gen_range`), and `random_range` with `gen_range`. If upgrading to rand 0.9, add an explicit dependency.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
docs/developer-guide.md (1)

261-281: ⚡ Quick win

Clarify "finite fallback" terminology.

The phrase "resolved contact point with finite fallback" (line 272) may be unclear to developers unfamiliar with the collision system. Consider specifying what the fallback behavior is (e.g., "uses ball position if contact point is unavailable").

📝 Suggested clarification
 - One effect is spawned per qualifying collision (wall, paddle, brick)
-- Effects spawn at resolved contact point with finite fallback
+- Effects spawn at resolved contact point (falls back to ball position if unavailable)
 - Particle count is constrained to 8-16 and lifetime to 0.20-0.35 seconds

If the actual fallback behavior differs, please adjust the clarification accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/developer-guide.md` around lines 261 - 281, Replace the vague phrase
"resolved contact point with finite fallback" in the Collision Feedback Notes
with an explicit description of the fallback used by the collision VFX (e.g.,
"spawns at the resolved contact point; if no contact point is available, falls
back to the ball's position" or the actual behavior implemented), and update the
sentence near the references to CollisionFeedbackTriggered,
spawn_collision_feedback_effect, and update_feedback_effect_lifetimes so readers
know exactly what fallback to expect (adjust the example fallback wording if the
real implementation differs).
.github/agents/copilot-instructions.md (1)

46-46: 💤 Low value

Consider adding blank line for markdown consistency.

The markdown linter suggests adding a blank line before this list item for better formatting consistency. While this is cosmetic, it improves readability and passes standard markdown linters.

📝 Optional formatting fix
 ## Recent Changes
+
 - 029-collision-particle-feedback: Added Rust 1.81 (edition 2021) + Bevy 0.17.3, bevy_rapier3d 0.32.0, tracing 0.1, rand 0.x
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/agents/copilot-instructions.md at line 46, Insert a single blank
line immediately before the list item line that reads
"029-collision-particle-feedback: Added Rust 1.81 (edition 2021) + Bevy 0.17.3,
bevy_rapier3d 0.32.0, tracing 0.1, rand 0.x" to satisfy the markdown linter and
ensure consistent spacing between list entries; locate the line in
.github/agents/copilot-instructions.md and add the blank line above it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/agents/copilot-instructions.md:
- Line 20: Update the documented dependency string in the stack line that
currently shows "rand 0.x" to match the repository's Cargo constraint "rand =
\"0\"" (or replace it with the exact version from Cargo.lock if you want a
pinned, reproducible value); find the stack line containing "Rust 1.81 (edition
2021) + Bevy 0.17.3, bevy_rapier3d 0.32.0, tracing 0.1, rand 0.x
(029-collision-particle-feedback)" and change the "rand 0.x" token to the chosen
form ("rand = \"0\"" or the exact pinned version).

In `@specs/029-collision-particle-feedback/tasks.md`:
- Line 40: Task T014 (the red-phase proof) is missing; update the feature
checklist entry for T014 in the requirements.md checklist by recording the
failing-test proof: include the failing test name(s), clear steps-to-reproduce,
the minimal failing assertion/output, the commit hash (or PR/branch) that
contains the red-phase proof, author and timestamp, and any relevant
logs/screenshots; alternatively, if the red-phase was already captured
elsewhere, add a brief cross-reference in the T014 checklist entry pointing to
the exact commit/PR/test file and mark T014 complete.

In `@src/lib.rs`:
- Around line 845-846: The field brick_destroyed_on_impact is being set too
early using !is_multi_hit_brick(current_type) and can be wrong when paddle-only
or indestructible skip logic later prevents destruction; move or recompute this
field after the paddle-only/indestructible skip checks and use the actual
destruction outcome (e.g., a boolean like was_destroyed or the final logic that
applies skips and decrements hits) when constructing the event instead of
relying solely on is_multi_hit_brick(current_type).

In `@tests/collision_particle_feedback.rs`:
- Line 149: The call to effect_instances currently passes an immutable world
with effect_instances(app.world()), but effect_instances expects a mutable
reference (&mut World); update the invocation to pass a mutable world by calling
effect_instances(app.world_mut()) so it compiles against the signature of
effect_instances(&mut World).

---

Nitpick comments:
In @.github/agents/copilot-instructions.md:
- Line 46: Insert a single blank line immediately before the list item line that
reads "029-collision-particle-feedback: Added Rust 1.81 (edition 2021) + Bevy
0.17.3, bevy_rapier3d 0.32.0, tracing 0.1, rand 0.x" to satisfy the markdown
linter and ensure consistent spacing between list entries; locate the line in
.github/agents/copilot-instructions.md and add the blank line above it.

In `@docs/developer-guide.md`:
- Around line 261-281: Replace the vague phrase "resolved contact point with
finite fallback" in the Collision Feedback Notes with an explicit description of
the fallback used by the collision VFX (e.g., "spawns at the resolved contact
point; if no contact point is available, falls back to the ball's position" or
the actual behavior implemented), and update the sentence near the references to
CollisionFeedbackTriggered, spawn_collision_feedback_effect, and
update_feedback_effect_lifetimes so readers know exactly what fallback to expect
(adjust the example fallback wording if the real implementation differs).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d274f8aa-6f95-4af0-9062-4061685245e4

📥 Commits

Reviewing files that changed from the base of the PR and between dbd37ad and 3c69983.

📒 Files selected for processing (18)
  • .cargo/config.toml
  • .github/agents/copilot-instructions.md
  • .github/copilot-instructions.md
  • .specify/feature.json
  • docs/developer-guide.md
  • specs/029-collision-particle-feedback/checklists/requirements.md
  • specs/029-collision-particle-feedback/contracts/collision-feedback-observer.md
  • specs/029-collision-particle-feedback/data-model.md
  • specs/029-collision-particle-feedback/plan.md
  • specs/029-collision-particle-feedback/quickstart.md
  • specs/029-collision-particle-feedback/research.md
  • specs/029-collision-particle-feedback/spec.md
  • specs/029-collision-particle-feedback/tasks.md
  • src/lib.rs
  • src/signals.rs
  • src/systems/collision_feedback.rs
  • src/systems/mod.rs
  • tests/collision_particle_feedback.rs
💤 Files with no reviewable changes (1)
  • .cargo/config.toml

- RON files in `assets/levels/` directory; in-memory ECS state only (no persistent storage) (024-level-navigation-bricks)
- Rust 1.81 (2021 edition), Bevy 0.17.3 + bevy_rapier3d 0.32.0 (physics), serde 1.0 + ron 0.8 (level loading), tracing 0.1 (logging) (025-ball-spawn-bricks)
- In-memory ECS state only; levels persisted as RON files in `assets/levels/` (025-ball-spawn-bricks)
- Rust 1.81 (edition 2021) + Bevy 0.17.3, bevy_rapier3d 0.32.0, tracing 0.1, rand 0.x (029-collision-particle-feedback)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Extract the exact rand version from Cargo.toml

rg -n '^rand\s*=' Cargo.toml

Repository: cleder/brkrs

Length of output: 70


Update the documented rand dependency to match the repo’s actual constraint (and pin if reproducibility is required)

.github/agents/copilot-instructions.md lists rand 0.x, but the root Cargo.toml uses rand = "0" (major-only, still not exact). Update the stack line to match rand = "0" (or pin the exact rand version used via Cargo.lock) to keep the documentation reproducible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/agents/copilot-instructions.md at line 20, Update the documented
dependency string in the stack line that currently shows "rand 0.x" to match the
repository's Cargo constraint "rand = \"0\"" (or replace it with the exact
version from Cargo.lock if you want a pinned, reproducible value); find the
stack line containing "Rust 1.81 (edition 2021) + Bevy 0.17.3, bevy_rapier3d
0.32.0, tracing 0.1, rand 0.x (029-collision-particle-feedback)" and change the
"rand 0.x" token to the chosen form ("rand = \"0\"" or the exact pinned
version).

- [X] T011 [P] [US1] Add brick collision same-frame effect test in `tests/collision_particle_feedback.rs`
- [X] T012 [US1] Add exact contact-point spawn assertion test in `tests/collision_particle_feedback.rs`
- [X] T013 [US1] Add brick-destroyed-on-impact still-spawns-effect test in `tests/collision_particle_feedback.rs`
- [ ] T014 [US1] Record red-phase failing test proof notes in `specs/029-collision-particle-feedback/checklists/requirements.md`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Complete the red-phase proof documentation to satisfy TDD gate requirement.

Task T014 remains unchecked while all implementation tasks (T015-T019) are marked complete. This violates the constitution's TDD gate requirement that proof-of-failure commits must be recorded before implementation begins.

Based on learnings, the TDD workflow requires documenting the failing-test phase. Please complete T014 or confirm that the red-phase proof has been properly captured in the feature's requirement checklist.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/029-collision-particle-feedback/tasks.md` at line 40, Task T014 (the
red-phase proof) is missing; update the feature checklist entry for T014 in the
requirements.md checklist by recording the failing-test proof: include the
failing test name(s), clear steps-to-reproduce, the minimal failing
assertion/output, the commit hash (or PR/branch) that contains the red-phase
proof, author and timestamp, and any relevant logs/screenshots; alternatively,
if the red-phase was already captured elsewhere, add a brief cross-reference in
the T014 checklist entry pointing to the exact commit/PR/test file and mark T014
complete.

Comment thread src/lib.rs Outdated
Comment on lines +845 to +846
brick_destroyed_on_impact: !is_multi_hit_brick(current_type),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

brick_destroyed_on_impact can be wrong for non-destroyed brick hits.

This field is set before paddle-only/indestructible skip checks, so some non-destroying ball impacts can be emitted as true.

💡 Proposed fix
-                        brick_destroyed_on_impact: !is_multi_hit_brick(current_type),
+                        brick_destroyed_on_impact: !is_multi_hit_brick(current_type)
+                            && !crate::level_format::is_paddle_destroyable_brick(current_type)
+                            && current_type < crate::level_format::INDESTRUCTIBLE_BRICK,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib.rs` around lines 845 - 846, The field brick_destroyed_on_impact is
being set too early using !is_multi_hit_brick(current_type) and can be wrong
when paddle-only or indestructible skip logic later prevents destruction; move
or recompute this field after the paddle-only/indestructible skip checks and use
the actual destruction outcome (e.g., a boolean like was_destroyed or the final
logic that applies skips and decrements hits) when constructing the event
instead of relying solely on is_multi_hit_brick(current_type).

Comment thread tests/collision_particle_feedback.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/collision_particle_feedback.rs (1)

33-36: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix nondeterministic advance_time in tests/collision_particle_feedback.rs by using TimeUpdateStrategy::ManualDuration

  • update_feedback_effect_lifetimes increments lifetimes using time.delta_secs(), and make_test_app uses MinimalPlugins with no TimeUpdateStrategy configured in the repo—so Bevy’s time system runs ahead of Update and overrides the Time::advance_by(...) delta you set in advance_time.
💡 Fix
fn advance_time(app: &mut App, delta_secs: f32) {
    app.insert_resource(TimeUpdateStrategy::ManualDuration(
        Duration::from_secs_f32(delta_secs),
    ));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/collision_particle_feedback.rs` around lines 33 - 36, The test's
advance_time function is nondeterministic because Bevy's time system advances on
its own; instead of mutating Time::advance_by, set
TimeUpdateStrategy::ManualDuration so the delta used by
update_feedback_effect_lifetimes is exactly the value you want. Replace the body
of advance_time to insert the resource
TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(delta_secs)) into the
App (references: function advance_time, test helper make_test_app, and the
update_feedback_effect_lifetimes logic that reads time.delta_secs()).
🧹 Nitpick comments (1)
src/systems/collision_feedback.rs (1)

257-291: 💤 Low value

Redundant particle_refs query.

particles can be iterated again for the despawn pass once the mutable borrow from the first loop ends, so the extra read-only particle_refs parameter isn't needed.

♻️ Drop the duplicate query
 pub fn update_feedback_effect_lifetimes(
     time: Res<Time>,
     mut commands: Commands,
     mut effects: Query<(Entity, &mut FeedbackEffectInstance)>,
     mut particles: Query<(Entity, &CollisionFeedbackParticle, &mut Transform)>,
-    particle_refs: Query<(Entity, &CollisionFeedbackParticle)>,
 ) {
@@
-    for (particle_entity, particle) in particle_refs.iter() {
+    for (particle_entity, particle, _) in particles.iter() {
         if expired.contains(&particle.source_effect) {
             commands.entity(particle_entity).despawn();
         }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/systems/collision_feedback.rs` around lines 257 - 291, The function
update_feedback_effect_lifetimes has an unused duplicate read-only query
parameter particle_refs; remove the particle_refs parameter from the function
signature and all call sites, and after the first loop that updates transforms
(which ends the mutable borrow), iterate the existing particles Query<(Entity,
&CollisionFeedbackParticle, &mut Transform)> a second time (the mutable borrow
will have ended) to despawn particles whose source_effect is in expired—i.e.,
replace the particle_refs-based loop with a loop over the same particles Query
to call commands.entity(particle_entity).despawn() when
expired.contains(&particle.source_effect); keep the existing logic that collects
expired effects and despawns effects via commands.entity(*effect).despawn().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@tests/collision_particle_feedback.rs`:
- Around line 33-36: The test's advance_time function is nondeterministic
because Bevy's time system advances on its own; instead of mutating
Time::advance_by, set TimeUpdateStrategy::ManualDuration so the delta used by
update_feedback_effect_lifetimes is exactly the value you want. Replace the body
of advance_time to insert the resource
TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(delta_secs)) into the
App (references: function advance_time, test helper make_test_app, and the
update_feedback_effect_lifetimes logic that reads time.delta_secs()).

---

Nitpick comments:
In `@src/systems/collision_feedback.rs`:
- Around line 257-291: The function update_feedback_effect_lifetimes has an
unused duplicate read-only query parameter particle_refs; remove the
particle_refs parameter from the function signature and all call sites, and
after the first loop that updates transforms (which ends the mutable borrow),
iterate the existing particles Query<(Entity, &CollisionFeedbackParticle, &mut
Transform)> a second time (the mutable borrow will have ended) to despawn
particles whose source_effect is in expired—i.e., replace the
particle_refs-based loop with a loop over the same particles Query to call
commands.entity(particle_entity).despawn() when
expired.contains(&particle.source_effect); keep the existing logic that collects
expired effects and despawns effects via commands.entity(*effect).despawn().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 285b31b5-42bd-4554-bc26-a3668efa56a3

📥 Commits

Reviewing files that changed from the base of the PR and between 3c69983 and 6101c5b.

📒 Files selected for processing (4)
  • src/lib.rs
  • src/signals.rs
  • src/systems/collision_feedback.rs
  • tests/collision_particle_feedback.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/signals.rs
  • src/lib.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation 📚 Improvements or additions to documentation Game-play 👾🎲 rust 🦀 Pull requests that update rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants