Skip to content

Add sweep_sprite for collisions along a moving sprite's path - #2914

Merged
pvcraven merged 1 commit into
developmentfrom
feature/sweep-sprite
Oct 5, 2026
Merged

pvcraven merged 1 commit into
developmentfrom
feature/sweep-sprite

Conversation

@pvcraven

@pvcraven pvcraven commented Oct 5, 2026

Copy link
Copy Markdown
Member

Summary

Adds arcade.sweep_sprite(sprite, dx, dy, sprite_list), a collision check for fast-moving sprites (#5 on the collision list). check_for_collision only checks where a sprite is, so a sprite moving far enough in one frame can jump right over a thin wall. sweep_sprite checks the whole straight-line path and returns the first sprite it would hit. It doesn't move the sprite.

hit = arcade.sweep_sprite(bullet, bullet.change_x, bullet.change_y, walls)
if hit:
    bullet.position += Vec2(bullet.change_x, bullet.change_y) * hit.fraction  # stop at the wall
    bullet.remove_from_sprite_lists()

It returns None if the path is clear, otherwise a SweepInfo named tuple:

Field Meaning
sprite The first sprite hit
fraction How far along the move, 0.0 to just under 1.0
distance Pixels traveled before the hit
normal Unit vector out of the hit sprite's surface, back toward the mover (for bouncing)

Behavior

  • Starting already overlapping a sprite is an immediate hit at fraction 0.0, with the push-out direction from get_collision_info as the normal. If several overlap, the deepest is returned, even if something else is in the way.
  • Touching isn't a hit, as with check_for_collision. A sprite can slide along a wall or move away from one it touches. Moving into a touching wall is a hit at 0.0. A move that ends exactly touching isn't a hit.
  • Assumptions: it's exact for convex hit boxes that keep their angle during the move. Both are documented.
  • Candidates: with a spatial hash, only sprites near the path's bounding box are checked; otherwise every sprite in the list is. The sprite itself is skipped. If two sprites are hit at exactly the same moment, either may be returned (documented).

How it works

This extends the separating axis test to a moving shape. For each axis (x and y from the bounding boxes, then the cached distinct edge normals), it computes when the two projections overlap during the move. The sprites first overlap at the latest of those start times, if that's before the earliest end time. The axis that sets the start time gives the normal.

Per sprite, it does a radius check against the path's bounding box, then a bounding-box check, then the axis test, stopping early once an axis rules the sprite out or it can't beat the best hit so far.

Example (code, docs page, and gallery entry)

sprite_bullets_sweep

  • Code: arcade/examples/sprite_bullets_sweep.py has two lanes of 80 px/frame lasers firing at three 6 px walls. The top lane moves and then calls check_for_collision_with_list, so lasers skip walls and some pass through all three. The bottom lane uses sweep_sprite and every laser stops at the first wall. Both lanes show their counts on screen. In a 1,200-frame headless run: top lane 265 hits and 33 pass-throughs; bottom lane 298 hits, 0 pass-throughs, all at the first wall.
  • Docs page: doc/example_code/sprite_bullets_sweep.rst, with a screenshot rendered from the example.
  • Gallery entry: added to doc/example_code/index.rst under Shooting with Sprites. The full docs build (with -W) generates its thumbnail, and the built gallery page links to it.

Performance

µs per call, one laser against 400 randomly placed boxes:

check_for_collision_with_list sweep_sprite (12 px or 60 px move)
No spatial hash 57–66 25–30
Spatial hash 5.8–6.6 13–14

Without a spatial hash, sweep_sprite is faster than the plain list check, because its circle check against the path's bounding box rejects distant sprites cheaply. With a spatial hash it's about 2× a plain check; most of the extra time is the rectangle query on the hash.

Tests

  • Exact cases:
    • test_sweep_sprite_thin_wall (with and without a spatial hash): the plain check at the end position misses, and the sweep hits at exactly 22/50, with normal (-1, 0).
    • test_sweep_sprite_misses: moving away, stopping short, ending exactly touching, not moving, passing just above, empty list.
    • test_sweep_sprite_touching: away, sliding along, and into.
    • test_sweep_sprite_ends_touching_slanted_edge: diamond hit boxes with integer corners, so the touch along a 45° edge is exact.
    • test_sweep_sprite_starts_overlapping: deepest overlap wins over a sprite in the way, with the push-out normal.
    • test_sweep_sprite_first_hit: the closest sprite, not the first in the list, with and without a spatial hash.
    • test_sweep_sprite_diagonal: normal of a 45° wall.
    • Type errors.
  • test_sweep_sprite_matches_stepping: 2,000 random rotated, flipped and scaled pairs with random moves, compared against stepping along the path. For each hit:
    • nothing collides before fraction,
    • the sprites overlap just after it,
    • the normal is unit length and points against the move.
      For each miss, no sampled point collides.
  • Larger check (not committed): the same comparison on 20,000 pairs, with 400 samples per path: no mismatches across 1,269 hits and 6,751 starting overlaps.
  • Deliberate bugs are caught:
    • letting a move that ends exactly touching count as a hit,
    • dropping the edge normals (x and y only),
    • taking the first starting overlap instead of the deepest,
    • flipping the edge normal.
  • Full suite on pyglet 3.0.dev11: 1473 passed. The 3 failures are the render tests that only fail on my machine. The collision tests also pass with Linux's sin(π/4) value simulated. Ruff is clean, and mypy reports no errors in collision.py.

Found along the way (not changed here)

Assigning sprite.hit_box = HitBox(points) leaves the new hit box at (0, 0) until the sprite next moves, rather than at the sprite's position. One of my tests tripped on this; the test sets the position after the hit box. It's probably worth a separate fix.

🤖 Generated with Claude Code

sweep_sprite(sprite, dx, dy, sprite_list) checks the whole straight-line
path of a moving sprite and returns a SweepInfo (sprite, fraction,
distance, normal) for the first sprite it would hit, or None. Fast
sprites can no longer jump over thin walls between frames. It doesn't
move the sprite.

It extends the separating axis test to a moving shape: for each axis
(x and y from the bounds, then the cached edge normals) it finds when
the projections overlap during the move, and the sprites first overlap
at the latest start, if before the earliest end. Starting already
overlapping is an immediate hit at fraction 0, with the deepest overlap
and get_collision_info's push-out normal. Touching isn't a hit, and a
move ending exactly touching isn't either. Exact for convex hit boxes
that don't rotate during the move.

Candidates come from the spatial hash (near the path's bounding box)
if there is one, otherwise the whole list, with a radius and bounding
box check before the axis test.

Add the sprite_bullets_sweep example comparing it with
check_for_collision_with_list on fast lasers and thin walls, with its
docs page, screenshot, and gallery entry under Shooting with Sprites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@pvcraven
pvcraven merged commit b391f74 into development Oct 5, 2026
8 of 9 checks passed
@pvcraven
pvcraven deleted the feature/sweep-sprite branch October 5, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant