Skip to content

Game Loop

This chapter introduces the core loop: update for simulation and render for drawing. We’ll animate a rotating square, scale it over time, and display FPS so you can see the loop in action.

The Engine base class

We extend the shared Engine from the library:

ts
class CH03 extends Engine {
	// ...
}

The engine calls update(deltaTime) and render() on every frame once started.

Engine implementation (from lib, ch03)

This is the Engine class as it exists at the ch03 tag. Later chapters expand it with input, entities, and UI layers.

ts
import { Timer } from './Timer';

type EngineInitOptions = {
	selector: string;
	width?: number;
	height?: number;
	backgroundColor?: string;
};

export class Engine {
	protected timer: Timer = new Timer();
	private isRunning: boolean = false;
	protected ctx!: CanvasRenderingContext2D;

	init(opts: EngineInitOptions): void {
			const app = document.querySelector<HTMLDivElement>(opts.selector);
			if (!app) {
					throw new Error(`Element with selector "${opts.selector}" not found.`);
			}
			const canvas = document.createElement('canvas');
			canvas.width = opts.width || 800;
			canvas.height = opts.height || 600;
			canvas.style.backgroundColor = opts.backgroundColor || 'black';
			canvas.id = 'game';

			app.appendChild(canvas);
			canvas.focus();

			const context = canvas.getContext('2d');
			if (!context) {
					throw new Error('Failed to get 2D context from canvas.');
			}
			this.ctx = context;
	}

	start() {
			this.isRunning = true;
			this.timer.resume();
			requestAnimationFrame(this.gameLoop.bind(this));
	}

	stop() {
			this.isRunning = false;
			this.timer.pause();
	}

	private gameLoop() {
			if (!this.isRunning) return;

			const deltaTime = this.timer.tick();

			this.update(deltaTime);
			this.render();

			requestAnimationFrame(this.gameLoop.bind(this));
	}

	protected update(deltaTime: number) {
			// Update game state here
	}

	protected render(renderCallback?: () => void) {
			this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
			this.ctx.save();

			if (renderCallback) {
					renderCallback();
			}

			this.ctx.restore();
	}
}

What each part does

Here’s the detailed breakdown of the core pieces in the ch03 Engine:

  • State fields

    • timer measures elapsed time and FPS.
    • isRunning gates the loop and prevents updates when stopped.
    • ctx is the 2D drawing context used by subclasses.
  • init(opts)

    • Looks up the mount element using selector.
    • Creates the <canvas> element and sets internal size (width, height).
    • Applies backgroundColor directly to the canvas style.
    • Retrieves the 2D context and stores it in this.ctx.
  • start()

    • Sets isRunning true.
    • Resumes the timer to reset timing.
    • Kicks off the loop with requestAnimationFrame.
  • stop()

    • Sets isRunning false and pauses the timer.
    • The loop still schedules frames, but it exits early when not running.
  • gameLoop()

    • Exits immediately if the engine is not running.
    • Calls timer.tick() to compute deltaTime.
    • Calls update(deltaTime) for simulation.
    • Calls render() for drawing.
    • Schedules the next frame via requestAnimationFrame.
  • update(deltaTime)

    • Intended for subclasses to override.
    • Receives seconds since last frame, enabling frame‑rate‑independent motion.
  • render(renderCallback?)

    • Clears the entire canvas with clearRect.
    • Wraps drawing in save()/restore() so subclasses can safely change styles or transforms.
    • If a callback is provided, it runs between save and restore.

Update vs render

update(deltaTime)

deltaTime is the elapsed time since the last frame in seconds. You use it to keep motion smooth and frame‑rate independent.

ts
override update(deltaTime: number): void {
	this.cubeAngle += 90 * deltaTime;
	if (this.cubeAngle >= 360) {
		this.cubeAngle -= 360;
	}

	const time = this.timer.getGameTime();
	this.cubeSize = 100 + 50 * Math.sin(time * 2);
}

What’s happening:

  • The square rotates at 90 degrees per second.
  • getGameTime() gives total elapsed time, perfect for oscillations.
  • Math.sin() drives a smooth size pulse between 50 and 150.

render()

Render is where you draw. The engine provides a draw wrapper to clear and manage the frame:

ts
override render(): void {
	super.render(() => {
		// drawing calls
	});
}

