Skip to content

PixelRoot32 Physics System Reference – Flat Solver ​

Note: For the complete physics system documentation with examples and API reference, visit the official documentation.

This document describes the Flat Solver, the current physics system in PixelRoot32. This version represents a major architectural overhaul from previous versions, focusing on stability, determinism, and microcontroller-friendly performance.

Modular Compilation: The entire physics system is only compiled when PIXELROOT32_ENABLE_PHYSICS=1. When disabled, all physics-related classes, collision detection, and solver components are excluded from the build, significantly reducing firmware size and RAM usage.


1. Overview: Flat Solver ​

1.1 Design Philosophy ​

  • Deterministic: Fixed timestep (1/60s) ensures consistent behavior across hardware.
  • Stable: Proper separation of velocity and position solvers eliminates jitter.
  • Hardware-Optimized: Uses Fixed16 on non-FPU microcontrollers (ESP32-C3/C6) for high-performance math without the overhead of floating-point emulation.
  • Precise Rounding: Uses MathUtil rounding functions to ensure that small penetrations and velocities are handled consistently.
  • Correct: Implements proper impulse-based collision response.

1.2 The Simulation Pipeline ​

Every frame, the CollisionSystem executes physics in strict order:

1. Detect Collisions       → Identify all overlapping pairs
2. Integrate Velocity      → Update velocities (v = v + a * dt) in CollisionSystem
3. Solve Velocity          → Apply impulse-based collision response
4. Integrate Positions     → Update positions: p = p + v * FIXED_DT
5. Solve Penetration      → Baumgarte stabilization + Slop
6. Trigger Callbacks       → Notify gameplay code (onCollision)

Note (v1.2.2+): Velocity integration has been centralized in CollisionSystem::update(). The Actor::update() no longer handles velocity integration — this ensures a single integration path where velocities are updated before positions, improving determinism and consistency.

This order is critical:

  • Velocity is solved before position integration (prevents energy loss)
  • Position integration happens before penetration correction (allows proper separation)
  • Callbacks happen last (gameplay can inspect final state)

1.3 Physics Pipeline Overview ​

CollisionSystem::update()
        │
        ▼
Broadphase (Spatial Grid)
        │
        ▼ (candidate pairs)
Narrowphase (AABB / Circle tests)
        │
        ▼ (contacts)
Contact Solver
        │
        ├──▶ Velocity Solve (Impulse response)
        │         └─ Bounce & friction
        │
        ├──▶ Position Integrate (p += v·dt)
        │
        └──▶ Penetration Correct (Baumgarte)
        │
        ▼
Collision Events
        │
        ▼ (dispatch)
onCollision Callbacks

Key insight: Steps 1-2 run per-frame. Steps 3-6 run inside the fixed timestep loop (1/60s).

Quick Reference: The simulation runs in CollisionSystem::update():

  1. Detect → find overlapping pairs (grid → AABB/circle checks)
  2. Integrate Velocity → v = v + a·dt
  3. Solve Velocity → impulse-based response
  4. Integrate Position → p = p + v·FIXED_DT
  5. Solve Penetration → Baumgarte stabilization
  6. Callbacks → onCollision() events

2. Key Constants ​

cpp
static constexpr Scalar FIXED_DT = toScalar(1.0f / 60.0f);  // Fixed timestep
static constexpr Scalar SLOP = toScalar(0.02f);              // Ignore penetration < 2cm
static constexpr Scalar BIAS = toScalar(0.2f);               // 20% correction per frame
static constexpr Scalar VELOCITY_THRESHOLD = toScalar(0.5f); // Zero restitution below this
static constexpr int VELOCITY_ITERATIONS = 2;                // Impulse solver iterations
static constexpr Scalar CCD_THRESHOLD = toScalar(3.0f);      // CCD activation threshold

// vPhysics Scheduler constants
static constexpr Scalar VELOCITY_DAMPING = toScalar(0.999f);  // Per-frame velocity damping
static constexpr Scalar MAX_VELOCITY = toScalar(500.0f);     // Maximum velocity cap (units/s)

2.1 PhysicsScheduler ​

The PhysicsScheduler implements a fixed timestep with time accumulator pattern to ensure consistent physics simulation regardless of frame rate variations (critical for ESP32).

