Physics & Colliders Tutorial

This tutorial covers 2D physics in AxisPy: rigidbodies, colliders, collision handling, and physics queries (raycasts/overlaps). It is based on the actual engine components and the PhysicsSystem that the runtime attaches to every scene.

Overview

The runtime adds a single PhysicsSystem per world. Default gravity is (0, 980) in pixels/s².

Editor workflow (no code)

  • Add physics components (Inspector) - Select an entity → Add Component → Physics → Rigidbody 2D. - Add a collider: Box Collider 2D, Circle Collider 2D, or Polygon Collider 2D. - Box/Circle sizes can be inferred from SpriteRenderer. Adjust Width/Height/Radius, Offset X/Y, Rotation, and toggle Is Trigger. - For Polygon, click “Start Adding New Point” in the Inspector to place points in the Scene viewport; per‑point X/Y editing and delete are available (3+ points required).

  • Configure Rigidbody in Inspector - Body Type: Dynamic / Kinematic / Static. - Use Gravity, Gravity Scale, Mass, Friction, Elasticity, Linear/Angular Damping, Freeze Rotation. - Optional initial Velocity X/Y and Angular Velocity.

  • Groups and Collision Matrix - Project → Project Settings → Layers/Groups. - Define logical groups (e.g., Player, Enemy, Environment). - In “Physics Collision Matrix”, tick which groups collide. The runtime loads this into world.physics_group_order and world.physics_collision_matrix when the project opens. - Assign entities to groups via the Inspector (Groups) or the Groups dock.

  • Visualize and edit colliders - In the Scene viewport toolbar, enable “Physics Debug Mode” (bug icon) to show collider outlines and handles. Drag handles to resize; polygon point add/remove integrates with this mode.

Getting Started: Rigidbody + Collider

from core.components import Transform, Rigidbody2D, BoxCollider2D

class PlayerSetup:
    def on_start(self):
        # Ensure a Transform exists
        if not self.entity.get_component(Transform):
            self.entity.add_component(Transform())

        # Add a dynamic rigidbody
        rb = self.entity.get_component(Rigidbody2D)
        if not rb:
            rb = Rigidbody2D(mass=1.0, use_gravity=True, linear_damping=0.05)
            self.entity.add_component(rb)

        # Add a box collider (auto-sizes from sprite if width/height=None)
        col = self.entity.get_component(BoxCollider2D)
        if not col:
            col = BoxCollider2D(width=None, height=None, is_trigger=False)
            self.entity.add_component(col)

Rigidbody2D Body Types

Rigidbody2D supports three body types via body_type:

  • "dynamic" — affected by forces and gravity (default).

  • "kinematic" — moved by setting velocity directly; not affected by forces.

  • "static" — immovable; used for the environment.

rb = self.entity.get_component(Rigidbody2D)
rb.body_type = Rigidbody2D.BODY_TYPE_STATIC
# or kinematic
rb.body_type = Rigidbody2D.BODY_TYPE_KINEMATIC

Forces, Impulses, and Rotation

from core.components import Rigidbody2D

class Thrust:
    def on_update(self, dt: float):
        rb = self.entity.get_component(Rigidbody2D)
        if not rb or not rb.is_dynamic:
            return
        # Continuous force (applied this frame only)
        rb.apply_force(500.0, 0.0)
        # One-shot velocity change
        # rb.apply_impulse(50.0, 0.0)
        # Spin
        # rb.apply_torque(10.0)
        # rb.apply_angular_impulse(1.0)

# Damping and friction
# rb.linear_damping = 0.05
# rb.angular_damping = 0.1
# rb.friction = 0.5
# Elasticity (alias: rb.elasticity)
# rb.restitution = 0.2

Colliders: Shapes, Offsets, Triggers, Masks

  • Box: BoxCollider2D(width=None, height=None, offset_x=0, offset_y=0, rotation=0)

  • Circle: CircleCollider2D(radius=None, offset_x=0, offset_y=0)

  • Polygon: PolygonCollider2D(points=[(x,y),…], offset_x=0, offset_y=0, rotation=0)

Notes:

  • If width/height (box) or radius (circle) is None, sizes are inferred from a SpriteRenderer if present, else reasonable defaults are used.

  • Set is_trigger=True for overlap-only detection (no physical resolution).

  • Use bitmasks to filter collisions per body: category_mask (what I am) and collision_mask (what I collide with).

Example:

from core.components import BoxCollider2D

# Player belongs to category bit 1, collides with bits 1 and 2
player_col = BoxCollider2D(
    category_mask=(1 << 0),
    collision_mask=(1 << 0) | (1 << 1)
)
self.entity.add_component(player_col)

