Usually in a game or some other type of real-time app, the movements of things on each frame (like every 16ms at 60 FPS) is usually small enough that you can get away with moving a rectangle and then checking for collisions after the fact. In this way, one rectangle is moving while the other is stationary, which is an approximation, or as the bump docs put it:
So the challenge was to find a way to calculate the exact collision moment when both of them are moving during the same frame.bump is not a good match for:
- Games that require very fast objects colliding realistically against each other (in bump, being gameistic, objects are moved and collided one at a time).
I also want to make some mockup images of how the math involved works, to practice some design -- and I'll add it in here later.
The code is all in the main.lua below, especially in function "find_sweep_collision".
It's pretty fun trying to find a near-miss scenario, like moving the destination of a rectangle far enough that it speeds up and nearly misses the other.
Code: Select all
-- Recipe for collision detection between two axis-aligned moving rectangles.
-- (Also works for moving vs still rectangles, and for still vs still rectangles)
--
-- By Rafael Navega (2025);
-- Frame of reference optimization by Pedro Gimeno (2025).
--
-- Public Domain
io.stdout:setvbuf('no')
local rect_a = {x = 80, y = 400, w = 220, h = 150, vx = 320, vy = -240}
local rect_b = {x = 650, y = 380, w = 50, h = 80, vx = -105, vy = -160}
rect_b.cx, rect_b.cy = rect_b.x, rect_b.y
local speed = 300.0
local anim_speed = 0.7
local preview_t = 0.25
function love.load()
love.graphics.setLineWidth(1.0)
end
function find_sweep_collision(a_x, a_y, a_w, a_h, a_vx, a_vy,
b_x, b_y, b_w, b_h, b_vx, b_vy)
-- Frame of reference optimization: reimagine the velocity of A
-- so that the rectangle B is considered as not moving.
-- This allows finding the moment of collision 't', if any, using the simpler
-- code for the "moving rectangle vs still rectangle" collision case.
a_vx = a_vx - b_vx
a_vy = a_vy - b_vy
-- Consider the line equation / linear motion formula:
-- p = p0 + v . t
-- Finding the 't' when the leading_x edge aligns with opposing_x edge:
-- opposing_x = leading_x + vx . t_align_x
-- t_align_x = (opposing_x - leading_x) / vx
-- (Similar for the Y axis, finding t_align_y.)
local t_align_x, t_align_y
-- Vertical edges.
if a_vx > 0.0 then
-- Leading X: a_x + a_w (A RIGHT edge)
-- Opposing X: b_x (B LEFT edge)
t_align_x = (b_x - (a_x + a_w)) / a_vx
elseif a_vx < 0.0 then
-- Leading X: a_x (A LEFT edge)
-- Opposing X: b_x + b_w (B RIGHT edge)
t_align_x = ((b_x + b_w) - a_x) / a_vx
else
t_align_x = -1.0
end
-- Horizontal edges.
if a_vy > 0.0 then
-- Leading Y: a_y + a_h (A BOTTOM edge)
-- Opposing Y: b_y (B TOP edge)
t_align_y = (b_y - (a_y + a_h)) / a_vy
elseif a_vy < 0.0 then
-- Leading Y: a_y (A TOP edge)
-- Opposing Y: b_y + b_h (B BOTTOM edge)
t_align_y = ((b_y + b_h) - a_y) / a_vy
else
t_align_y = -1.0
end
local collided = false
local collision_t
-- Empirically tested: if the alignment moments are both below zero, or if either
-- of them are above 1, then a sweeping collision can't happen.
if (t_align_x > 1.0 or t_align_y > 1.0) or
(t_align_x < 0.0 and t_align_y < 0.0) then
collision_t = nil
-- At least test for a pre-collision.
if a_x < b_x + b_w and b_x < a_x + a_w and
a_y < b_y + b_h and b_y < a_y + a_h then
collided = true
end
else
-- Through testing it was found that the edge of B that will collide
-- during the sweep of A will be the one with the largest parameter 't'.
if t_align_x > t_align_y then
local a_edge_start = a_y + a_vy * t_align_x
local a_edge_end = a_edge_start + a_h
if (a_edge_start < (b_y + b_h) and b_y < a_edge_end) then
collided = true
collision_t = t_align_x
end
else
local a_edge_start = a_x + a_vx * t_align_y
local a_edge_end = a_edge_start + a_w
if (a_edge_start < (b_x + b_w) and b_x < a_edge_end) then
collided = true
collision_t = t_align_y
end
end
end
return collided, collision_t
end
function draw_movement_shape(rect)
local left1 = rect.x
local right1 = rect.x + rect.w
local top1 = rect.y
local bottom1 = rect.y + rect.h
local left2 = left1 + rect.vx
local right2 = right1 + rect.vx
local top2 = top1 + rect.vy
local bottom2 = bottom1 + rect.vy
if rect.vx > 0.0 then
if rect.vy > 0.0 then
-- RIGHT, BOTTOM.
love.graphics.line(left1, bottom1, left2, bottom2)
love.graphics.line(right1, top1, right2, top2)
love.graphics.line(right1, bottom1, right2, bottom2)
love.graphics.line(right2, top2, right2, bottom2)
love.graphics.line(left2, bottom2, right2, bottom2)
elseif rect.vy < 0.0 then
-- RIGHT, UP.
love.graphics.line(left1, top1, left2, top2)
love.graphics.line(right1, bottom1, right2, bottom2)
love.graphics.line(right1, top1, right2, top2)
love.graphics.line(right2, top2, right2, bottom2)
love.graphics.line(left2, top2, right2, top2)
else
-- RIGHT.
love.graphics.line(right1, top1, right2, top2)
love.graphics.line(right1, bottom1, right2, bottom2)
love.graphics.line(right2, top2, right2, bottom2)
end
elseif rect.vx < 0.0 then
if rect.vy > 0.0 then
-- LEFT, BOTTOM.
love.graphics.line(left1, top1, left2, top2)
love.graphics.line(right1, bottom1, right2, bottom2)
love.graphics.line(left1, bottom1, left2, bottom2)
love.graphics.line(left2, top2, left2, bottom2)
love.graphics.line(left2, bottom2, right2, bottom2)
elseif rect.vy < 0.0 then
-- LEFT, TOP.
love.graphics.line(left1, bottom1, left2, bottom2)
love.graphics.line(right1, top1, right2, top2)
love.graphics.line(left1, top1, left2, top2)
love.graphics.line(left2, top2, left2, bottom2)
love.graphics.line(left2, top2, right2, top2)
else
-- LEFT.
love.graphics.line(left1, top1, left2, top2)
love.graphics.line(left1, bottom1, left2, bottom2)
love.graphics.line(left2, top2, left2, bottom2)
end
elseif rect.vy > 0.0 then
-- BOTTOM.
love.graphics.line(left1, bottom1, left2, bottom2)
love.graphics.line(right1, bottom1, right2, bottom2)
love.graphics.line(left2, bottom2, right2, bottom2)
elseif rect.vy < 0.0 then
-- TOP.
love.graphics.line(left1, top1, left2, top2)
love.graphics.line(right1, top1, right2, top2)
love.graphics.line(left2, top2, right2, top2)
end
end
function love.draw()
-- Rectangle A movement shape.
love.graphics.setColor(0.2, 0.2, 0.2)
draw_movement_shape(rect_a)
-- The "preview" of rectangle A.
love.graphics.setColor(1, 1, 1, 0.4)
love.graphics.rectangle('line',
rect_a.x + rect_a.vx * preview_t,
rect_a.y + rect_a.vy * preview_t,
rect_a.w, rect_a.h)
love.graphics.setColor(1, 1, 1)
love.graphics.rectangle('line', rect_a.x, rect_a.y, rect_a.w, rect_a.h)
-- Rectangle B movement shape.
love.graphics.setColor(0.0, 1, 1, 0.4)
draw_movement_shape(rect_b)
-- The "preview" of rectangle B.
love.graphics.setColor(0, 1, 1, 0.4)
love.graphics.rectangle('line',
rect_b.x + rect_b.vx * preview_t,
rect_b.y + rect_b.vy * preview_t,
rect_b.w, rect_b.h)
love.graphics.setColor(0, 1, 1)
love.graphics.rectangle('line', rect_b.x, rect_b.y, rect_b.w, rect_b.h)
-- Draw the debug text overlay. Switch to false to hide it.
if true then
love.graphics.setColor(1, 1, 1)
love.graphics.print('Use A,W,S,D or the ARROW KEYS to move the rectangle destinations', 10, 10)
love.graphics.print('Hold Shift to move the rectangles themselves', 10, 30)
love.graphics.print('Hold 1 or 2 to control the "preview" t', 10, 50)
love.graphics.print(('Hold Ctrl or Space while using the other keys ' ..
'for precision mode (slow motion)'), 10, 70)
love.graphics.print(('PREVIEW t = %.3f'):format(preview_t), 10, 120)
end
local collided, col_t = find_sweep_collision(
rect_a.x, rect_a.y, rect_a.w, rect_a.h, rect_a.vx, rect_a.vy,
rect_b.x, rect_b.y, rect_b.w, rect_b.h, rect_b.vx, rect_b.vy
)
if col_t then
love.graphics.setColor(1, 0.6, 0)
local col_x = rect_a.x + rect_a.vx * col_t
local col_y = rect_a.y + rect_a.vy * col_t
love.graphics.rectangle('line', col_x, col_y, rect_a.w, rect_a.h)
love.graphics.print(('Collision moment (t = %.3f)'):format(col_t), col_x, col_y - 30)
end
if collided then
love.graphics.setColor(0, 1, 1)
love.graphics.rectangle('fill', rect_b.x, rect_b.y, rect_b.w, rect_b.h)
-- A valid collision with nil 't' means a pre-collision (colliding at their
-- starting positions).
if not col_t then
love.graphics.print('(Pre-collision)', rect_b.x, rect_b.y + rect_b.h + 5)
end
end
end
function love.keypressed(key)
if key == 'escape' then
love.event.quit()
end
end
function love.update(dt)
if (love.keyboard.isDown('lctrl')
or love.keyboard.isDown('rctrl')
or love.keyboard.isDown('space')) then
dt = dt * 0.1
end
if love.keyboard.isDown('1') then
preview_t = preview_t - anim_speed * dt
if preview_t < 0.0 then preview_t = 0.0 end
elseif love.keyboard.isDown('2') then
preview_t = preview_t + anim_speed * dt
if preview_t > 1.0 then preview_t = 1.0 end
end
local a_dir_x = (love.keyboard.isDown('d') and dt or (love.keyboard.isDown('a') and -dt or 0))
local a_dir_y = (love.keyboard.isDown('s') and dt or (love.keyboard.isDown('w') and -dt or 0))
local b_dir_x = (love.keyboard.isDown('right') and dt or (love.keyboard.isDown('left') and -dt or 0))
local b_dir_y = (love.keyboard.isDown('down') and dt or (love.keyboard.isDown('up') and -dt or 0))
if love.keyboard.isDown('lshift') or love.keyboard.isDown('rshift') then
rect_a.x = rect_a.x + speed * a_dir_x
rect_a.y = rect_a.y + speed * a_dir_y
rect_a.vx = rect_a.vx - speed * a_dir_x
rect_a.vy = rect_a.vy - speed * a_dir_y
rect_b.x = rect_b.x + speed * b_dir_x
rect_b.y = rect_b.y + speed * b_dir_y
rect_b.vx = rect_b.vx - speed * b_dir_x
rect_b.vy = rect_b.vy - speed * b_dir_y
else
rect_a.vx = rect_a.vx + speed * a_dir_x
rect_a.vy = rect_a.vy + speed * a_dir_y
rect_b.vx = rect_b.vx + speed * b_dir_x
rect_b.vy = rect_b.vy + speed * b_dir_y
end
end