2.1.1 Why Do We Need It? ​

On ESP32, frame rates vary widely (30-60 FPS) due to WiFi/BT interrupts and limited resources. Without a scheduler:

  • 30 FPS → ~33ms/frame → 1 physics step per frame → slower physics
  • 60 FPS → ~16ms/frame → 1 physics step per frame → faster physics

This caused inconsistent motion speeds between ESP32 and Native targets.

2.1.2 How It Works ​

Frame 1 (16ms): accumulator = 16000µs → 0 steps (accumulate)
Frame 2 (16ms): accumulator = 32000µs → 1 step, accumulator = 15333µs
Frame 3 (16ms): accumulator = 31333µs → 1 step, accumulator = 14666µs
Frame 4 (16ms): accumulator = 30666µs → 1 step, accumulator = 14000µs

2.1.3 Key Features ​

FeatureDescription
Time AccumulatorAccumulates real microseconds, never discards time
Adaptive Steps1 step normal (2 on desktop), up to 4 when behind (catch-up mode)
No ClampingPreserves real time for catch-up, avoids "slow motion"
ESP32 OptimizedEarly skip for stationary bodies, skip invisible entities, IRAM_ATTR
Skip InvisibleCollision detection skips entities with isVisible() == false for performance

Note (v1.2.2+): MAX_STEPS_NORMAL was reduced from 2 to 1 for ESP32-C3 stability while maintaining simulation accuracy. Desktop builds may use 2 steps.

2.1.4 Integration ​

The PhysicsScheduler is owned by Scene and called in Scene::update():

cpp
// Scene.cpp
void Scene::update(unsigned long deltaTime) {
    // 1. Logic update - entities update game logic only
    for (int i = 0; i < entityCount; i++) {
        entities[i]->update(deltaTime);
    }
    
    // 2. Physics update with fixed timestep scheduler
    #if PIXELROOT32_ENABLE_PHYSICS
        uint32_t deltaMicros = static_cast<uint32_t>(deltaTime * 1000);
        physicsScheduler.update(deltaMicros, collisionSystem);
    #endif
}

2.1.5 Build Flags ​

ini
# platformio.ini
-D PIXELROOT32_VELOCITY_DAMPING=0.999           ; Per-frame damping (default)
-D PIXELROOT32_MAX_VELOCITY=500                  ; Max velocity (units/s, default)

3. Collision Detection ​

3.1 Broadphase: Dual-Layer Spatial Grid ​

  • Static layer: Contains only STATIC bodies. Rebuilt only when entities are added or removed (markStaticDirty()). Not cleared each frame.
  • Dynamic layer: Contains RIGID and KINEMATIC bodies. Cleared and refilled every frame.
  • Query: getPotentialColliders() merges results from both layers (per cell), with deduplication via Actor::queryId.
  • Config: SPATIAL_GRID_CELL_SIZE (default 32px), SPATIAL_GRID_MAX_STATIC_PER_CELL (12), SPATIAL_GRID_MAX_DYNAMIC_PER_CELL (12). Reduces per-frame cost when many static tiles are present.

3.2 Narrowphase: Shape Interactions ​

InteractionAlgorithm
AABB vs AABBSAT (Separating Axis Theorem)
Circle vs CircleDistance check with vertical fallback for perfect overlap
Circle vs AABBClosest point clamping

3.3 Contact Generation and Pool ​

  • Contacts are stored in a fixed-size array (PHYSICS_MAX_CONTACTS, default 128). No heap allocation in the hot path.
  • When the contact count would exceed the maximum, additional contacts are dropped (no crash). Tune PHYSICS_MAX_CONTACTS in EngineConfig.h if needed.
  • Each contact carries isSensorContact = true when either body is a sensor; these are skipped in the velocity and penetration solvers.

3.4 Contact Restitution ​

When a contact is generated, the solver pre-calculates the restitution coefficient:

cpp
// Combined restitution
if (a->isBounce() && b->isBounce()) {
    contact.restitution = min(a->getRestitution(), b->getRestitution());
} else {
    contact.restitution = 0.0f; // No bounce if either body is absorbent
}

