Game Loop¶
Understanding Heaps' game loop is fundamental to creating smooth, responsive games. Heaps provides a robust fixed-timestep loop that ensures consistent gameplay across different devices.
The Update Cycle¶
Heaps calls your update()
method at a fixed rate (60 FPS by default):
class Main extends hxd.App {
override function update(dt:Float) {
// Called 60 times per second
// dt = time since last update in seconds
// Usually ~0.016667 (1/60)
}
}
Fixed vs Variable Timestep¶
Heaps uses a fixed timestep with interpolation:
- Logic updates at exactly 60 FPS
- Rendering can run faster (144Hz, etc.)
- Visual interpolation ensures smooth motion
This provides: - ✅ Deterministic physics - ✅ Consistent gameplay - ✅ Smooth visuals on high-refresh displays
Delta Time Usage¶
Always use delta time for movement and animations:
class Player extends h2d.Object {
var speed = 200.0; // pixels per second
public function update(dt:Float) {
// CORRECT: Frame-rate independent
x += speed * dt;
// WRONG: Tied to frame rate
x += 3.33; // Don't do this!
}
}
The Complete Loop¶
Here's what happens each frame:
graph TD
A[Frame Start] --> B[Process Input]
B --> C[Update Logic]
C --> D[Render Scene]
D --> E[Present Frame]
E --> F[Wait for VSync]
F --> A
Customizing the Loop¶
Change Update Rate¶
class Main extends hxd.App {
override function init() {
hxd.Timer.wantedFPS = 120; // 120 FPS logic
// Or for slower games:
hxd.Timer.wantedFPS = 30; // 30 FPS logic
}
}
Separate Update and Render¶
class Main extends hxd.App {
var accumulator = 0.0;
var fixedTimestep = 1.0 / 60.0; // 60 FPS
override function update(dt:Float) {
accumulator += dt;
// Fixed timestep updates
while (accumulator >= fixedTimestep) {
fixedUpdate(fixedTimestep);
accumulator -= fixedTimestep;
}
// Render with interpolation
var alpha = accumulator / fixedTimestep;
render(alpha);
}
function fixedUpdate(dt:Float) {
// Physics, game logic
}
function render(alpha:Float) {
// Visual interpolation
}
}
Performance Monitoring¶
Built-in FPS Counter¶
Custom Performance Metrics¶
class PerformanceMonitor extends h2d.Text {
var updateTime = 0.0;
var renderTime = 0.0;
public function new(parent) {
super(hxd.res.DefaultFont.get(), parent);
}
override function sync(ctx:h2d.RenderContext) {
text = 'Update: ${Math.round(updateTime)}ms
Render: ${Math.round(renderTime)}ms
Draw Calls: ${ctx.drawCalls}
Triangles: ${ctx.triangles}';
super.sync(ctx);
}
}
Frame Timing¶
Measure Update Time¶
class Main extends hxd.App {
var stats : PerformanceStats;
override function update(dt:Float) {
var start = haxe.Timer.stamp();
// Game logic here
gameUpdate(dt);
var elapsed = haxe.Timer.stamp() - start;
stats.recordUpdate(elapsed * 1000); // Convert to ms
}
}
Handle Slow Frames¶
override function update(dt:Float) {
// Clamp delta time to prevent spiral of death
dt = Math.min(dt, 1.0 / 30.0); // Max 33ms
// Update with clamped dt
world.update(dt);
}
Common Patterns¶
Time Accumulator¶
class Timer {
var elapsed = 0.0;
var duration : Float;
var onComplete : Void->Void;
public function new(duration:Float, onComplete:Void->Void) {
this.duration = duration;
this.onComplete = onComplete;
}
public function update(dt:Float) : Bool {
elapsed += dt;
if (elapsed >= duration) {
onComplete();
return true; // Timer finished
}
return false;
}
public function getProgress() : Float {
return Math.min(elapsed / duration, 1.0);
}
}
Animation Controller¶
class AnimationController {
var animations : Map<String, Animation> = [];
var current : Animation;
var time = 0.0;
public function update(dt:Float) {
if (current == null) return;
time += dt;
var frame = Math.floor(time * current.fps);
if (frame >= current.frames.length) {
if (current.loop) {
time = 0;
frame = 0;
} else {
frame = current.frames.length - 1;
onAnimationComplete();
}
}
displayFrame(current.frames[frame]);
}
}
State Machine with Timing¶
class StateMachine {
var states : Map<String, State> = [];
var current : State;
var stateTime = 0.0;
public function update(dt:Float) {
stateTime += dt;
if (current != null) {
current.update(dt, stateTime);
// Check transitions
var next = current.checkTransitions();
if (next != null) {
changeState(next);
}
}
}
function changeState(name:String) {
if (current != null) {
current.exit();
}
current = states.get(name);
stateTime = 0.0;
if (current != null) {
current.enter();
}
}
}
Best Practices¶
1. Consistent Units¶
// Always use the same time units
class Constants {
// Speeds in pixels per second
public static inline var PLAYER_SPEED = 200.0;
public static inline var BULLET_SPEED = 500.0;
// Durations in seconds
public static inline var DASH_DURATION = 0.2;
public static inline var INVINCIBILITY_TIME = 1.0;
}
2. Frame-Rate Independence¶
// Good: Works at any frame rate
player.x += velocity * dt;
rotation += angularVelocity * dt;
timer += dt;
// Bad: Assumes 60 FPS
player.x += 3;
rotation += 0.1;
timer += 1;
3. Update Order¶
override function update(dt:Float) {
// 1. Input
inputManager.update();
// 2. Game logic
player.update(dt);
enemies.update(dt);
// 3. Physics
physics.simulate(dt);
// 4. Late update (camera, etc)
camera.lateUpdate(dt);
// 5. UI updates
ui.update(dt);
}
Debugging the Loop¶
Slow Motion¶
class Debug {
public static var timeScale = 1.0;
}
override function update(dt:Float) {
dt *= Debug.timeScale; // 0.5 for half speed
super.update(dt);
}
Frame Stepping¶
class FrameStepper {
static var paused = false;
static var step = false;
public static function update(dt:Float) : Float {
if (hxd.Key.isPressed(hxd.Key.F5)) {
paused = !paused;
}
if (paused) {
if (hxd.Key.isPressed(hxd.Key.F6)) {
step = true;
}
if (!step) {
return 0.0; // No update
}
step = false;
}
return dt;
}
}
Next Steps¶
Now that you understand the game loop, explore Scene Management → to organize your game states.