Skip to content

Game Loop ​

The game loop is the core mechanism that drives your game forward. Understanding its structure and timing is essential for building performant, stable games.

The Loop Structure ​

PixelRoot32 uses a traditional game loop with fixed phases:

Each phase has specific responsibilities and timing constraints.

Simplified per-frame sequence ​

Phase Breakdown ​

1. Input Phase ​

cpp
void Engine::update() {
    inputManager.update();  // Poll button states
    
    #if PIXELROOT32_ENABLE_TOUCH
    // Poll touch manager if attached
    if (touchManager) {
        // Process touch points, generate events
    }
    #endif
}

The input phase captures the current state of:

  • Digital buttons: GPIO pin states
  • Touch events: Screen touches (if enabled)
  • Simulated input: Mouse-to-touch mapping (native builds)

Input is sampled once per frame. All subsequent phases work with this snapshot.

2. Update Phase ​

cpp
void Scene::update(unsigned long deltaTime) {
    // Update all enabled entities
    for (int i = 0; i < entityCount; ++i) {
        if (entities[i]->isEnabled) {
            entities[i]->update(deltaTime);
        }
    }
}

The update phase runs game logic:

  • AI behavior: Enemy pathfinding, decision making
  • Animation: Advance animation frames
  • Game state: Score tracking, timer updates
  • Movement preparation: Calculate desired velocity

Frame rate independence — Always use deltaTime for time-based calculations:

cpp
// Bad: Frame-rate dependent
position.x += 5;  // Moves 5px/frame (300px/s at 60fps, 150px/s at 30fps)

// Good: Frame-rate independent  
position.x += speed * deltaTime / 1000.0f;  // Consistent speed in px/sec

3. Physics Phase ​

cpp
#if PIXELROOT32_ENABLE_PHYSICS
    collisionSystem.process(deltaTime);
#endif

Physics processing includes:

  • Broadphase: Spatial grid collision queries
  • Narrowphase: AABB intersection tests
  • Resolution: Position/velocity corrections
  • Callbacks: onCollision() notifications

Physics runs at a fixed timestep (typically 1/60s) for stability:

4. Draw Phase ​

cpp
void Renderer::beginFrame() {
    // Clear framebuffer (full or selective with dirty regions if enabled)
    // drawer->clear();  // Full clear
    // drawer->selectiveClear();  // Dirty region selective clear
}

void Scene::draw(Renderer& renderer) {
    // Sort entities by render layer if needed
    if (needsSorting) sortEntities();
    
    // Draw visible entities
    for (int i = 0; i < entityCount; ++i) {
        if (entities[i]->isVisible && isVisibleInViewport(entities[i], renderer)) {
            entities[i]->draw(renderer);
        }
    }
}

Rendering happens in layers (0 to N, back to front):

5. Present Phase ​

cpp
void Renderer::endFrame() {
    // Draw dirty cell debug overlay if enabled
    // drawer->drawDebugOverlay();
    
    drawer->present();  // Send to display
}

On ESP32 with TFT_eSPI:

  • DMA transfer: Non-blocking SPI transmission
  • Double buffering: Prepare next block while sending current
  • Scaling: Logical to physical resolution conversion

Timing and Delta Time ​

What is Delta Time? ​

deltaTime is the milliseconds elapsed since the last frame:

cpp
unsigned long currentMillis = getMillis();
deltaTime = currentMillis - previousMillis;
previousMillis = currentMillis;

Using Delta Time Correctly ​

cpp
class Player : public KinematicActor {
    math::Scalar speed = math::toScalar(100);  // 100 pixels/second
    
    void update(unsigned long deltaTime) override {
        // Convert deltaTime to seconds
        math::Scalar dt = math::toScalar(deltaTime) / math::toScalar(1000);
        
        // Calculate movement
        math::Vector2 velocity;
        if (input.isButtonPressed(ButtonName::LEFT)) {
            velocity.x = -speed;
        } else if (input.isButtonPressed(ButtonName::RIGHT)) {
            velocity.x = speed;
        }
        
        // Move with collision
        moveAndSlide(velocity * dt, deltaTime);
    }
};

Fixed Timestep vs Variable Timestep ​

ApproachProsConsUse Case
VariableSimple, adapts to performancePhysics can be unstableGeneral game logic
FixedDeterministic, stable physicsMay need interpolationPhysics simulation

PixelRoot32 uses fixed timestep for physics and variable for game logic:

cpp
// Fixed timestep accumulator
static constexpr unsigned long PHYSICS_STEP_MS = 16;  // ~60fps
unsigned long physicsAccumulator = 0;

void update(unsigned long deltaTime) {
    physicsAccumulator += deltaTime;
    
    // Run physics at fixed rate
    while (physicsAccumulator >= PHYSICS_STEP_MS) {
        collisionSystem.process(PHYSICS_STEP_MS);
        physicsAccumulator -= PHYSICS_STEP_MS;
    }
    
    // Game logic runs every frame (variable)
    processInput();
    updateAnimations(deltaTime);
}

Frame Rate Management ​

Understanding Frame Timing ​

Frame Limiting ​

The engine can limit frame rate to save power or maintain consistency:

cpp
// In main loop
unsigned long frameTime = getMillis() - frameStart;
if (frameTime < TARGET_FRAME_TIME) {
    delay(TARGET_FRAME_TIME - frameTime);
}

Adaptive Quality ​

Adjust quality based on performance:

cpp
void update(unsigned long deltaTime) {
    // Track average frame time
    avgFrameTime = (avgFrameTime * 0.9f) + (deltaTime * 0.1f);
    
    // Reduce particle count if struggling
    if (avgFrameTime > 20) {  // Below 50fps
        maxParticles = 50;
    } else {
        maxParticles = 100;
    }
}

Life cycle Methods ​

Understanding when methods are called helps organize code:

Method Call Timing ​

MethodCalled WhenFrequency
Engine::init()After constructionOnce
Scene::init()When scene becomes activeOnce per entry
Scene::update()Every frame60 times/second (target)
Scene::draw()Every frame60 times/second (target)
Entity::update()By Scene::update()60 times/second
Entity::draw()By Scene::draw()60 times/second

Best Practices ​

Do ​

  • ✅ Use deltaTime for all time-based calculations
  • ✅ Keep update logic under 16ms (for 60fps)
  • ✅ Pre-allocate resources in init(), not in the loop
  • ✅ Use object pooling for frequently created/destroyed objects
  • ✅ Profile with PIXELROOT32_ENABLE_DEBUG_OVERLAY

Don't ​

  • ❌ Allocate memory in update() or draw()
  • ❌ Perform file I/O in the game loop
  • ❌ Ignore deltaTime (causes frame-rate dependency)
  • ❌ Do heavy calculations every frame if unnecessary
  • ❌ Block the loop with delay() for long periods

Debugging the Loop ​

Enable the debug overlay to see real-time metrics:

ini
build_flags =
    -DPIXELROOT32_ENABLE_DEBUG_OVERLAY=1

This displays:

  • FPS: Current frame rate
  • CPU%: Estimated CPU load
  • RAM: Free heap memory

Next Steps ​

Released under the MIT License.