Note (v1.2.0+): The bounce property now defaults to true (previously false). Use setBounce(false) to disable bounce behavior. The property uses packed flags internally for memory efficiency.


4. The Solver ​

4.1 Velocity Solver (Impulse-Based) ​

cpp
// Sequential impulse solver (2 iterations)
for (int iter = 0; iter < 2; iter++) {
    for (auto& contact : contacts) {
        // Calculate relative velocity along normal
        Vector2 rv = bodyA->velocity - bodyB->velocity;
        Scalar vn = rv.dot(contact.normal);
        
        // Only resolve approaching velocities
        if (vn > 0) continue;
        
        // Apply velocity threshold for restitution
        Scalar e = (abs(vn) < VELOCITY_THRESHOLD) ? 0 : contact.restitution;
        
        // Calculate impulse
        Scalar j = -(1 + e) * vn / (invMassA + invMassB);
        
        // Apply to bodies
        bodyA->velocity += contact.normal * j * invMassA;
        bodyB->velocity -= contact.normal * j * invMassB;
    }
}

4.2 Position Integration ​

cpp
// Only for Rigid bodies
body->position += body->velocity * FIXED_DT;

Note: Position integration is done after velocity solver but before penetration correction.

4.3 Penetration Solver (Baumgarte + Slop) ​

cpp
// Skip small penetrations (slop)
if (contact.penetration <= SLOP) continue;

// Baumgarte stabilization: correct only a portion per frame
Scalar correction = (contact.penetration - SLOP) * BIAS;
Vector2 correctionVec = contact.normal * correction / totalInvMass;

// Apply position correction (doesn't affect velocity!)
bodyA->position += correctionVec * invMassA;
bodyB->position -= correctionVec * invMassB;

5. Sensors and One-Way Platforms ​

5.1 Sensors (Triggers) ​

  • PhysicsActor::setSensor(true): The body generates collision events and onCollision() is called, but no impulse and no penetration correction are applied.
  • Use for: collectibles, checkpoints, damage zones, area triggers.
  • SensorActor (include physics/SensorActor.h): A StaticActor subclass that calls setSensor(true) in the constructor.
  • In onCollision, you can check other->isSensor() to distinguish triggers from solid bodies.

5.2 One-Way Platforms ​

  • PhysicsActor::setOneWay(true): The body blocks only when the other body is "landing from above". The validation uses spatial crossing detection to determine approach direction:
    • Checks if the collision normal points upward (actor above platform)
    • Verifies the actor crossed the platform surface from above using previousPosition
    • Confirms the actor is moving downward or stationary
    • Rejects horizontal collisions (prevents getting stuck on platform edges/corners)
  • Contacts from below (e.g. jumping through the platform) are rejected.
  • Use for: platforms the player can jump through from below and land on from above.
  • Applies in both discrete narrowphase and in the CCD path (swept circle vs static AABB).
  • Implementation: Uses CollisionSystem::validateOneWayPlatform() which compares PhysicsActor::previousPosition with current position to detect spatial crossing. The validation includes a tolerance check (abs(normal.y) < 0.1) to reject side collisions.

Algorithm: validateOneWayPlatform() ​

cpp
bool validateOneWayPlatform(PhysicsActor* actor, PhysicsActor* platform, 
                            const Vector2& collisionNormal) {
    // 1. Must be marked as one-way
    if (!platform->isOneWay()) return true;
    
    // 2. Normal must be mostly vertical (reject side collisions)
    Scalar absNormalY = abs(collisionNormal.y);
    if (absNormalY < 0.1) return false;
    
    // 3. Normal must point UP (push actor upward)
    if (collisionNormal.y >= 0) return false;
    
    // 4. Check spatial crossing: was actor above platform?
    Scalar platformTop = platform->getHitBox().position.y;
    Scalar previousBottom = actor->getPreviousPosition().y + actor->height;
    Scalar currentBottom = actor->position.y + actor->height;
    
    bool crossedFromAbove = (previousBottom <= platformTop) && 
                           (currentBottom >= platformTop);
    
    // 5. Actor must be moving down or stationary
    bool movingDown = actor->getVelocity().y >= 0;
    
    return crossedFromAbove && movingDown;
}

