Skip to content

Instantly share code, notes, and snippets.

@nitori
Last active October 18, 2025 18:34
Show Gist options
  • Select an option

  • Save nitori/2451676e44e131df127b4266f88bb8c6 to your computer and use it in GitHub Desktop.

Select an option

Save nitori/2451676e44e131df127b4266f88bb8c6 to your computer and use it in GitHub Desktop.
Simple visual demontration of circle and capsule collision. You can drag the circle around.
import math
from dataclasses import dataclass
from collections import deque
import pygame
from pygame import Vector2
@dataclass
class Circle:
center: Vector2
radius: float
def __post_init__(self):
self.surf = pygame.Surface(self.rect.size, pygame.SRCALPHA)
pygame.draw.circle(self.surf, 'white', (self.radius, self.radius), self.radius, 0)
@property
def rect(self):
return pygame.Rect(
self.center.x - self.radius,
self.center.y - self.radius,
self.radius * 2,
self.radius * 2
)
@dataclass
class Capsule:
center: Vector2
half_length: float
radius: float
angle: float
def __post_init__(self):
if self.half_length < 0:
raise ValueError('Invalid value for half_length. Must be greater than 0')
self._surf = None
self._last_values = ()
@property
def start(self) -> Vector2:
v = Vector2(math.cos(self.angle), math.sin(self.angle)).normalize() * self.half_length
c = self.center
return c - v
@property
def end(self) -> Vector2:
v = Vector2(math.cos(self.angle), math.sin(self.angle)).normalize() * self.half_length
c = self.center
return c + v
@property
def surf(self):
if self._surf is not None:
if self._last_values == (self.half_length, self.radius, self.angle):
print('cached version')
return self._surf
r = self.rect
# get local start/end points
tl = Vector2(r.x, r.y)
lstart = self.start - tl
lend = self.end - tl
surf = pygame.Surface(r.size, pygame.SRCALPHA)
pygame.draw.circle(surf, 'white', lstart, self.radius, 0)
pygame.draw.circle(surf, 'white', lend, self.radius, 0)
# get normals of vector from start to end
v_se = lend - lstart
if self.half_length > 0:
n_se_1 = Vector2(-v_se.y, v_se.x).normalize() * self.radius
n_se_2 = Vector2(v_se.y, -v_se.x).normalize() * self.radius
else:
n_se_1 = Vector2()
n_se_2 = Vector2()
pygame.draw.polygon(surf, 'white', [
lstart + n_se_1,
lend + n_se_1,
lend + n_se_2,
lstart + n_se_2,
], 0)
self._surf = surf
self._last_values = self.half_length, self.radius, self.angle
return surf
@property
def rect(self) -> pygame.Rect:
x1 = min(self.start.x, self.end.x) - self.radius
y1 = min(self.start.y, self.end.y) - self.radius
x2 = max(self.start.x, self.end.x) + self.radius
y2 = max(self.start.y, self.end.y) + self.radius
return pygame.Rect(x1, y1, (x2 - x1), (y2 - y1))
def main():
pygame.init()
screen = pygame.display.set_mode((800, 600))
layer1 = pygame.Surface(screen.size, pygame.SRCALPHA)
clock = pygame.Clock()
circle = Circle(
center=Vector2(500, 200),
radius=40
)
capsule = Capsule(
center=Vector2(400, 300),
half_length=100,
radius=50,
angle=0.0
)
rotation_speed = .5
drag_circle_offset: Vector2 | None = None
font = pygame.Font(size=24)
fps_q = deque(maxlen=10)
while True:
delta = clock.tick(60) / 1000
fps_q.append(1 / delta)
screen.fill('black')
layer1.fill('black')
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
return
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
if math.dist(circle.center, event.pos) < circle.radius:
drag_circle_offset = Vector2(event.pos[0], event.pos[1]) - circle.center
if event.type == pygame.MOUSEBUTTONUP and event.button == 1:
drag_circle_offset = None
if not screen.get_rect().colliderect(circle.rect):
# out of bound. reset
circle.center.x = 500
circle.center.y = 200
# update capsule
ticks = pygame.time.get_ticks() / 1000
capsule.angle = (ticks * rotation_speed) % math.tau
# update circle
if drag_circle_offset is not None:
mx, my = pygame.mouse.get_pos()
circle.center.x = mx - drag_circle_offset.x
circle.center.y = my - drag_circle_offset.y
# project the circle into the capsules "local space"
centers_vector = circle.center - capsule.center
u = Vector2(math.cos(capsule.angle), math.sin(capsule.angle))
# we only need the x coord in that local spaces, as the y values is always 0.
local_x = centers_vector.dot(u)
# the point on the capsules central line in local space
# by clamping the projected circles x value to -half_length, +half_length.
proj_line_point = Vector2(
# 'x
pygame.math.clamp(local_x, -capsule.half_length, capsule.half_length),
# 'y
0
)
# calculate the "world space" of proj_line_point
# Note: for y we would add this as well:
# proj_line_point.y * Vector2(-u.y, u.x)
# but since it's 0 anyway, we can just skip it.
world_line_point = capsule.center + proj_line_point.x * u
# check collision
# distance (squared) between circle and closest point on capsule central line
length_sq = (circle.center - world_line_point).length_squared()
# sum of radius (squared)
radius_sum_sq = (capsule.radius + circle.radius) ** 2
# colliding if lenght is shorter than the radius
colliding: bool = length_sq < radius_sum_sq
# draw yellow area the mark the "perpendicular area" of the capsule.
# the same calculations as in the capsule surf rendering, just extended
v_se = capsule.end - capsule.start
n_se_1 = Vector2(-v_se.y, v_se.x).normalize() * screen.width * 2
n_se_2 = Vector2(v_se.y, -v_se.x).normalize() * screen.width * 2
perp_area = [capsule.start + n_se_1, capsule.end + n_se_1, capsule.end + n_se_2, capsule.start + n_se_2]
# draw on layer1, as we can't draw polygons with transparent color
pygame.draw.polygon(layer1, 'yellow', perp_area, 0)
layer1.set_alpha(32)
screen.blit(layer1)
pygame.draw.polygon(screen, 'yellow', perp_area, 2)
# draw shapes
screen.blit(circle.surf, circle.rect)
screen.blit(capsule.surf, capsule.rect)
pygame.draw.line(screen, 'blue', world_line_point, circle.center, 2)
pygame.draw.line(screen, 'black', capsule.start, capsule.end, 2)
pygame.draw.circle(screen, 'red', world_line_point, capsule.radius, 0 if colliding else 2)
# draw text
radius_sum_text = f'Radius sum: {capsule.radius} (capsule) + {circle.radius} (circle) = {radius_sum_sq ** .5}'
distance_text = f'Blue line length = {length_sq ** .5:.1f}'
fps_test = f'FPS: {round(sum(fps_q) / len(fps_q))}'
collide_text = None
if colliding:
collide_text = ' COLLISION'
sum_surf = font.render(radius_sum_text, True, 'white')
distance_surf = font.render(distance_text, True, 'white')
fps_surf = font.render(fps_test, True, 'white')
screen.blit(sum_surf, (10, 10))
screen.blit(distance_surf, (10, 10 * 2 + sum_surf.height))
screen.blit(fps_surf, (10, 10 * 3 + sum_surf.height + distance_surf.height))
if collide_text:
collide_surf = font.render(collide_text, True, (255, 64, 64))
screen.blit(collide_surf, (
10 + distance_surf.width + 10,
10 + sum_surf.height + 10
))
pygame.display.flip()
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment