TypeScript Patterns for Game Development
TypeScript Patterns for Game Development
Game development often involves complex state management, performance-critical code, and frequent iteration. TypeScript's type system can dramatically improve the development experience when applied thoughtfully.
Discriminated Unions for Game States
Traditional game state management often uses boolean flags or string constants, leading to invalid state combinations:
// ❌ Problematic: Allows invalid states
type GameState = {
isPlaying: boolean;
isPaused: boolean;
isGameOver: boolean;
score: number;
lives: number;
}
// Could be playing AND paused AND game over simultaneously
Instead, use discriminated unions to make illegal states unrepresentable:
// ✅ Better: Only valid states are possible
type GameState =
| { status: 'menu'; selectedOption: number }
| { status: 'playing'; score: number; lives: number; startTime: number }
| { status: 'paused'; score: number; lives: number; pausedAt: number }
| { status: 'gameOver'; finalScore: number; duration: number }
function updateGame(state: GameState, deltaTime: number): GameState {
switch (state.status) {
case 'playing':
// TypeScript knows we have score, lives, startTime
return updatePlaying(state, deltaTime);
case 'paused':
// TypeScript knows we have pausedAt
return state; // No updates while paused
case 'gameOver':
// TypeScript knows we have finalScore, duration
return state;
case 'menu':
// TypeScript knows we have selectedOption
return handleMenuInput(state);
}
}
Type-Safe Entity Component System
Component systems in games often use string-based lookups. TypeScript can make this type-safe:
// Define component types
type Transform = { x: number; y: number; rotation: number };
type Velocity = { vx: number; vy: number };
type Sprite = { texture: PIXI.Texture; tint: number };
type Health = { current: number; max: number };
type ComponentMap = {
transform: Transform;
velocity: Velocity;
sprite: Sprite;
health: Health;
};
class Entity {
private components = new Map<keyof ComponentMap, any>();
add<K extends keyof ComponentMap>(
type: K,
component: ComponentMap[K]
): this {
this.components.set(type, component);
return this;
}
get<K extends keyof ComponentMap>(
type: K
): ComponentMap[K] | undefined {
return this.components.get(type);
}
has<K extends keyof ComponentMap>(type: K): boolean {
return this.components.has(type);
}
}
// Usage is now type-safe
const player = new Entity()
.add('transform', { x: 100, y: 100, rotation: 0 })
.add('velocity', { vx: 0, vy: 0 })
.add('health', { current: 100, max: 100 });
const transform = player.get('transform'); // TypeScript knows this is Transform | undefined
const sprite = player.get('sprite'); // TypeScript knows this is Sprite | undefined
Branded Types for Game Values
Prevent unit confusion and value mixing with branded types:
type Pixels = number & { __brand: 'Pixels' };
type Seconds = number & { __brand: 'Seconds' };
type Degrees = number & { __brand: 'Degrees' };
type Radians = number & { __brand: 'Radians' };
function pixels(value: number): Pixels {
return value as Pixels;
}
function seconds(value: number): Seconds {
return value as Seconds;
}
function degrees(value: number): Degrees {
return value as Degrees;
}
function radians(value: number): Radians {
return value as Radians;
}
// Conversion functions
function degreesToRadians(deg: Degrees): Radians {
return radians(deg * Math.PI / 180);
}
// Now function signatures are self-documenting
function moveEntity(
entity: Entity,
distance: Pixels,
angle: Radians,
duration: Seconds
): void {
// Implementation
}
// Usage prevents common mistakes
const angle = degrees(45);
const distance = pixels(100);
const time = seconds(1.5);
moveEntity(player, distance, degreesToRadians(angle), time);
// ❌ This would be a compile error:
// moveEntity(player, angle, distance, time); // Wrong parameter order
Type-Safe Animation System
Animations often involve string-based property names. TypeScript can make this safer:
type AnimatableProperties = {
x: number;
y: number;
rotation: number;
alpha: number;
scaleX: number;
scaleY: number;
};
type AnimationTarget = Partial<AnimatableProperties>;
class Animation<T extends AnimationTarget> {
constructor(
private target: AnimatableProperties,
private to: T,
private duration: Seconds
) {}
update(deltaTime: Seconds): void {
// Type-safe property updates
for (const key in this.to) {
if (key in this.target && key in this.to) {
const current = this.target[key as keyof AnimatableProperties];
const target = this.to[key as keyof T]!;
// Smooth interpolation logic here
}
}
}
}
// Usage ensures only valid properties can be animated
const sprite = { x: 0, y: 0, rotation: 0, alpha: 1, scaleX: 1, scaleY: 1 };
const animation = new Animation(sprite, {
x: 100,
alpha: 0.5,
// ❌ This would be a compile error:
// invalidProperty: 123
}, seconds(2));
Resource Loading with Type Safety
Game assets often use string paths. TypeScript can provide better safety:
type AssetManifest = {
textures: {
'player': 'sprites/player.png';
'enemy': 'sprites/enemy.png';
'background': 'backgrounds/level1.png';
};
sounds: {
'jump': 'audio/jump.wav';
'shoot': 'audio/shoot.wav';
};
};
class AssetLoader {
private textures = new Map<keyof AssetManifest['textures'], PIXI.Texture>();
private sounds = new Map<keyof AssetManifest['sounds'], HTMLAudioElement>();
async loadTexture<K extends keyof AssetManifest['textures']>(
key: K
): Promise<PIXI.Texture> {
if (this.textures.has(key)) {
return this.textures.get(key)!;
}
// Load texture using the manifest path
const texture = await PIXI.Texture.fromURL(assetManifest.textures[key]);
this.textures.set(key, texture);
return texture;
}
getTexture<K extends keyof AssetManifest['textures']>(
key: K
): PIXI.Texture | undefined {
return this.textures.get(key);
}
}
// Usage prevents typos in asset names
const loader = new AssetLoader();
const playerTexture = await loader.loadTexture('player'); // ✅ Type-safe
// const invalidTexture = await loader.loadTexture('plaeyr'); // ❌ Compile error
Performance Considerations
While TypeScript adds compile-time safety, be mindful of runtime performance:
- Use
constassertions for lookup tables - Prefer interfaces over classes for data structures
- Use object pools for frequently created objects
- Type-only imports for large type definitions
// ✅ Efficient lookup table
const ENEMY_STATS = {
goblin: { health: 50, speed: 2, damage: 10 },
orc: { health: 100, speed: 1, damage: 20 },
dragon: { health: 500, speed: 3, damage: 50 },
} as const;
type EnemyType = keyof typeof ENEMY_STATS;
// ✅ Type-only import (doesn't affect bundle size)
import type { LargeGameConfig } from './game-config';
Conclusion
TypeScript's type system can catch entire classes of game development bugs at compile time. By using discriminated unions, branded types, and careful API design, you can build game systems that are both performant and maintainable.
The key is finding the right balance between type safety and performance. Start with the areas most prone to bugs (state management, asset loading) and gradually add types where they provide the most value.