Test Coverage: test/unit/test_collision_system/test_collision_system.cpp includes:

  • test_one_way_platform_crossing_from_above
  • test_one_way_platform_crossing_from_below
  • test_one_way_platform_wrong_normal_direction
  • test_one_way_platform_moving_upward
  • test_one_way_platform_large_delta_movement
  • test_one_way_platform_velocity_sign_change
  • test_one_way_platform_stationary_on_surface
  • test_one_way_platform_not_one_way

5.3 Sensors and Kinematic Bodies ​

Sensors interact with Kinematic bodies differently than solid bodies:

KinematicActor + Sensor ​

  • Sensors do not block kinematic movement: KinematicActor::moveAndCollide() and moveAndSlide() skip isSensor() bodies with continue.
  • Overlap still triggers onCollision(): When a kinematic body overlaps a sensor, the collision system generates a contact and fires the callback, but no position correction or velocity response is applied.
  • This allows sensors to detect overlap (collectibles, triggers) without physically blocking the player.
cpp
// In KinematicActor::moveAndCollide():
for (auto& physOther : potentialColliders) {
    if (physOther->isSensor()) {
        continue;  // Sensors do not block kinematic movement
    }
    // ... collision resolution for solid bodies
}

Pattern: Sensor in onCollision ​

cpp
void PlayerActor::onCollision(Actor* other) override {
    if (other->isSensor()) {
        // Check userData for tile-based sensors
        if (other->getUserData()) {
            uintptr_t packed = reinterpret_cast<uintptr_t>(other->getUserData());
            uint16_t tx, ty;
            TileFlags flags;
            unpackTileData(packed, tx, ty, flags);
            
            if (flags & TILE_COLLECTIBLE) {
                // Collect item (sensor blocks nothing, so player passes through)
                collectItem(tx, ty);
            }
            if (flags & TILE_DAMAGE) {
                // Take damage (player continues moving)
                takeDamage();
            }
        }
    }
}

6. Tile Attributes (Physics) ​

For tile-based colliders, the engine provides physics/TileAttributes.h with two APIs:

  • TileFlags: Bit flags (TILE_NONE, TILE_SOLID, TILE_SENSOR, TILE_DAMAGE, TILE_COLLECTIBLE, TILE_ONEWAY, TILE_TRIGGER). One byte per tile; no strings at runtime.
  • packTileData(x, y, flags) / unpackTileData(packed, x, y, flags): Encode tile coords (10+10 bits) and flags (8 bits) into a single value for setUserData().
  • TileBehaviorLayer: Struct holding data (dense uint8_t array), width, height. Exported by the Tilemap Editor; use with getTileFlags(layer, x, y) for O(1) lookup with bounds checking.
  • isSensorTile(flags) / isOneWayTile(flags) / isSolidTile(flags): Derive sensor/one-way/solid from flags when building StaticActor or SensorActor.

Builder workflow: For each tile with flags != TILE_NONE, create StaticActor or SensorActor, call setSensor(isSensorTile(flags)), setOneWay(isOneWayTile(flags)), and setUserData(reinterpret_cast<void*>(packTileData(tx, ty, flags))), then scene.addEntity(...).

6.2 Legacy behavior enum ​

  • TileCollisionBehavior: SOLID, SENSOR, ONE_WAY_UP, DAMAGE, DESTRUCTIBLE.
  • packTileData(x, y, behavior) / unpackTileData(..., behavior): Same encoding with 4-bit behavior (deprecated for new code).

6.3 Consumible tiles (Phase 7) ​

When a tile is consumed (e.g. coin collected), remove its body and hide it visually:

  1. scene.removeEntity(tileActor) so the CollisionSystem no longer considers it.
  2. tilemap->setTileActive(tileX, tileY, false) so drawTileMap skips it (reuses runtimeMask; no separate consumed mask).

physics/TileConsumptionHelper.h wraps this: TileConsumptionHelper (constructor: scene, tilemap, config) provides consumeTile(tileActor, tileX, tileY) and consumeTileFromUserData(tileActor, packedUserData) (only consumes if TILE_COLLECTIBLE). Convenience consumeTileFromCollision(tileActor, packedUserData, scene, tilemap) for use inside onCollision. Destruction and damage logic remain in game code using getUserData() and flags.

