12/02/25
Building a JS Game Loop: A Practical Guide
Game development in the browser has come a long way, and at the heart of smooth, performant games is the requestAnimationFrame API. In this post, we'll explore how to build a proper game loop using requestAnimationFrame, complete with frame rate limiting and state management—all based on a real working example.
Why requestAnimationFrame?
Before we dive in, let's understand why requestAnimationFrame is the go-to choice for browser-based games:
- Optimized for animations: The browser calls your callback before the next repaint, ensuring smooth visuals
- Automatic pausing: When the tab is hidden,
requestAnimationFramepauses, saving CPU and battery - 60 FPS target: Syncs with the display's refresh rate (typically 60Hz)
- Better than
setInterval: More precise timing and better performance
The Game Loop Class
Let's start by building a reusable GameLoop class that handles the frame timing logic:
class GameLoop {
private lastFrameTime = 0;
private FPS = 30;
private FrameInterval = 1000 / this.FPS;
private render: Function;
private isDone = false;
constructor(renderCallback: Function) {
if (!window) throw new Error('global window object is required but not found');
this.render = renderCallback;
}
run() {
window.requestAnimationFrame(this.loop.bind(this));
}
stop() {
this.isDone = true;
}
loop(currentTime: number) {
if (this.isDone) return;
this.run();
const deltaTime = currentTime - this.lastFrameTime;
if (deltaTime >= this.FrameInterval) {
this.lastFrameTime = currentTime - (deltaTime % this.FrameInterval);
this.render();
}
}
}
Breaking Down the Loop
The Constructor: Takes a callback function that will handle your game's rendering and logic each frame.
The run() Method: Kicks off the animation loop by calling requestAnimationFrame, which schedules the loop method to run before the next repaint.
The loop() Method: This is where the magic happens. requestAnimationFrame automatically passes a currentTime timestamp to your callback. We use this to:
- Check if we should stop: The
isDoneflag lets us cleanly exit the loop - Schedule the next frame: We recursively call
run()to keep the loop going - Limit frame rate: Calculate
deltaTimeto ensure we only render at our target FPS
Frame Rate Limiting with Delta Time
You might wonder: "Why limit the frame rate if requestAnimationFrame already targets 60 FPS?"
Great question! Here's why delta time is crucial:
const deltaTime = currentTime - this.lastFrameTime;
if (deltaTime >= this.FrameInterval) {
this.lastFrameTime = currentTime - (deltaTime % this.FrameInterval);
this.render();
}
This pattern:
- Gives you control over game speed: A 30 FPS game runs at the same speed on 60Hz and 144Hz displays
- Ensures consistency: Your game logic executes at predictable intervals
- Prevents over-rendering: Saves resources by only rendering when needed
The modulo operation (deltaTime % this.FrameInterval) carries over any excess time, preventing time drift over long sessions.
Putting It to Use: A Complete Game Example
Here's how you'd use the GameLoop to power an actual game:
function setup(canvas: HTMLCanvasElement) {
let obstacles: Position[] = [];
let isGameOver = false;
let state = {
direction: '',
position: {
x: canvas.width / 2 - 1,
y: canvas.height - 10,
w: 10,
h: 10
},
score: 0,
speed: 3
};
const ctx = canvas.getContext('2d');
const gl = new GameLoop(function() {
// Update game state
if (state.direction === 'right' && state.position.x < canvas.width - 10) {
state.position.x += state.speed;
}
if (state.direction === 'left' && state.position.x >= 0) {
state.position.x -= state.speed;
}
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Render everything
drawPlayer(ctx);
moveObstacles(ctx);
drawObstacles(ctx);
writeScore(ctx);
// Update score
state.score += 1;
// Check game over
if (isGameOver) {
finish(ctx);
gl.stop();
}
});
gl.run();
}
The Game Loop Pattern
Every frame, our callback follows this pattern:
- Update State: Move the player based on input
- Clear Canvas: Wipe the previous frame
- Render Everything: Draw player, obstacles, UI elements
- Update Game Logic: Increment score, check collisions
- Check Exit Conditions: Stop the loop if the game is over
Integrating with React
In a React application, you'll typically initialize your game in a useEffect hook:
export default function Game() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!ref.current) return;
setup(ref.current);
}, []);
return (
<div className="game_wrapper">
<canvas ref={ref} id="game_container" />
</div>animate
);
}
The empty dependency array ensures setup only runs once when the component mounts.
Key Takeaways
- Use
requestAnimationFramefor smoother animations that sync with the display - Implement delta time to control frame rate and ensure consistent game speed
- Bind your callback properly with
.bind(this)to maintain context - Provide a stop mechanism for clean game-over states and memory management
- Separate concerns: Keep your game loop logic separate from your rendering logic
Beyond the Basics
This game loop can be extended with:
- Variable time steps: Use delta time to smooth movement on different frame rates
- Fixed time step with interpolation: For physics-heavy games
- Performance monitoring: Track actual FPS vs target FPS
- Pause/resume functionality: Add a paused state that skips rendering
The requestAnimationFrame API is powerful yet simple. With a solid game loop foundation, you can build everything from simple arcade games to complex simulations—all running smoothly in the browser.
And if you want to see it in action, I wrote a small arcade game, which you can play on the homepage of my blog. Just click the videogame controller.
Happy coding!
