← 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

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() );
}