Start

Theory

Reference

Examples

← All examples
Physics · ECS

Physics

Gravity, collision response and triggers

Showcase for Rigidbody, gravity, mass-weighted collision response, and Trigger colliders, all driven through systems::PhysicsUpdate.

A floor and two side walls are static Solid Colliders — deliberately no Velocity/Rigidbody, so collision response treats them as immovable. A yellow-outlined zone acts as a Trigger, reporting overlap without any physical push-back.

Controls

  • Click Drop a new box at the cursor
  • R Reset the scene
PhysicsECS
Physics screenshot

Run it yourself

build just this example
cmake --build --preset windows-debug --target physics_demo
# binary lands under bin/ (e.g. bin/Debug/physics_demo.exe on Windows)

See Installation for the full build setup.

Source

examples/physics_demo/Game.hpp
#pragma once

/**
 * @brief Showcase for Rigidbody, gravity, mass-weighted collision response,
 *        and Trigger colliders, all driven through systems::PhysicsUpdate.
 *
 * A floor and two side walls are static Solid Colliders (Transform +
 * Collider, deliberately no Velocity or Rigidbody, so collision response
 * treats them as immovable). A yellow-outlined zone near the bottom-right
 * is a static Trigger Collider instead -- boxes fall/settle against the
 * Solid geometry as normal, but pass straight through the trigger zone
 * without being pushed; overlapping it despawns the box, via
 * events::OnCollisionTriggerEnter rather than a Collider push-out.
 *
 * Left-click drops a new box -- Transform + Velocity + Collider + Rigidbody
 * with a randomized mass -- at the cursor; every frame runs
 * systems::PhysicsUpdate (gravity, movement, collision detection/response,
 * trigger events, in that order), so boxes fall, land on the floor/each
 * other/the trigger zone, and settle (or get despawned). R resets the scene.
 *
 * Two extra "ghost" boxes near the left wall showcase Collider::m_Layer/
 * m_Mask: dropped at the same X, on two layers whose masks exclude each
 * other. Both still land on the floor/walls normally (m_Mask still includes
 * the default layer everything else uses), but the second one falls
 * straight through the first instead of stacking on it -- see SpawnBox's
 * inLayer/inMask parameters and components::LayersCanCollide.
 */

#include <ASGE/ASGE.hpp>
#include <random>
#include <vector>

class PhysicsDemoState final : public asge::game::state::IGameState<int>
{
    asge::ecs::Registry&              m_Registry;
    std::vector<asge::ecs::Entity>    m_Boxes; // dynamic entities only -- static geometry is never touched by Reset()
    std::mt19937                      m_Rng{ std::random_device{}() };

    asge::ecs::Entity m_TriggerZone{ asge::ecs::Entity::Null() };
    std::vector<asge::ecs::Entity> m_ConsumedByTrigger; // boxes to destroy on the next Update() -- see HandleTriggerOverlap
    asge::signals::Connection<asge::ecs::Entity, asge::ecs::Entity> m_TriggerConnection;

    void SpawnStaticGeometry();
    void SpawnTriggerZone();
    void SpawnBox( asge::math::Float2 inCenter,
        asge::game::components::CollisionLayer inLayer = 1u,
        asge::game::components::CollisionLayer inMask = ~asge::game::components::CollisionLayer{0},
        float inSize = 0.0f );
    void SpawnInitialStack();
    void Reset();
    void DestroyBox( asge::ecs::Entity inEntity );
    void DespawnFallenBoxes();
    void HandleTriggerOverlap( asge::ecs::Entity inA, asge::ecs::Entity inB );
    void ProcessTriggerDespawns();
    void HandleInput( asge::input::InputState const& inInput );

public:
    explicit PhysicsDemoState(asge::ecs::Registry& inRegistry);

    [[nodiscard]] std::optional<asge::game::state::Transition<int>>
    Update(float inDeltaTime, asge::input::InputState const& inInput) override;
    void Render(asge::video::IRenderer& inRenderer) override;
    [[nodiscard]] asge::graphics::RGBA_Color ClearColor() const noexcept override { return { 18, 18, 24, 255 }; }
    void OnSystemEvent(asge::event::SystemEvent const& inSysEvent) override;
};

class PhysicsDemoGame final : public asge::game::Game<int>
{
public:
    explicit PhysicsDemoGame(asge::video::IRenderer& inRenderer, asge::audio::AudioDevice& inAudioDev);

protected:
    [[nodiscard]] std::unique_ptr<StateType> CreateState(int inId) override;
};
examples/physics_demo/Game.cpp
#include "Game.hpp"

