I am making a Sonic game in Pygame and I have recently gotten my hands on a Python class that uses pygame.rect and pygame.mask in unison to create sensors that can be used to accurately detect the ground and slopes. I do not know how to actually utilise it and my implementation does not work correctly with Sonic jittering across the map (likely caused by how his position is changed according to the collision detection from the sensors). Can someone please help interpret how the custom Mask class works and how I can use it to create pixel perfect collision? The class that I used is below:
LOOPMAX = 32
OUT_SIDE = 256
class_type = list[pygame.Mask, pygame.Rect, pygame.Rect]
class Mask:
# Copyright (c) 2023-2025 UCSTORM
# Tous droits réservés.
class_type = class_type
def clear(sensor1):
sensor1[0].clear()
def newSensor(rect, center_point) -> class_type: # rect_to_mask
""" INSIDE: MASK, RECT+CENTER_POINT, ORIGINAL_RECT"""
mask = pygame.mask.from_surface(pygame.Surface((rect[2], rect[3])))
return [mask, pygame.Rect(rect[0]+center_point[0], rect[1]+center_point[1], rect[2], rect[3]), rect]
def surface_to_mask(surface, rect) -> class_type:
mask = pygame.mask.from_surface(surface)
return [mask, pygame.Rect(rect[0], rect[1], surface.get_size()[0], surface.get_size()[1]), pygame.Rect(rect[0], rect[1], surface.get_size()[0], surface.get_size()[1])]
def blit(mask_chunk, coord, sensor) -> class_type:
sensor[0].draw(mask_chunk[0],coord)
return sensor
def collide(sensor1, sensor2):
offset = [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - sensor1[1][1]]
overlap = sensor1[0].overlap(sensor2[0], offset)
if overlap:
print("Overlap found at offset:", offset, "Overlap point:", overlap)
return overlap
def colliderect(sensor1, sensor2):
return sensor1[1].colliderect(sensor2[1])
def sensor_draw(surface, sensor, color):
pygame.draw.rect(surface, color, sensor[1])
def rotation_sensor(sensor, MODE, center_point):
""" POSSIBILITY: 0 ,1, 2, 3"""
rect = [0, 0, 0, 0]
if MODE == 0: rect = [sensor[2][0], sensor[2][1], sensor[2][2], sensor[2][3]]
elif MODE == 1: rect = [sensor[2][1], -(sensor[2][0] + sensor[2][2]), sensor[2][3], sensor[2][2]]
elif MODE == 2: rect = [-(sensor[2][0] + sensor[2][2]), -(sensor[2][1] + sensor[2][3]), sensor[2][2], sensor[2][3]]
elif MODE == 3: rect = [-(sensor[2][1] + sensor[2][3]), sensor[2][0], sensor[2][3], sensor[2][2]]
return Mask.rect_to_mask(rect, center_point)
def collide_inside_y(sensor1, sensor2):
running = True
LOOP = 0
while running:
if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1]-LOOP)]):
LOOP += 1
else: running = False
if LOOP >= LOOPMAX:
running = False
return LOOP
def collide_outside_y(sensor1, sensor2):
running = True
LOOP = 0
while running:
if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1] + LOOP)]):
LOOP += 1
else:running = False
if LOOP >= LOOPMAX: running = False
return LOOP
def collide_inside_x(sensor1, sensor2):
running = True
LOOP = 0
while running:
if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]-LOOP), sensor2[1][1] - (sensor1[1][1])]):
LOOP += 1
else: running = False
if LOOP >= LOOPMAX:running = False
return LOOP
def collide_outside_x(sensor1, sensor2):
running = True
LOOP = 0
while running:
if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]+ LOOP), sensor2[1][1] - (sensor1[1][1])]):
LOOP += 1
else:running = False
if LOOP >= LOOPMAX: running = False
return LOOP
def collide_inside_y_minus(sensor1, sensor2):
running = True
LOOP = 0
while running:
if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1]+LOOP)]):
LOOP += 1
else: running = False
if LOOP >= LOOPMAX:running = False
return -LOOP
def collide_outside_y_minus(sensor1, sensor2):
running = True
LOOP = 0
while running:
if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - (sensor1[1][1] - LOOP)]):
LOOP += 1
else:running = False
if LOOP >= LOOPMAX: running = False
return -LOOP
def collide_inside_x_minus(sensor1, sensor2):
running = True
LOOP = 0
while running:
if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]+LOOP), sensor2[1][1] - (sensor1[1][1])]):
LOOP += 1
else: running = False
if LOOP >= LOOPMAX:running = False
return -LOOP
def collide_outside_x_minus(sensor1, sensor2):
running = True
LOOP = 0
while running:
if not sensor1[0].overlap(sensor2[0], [sensor2[1][0] - (sensor1[1][0]- LOOP), sensor2[1][1] - (sensor1[1][1])]):
LOOP += 1
else:running = False
if LOOP >= LOOPMAX: running = False
return -LOOP
Below is a very barebones, minimal reproducible example of my problem. You will be able to see that the overall detection works but the logic for adjusting the position does not work on the Y axis, causing the player to jitter up and down. The TMX data and the tile set image (both required to run example) are linked here: https://drive.google.com/file/d/16Enz4bjr414fjp5nfqRg4rm4FKiEkooE/view?usp=sharing
import pygame
import pytmx
import os
# Initialize pygame
pygame.init()
SCREEN_WIDTH, SCREEN_HEIGHT = 800, 600
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("2D Platformer with Camera")
clock = pygame.time.Clock()
LOOPMAX = 32
# Mask class with minimal required methods
class_type = list[pygame.Mask, pygame.Rect, pygame.Rect]
class Mask:
@staticmethod
def newSensor(rect, center_point):
mask = pygame.mask.from_surface(pygame.Surface((rect[2], rect[3])))
return [mask, pygame.Rect(rect[0]+center_point[0], rect[1]+center_point[1], rect[2], rect[3]), rect]
@staticmethod
def surface_to_mask(surface, rect):
mask = pygame.mask.from_surface(surface)
return [mask, pygame.Rect(rect[0], rect[1], surface.get_width(), surface.get_height()),
pygame.Rect(rect[0], rect[1], surface.get_width(), surface.get_height())]
@staticmethod
def collide(sensor1, sensor2):
offset = [sensor2[1][0] - sensor1[1][0], sensor2[1][1] - sensor1[1][1]]
return sensor1[0].overlap(sensor2[0], offset)
@staticmethod
def collide_inside_y_minus(sensor1, sensor2):
loop = 0
while loop < LOOPMAX:
if sensor1[0].overlap(sensor2[0], [sensor2[1][0] - sensor1[1][0],
sensor2[1][1] - (sensor1[1][1]+loop)]):
loop += 1
else:
break
return -loop
# Camera class
class Camera:
def __init__(self, width, height):
self.viewport = pygame.Rect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
self.width = width
self.height = height
self.offset_x = 0
self.offset_y = 0
def apply(self, entity_rect):
"""Apply camera offset to entity rect"""
return entity_rect.move(self.offset_x, self.offset_y)
def update(self, target_rect):
"""Update camera position to follow the target"""
# Center the camera on the target
self.offset_x = SCREEN_WIDTH // 2 - target_rect.centerx
self.offset_y = SCREEN_HEIGHT // 2 - target_rect.centery
# Clamp camera to level boundaries
self.offset_x = min(0, max(-(self.width - SCREEN_WIDTH), self.offset_x))
self.offset_y = min(0, max(-(self.height - SCREEN_HEIGHT), self.offset_y))
# Update viewport for other calculations
self.viewport = pygame.Rect(-self.offset_x, -self.offset_y, self.width, self.height)
# Load TMX map
try:
tmx_map = pytmx.load_pygame("sonic test world.tmx")
# Calculate level dimensions based on map properties
level_width = tmx_map.width * 64 # Assuming 64px tiles
level_height = tmx_map.height * 64
except Exception as e:
print(f"Error loading TMX map: {e}")
# Fallback to a simple level size
level_width = 2000
level_height = 1000
tmx_map = None
# Player class
class Player:
def __init__(self, x, y):
self.x = float(x)
self.y = float(y)
self.rect = pygame.Rect(x, y, 32, 64)
self.x_vel = 0
self.y_vel = 0
self.speed = 5
self.gravity = 0.5
self.grounded = False
self.jump_power = -10
self.color = (255, 0, 0)
# Create sensors
self.sensor_thickness = 2
self.sensor_length = 10
self.update_sensors()
def update_sensors(self):
left_rect = [self.rect.left, self.rect.bottom - 5, self.sensor_thickness, self.sensor_length]
right_rect = [self.rect.right - self.sensor_thickness, self.rect.bottom - 5,
self.sensor_thickness, self.sensor_length]
self.left_sensor = Mask.newSensor(left_rect, (self.sensor_thickness // 2, 0))
self.right_sensor = Mask.newSensor(right_rect, (self.sensor_thickness // 2, 0))
def update(self):
# Handle movement
keys = pygame.key.get_pressed()
self.x_vel = 0
if keys[pygame.K_LEFT]: self.x_vel = -self.speed
if keys[pygame.K_RIGHT]: self.x_vel = self.speed
# Handle jumping
if keys[pygame.K_SPACE] and self.grounded:
self.y_vel = self.jump_power
self.grounded = False
# Apply gravity if not on ground
if not self.grounded:
self.y_vel += self.gravity
# Limit fall speed
self.y_vel = min(self.y_vel, 10)
# Update position
self.x += self.x_vel
self.rect.x = int(self.x)
self.y += self.y_vel
self.rect.y = int(self.y)
self.update_sensors()
# Reset grounded state - will be set to True again if collision detected
self.grounded = False
def draw(self, surface, camera):
# Draw player with camera offset
pygame.draw.rect(surface, self.color, camera.apply(self.rect))
# Draw sensors (for debugging)
pygame.draw.rect(surface, (0, 0, 255), camera.apply(self.left_sensor[1]))
pygame.draw.rect(surface, (0, 0, 255), camera.apply(self.right_sensor[1]))
# Create tiles list
tiles = []
for layer in tmx_map.visible_layers:
if isinstance(layer, pytmx.TiledTileLayer):
for x, y, tile_gid in layer.tiles():
if tile_gid:
tile_x, tile_y = x * 64, y * 64
if isinstance(tile_gid, pygame.Surface):
tile_image = tile_gid
else:
# Retrieve the tile image by GID if it's not already a surface
tile_image = tmx_map.get_tile_image_by_gid(tile_gid)
if tile_image:
scaled_image = pygame.transform.scale(tile_image, (64, 64))
tile_rect = pygame.Rect(tile_x, tile_y, 64, 64)
tiles.append((scaled_image, tile_rect, pygame.mask.from_surface(scaled_image)))
# Create player
player = Player(100, 100)
# Create camera
camera = Camera(max(level_width, 2000), max(level_height, 1000))
# FPS display
font = pygame.font.SysFont(None, 24)
# Main game loop
running = True
ground_correction_factor = 0.5
max_ground_correction = 10.0
while running:
# Handle events
for event in pygame.event.get():
if event.type == pygame.QUIT or (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
running = False
# Update player
player.update()
# Handle collisions
ground_corrections = []
for _, tile_rect, tile_mask in tiles:
# Only check tiles that are near the player (optimization)
if abs(tile_rect.x - player.rect.x) < SCREEN_WIDTH and abs(tile_rect.y - player.rect.y) < SCREEN_HEIGHT:
# Create tile mask object
tile_mask_obj = Mask.surface_to_mask(pygame.Surface((tile_rect.width, tile_rect.height)),
(tile_rect.x, tile_rect.y))
tile_mask_obj[0] = tile_mask # Set the correct mask
# Check ground collision with both sensors
for sensor in [player.left_sensor, player.right_sensor]:
if Mask.collide(sensor, tile_mask_obj):
y_correction = Mask.collide_inside_y_minus(sensor, tile_mask_obj)
if y_correction < 0:
ground_corrections.append(-y_correction)
player.grounded = True
# Apply ground correction
if ground_corrections:
avg_correction = sum(ground_corrections) / len(ground_corrections)
smooth_correction = min(avg_correction * ground_correction_factor, max_ground_correction)
player.y -= smooth_correction
player.rect.y = int(player.y)
player.y_vel = 0 # Reset vertical velocity when landing
# Update camera to follow player
camera.update(player.rect)
# Draw everything
screen.fill((200, 230, 255)) # Sky blue background
# Draw only tiles that are visible on screen
for tile_img, tile_rect, _ in tiles:
# Check if the tile is in the viewport before drawing
if camera.viewport.colliderect(tile_rect):
screen.blit(tile_img, camera.apply(tile_rect))
# Draw player
player.draw(screen, camera)
# Draw FPS
fps_text = font.render(f"FPS: {int(clock.get_fps())}", True, (0, 0, 0))
screen.blit(fps_text, (10, 10))
# Draw coordinates
coord_text = font.render(f"X: {int(player.x)}, Y: {int(player.y)}", True, (0, 0, 0))
screen.blit(coord_text, (10, 30))
pygame.display.flip()
clock.tick(60)
pygame.quit()
What I expect to happen is that Sonic should be able to detect the ground using the sensors, essentially snap to the mask of the tile and from there, act like a normal platformer. If the sensors detect a change in ground level (i.e the sensors detect a pixel increase in height) then Sonic should move up/down to allow the sensors to be in contact with the mask. A good example of how this should look is below:
What happens instead is a jittery mess of a collision system. Technically, the collisions are being detected and do work as Sonic for a brief moment is able to snap to the correct positions of the tile mask. The problem mostly lies in the calculations done AFTER the collision which is where Sonic's position is updated:
I got AI to help me implement this into the project, which yes, I understand is not the smartest idea when you yourself have absolutely no clue on how to even begin to understand the logic here. That's why I'm posting this issue here, so that someone may be able to help.
EDIT: The creator of the Mask class has a Sonic Pygame project of their own which utilises it beautifully: https://youtu.be/OFjInMdYlB4
I am not sure if the above video helps in dissecting the Mask class and how to better implement it, but it is there for reference.