6.4 TileCollisionBuilder (High-Level API) ​

Include: physics/TileCollisionBuilder.h

The TileCollisionBuilder class provides a high-level builder that generates physics bodies from a TileBehaviorLayer with a single function call. This is the recommended way to populate physics for tilemap-based levels.

Configuration ​

cpp
struct TileCollisionBuilderConfig {
    uint8_t tileWidth;      // Tile width in world units (e.g., 16)
    uint8_t tileHeight;     // Tile height in world units (e.g., 16)
    uint16_t maxEntities;   // Safety limit (0xFFFF = unlimited)
    
    TileCollisionBuilderConfig(uint8_t w = 16, uint8_t h = 16, uint16_t max = 0xFFFF);
};

Workflow ​

Tilemap Editor Export
    ↓
TileBehaviorLayer { data: uint8_t[], width, height }
    ↓
TileCollisionBuilder::buildFromBehaviorLayer()
    ↓
For each tile (x, y) with flags != 0:
    ├── O(1) lookup: getTileFlags(layer, x, y)
    ├── Create body: isSensorTile(flags) ? SensorActor : StaticActor
    ├── Configure: setSensor(), setOneWay()
    ├── Pack data: setUserData(packTileData(x, y, flags))
    ├── Set layers: setCollisionLayer(), setCollisionMask()
    └── Add to scene: scene.addEntity()

Usage ​

cpp
#include "physics/TileCollisionBuilder.h"

void GameScene::init() override {
    // Layer exported by Tilemap Editor
    TileBehaviorLayer layer = { behaviorData, 32, 32 };
    
    // One-liner
    int count = buildTileCollisions(*this, layer, 16, 16, 0);
    
    // Or explicit config
    TileCollisionBuilderConfig config(16, 16, 2048);
    TileCollisionBuilder builder(*this, config);
    int entities = builder.buildFromBehaviorLayer(layer, 0);
}

Memory Considerations ​

  • Each created actor is a heap allocation (new StaticActor / new SensorActor).
  • A 32×32 tilemap with every tile solid = 1024 bodies.
  • Call scene.clearEntities() before rebuilding to avoid duplicates.
  • On ESP32 with limited DRAM, consider using maxEntities as a safety limit.

7. Actor Types ​

7.1 RigidActor ​

  • Fully simulated: gravity, forces, collisions
  • Position integrated by CollisionSystem
  • Supports both CIRCLE and AABB shapes
  • Use for: Balls, props, debris

7.2 StaticActor ​

  • Immovable, infinite mass
  • Participates in collisions but never moves
  • Use for: Walls, floors, platforms

7.3 KinematicActor ​

  • Moved by game logic, not physics
  • Participates in collisions (pushes Rigid actors)
  • Use for: Player, moving platforms
  • Properly detected in broadphase vs Rigid

8. Continuous Collision Detection (CCD) ​

8.1 When It Activates ​

CCD is used only when necessary:

cpp
bool needsCCD(PhysicsActor* body) {
    // Only for circles
    if (body->getShape() != CIRCLE) return false;
    
    // Activate when: velocity * dt > radius * 3
    Scalar speed = body->getVelocity().length();
    Scalar movement = speed * FIXED_DT;
    Scalar threshold = body->getRadius() * CCD_THRESHOLD;
    
    return movement > threshold;
}

8.2 Swept Test Algorithm ​

cpp
// Simple swept circle vs AABB
// Samples 2-8 positions along movement vector
// Returns collision time and normal
bool sweptCircleVsAABB(circle, box, outTime, outNormal);

Use case: Prevents tunneling when ball moves extremely fast (> 3x radius per frame).


9. Configuration ​

9.1 Physics Constants ​

Tune in CollisionSystem.h or override via platforms/EngineConfig.h / build flags (e.g. -D PHYSICS_MAX_CONTACTS=64):

cpp
// Contact pool size (fixed array, no heap)
#define PHYSICS_MAX_CONTACTS 128

// Spatial grid: static = rebuilt when entities change; dynamic = per frame
#define SPATIAL_GRID_MAX_STATIC_PER_CELL  12
#define SPATIAL_GRID_MAX_DYNAMIC_PER_CELL 12