#include <algorithm>
#include <cstddef>
#include <iterator>
#include <type_traits>

namespace
{
using asge::game::components::Collider;
using asge::game::components::CollisionLayer;
using asge::game::components::ResolutionType;
using asge::game::components::Rigidbody;
using asge::game::components::Transform;
using asge::game::components::Velocity;

constexpr float kWindowWidth   = 800.0f;
constexpr float kWindowHeight  = 600.0f;
constexpr float kWallThickness = 20.0f;
constexpr float kFloorHeight   = 30.0f;
constexpr float kMinBoxSize    = 24.0f;
constexpr float kMaxBoxSize    = 48.0f;
constexpr float kMinMass       = 0.5f;
constexpr float kMaxMass       = 3.0f;
constexpr float kFallLimit     = kWindowHeight + 200.0f; // safety net for a box tunneling past the floor
constexpr std::size_t kMaxBoxes = 80; // keeps CollisionSystem's narrow phase cheap

// A Trigger-resolution despawn zone near the bottom-right, sitting on top
// of the floor -- any box's Collider overlapping it is removed instead of
// being pushed out, since a Trigger never participates in solid push-out.
constexpr float kTriggerZoneSize = 150.0f;
constexpr float kTriggerZoneX    = kWindowWidth - kTriggerZoneSize - 30.0f;
constexpr float kTriggerZoneY    = kWindowHeight - kFloorHeight - kTriggerZoneSize;

constexpr asge::graphics::RGBA_Color kStaticColor{ 70, 70, 80, 255 };
constexpr asge::graphics::RGBA_Color kTriggerColor{ 240, 210, 60, 255 };
constexpr asge::graphics::RGBA_Color kBoxPalette[] = {
    { 220, 90, 90, 255 },
    { 90, 180, 220, 255 },
    { 230, 200, 90, 255 },
    { 120, 200, 120, 255 },
    { 190, 120, 220, 255 },
};

constexpr CollisionLayer kLayerDefault   = 1u;
constexpr CollisionLayer kLayerGhostRed  = 1u << 1;
constexpr CollisionLayer kLayerGhostBlue = 1u << 2;
constexpr CollisionLayer kMaskGhostRed   = kLayerDefault | kLayerGhostRed;
constexpr CollisionLayer kMaskGhostBlue  = kLayerDefault | kLayerGhostBlue;

constexpr float kGhostDemoRedX  = 110.0f;
constexpr float kGhostDemoBlueX = 140.0f;
constexpr asge::graphics::RGBA_Color kGhostRedColor{ 235, 60, 60, 255 };
constexpr asge::graphics::RGBA_Color kGhostBlueColor{ 60, 130, 235, 255 };
}

PhysicsDemoState::PhysicsDemoState(asge::ecs::Registry& inRegistry)
: m_Registry(inRegistry)
{
    SpawnStaticGeometry();
    SpawnTriggerZone();
    SpawnInitialStack();

    // Enter, not Stay/Exit -- this demo only cares about the first frame a
    // box touches the trigger zone (it gets despawned that same frame, so
    // there's never a "still overlapping" frame after to report Stay for).
    m_TriggerConnection = asge::game::events::OnCollisionTriggerEnter().Connect(
        [this]( asge::ecs::Entity inA, asge::ecs::Entity inB ) { HandleTriggerOverlap( inA, inB ); }
    );
}

void PhysicsDemoState::SpawnStaticGeometry()
{
    // Floor and side walls: Transform + Collider only -- no Velocity or
    // Rigidbody, so CollisionSystem treats them as immovable.
    auto makeStatic = [this]( asge::math::Rect inBounds )
    {
        auto entity = m_Registry.CreateEntity();
        if ( !entity ) { entity.LogError(); return; }

        m_Registry.AddComponent<Transform>( entity.Value(), Transform{
            .m_LocalCoordinates = {inBounds.m_X, inBounds.m_Y}, .m_WorldCoordinates = {inBounds.m_X, inBounds.m_Y}
        } );
        m_Registry.AddComponent<Collider>( entity.Value(),
            Collider{ asge::math::Rect{ 0.0f, 0.0f, inBounds.m_Width, inBounds.m_Height } } );
    };

    makeStatic({ 0.0f, kWindowHeight - kFloorHeight, kWindowWidth, kFloorHeight });      // floor
    makeStatic({ -kWallThickness, 0.0f, kWallThickness, kWindowHeight });                // left wall
    makeStatic({ kWindowWidth, 0.0f, kWallThickness, kWindowHeight });                   // right wall
}

