Learn Design Patterns: Build a “Monster Arena” Game and Master TypeScript Design Patterns (Part…



This content originally appeared on Level Up Coding – Medium and was authored by Robin Viktorsson

🎮 Learn Design Patterns: Build a “Monster Arena” Game and Master TypeScript Design Patterns (Part 1)

Design patterns aren’t just academic concepts — they’re practical tools that bring structure, clarity, and flexibility to your code. And what better way to learn them than by building a game?

In this two-part series, we’re creating a Monster Battle Arena in TypeScript and Node.js, where players summon goblins and dragons, assign them attack strategies, and trigger combat through an interactive terminal.

Part 1 focuses on implementing the core gameplay mechanics:

  • A central GameManager to manage game state
  • Adding players to the game
  • Summoning different types of monsters
  • Fighting monsters
  • Logging events to a UI terminal

Along the way, you’ll learn how to apply essential software design patterns with clean, practical examples:

  • 🧙♂ Singleton — Centralized game state manager
  • ⚔ Strategy — Flexible monster attack behaviours
  • 👁 Observer — Real-time battle notifications
  • 🏭 Factory — Dynamic monster creation
  • 🎮 Command — Encapsulated terminal actions
  • 🔁 State — Dynamic monster condition changes
  • 🔗 Chain of Responsibility — Layered damage mitigation

Part 2 will evolve the existing logic with richer mechanics, new design patterns, enhanced UI interactions, and persistence — turning this codebase into a fully modular game simulation.

All gameplay is simulated via a terminal UI — proving that design patterns are not just powerful, but also fun to learn.

What Are Design Patterns? 🤔

Design patterns are tried-and-true solutions to common software design problems. Rather than reinventing the wheel every time we structure code, design patterns offer reusable templates that make your codebase more understandable, scalable, and maintainable.

They’re not libraries or frameworks, but language-agnostic blueprints that help you solve recurring architectural challenges — whether you’re building a backend service, a UI component, or, in our case, a game.

Think of them as battle-tested strategies used by experienced developers to communicate intent and reduce complexity.

Let’s Code the Monster Arena Game! 🛠

With the core concepts of our game idea and design patterns under our belt, it’s time to bring them to life. In this section, we’ll dive into coding the Monster Arena step by step — from setting up your environment to implementing gameplay features like player management, monster summoning, and combat logic.

By following along, you’ll gain hands-on experience applying design patterns in a fun, practical, and interactive way.

1. Prerequisites 🧱

Ensure you have the following installed:

2. Project Structure 📁

Initialize a Node.js project and generate a package.json file with default values using the npm init -y command. Create necessary directories and files. The final structure should look like this:

/monster-arena-game
├── node_modules/
├── src/
│ ├── core/
│ │ ├── BattleSubject.ts
│ │ └── GameManager.ts
│ ├── combat/
│ │ ├── AttackStrategy.ts
│ │ └── DamageHandler.ts
│ ├── logs/
│ │ └── ConsoleLogger.ts
│ ├── factories/
│ │ └── MonsterFactory.ts
│ ├── ui/
│ │ └── Terminal.ts
│ └── commands/
│ ├── Command.ts
│ ├── AddPlayerCommand.ts
│ ├── SummonMonsterCommand.ts
│ ├── ShowPlayersCommand.ts
│ ├── ShowLogsCommand.ts
│ └── AttackMonsterCommand.ts
├── package.json
├── package-lock.json
└── tsconfig.json

3. Install Dependencies 📦

Let’s set up the project with the necessary tools for a smooth TypeScript and Node.js development experience.

// Install TypeScript and related packages
npm install typescript ts-node @types/node

// Initialize the TypeScript configuration
npx tsc --init

Add a start script to your package.json:

"scripts": {
"start": "ts-node src/index.ts"
}

This lets you run your game with a simple command: npm run start.

4. Singleton Pattern — Global Game Manager 🧙‍♂️

We begin with the Singleton Pattern, which ensures that a class has only one instance and provides a global point of access to it.

In our game, we use a GameManager to control player registration and manage shared game-wide state.

// src/core/GameManager.ts
import { Monster } from './Monster';

export class GameManager {
private static instance: GameManager;
private players: string[] = [];
private monsters: Map<string, Monster> = new Map();

private constructor() {}

static getInstance(): GameManager {
if (!GameManager.instance) {
GameManager.instance = new GameManager();
}
return GameManager.instance;
}

addPlayer(name: string) {
this.players.push(name);
console.log(`[GameManager] ${name} joined the arena.`);
}

getPlayers(): string[] {
return this.players;
}

addMonster(monster: Monster) {
this.monsters.set(monster.name.toLowerCase(), monster);
console.log(`[GameManager] ${monster.name} has entered the battlefield.`);
}

getMonster(name: string): Monster | undefined {
return this.monsters.get(name.toLowerCase());
}

getMonsters(): string[] {
return Array.from(this.monsters.keys());
}
}

The Singleton pattern enforces a single source of truth for global game state — ideal for managing sessions, player data, and coordination logic.

5. Strategy Pattern — Monster Behaviours ⚔

Monsters in our game attack in different ways — some slash with claws, others hurl fireballs. Instead of hardcoding behaviour, we define them as interchangeable strategies using the Strategy Pattern.

This pattern enables behaviour to be selected at runtime, keeping monster logic clean and flexible.

// src/combat/AttackStrategy.ts
export interface AttackStrategy {
attack(monsterName: string): void;
}

export class MeleeAttack implements AttackStrategy {
attack(name: string) {
console.log(`[Strategy] ${name} performs a claw slash!`);
}
}

export class RangedAttack implements AttackStrategy {
attack(name: string) {
console.log(`[Strategy] ${name} hurls a fireball!`);
}
}

The Strategy pattern lets monsters dynamically switch their attack style (e.g., from melee to ranged) without altering their core structure.

6. Observer Pattern — Broadcast Battle Updates 👁

During combat, we want to keep different parts of the system — like logs or future UI components — in sync with what’s happening. The Observer Pattern lets us broadcast important events to multiple listeners without tightly coupling them to the source.

Here’s how we implement it:

// src/core/BattleSubject.ts
export interface Observer {
update(event: string): void;
}

export class BattleSubject {
private observers: Observer[] = [];

subscribe(observer: Observer) {
this.observers.push(observer);
}

notify(event: string) {
for (const obs of this.observers) obs.update(event);
}
}

And here’s a simple observer that logs updates to the terminal:

// src/logs/ConsoleLogger.ts
import { Observer } from '../core/BattleSubject';

export class ConsoleLogger implements Observer {
update(event: string): void {
console.log(`[Observer] ${event}`);
}
}

The Observer Pattern decouples the event source (e.g., monsters, combat actions) from the event handlers (e.g., console logs, UI panels). This makes the system more modular and extensible — you can add more observers (like a file logger, WebSocket publisher, or in-game notification panel) without changing the battle logic.

7. Monster Class With Strategy and State 🧬

Our Monster class leverages both the Strategy and State patterns. It uses Strategy to handle different attack styles and State to reflect changing health conditions (like being healthy, wounded, or critical).

// src/core/Monster.ts
import { AttackStrategy } from '../combat/AttackStrategy';
import { MonsterState, HealthyState } from './MonsterState';

export class Monster {
private state: MonsterState = new HealthyState();

constructor(
public name: string,
private strategy: AttackStrategy
) {}

performAttack() {
this.strategy.attack(this.name);
this.state.handleState(this.name);
}

setAttackStrategy(strategy: AttackStrategy) {
this.strategy = strategy;
}

setState(state: MonsterState) {
this.state = state;
}
}

This shows the power of composition over conditionals. We can change both attack behaviour and state-based responses cleanly and independently.

8. Factory Pattern — Summon Monsters Easily 🏭

Instead of manually creating monsters with specific configurations, we use the Factory Pattern to encapsulate creation logic.

// src/factories/MonsterFactory.ts
import { Monster } from '../core/Monster';
import { MeleeAttack, RangedAttack } from '../combat/AttackStrategy';

export class MonsterFactory {
static create(type: 'goblin' | 'dragon'): Monster {
switch (type) {
case 'goblin': return new Monster('Goblin', new MeleeAttack());
case 'dragon': return new Monster('Dragon', new RangedAttack());
default: throw new Error('Unknown monster type');
}
}
}

The Factory pattern centralizes and abstracts the creation of complex objects. Need more monster types later? Just add a new case.

9. Command Pattern — Queue and Execute Attacks 🎮

The Command Pattern encapsulates requests as objects — perfect for queuing, logging, or undoing actions. Instead of invoking .performAttack() directly, we wrap it in a command object.

// src/commands/AttackMonsterCommand.ts
import { Command } from './Command';
import { GameManager } from '../core/GameManager';

export class AttackMonsterCommand implements Command {
constructor(private name: string) {}

execute(): void {
const monster = GameManager.getInstance().getMonster(this.name);
if (monster) {
monster.performAttack();
} else {
console.log(`[Error] Monster "${this.name}" not found.`);
}
}
}

The Command pattern separates what should happen from when it happens — ideal for turn-based combat, replays, or action batching.

10. State Pattern — Monster Condition Handling 🔁

Monsters behave differently based on how injured they are. Instead of using conditionals everywhere, we apply the State Pattern to encapsulate each behaviour.

// src/core/MonsterState.ts
export interface MonsterState {
handleState(name: string): void;
}

export class HealthyState implements MonsterState {
handleState(name: string) {
console.log(`[State] ${name} is strong and confident.`);
}
}

export class WoundedState implements MonsterState {
handleState(name: string) {
console.log(`[State] ${name} is hurt but fighting.`);
}
}

export class CriticalState implements MonsterState {
handleState(name: string) {
console.log(`[State] ${name} is barely alive.`);
}
}

The State pattern eliminates complex if-else chains and allows you to swap state objects to change monster behaviour dynamically.

11. Chain of Responsibility Pattern — Damage Reduction 🔗

Incoming damage might be mitigated by multiple defences — like armour, shields, or magical wards. The Chain of Responsibility Pattern lets us pass damage through each handler in sequence.

// src/combat/DamageHandler.ts
export interface DamageHandler {
setNext(handler: DamageHandler): DamageHandler;
handle(damage: number): number;
}

export class ArmorHandler implements DamageHandler {
private nextHandler?: DamageHandler;

setNext(handler: DamageHandler): DamageHandler {
this.nextHandler = handler;
return handler;
}

handle(damage: number): number {
const reduced = damage * 0.9;
console.log(`[Chain] Armor reduced damage to ${reduced}`);
return this.nextHandler?.handle(reduced) ?? reduced;
}
}

export class ShieldHandler implements DamageHandler {
private nextHandler?: DamageHandler;

setNext(handler: DamageHandler): DamageHandler {
this.nextHandler = handler;
return handler;
}

handle(damage: number): number {
const reduced = damage - 5;
console.log(`[Chain] Shield blocked 5 points. New damage: ${reduced}`);
return this.nextHandler?.handle(reduced) ?? reduced;
}
}

Each handler focuses on its own logic and passes the result forward. New handlers (e.g., MagicBarrierHandler) can be added without modifying existing code.

12. Command Pattern: Powering the Terminal Interface 🧩

We’ve already used the Command pattern to encapsulate game actions. Now, let’s take it a step further — by turning our terminal into a fully interactive UI powered entirely by commands.

Every user input is parsed into a Command object, which is then executed. This clean separation allows us to decouple user input logic from game logic.

Command Interface 💻
A simple contract that ensures all commands implement the same execute method.

// src/commands/Command.ts
export interface Command {
execute(): void;
}

AddPlayerCommand 👤
Adds a new player to the game through the GameManager.

// src/commands/AddPlayerCommand.ts
import { Command } from './Command';
import { GameManager } from '../core/GameManager';

export class AddPlayerCommand implements Command {
constructor(private name: string) {}

execute(): void {
GameManager.getInstance().addPlayer(this.name);
}
}

SummonMonsterCommand 🐲
Creates and adds a monster (goblin or dragon) to the arena, then triggers its attack behavior.

// src/commands/SummonMonsterCommand.ts
import { Command } from './Command';
import { MonsterFactory } from '../factories/MonsterFactory';
import { GameManager } from '../core/GameManager';

export class SummonMonsterCommand implements Command {
constructor(private type: 'goblin' | 'dragon') {}

execute(): void {
const monster = MonsterFactory.create(this.type);
GameManager.getInstance().addMonster(monster);
}
}

ShowPlayersCommand 👥
Displays a list of all current players in the game.

// src/commands/ShowPlayersCommand.ts
import { Command } from './Command';
import { GameManager } from '../core/GameManager';

export class ShowPlayersCommand implements Command {
execute(): void {
console.log('[Players]', GameManager.getInstance().getPlayers());
}
}

ShowLogsCommand 📜
Outputs a placeholder log message (you can expand this later with Event Sourcing).

// src/commands/ShowLogsCommand.ts
import { Command } from './Command';

export class ShowLogsCommand implements Command {
execute(): void {
console.log('[Logs] No persistent log storage yet.');
}
}

Terminal: Parsing Commands and Executing Actions 👩‍💻
This class powers our command-line interface. It reads user input, translates it into commands, and runs them. It keeps UI logic separate from game mechanics.

// src/ui/Terminal.ts
import readline from 'readline';
import { AddPlayerCommand } from '../commands/AddPlayerCommand';
import { SummonMonsterCommand } from '../commands/SummonMonsterCommand';
import { ShowPlayersCommand } from '../commands/ShowPlayersCommand';
import { Command } from '../commands/Command';
import { GameManager } from '../core/GameManager';
import { AttackMonsterCommand } from '../commands/AttackMonsterCommand';

export class Terminal {
private rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

start() {
console.log('Welcome to the Monster Battle Game!');
this.printHelp();
this.prompt();
}

private printHelp(): void {
console.log('\nAvailable commands:');
console.log(' add-player <name> - Add a new player');
console.log(' summon-monster <type> - Summon a monster (goblin or dragon)');
console.log(' attack <monsterName> - Make a monster attack');
console.log(' show-players - Show all current players');
console.log(' help - Show this help menu');
console.log(' exit - Quit the game');
}

private prompt() {
this.rl.question('> ', (input) => {
const [command, ...args] = input.trim().split(' ');

if (command === 'help') {
this.printHelp();
} else if (command === 'exit') {
console.log('Exiting game. Goodbye!');
this.rl.close();
return;
} else {
const cmd = this.createCommand(command, args);
if (cmd) cmd.execute();
else console.log('Unknown command or invalid arguments. Type "help" to see available options.');
}

this.prompt();
});
}

private createCommand(command: string, args: string[]): Command | null {
switch (command) {
case 'add-player':
if (!args[0]) return null;
return new AddPlayerCommand(args[0]);

case 'summon-monster':
if (args[0] !== 'goblin' && args[0] !== 'dragon') return null;
return new SummonMonsterCommand(args[0] as 'goblin' | 'dragon');

case 'attack': {
const name = args[0]?.toLowerCase();
if (!name) return null;

const monster = GameManager.getInstance().getMonster(name);
if (!monster) {
console.log(`[Error] Monster "${args[0]}" not found.`);
return null;
}

return new AttackMonsterCommand(monster.name);
}

case 'show-players':
return new ShowPlayersCommand();

default:
return null;
}
}
}

14. Initializing the Game Runtime ⚙

Let’s wire everything together by creating the main entry point of the application. This is where your Monster Arena game kicks off and the terminal interface comes to life.

// src/index.ts
import { Terminal } from './ui/Terminal';

new Terminal().start();

This simple snippet initializes the terminal UI and starts listening for user input, allowing players to join the game, summon monsters, and initiate battles — all from the command line.

15. Running the Game ▶

The game can be started using the following command:

npm run start

You’ll be greeted with the interactive terminal interface, ready for action. Here’s a quick example of what a session might look like:

C:\Users\monster-arena-game> npm run start

> monster-arena-game@1.0.0 start
> ts-node src/index.ts

Welcome to the Monster Battle Game!

Available commands:
add-player <name> - Add a new player
summon-monster <type> - Summon a monster (goblin or dragon)
attack <monsterName> - Make a monster attack
show-players - Show all current players
help - Show this help menu
exit - Quit the game
> add-player Robin
[GameManager] Robin joined the arena.
> show-players
[Players] [ 'Robin' ]
> summon-monster dragon
[GameManager] Dragon has entered the battlefield.
> attack dragon
[Strategy] Dragon hurls a fireball!
[State] Dragon is strong and confident.
> help

Available commands:
add-player <name> - Add a new player
summon-monster <type> - Summon a monster (goblin or dragon)
attack <monsterName> - Make a monster attack
show-players - Show all current players
help - Show this help menu
exit - Quit the game
> exit
Exiting game. Goodbye!

And just like that — you’ve created a fully functional, design-pattern-powered monster game right in your terminal. But this is only the beginning.

In Part 2 of this series, we’ll deepen the architecture by refining existing code base, introducing new design patterns, and adding exciting features: a turn-based system for player actions, health and life mechanics for both players and monsters, plus loot and experience systems to level up your gameplay, and more.

Conclusion 📣

Design patterns are more than just theoretical concepts — they’re practical tools that can transform your code from a tangled mess into a well-structured, flexible system. By building the Monster Arena game, you’ve seen first-hand how these patterns can be applied to solve common software design challenges in a modular and maintainable way.

From managing global state with Singleton, to crafting dynamic monster behaviours using Strategy and State, to enabling real-time updates with Observer, and streamlining object creation with Factory, each pattern brought a unique and powerful advantage to our game architecture. Wrapping actions in Command objects not only made our game logic cleaner but also paved the way for an extensible terminal UI. And finally, the Chain of Responsibility pattern showed how layered logic like damage mitigation can be elegantly handled.

This hands-on project proves that design patterns are not just abstract ideas — they’re practical, approachable, and fun. Whether you’re building games, web apps, or backend services, mastering these patterns in TypeScript will empower you to write cleaner, more scalable, and easier-to-maintain code.

🙏 Thanks for reading to the end! If you have any questions, feel free to drop a comment below.

If you enjoyed this article, follow me on medium or social media — I’d love to connect and follow you back:


🎮 Learn Design Patterns: Build a “Monster Arena” Game and Master TypeScript Design Patterns (Part… was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.


This content originally appeared on Level Up Coding – Medium and was authored by Robin Viktorsson