Inside, we:

  • Translate to the center.
  • Rotate the canvas.
  • Draw the square centered on the origin.
ts
const centerX = this.ctx.canvas.width / 2;
const centerY = this.ctx.canvas.height / 2;
const size = this.cubeSize;

this.ctx.save();
this.ctx.translate(centerX, centerY);
this.ctx.rotate((this.cubeAngle * Math.PI) / 180);
this.ctx.fillStyle = '#203897';
this.ctx.fillRect(-size / 2, -size / 2, size, size);
this.ctx.restore();

We then draw a small status line with FPS:

ts
this.ctx.fillText(
	`Game Loop Running... FPS: ${this.timer.getFPS()}`,
	this.ctx.canvas.width / 2,
	this.ctx.canvas.height - 10
);

The timer

The engine exposes a timer that keeps track of both elapsed time and frame pacing.

  • getGameTime() returns total time since the engine started, in seconds.
  • getFPS() returns a smoothed frame‑rate estimate based on recent frames.

How time is used

  • Delta time (passed into update) measures how long the last frame took. Multiply speeds by it to keep motion consistent across different frame rates.
  • Game time is a continuously increasing clock. Use it for periodic effects (pulses, oscillations, timers).

Why this matters

If you animate purely by frame count, the game runs faster on high‑refresh monitors and slower on low‑end machines. Using time keeps simulation stable and predictable.

Timer implementation (from lib)

Here is the full Timer class used by the engine:

ts
export class Timer {
	private gameTime: number = 0;
	private maxStep: number = 0.05;
	private lastTime: number = 0;
	private fps: number = 0;
	private isPaused: boolean = false;

	tick(): number {
		if (this.isPaused) {
			return 0;
		}

		const currentTime = performance.now();
		let deltaTime = (currentTime - this.lastTime) / 1000;
		this.fps = 1000 / (currentTime - this.lastTime);
		this.lastTime = currentTime;

		if (deltaTime > this.maxStep) {
			deltaTime = this.maxStep;
		}

		this.gameTime += deltaTime;
		return deltaTime;
	}

	pause(): void {
		this.isPaused = true;
	}

	resume(): void {
		this.isPaused = false;
		this.lastTime = performance.now();
	}

	getGameTime(): number {
		return this.gameTime;
	}

	getFPS(): number {
		return Math.round(this.fps);
	}
}

How it works

  • performance.now() returns a high‑resolution timestamp in milliseconds.
  • deltaTime converts that to seconds, which is what we pass into update.
  • maxStep clamps large time jumps (for example, when the tab is backgrounded) to keep physics stable.
  • gameTime accumulates the clamped delta so total time stays consistent.
  • fps is computed from the last frame duration and rounded for display.

Pause and resume

When paused, tick() returns 0 and simulation stops. resume() resets lastTime to avoid a huge delta when the game continues.

Full timer usage example

Below is the exact pattern used in this chapter, with comments explaining how each value is used:

ts
override update(deltaTime: number): void {
	// deltaTime: seconds since last frame
	// rotate at 90 degrees per second
	this.cubeAngle += 90 * deltaTime;
	if (this.cubeAngle >= 360) {
		this.cubeAngle -= 360;
	}

	// game time: total seconds since start
	// use it for smooth oscillation
	const time = this.timer.getGameTime();
	this.cubeSize = 100 + 50 * Math.sin(time * 2);
}

override render(): void {
	super.render(() => {
		// FPS is a smoothed estimate of recent frames
		this.ctx.fillText(
			`Game Loop Running... FPS: ${this.timer.getFPS()}`,
			this.ctx.canvas.width / 2,
			this.ctx.canvas.height - 10
		);
	});
}

Starting the loop

ts
const engine = new CH03();

engine.init({
	selector: '#app',
	width: 800,
	height: 600,
	backgroundColor: '#4433ee',
});

engine.start();

init() builds the canvas and attaches it to the DOM. start() begins the main loop.

Key takeaways

  • Game logic lives in update(deltaTime).
  • Rendering lives in render().
  • Use deltaTime for frame‑rate‑independent motion.
  • Use save()/restore() to isolate transforms.

Library changes

This is the first chapter that uses the shared engine:

  • lib/Engine is introduced and provides the core loop, canvas setup, and a timer.
  • lib/Timer provides getGameTime() and getFPS() used in this chapter.