void PhysicsDemoState::SpawnTriggerZone()
{
    auto entity = m_Registry.CreateEntity();
    if ( !entity ) { entity.LogError(); return; }

    m_Registry.AddComponent<Transform>( entity.Value(),
        Transform{ .m_LocalCoordinates = {kTriggerZoneX, kTriggerZoneY}, .m_WorldCoordinates = {kTriggerZoneX, kTriggerZoneY} } );
    m_Registry.AddComponent<Collider>( entity.Value(), Collider{
        asge::math::Rect{ 0.0f, 0.0f, kTriggerZoneSize, kTriggerZoneSize },
        ResolutionType::Trigger
    } );

    m_TriggerZone = entity.Value();
}

void PhysicsDemoState::SpawnBox(asge::math::Float2 inCenter, CollisionLayer inLayer, CollisionLayer inMask, float inSize)
{
    if ( m_Boxes.size() >= kMaxBoxes ) return;

    auto entity = m_Registry.CreateEntity();
    if ( !entity ) { entity.LogError(); return; }

    std::uniform_real_distribution<float> sizeDist( kMinBoxSize, kMaxBoxSize );
    std::uniform_real_distribution<float> massDist( kMinMass, kMaxMass );
    float const size = inSize > 0.0f ? inSize : sizeDist( m_Rng );

    asge::math::Float2 const spawnPos{ inCenter.x() - size * 0.5f, inCenter.y() - size * 0.5f };
    m_Registry.AddComponent<Transform>( entity.Value(),
        Transform{ .m_LocalCoordinates = spawnPos, .m_WorldCoordinates = spawnPos } );
    m_Registry.AddComponent<Velocity>( entity.Value(), Velocity{} );
    m_Registry.AddComponent<Collider>( entity.Value(), Collider{
        asge::math::Rect{ 0.0f, 0.0f, size, size }, ResolutionType::Solid, inLayer, inMask
    } );
    m_Registry.AddComponent<Rigidbody>( entity.Value(), Rigidbody{ massDist( m_Rng ), true } );

    m_Boxes.push_back( entity.Value() );
}

void PhysicsDemoState::SpawnInitialStack()
{
    // A staggered column dropped from above the floor, so gravity and
    // mass-weighted collision resolution have something to settle right
    // away, without waiting for a click.
    for ( int i = 0; i < 6; ++i )
    {
        float const xOffset = (i % 2 == 0) ? -20.0f : 20.0f;
        SpawnBox({ kWindowWidth * 0.5f + xOffset, 80.0f * float(i) });
    }

    // One extra box dropped straight above the trigger zone, so the
    // despawn-on-overlap behavior is visible immediately on startup/reset,
    // without needing a click.
    SpawnBox({ kTriggerZoneX + kTriggerZoneSize * 0.5f, 50.0f });

    SpawnBox( { kGhostDemoRedX, 20.0f },   kLayerGhostRed,  kMaskGhostRed,  48.0f );
    SpawnBox( { kGhostDemoBlueX, -80.0f }, kLayerGhostBlue, kMaskGhostBlue, 32.0f );
}

void PhysicsDemoState::Reset()
{
    for ( auto entity : m_Boxes )
    {
        if ( auto result = m_Registry.DestroyEntity( entity ); !result ) result.LogError();
    }
    m_Boxes.clear();
    m_ConsumedByTrigger.clear(); // nothing pending should survive a reset
    SpawnInitialStack();
}

void PhysicsDemoState::DestroyBox(asge::ecs::Entity inEntity)
{
    if ( auto result = m_Registry.DestroyEntity( inEntity ); !result ) result.LogError();
    m_Boxes.erase( std::remove( m_Boxes.begin(), m_Boxes.end(), inEntity ), m_Boxes.end() );
}

void PhysicsDemoState::DespawnFallenBoxes()
{
    m_Boxes.erase(
        std::remove_if( m_Boxes.begin(), m_Boxes.end(), [this]( asge::ecs::Entity inEntity )
        {
            auto transform = m_Registry.GetComponent<Transform>( inEntity );
            bool const fellThrough = transform.IsOk() && transform.Value().get().m_WorldCoordinates.y() > kFallLimit;
            if ( fellThrough )
            {
                if ( auto result = m_Registry.DestroyEntity( inEntity ); !result ) result.LogError();
            }
            return fellThrough;
        }),
        m_Boxes.end()
    );
}