ESP32 DRAM: On boards with limited internal RAM, reducing PHYSICS_MAX_CONTACTS and PHYSICS_MAX_PAIRS (e.g. to 64) and/or SPATIAL_GRID_MAX_STATIC_PER_CELL and SPATIAL_GRID_MAX_DYNAMIC_PER_CELL (e.g. to 4) lowers .dram0.bss usage. See Memory Management Guide.

Solver tuning (in code):

cpp
// For more stable stacking (slower)
static constexpr int VELOCITY_ITERATIONS = 4;  // Default: 2
static constexpr Scalar BIAS = toScalar(0.3f); // Default: 0.2

// For looser collision (faster)
static constexpr Scalar SLOP = toScalar(0.05f); // Default: 0.02

Note: These constants are only compiled when PIXELROOT32_ENABLE_PHYSICS=1.

Note: These constants are only compiled when PIXELROOT32_ENABLE_PHYSICS=1.

9.2 Per-Actor Properties ​

cpp
// Restitution (bounciness): 0.0 to 1.0+
physicsActor->setRestitution(toScalar(1.0f));  // Perfect bounce

// Friction: 0.0 (none) to 1.0 (high)
physicsActor->setFriction(toScalar(0.0f));

// Gravity scale: 0.0 (no gravity) to 1.0+ (heavy)
physicsActor->setGravityScale(toScalar(0.0f)); // No gravity

// Shape
physicsActor->setShape(CollisionShape::CIRCLE);
physicsActor->setRadius(toScalar(6));

10. Performance Guide ​

10.1 ESP32-C3 (Non-FPU) ​

  • Target: < 20 dynamic bodies @ 60 FPS
  • Use AABB over Circle when possible (cheaper)
  • CCD has overhead: Only triggers when needed
  • Slop helps: Skip unnecessary corrections
  • Memory Impact: Physics system disabled saves ~12KB RAM

Modular Compilation: On memory-constrained platforms, consider disabling physics entirely with PIXELROOT32_ENABLE_PHYSICS=0 and using simple AABB checks instead.

10.2 ESP32 (With FPU) ​

  • Target: < 50 dynamic bodies @ 60 FPS
  • Circles are fine
  • Can increase VELOCITY_ITERATIONS to 4 for better stability
  • Memory Impact: Physics system disabled saves ~12KB RAM

11. Migration from v0.8.x / v0.9.0 ​

11.1 Key Changes ​

Old (v0.8.x)New
Position integrated in Actor::update()Position integrated in CollisionSystem::integratePositions()
Relaxation-based solverImpulse-based velocity solver + Baumgarte position solver
PHYSICS_RELAXATION_ITERATIONSVELOCITY_ITERATIONS (default: 2)
No CCDCCD for fast circles
Kinematic vs Rigid detection brokenFixed and working
Variable timestepFixed timestep (1/60s)

11.2 Code Changes Required ​

Before:

cpp
void RigidActor::update(unsigned long dt) {
    // Integrated position here
    position += velocity * dt;
}

After:

cpp
void RigidActor::update(unsigned long deltaTime) {
    // Only integrate velocity (forces)
    // Position handled by CollisionSystem
    integrate(CollisionSystem::FIXED_DT);
}

11.3 Behavior Differences ​

  1. More stable stacking: Impulse solver handles multiple contacts better
  2. Perfect elastic collisions: Restitution 1.0 actually works now
  3. No more sticking: Proper separation of velocity/position phases
  4. Deterministic: Same inputs always produce same outputs

12. Best Practices ​

  1. Always set shape: setShape(CollisionShape::CIRCLE) or AABB
  2. Set radius for circles: setRadius(toScalar(r)) (critical for CCD)
  3. Use collision layers: Don't rely on expensive broadphase checks
  4. Keep callbacks light: onCollision() should only notify, not modify physics
  5. Test on target hardware: Physics feels different on ESP32-C3 vs PC
  6. Consider modular compilation: For simple games, disabling physics (PIXELROOT32_ENABLE_PHYSICS=0) and using basic AABB checks can save significant RAM and firmware size

References ​

Released under the MIT License.