On this page
- NaN Is the Physics Engine’s Worst Enemy
- Gravity, Friction, and the Three Phases of a Physics Update
- The SIDE_BIAS Trick: Killing Edge-Catching on Tile Seams
- The Full Pipeline: From Movement to Resolved Contacts
- PhaseShift, WaterWalk, and Ability-Aware Resolution
- The Strategy Pattern: Four Obstacles, One Interface
- Proximity Detection and the Interaction Lifecycle
Two systems do the heavy lifting in Multiversal Consciousness: the PhysicsSystem that governs gravity, movement, and collision resolution, and the InteractiveObstacleSystem that lets players chop trees, unlock doors, and flip switches. They look independent but they’re tightly coupled — the physics system determines where entities can be, and the obstacle system determines what they can do when they get there.
This tutorial covers both. You’ll understand how to build a complete AABB physics pipeline with slide mechanics, and how the Strategy pattern makes obstacle behavior extensible without touching existing code.
Prerequisites: ECS architecture, basic vector math, C++ polymorphism. You should understand how EntityManager and ComponentRegistry work.
NaN Is the Physics Engine’s Worst Enemy
Before any forces are applied or positions updated, the physics pipeline protects itself from numerical corruption. A single NaN in velocity_x propagates to transform.x on the next update, which produces a NaN AABB, which causes every collision check involving that entity to return invalid results. The corruption cascades silently through every downstream system.
PhysicsSystem declares two sentinel constants:
class PhysicsSystem : public ISystem {
private:
static constexpr float EPSILON = 1e-6f;
static constexpr int MAX_COLLISION_ITERATIONS = 10;
};
EPSILON is the threshold below which floating-point values are treated as zero. It prevents friction from oscillating around zero velocity and guards against division by near-zero masses.
The validation gate uses std::isfinite() from <cmath> to reject NaN and Infinity before they can infect the simulation:
bool PhysicsSystem::is_valid_physics_state(const PhysicsComponent& physics) const {
return std::isfinite(physics.velocity_x) &&
std::isfinite(physics.velocity_y) &&
std::isfinite(physics.acceleration_x) &&
std::isfinite(physics.acceleration_y) &&
physics.mass > EPSILON;
}
When validation fails, the engine resets to safe defaults rather than crashing or skipping the entity:
if (!is_valid_physics_state(*physics)) {
physics->velocity_x = 0.0f;
physics->velocity_y = 0.0f;
physics->acceleration_x = 0.0f;
physics->acceleration_y = 980.0f; // Default gravity
continue;
}
Gravity, Friction, and the Three Phases of a Physics Update
The update pipeline has three distinct phases for every entity with both a Transform and a PhysicsComponent. They’re deliberately separated so each stage operates on clean data from the previous stage.
Phase 1 — Gravity. Applied only when apply_gravity is true and the entity is not grounded. Grounded entities don’t accumulate downward velocity while standing still:
void PhysicsSystem::apply_gravity(PhysicsComponent& physics, float delta_time) {
if (physics.apply_gravity && !physics.is_grounded) {
physics.velocity_y += physics.acceleration_y * delta_time;
}
}
Phase 2 — Velocity update with directional friction. Horizontal acceleration integrates into velocity. If the entity is grounded and moving horizontally, friction decelerates it toward zero. The std::max/std::min clamps ensure friction never reverses direction — you never get stuck in a micro-oscillation around zero:
void PhysicsSystem::update_velocity(PhysicsComponent& physics, float delta_time) {
physics.velocity_x += physics.acceleration_x * delta_time;
if (physics.is_grounded && std::abs(physics.velocity_x) > EPSILON) {
float friction_force = physics.friction * delta_time;
if (physics.velocity_x > 0) {
physics.velocity_x = std::max(0.0f, physics.velocity_x - friction_force);
} else {
physics.velocity_x = std::min(0.0f, physics.velocity_x + friction_force);
}
}
}
Phase 3 — Position integration. Simple Euler integration. Both axes update simultaneously:
void PhysicsSystem::update_position(Transform& transform,
const PhysicsComponent& physics,
float delta_time) {
transform.x += physics.velocity_x * delta_time;
transform.y += physics.velocity_y * delta_time;
}
This engine uses explicit Euler integration — simple and fast, but it introduces energy drift over long simulations. For a puzzle-platformer with short time steps and grounded friction, this is perfectly acceptable. Space simulations with long orbital trajectories need semi-implicit Euler or Verlet integration instead.
After the three phases, horizontal acceleration resets to zero for the next frame while vertical acceleration (gravity) persists:
physics->acceleration_x = 0.0f;
// Gravity acceleration persists — it's a constant force
The SIDE_BIAS Trick: Killing Edge-Catching on Tile Seams
The engine uses Axis-Aligned Bounding Boxes — rectangles that never rotate, making intersection tests fast. The AABB struct is constructed from a Transform and a BoundingBoxComponent:
static AABB from_components(const Transform& transform,
const BoundingBoxComponent& bbox) {
float half_width = bbox.width * 0.5f;
float half_height = bbox.height * 0.5f;
float center_x = transform.x + bbox.offset_x;
float center_y = transform.y + bbox.offset_y;
return AABB{
center_x - half_width,
center_y - half_height,
center_x + half_width,
center_y + half_height
};
}
Two AABBs intersect when they overlap on both axes simultaneously. The overlap on each axis tells us how deeply the entities penetrate each other. The critical decision is: when an entity overlaps a wall, do we push it out horizontally or vertically?
The naive approach — resolve along the axis of minimum penetration — causes edge-catching. An agent running along the top of a row of tiles hits the seam between two tiles. Due to floating-point imprecision, the horizontal penetration briefly becomes smaller than the vertical penetration, so the system resolves horizontally — the agent slams into the side of the next tile and stops dead.
The fix is SIDE_BIAS — a small constant (3.0 pixels) added to vertical overlap before comparison. This biases resolution toward horizontal push-out, so running along tile seams always resolves vertically (pushing the agent upward onto the surface):
const float SIDE_BIAS = 3.0f;
if (overlap_x < overlap_y + SIDE_BIAS) {
// Horizontal collision — push out along X
collision_info.penetration_x = overlap_x;
collision_info.penetration_y = 0.0f;
float center1_x = (aabb1.min_x + aabb1.max_x) * 0.5f;
float center2_x = (aabb2.min_x + aabb2.max_x) * 0.5f;
collision_info.normal_x = (center1_x < center2_x) ? -1.0f : 1.0f;
collision_info.normal_y = 0.0f;
} else {
// Vertical collision — push out along Y
collision_info.penetration_x = 0.0f;
collision_info.penetration_y = overlap_y;
float center1_y = (aabb1.min_y + aabb1.max_y) * 0.5f;
float center2_y = (aabb2.min_y + aabb2.max_y) * 0.5f;
collision_info.normal_x = 0.0f;
collision_info.normal_y = (center1_y < center2_y) ? -1.0f : 1.0f;
}
The 3.0-pixel value is tuned for 32x32 tile grids. For larger tiles, increase it; for smaller tiles, decrease it. The bias should be large enough to absorb floating-point drift at tile boundaries but small enough that genuine horizontal collisions (running into a wall head-on) still resolve correctly.
The Full Pipeline: From Movement to Resolved Contacts
graph TD
A["Reset Grounded State<br/><i>is_grounded = false for all entities</i>"] --> B
B["Apply Gravity<br/><i>velocity_y += acceleration_y * dt</i>"] --> C
C["Update Velocity<br/><i>Apply acceleration, friction</i>"] --> D
D["Update Position<br/><i>transform += velocity * dt</i>"] --> E
E["Detect Collisions<br/><i>O(n^2) brute-force AABB pairs</i>"] --> F
F["Group by Entity<br/><i>Bidirectional collision records</i>"] --> G
G["Resolve per Entity<br/><i>Slide mechanics, grounding, ability bypass</i>"]
style A fill:#2d2d3d,stroke:#6c6c9a,color:#e0e0ff
style B fill:#2d2d3d,stroke:#6c6c9a,color:#e0e0ff
style C fill:#2d2d3d,stroke:#6c6c9a,color:#e0e0ff
style D fill:#2d2d3d,stroke:#6c6c9a,color:#e0e0ff
style E fill:#3d2d2d,stroke:#9a6c6c,color:#ffe0e0
style F fill:#3d2d2d,stroke:#9a6c6c,color:#ffe0e0
style G fill:#3d2d2d,stroke:#9a6c6c,color:#ffe0e0
The grounded-state reset happens first, every frame. Without it, an entity that was grounded last frame would remain grounded even after walking off a cliff. Grounding is re-established only through collision resolution.
Collision detection uses O(n^2) brute-force pair checking. For each colliding pair, two CollisionInfo records are generated — one per entity, with reversed normals — so both sides of the collision resolve independently. This approach works well for levels with fewer than ~500 collidable entities. Beyond that, you need spatial partitioning (quadtree, sweep-and-prune, or a grid).
PhaseShift, WaterWalk, and Ability-Aware Resolution
Collision resolution in this engine is deeply coupled to gameplay. The resolve_collision_with_slide() function doesn’t just push entities apart — it respects ability-based bypass rules.
Non-solid entities are normally skipped. Water is a special case: agents without WaterWalk fall through it (non-solid), while agents with WaterWalk treat it as solid ground:
if (!other_bbox->is_solid) {
const WaterLevel* water = component_registry_->get_component<WaterLevel>(collision.other_entity);
if (water) {
const LoadoutComponent* loadout = component_registry_->get_component<LoadoutComponent>(entity);
if (!loadout || loadout->current_ability != AbilityType::WaterWalk) {
return; // Fall through
}
// Has WaterWalk — treat as solid, continue
} else {
return; // Non-solid, non-water — skip
}
}
PhaseShift lets agents pass through walls tagged as "phaseable":
const LoadoutComponent* loadout = component_registry_->get_component<LoadoutComponent>(entity);
if (loadout && loadout->current_ability == AbilityType::PhaseShift) {
const Wall* wall = component_registry_->get_component<Wall>(collision.other_entity);
if (wall && wall->wall_type == "phaseable") {
return;
}
}
Position correction pushes the entity out along the collision normal. Then velocity is zeroed only along the collision axis — the perpendicular component is preserved. This creates slide mechanics: an agent sliding down a wall retains horizontal input, and landing on a platform doesn’t kill horizontal momentum:
transform->x += collision.normal_x * collision.penetration_x;
transform->y += collision.normal_y * collision.penetration_y;
if (std::abs(collision.normal_x) > EPSILON) {
if (collision.normal_x * physics->velocity_x > 0) {
physics->velocity_x = 0.0f;
}
}
if (std::abs(collision.normal_y) > EPSILON) {
if (collision.normal_y < 0) {
physics->is_grounded = true;
physics->has_double_jumped = false;
if (physics->velocity_y > 0) { physics->velocity_y = 0.0f; }
} else {
if (physics->velocity_y < 0) { physics->velocity_y = 0.0f; }
}
}
The check collision.normal_x * physics->velocity_x > 0 is subtle but important. It only zeroes velocity when the entity is moving into the wall (same direction as the normal from the entity’s perspective). Without this check, an entity moving away from a wall it’s still overlapping would have its escape velocity killed — trapping it inside.
The Strategy Pattern: Four Obstacles, One Interface
The InteractiveObstacleSystem handles active interactions — chopping trees, unlocking doors, flipping switches. The Strategy pattern makes each obstacle type’s behavior polymorphic and extensible.
At the center of the design is a pure virtual interface:
class IInteractable {
public:
virtual ~IInteractable() = default;
virtual bool can_interact(EntityID agent_id, const LoadoutComponent& loadout) const = 0;
virtual void interact(EntityID agent_id, EntityManager& entity_manager,
ComponentRegistry& component_registry) = 0;
virtual std::string get_interaction_prompt() const = 0;
virtual float get_interaction_radius() const = 0;
virtual std::string get_feedback_message() const { return ""; }
};
Each concrete strategy implements this interface:
TreeObstacle — requires Axe, destroys the entity:
class TreeObstacle : public IInteractable {
EntityID entity_id_;
public:
explicit TreeObstacle(EntityID entity_id) : entity_id_(entity_id) {}
bool can_interact(EntityID, const LoadoutComponent& loadout) const override {
return loadout.current_ability == AbilityType::Axe;
}
void interact(EntityID, EntityManager& entity_manager, ComponentRegistry&) override {
entity_manager.destroy_entity(entity_id_);
}
float get_interaction_radius() const override { return 48.0f; }
};
DoorObstacle — requires Keycard, mutates multiple components to open the door and make it passable. ChasmObstacle — requires DoubleJump, applies an upward velocity impulse to the agent. SwitchObstacle — requires no ability (any agent can activate it), toggles an environmental switch and propagates effects to target entities (unlocking all doors, solidifying/draining water).
A factory method instantiates the correct strategy from the InteractionType enum stored in the entity’s InteractableComponent:
std::unique_ptr<IInteractable> InteractiveObstacleSystem::create_obstacle(
EntityID entity, InteractionType type) {
switch (type) {
case InteractionType::Tree: return std::make_unique<TreeObstacle>(entity);
case InteractionType::Door: return std::make_unique<DoorObstacle>(entity);
case InteractionType::Chasm: return std::make_unique<ChasmObstacle>(entity);
case InteractionType::Switch: return std::make_unique<SwitchObstacle>(entity);
case InteractionType::QuantumNode: return nullptr; // Handled by QuantumSystem
default: return nullptr;
}
}
To add a BoulderObstacle that requires the Dash ability to shatter: add Boulder to the enum, write the class implementing IInteractable, add one case to create_obstacle(). No existing code changes. That’s the Open-Closed Principle working as intended.
Proximity Detection and the Interaction Lifecycle
Every frame, the system finds the closest interactive obstacle within range of the possessed agent. For tall trees, the distance calculation clamps the agent’s Y position to the tree’s vertical extent — if you’re standing beside the middle of a tall tree, dy is zero and only horizontal distance matters:
if (interactable->type == InteractionType::Tree) {
const auto* wall = component_registry_->get_component<Wall>(entity);
if (wall) {
float half_height = wall->height * 0.5f;
float tree_top = obstacle_transform->y - half_height;
float tree_bottom = obstacle_transform->y + half_height;
if (agent_transform->y < tree_top)
dy = agent_transform->y - tree_top;
else if (agent_transform->y > tree_bottom)
dy = agent_transform->y - tree_bottom;
else
dy = 0.0f;
}
}
Input handling checks whether INTERACT was just_pressed (not held), ensuring single-trigger behavior. force_interaction() validates the agent’s ability, executes the strategy, sends HUD feedback, and unregisters destroyed entities.
One implementation detail matters here: force_interaction() checks entity validity after calling interact(). TreeObstacle::interact() destroys the entity during execution, so checking validity before the call and then accessing the entity afterward would give you a dangling iterator. Always validate post-mutation when the mutation may invalidate the entity you’re tracking.