void PhysicsDemoState::HandleTriggerOverlap(asge::ecs::Entity inA, asge::ecs::Entity inB)
{
    // events::OnCollisionTriggerEnter() fires synchronously, from inside
    // systems::CollisionSystem -- itself called partway through
    // PhysicsUpdate, still iterating this frame's contact list. Destroying
    // an entity here rather than deferring it could disturb that in-progress
    // iteration (or any later contact in the same list still referencing this
    // entity), so just record which box to remove; the actual destruction
    // happens in ProcessTriggerDespawns(), after PhysicsUpdate has returned
    // for this frame.
    asge::ecs::Entity other;
    if ( inA == m_TriggerZone )      other = inB;
    else if ( inB == m_TriggerZone ) other = inA;
    else return; // this overlap doesn't involve our despawn zone

    m_ConsumedByTrigger.push_back( other );
}

void PhysicsDemoState::ProcessTriggerDespawns()
{
    for ( auto entity : m_ConsumedByTrigger )
    {
        LOG_INFO( "[Trigger] box (entity index ", entity.m_Index, ") despawned by the trigger zone" );
        DestroyBox( entity );
    }
    m_ConsumedByTrigger.clear();
}

void PhysicsDemoState::HandleInput(asge::input::InputState const &inInput)
{
    using asge::input::Keycode;
    using asge::input::MouseButton;

    if ( inInput.IsMouseButtonPressed( MouseButton::LEFT ) )
    {
        SpawnBox( inInput.GetMousePosition() );
    }

    if ( inInput.IsKeyPressed( Keycode::R ) )
    {
        Reset();
    }
}

std::optional<asge::game::state::Transition<int>>
PhysicsDemoState::Update([[maybe_unused]] float inDeltaTime, asge::input::InputState const &inInput)
{
    HandleInput( inInput );

    ProcessTriggerDespawns();
    DespawnFallenBoxes();
    return std::nullopt;
}

void PhysicsDemoState::Render(asge::video::IRenderer &inRenderer)
{
    // No Sprite/RenderSystem here -- Colliders don't carry a texture, so
    // this demo draws each entity's world-space Collider bounds directly.
    // Every Collider this demo spawns is a Rect today, but drawing through
    // std::visit rather than assuming .x/.y/.w/.h keeps this correct if a
    // Circle collider ever gets spawned here too.
    for ( auto [ entity, transform, collider ] : m_Registry.View<Transform, Collider>() )
    {
        auto const& t = transform.get();
        auto const& c = collider.get();

        // Trigger geometry is drawn outlined-only in a distinct color, so
        // it visually reads as "a zone to watch for", not solid geometry.
        bool const isTrigger = c.m_Resolution == ResolutionType::Trigger;
        bool const isBox = m_Registry.HasComponent<Rigidbody>( entity );
        auto const color = isTrigger ? kTriggerColor
            : c.m_Layer == kLayerGhostRed  ? kGhostRedColor
            : c.m_Layer == kLayerGhostBlue ? kGhostBlueColor
            : isBox ? kBoxPalette[ entity.m_Index % std::size(kBoxPalette) ]
            : kStaticColor;
        bool const fill = !isTrigger;

        std::visit( [&]( auto const& inShape )
        {
            using ShapeT = std::decay_t<decltype(inShape)>;
            if constexpr ( std::is_same_v<ShapeT, asge::math::Rect> )
            {
                asge::math::Rect const bounds{
                    t.m_WorldCoordinates.x() + inShape.m_X, t.m_WorldCoordinates.y() + inShape.m_Y,
                    inShape.m_Width, inShape.m_Height
                };
                inRenderer.DrawRect( bounds, color, fill );
            }
            else
            {
                asge::math::Int2 const center{
                    static_cast<int>( t.m_WorldCoordinates.x() + inShape.m_Center.x() ),
                    static_cast<int>( t.m_WorldCoordinates.y() + inShape.m_Center.y() )
                };
                inRenderer.DrawCircle( center, static_cast<int>( inShape.m_Radius ), color, fill );
            }
        }, c.m_LocalBounds );
    }
}

void PhysicsDemoState::OnSystemEvent([[maybe_unused]] asge::event::SystemEvent const &inSysEvent)
{
    // Everything here is driven by polling InputState in Update() instead.
}

PhysicsDemoGame::PhysicsDemoGame(asge::video::IRenderer& inRenderer, asge::audio::AudioDevice& inAudioDev)
: Game(inRenderer, inAudioDev)
{
    SetInitialState(0);
}

std::unique_ptr<PhysicsDemoGame::StateType> PhysicsDemoGame::CreateState([[maybe_unused]] int inId)
{
    return std::make_unique<PhysicsDemoState>( m_SceneManager.GetRegistry() );
}

Browse the full example on GitHub →