Understanding Game Loops in Game Development
A game loop is the heartbeat of every game engine, orchestrating the continuous cycle of processing input, updating game state, and rendering frames. It's the fundamental mechanism that determines how your game runs, responds to player input, and maintains smooth gameplay.
In this article, we'll explore the intricacies of implementing game loops in TypeScript, focusing on advanced patterns like fixed timestep updates , efficient frame timing , and crucial performance optimizations that will help you build professional-grade games.
Core Game Loop Concepts
A typical game loop consists of three primary phases, which repeat over and over:
- Process input
- Update game state
- Render the current game state then repeat.
Let's dive into what happens in each phase:
Process Input : Input processing involves checking the current state of:
- Keyboard keys (pressed/released)
- Mouse position and buttons
- Gamepad inputs
- Touch events
- Any other input devices
Update Game Physics : The physics update step:
- Updates object positions based on velocity
- Applies forces or acceleration
- Checks for collisions
- Resolves physics interactions
- Updates game state based on input
Render Frame : The render phase:
- Clears the previous frame
- Draws the game world
- Renders game objects
- Applies visual effects
- Updates the display
The Naive Approach: While Loop
Let's start with the simplest possible implementation - a while loop:
function naiveGameLoop() {
while (true) {
processInput();
updateGamePhysics();
render();
}
}
This approach has several critical problems:
- The main thread gets blocked, causing the browser to freeze : Because the javascript engine is single-threaded, the while loop will not allow the main thread to perform other tasks.
- No consistent timing between updates : The loop runs as fast as possible, without any control over the game speed.
- Different devices will run at different speeds : Different devices may run at different speeds, leading to inconsistent timing between updates.
The Recursive Approach
Another attempt might be to use recursion for the game loop:
function recursiveGameLoop() {
processInput();
updateGamePhysics();
render();
// Call itself for the next frame
recursiveGameLoop();
}
This approach has the same problems as the while loop approach, with the following additional issues:
Each recursive call adds a new frame to the call stack : Each recursive call adds a new frame to the call stack, which can lead to a stack overflow after few frames.
The game will crash after few seconds with the following error: "Maximum call stack size exceeded"
Enter requestAnimationFrame
Neither the while loop nor recursive approach provides the control we need for a proper game loop. We need a way to:
- Control frame timing precisely
- Avoid stack overflow issues
- Keep the browser responsive
Is there any built-in way to create an optimized recursive loop in the browser?
Yes, there is and it's called requestAnimationFrame
. This browser API is specifically designed for smooth animations and game loops.
function betterGameLoop() {
function loop() {
processInput();
updateGamePhysics();
render();
// Browser handles timing and throttling
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
}
Using requestAnimationFrame
gives us several immediate benefits:
- The browser optimizes the timing of our animations
- The loop pauses automatically when switching tabs
- No more stack overflow or browser freezing
- Smoother animations by syncing with the screen refresh rate
Performance Optimization
While requestAnimationFrame
solves our initial problems, this implementation still has important issues:
- No control over game speed across different devices
- Physics calculations are tied to frame rate
- Inconsistent time steps between updates
- No way to handle performance drops gracefully
To address these challenges, we'll implement a robust game loop with fixed timestep updates. Let's break down the implementation piece by piece.
The GameLoop Class Implementation
Our GameLoop
class uses the Singleton pattern and implements a fixed timestep game loop. This ensures consistent physics updates across different devices while maintaining smooth rendering. Let's examine each component:
Core Properties and State Management
At the heart of our game loop, we need to track various timing-related states:
export class GameLoop {
private lastRequestId?: number;
private isRunning: boolean = false;
public lastTimestamp: number = 0;
private deltaTime: number = 0;
private accumulator: number = 0;
public gameStartTime: number = 0;
}
Each variable serves a specific purpose:
-
lastRequestId
: Stores the animation frame ID for cleanup when stopping the loop -
isRunning
: Controls the game loop state (running/stopped) -
lastTimestamp
: Records the previous frame's time for delta calculations -
deltaTime
: Time elapsed since last frame, used for time-based updates -
accumulator
: Tracks leftover time for fixed timestep updates -
gameStartTime
: Records when the game started for total time tracking
Together, these variables ensure smooth gameplay timing and proper game loop control.
Frame Rate Control
To ensure consistent performance across different devices, we implement FPS boundaries:
// FPS settings
private readonly DEFAULT_FPS = 60;
private readonly MIN_FPS = 20;
private readonly MAX_FPS = 144;
private targetFps: number = this.DEFAULT_FPS;
These settings provide a balance between smooth gameplay and performance constraints, with 60 FPS being the sweet spot for most games.
Singleton Pattern Implementation
private static instance: GameLoop;
private constructor() {}
public static getInstance(): GameLoop {
if (!this.instance) {
this.instance = new GameLoop();
}
return this.instance;
}
To ensure that only one instance of the game loop exists, we use the Singleton pattern.
The getInstance
method ensures that there is only one instance of the game loop, making it a singleton.
The constructor is made private to prevent direct instantiation, ensuring that only one instance exists.
Time Management System
The timing system provides crucial calculations for frame timing and game duration:
public get time(): number {
return this.lastTimestamp - this.gameStartTime;
}
private get targetFrameTime(): number {
return 1000 / this.targetFps;
}
private get maxDeltaTime(): number {
return 1000 / this.MIN_FPS;
}
Let's look at what each getter does:
-
time
: Returns the total elapsed time since game start in milliseconds. -
targetFrameTime
: Calculates the ideal time per frame (e.g., 16.67ms for 60 FPS). -
maxDeltaTime
: The maximum allowed delta time per frame (50ms at 20 FPS).
Frame Time Calculation
private calculateDeltaTime(timestamp: number): number {
const deltaTime = timestamp - this.lastTimestamp;
this.lastTimestamp = timestamp;
return Math.min(deltaTime, this.maxDeltaTime);
}
Delta time represents how long it took to render the previous frame. This is crucial for two reasons:
- On a fast device running at 120 FPS, each frame takes about 8ms
- On a slower device running at 30 FPS, each frame takes about 33ms
Without delta time , game objects would move 4 times faster on the 120 FPS device! By multiplying movement by delta time, we ensure consistent speed across all devices.
However, we need to cap the delta time to handle extreme cases( Spiral of Death ). Consider this scenario:
- Player is moving their character forward at 100 pixels per second
- Player switches to another browser tab for 5 seconds
- When they return to the game tab:
- Uncapped delta time: 5000ms × 100 pixels/second = character teleports 500 pixels forward!
- Capped delta time (50ms): Maximum movement is 5 pixels per frame, preventing the teleport
This is why we use Math.min(deltaTime, this.maxDeltaTime)
- it ensures smooth gameplay even after interruptions.
Why "Spiral of Death"? Imagine a game starting to lag. Without a cap, the lag makes objects move too far, which causes more lag, which makes them move even further... Like a spiral that keeps getting bigger until the game crashes. That's why we cap it!
Fixed Timestep Update System
private updateGameLogic(update: (dt: number) => void) {
this.accumulator += this.deltaTime;
if (this.accumulator > this.maxDeltaTime) {
this.accumulator = this.maxDeltaTime;
}
const NumberOfUpdates = Math.floor(this.accumulator / this.targetFrameTime);
for (let i = 0; i < NumberOfUpdates; i++) {
update(this.targetFrameTime / 1000);
this.accumulator -= this.targetFrameTime;
}
}
This method uses an accumulator pattern to handle time. Here's how it works:
First, we add the frame's
deltaTime
to ouraccumulator
:We cap the accumulator to prevent the spiral of death:
We calculate how many updates we need to perform:
Finally, we perform the updates in fixed steps:
The accumulator ensures we never lose time: any remainder is carried over to the next frame, maintaining perfect timing.
Main Loop Implementation
public start(update: (dt: number) => void, render: () => void) {
this.isRunning = true;
this.lastTimestamp = performance.now();
this.gameStartTime = this.lastTimestamp;
const loop = (timestamp: number) => {
if (!this.isRunning) return;
this.lastRequestId = requestAnimationFrame(loop);
this.deltaTime = this.calculateDeltaTime(timestamp);
this.updateGameLogic(update);
render();
};
requestAnimationFrame(loop);
}
The start
method takes two callbacks: update
for game logic and physics, and render
for drawing the game. The update callback receives delta time in seconds, while render is called after each update.
First, we initialize our timing system:
this.isRunning = true;
this.lastTimestamp = performance.now();
this.gameStartTime = this.lastTimestamp;
Then we define our core game loop. It first checks if the game is still running and schedules the next frame immediately:
const loop = (timestamp: number) => {
if (!this.isRunning) return;
this.lastRequestId = requestAnimationFrame(loop);
Next, we calculate how much time passed since the last frame using our delta time system:
this.deltaTime = this.calculateDeltaTime(timestamp);
Finally, we update game logic at a fixed timestep and render the frame:
this.updateGameLogic(update); // Fixed timestep physics
render(); // Smooth visual updates
By requesting the next animation frame early, we give the browser more time to prepare, improving performance.
Loop Control Interface
public stop() {
if (!this.lastRequestId) return;
this.isRunning = false;
cancelAnimationFrame(this.lastRequestId);
this.lastRequestId = undefined;
this.gameStartTime = 0;
}
setTargetFPS(fps: number) {
this.targetFps = Math.min(Math.max(fps, this.MIN_FPS), this.MAX_FPS);
}
The stop()
method safely shuts down the game loop:
- Checks if the loop is actually running
- Cancels the next animation frame
- Resets game timing variables
The setTargetFPS()
method controls game speed:
- Clamps FPS between 20 (MIN_FPS) and 144 (MAX_FPS)
- Example:
setTargetFPS(30)
for slower devices - Example:
setTargetFPS(144)
for high-end displays
Putting It All Together
Our GameLoop
class creates a robust game engine through several key components:
Time Management :
Fixed Physics Updates :
Render vs Update Calls :
The result is a game loop that maintains perfect timing while adapting to any device's capabilities.
Conclusion
Understanding game loops is crucial for building performant games that provide consistent experiences across different devices. We've covered everything from basic implementations to advanced concepts like fixed timestep updates and delta time handling. These patterns will help you create smooth, professional-grade games in TypeScript.
I learned about and implemented these concepts while building a Flappy Bird clone during a live coding session. If you'd like to see these concepts in action and learn more about game development, you can watch the implementation here:
- YouTube: Spitha Code Youtube
- Twitch: Spitha Code Twitch
The live stream demonstrates how to apply these game loop concepts in a real project, making it easier to understand their practical applications.
Top comments (0)