Layered Filtering via World Groups

Instead of hardcoding bitmasks, you can derive effective masks from entity groups using world-level settings (read by the PhysicsSystem).

from core.systems.physics_system import PhysicsSystem

class GameManager:
    def on_start(self):
        # Define physics groups (order = bit position)
        self.entity.world.physics_group_order = ["Player", "Enemy", "Environment"]

        # Define which groups collide with which
        self.entity.world.physics_collision_matrix = {
            "Player": ["Environment", "Enemy"],
            "Enemy": ["Environment", "Player"],
            "Environment": ["Player", "Enemy"],
        }

# Mark entities with groups; `PhysicsSystem` converts groups to masks
self.entity.add_group("Player")

Collision Handling

You can handle collisions either via script callbacks on the attached script class or by subscribing to events.

  • Script callbacks recognized by the engine: - on_collision_enter(other, info) - on_collision_exit(other)

info is a lightweight object with normal (Vector2) and penetration (float).

class DamageOnHit:
    def on_collision_enter(self, other, info):
        self.logger.info("Hit", other=other.name, normal=(info.normal.x, info.normal.y))

# Or via events (immediate dispatch within the same frame):
class EventListener:
    def on_start(self):
        # Subscribe to this entity's local collision events
        self.subscribe_to_event("collision_enter", self._on_enter, target_entity=self.entity)
        self.subscribe_to_event("collision_exit", self._on_exit, target_entity=self.entity)

    def _on_enter(self, other, info):
        self.logger.debug("collision_enter", other=other.name)

    def _on_exit(self, other):
        self.logger.debug("collision_exit", other=other.name)

Physics Queries: Raycasts and Overlaps

Access the PhysicsSystem from the world and use queries for detection.

from core.vector import Vector2
from core.components import Transform
from core.systems.physics_system import PhysicsSystem

class Sensor:
    def on_update(self, dt: float):
        phys = self.entity.world.get_system(PhysicsSystem)
        if not phys:
            return
        # Raycast forward 500 px from this entity's position
        t = self.entity.get_component(Transform)
        origin = t.position if t else Vector2(0, 0)
        hit = phys.raycast_first(origin, Vector2(1, 0), 500)
        if hit:
            self.logger.info("Ray hit", entity=hit["entity"].name, dist=hit["distance"])

        # Overlap box centered on entity (100x50 half extents)
        center = origin
        overlaps = phys.overlap_box(center, Vector2(100, 50))
        for e in overlaps:
            self.logger.debug("Overlap", entity=e.name)

Adjusting Gravity

Change global gravity via the PhysicsSystem.

from core.systems.physics_system import PhysicsSystem
from core.vector import Vector2

class GravitySetup:
    def on_start(self):
        phys = self.entity.world.get_system(PhysicsSystem)
        if phys:
            phys.gravity = Vector2(0, 1200)  # stronger downward gravity

Disabling Physics Processing per Entity

Temporarily opt an entity out of physics (integration and collisions):

# Pause/resume physics on this entity and its children
self.entity.process_physics(False)
# ... later ...
self.entity.process_physics(True)

Script Editor snippets you may need

  • Apply a force while a key is held

import pygame
from core.input import Input
from core.components import Rigidbody2D

class Thruster:
    def on_update(self, dt: float):
        rb = self.entity.get_component(Rigidbody2D)
        if rb and rb.is_dynamic and Input.get_key(pygame.K_RIGHT):
            rb.apply_force(800.0, 0.0)
  • Raycast from the mouse

from core.input import Input
from core.systems.render_system import RenderSystem
from core.systems.physics_system import PhysicsSystem
from core.vector import Vector2

class MouseProbe:
    def on_update(self, dt: float):
        rs = self.entity.world.get_system(RenderSystem)
        phys = self.entity.world.get_system(PhysicsSystem)
        if not rs or not phys:
            return
        mx, my = Input.get_game_mouse_position()
        wx, wy = rs.screen_to_world(mx, my, entities=self.entity.world.entities)
        hit = phys.raycast_first(Vector2(wx, wy), Vector2(1, 0), 600)
        if hit:
            self.logger.info("hit", target=hit["entity"].name, dist=hit["distance"])
  • Toggle a collider’s trigger flag at runtime

from core.components import BoxCollider2D

class Toggle:
    def on_update(self, dt: float):
        col = self.entity.get_component(BoxCollider2D)
        if col:
            col.is_trigger = not col.is_trigger
            self.logger.info("trigger", value=col.is_trigger)