TypeScript Tutorial: Creating a Simple Text-Based Adventure Game

Embark on a journey into the world of TypeScript game development! In this tutorial, we’ll build a simple text-based adventure game. This project is perfect for beginners and intermediate developers looking to learn TypeScript fundamentals, object-oriented programming, and basic game logic. Text-based adventure games, while seemingly simple, provide a fantastic platform for understanding core programming concepts without the complexities of graphics or user interfaces. They allow you to focus on the logic, flow, and structure of your code.

Why Build a Text-Based Adventure Game?

Creating a text-based adventure game offers several benefits, especially for learning TypeScript:

  • Foundation in Logic: You’ll learn how to structure code, handle user input, and manage game states.
  • Object-Oriented Programming (OOP): This project provides a practical introduction to classes, objects, inheritance, and polymorphism.
  • TypeScript Fundamentals: You’ll solidify your understanding of types, interfaces, and other TypeScript features.
  • Problem-Solving Skills: You’ll practice breaking down complex problems into smaller, manageable tasks.
  • Fun and Engaging: Building a game is inherently more engaging than some other types of tutorials, making the learning process more enjoyable.

Let’s dive in and create a fun and educational game!

Setting Up Your Project

Before we start coding, let’s set up our project environment. We’ll use Node.js and npm (Node Package Manager) for this tutorial. Make sure you have Node.js and npm installed on your system. You can download them from the official Node.js website.

Once Node.js and npm are installed, create a new directory for your project and navigate into it using your terminal or command prompt.

mkdir text-adventure-game
cd text-adventure-game

Next, initialize a new npm project:

npm init -y

This command creates a package.json file, which manages your project’s dependencies and other settings. Now, let’s install TypeScript as a development dependency:

npm install typescript --save-dev

This command installs the TypeScript compiler. After the installation is complete, create a tsconfig.json file. This file configures the TypeScript compiler. You can generate a basic tsconfig.json file using the following command:

npx tsc --init

Open tsconfig.json in your code editor and modify the following settings (you can leave the other settings as default for this project):

  • compilerOptions.outDir: Set this to “./dist” (this is where the compiled JavaScript files will be placed).
  • compilerOptions.module: Set this to “commonjs” (this is the module system to be used).
  • compilerOptions.esModuleInterop: Set this to true (allows you to import CommonJS modules as ES modules).
  • compilerOptions.sourceMap: Set this to true (enables source maps for easier debugging).
  • compilerOptions.strict: Set this to true (enables strict type checking).

Your tsconfig.json file should look something like this (with other default settings as well):

{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "sourceMap": true
  },
  "include": ["src/**/*"]
}

Create a directory named src in your project’s root directory. This is where we’ll put our TypeScript source files.

mkdir src

Now, we are ready to start coding our game!

Core Concepts: Classes and Objects

Our game will revolve around a few key concepts. Let’s start with classes and objects. In object-oriented programming, a class is a blueprint, and an object is an instance of that blueprint. Let’s define a Player class:

// src/player.ts
class Player {
  name: string;
  health: number;
  inventory: string[];

  constructor(name: string) {
    this.name = name;
    this.health = 100;
    this.inventory = [];
  }

  displayInventory(): void {
    if (this.inventory.length === 0) {
      console.log("Your inventory is empty.");
    } else {
      console.log("Inventory:");
      this.inventory.forEach((item) => console.log(`- ${item}`));
    }
  }

  addItem(item: string): void {
    this.inventory.push(item);
    console.log(`${item} added to your inventory.`);
  }

  removeItem(item: string): void {
    const index = this.inventory.indexOf(item);
    if (index > -1) {
      this.inventory.splice(index, 1);
      console.log(`${item} removed from your inventory.`);
    } else {
      console.log(`You don't have ${item} in your inventory.`);
    }
  }

  attack(target: Enemy): void {
    const damage = Math.floor(Math.random() * 20) + 10; // Damage between 10 and 29
    target.health -= damage;
    console.log(`${this.name} attacks ${target.name} for ${damage} damage!`);
  }
}

export default Player;

In this code:

  • We define a Player class.
  • The class has properties: name, health, and inventory.
  • The constructor initializes a new Player object with a name, starting health, and an empty inventory.
  • Methods like displayInventory, addItem, removeItem and attack define the player’s actions.

Now, let’s define an Enemy class:


// src/enemy.ts
class Enemy {
  name: string;
  health: number;

  constructor(name: string, health: number) {
    this.name = name;
    this.health = health;
  }

  attack(player: Player): void {
    const damage = Math.floor(Math.random() * 15) + 5; // Damage between 5 and 19
    player.health -= damage;
    console.log(`${this.name} attacks ${player.name} for ${damage} damage!`);
  }
}

export default Enemy;

This Enemy class is similar to the Player class, with properties for its name and health, and an attack method.

Creating Game Rooms

Our game will consist of multiple rooms, each with a description, possible exits, and items. Let’s create a Room class:


// src/room.ts
class Room {
  name: string;
  description: string;
  exits: { [direction: string]: string }; // Maps direction to room name
  items: string[];

  constructor(name: string, description: string, exits: { [direction: string]: string } = {}, items: string[] = []) {
    this.name = name;
    this.description = description;
    this.exits = exits;
    this.items = items;
  }

  displayRoom(): void {
    console.log(`n--- ${this.name} ---`);
    console.log(this.description);
    if (this.items.length > 0) {
      console.log("You see:", this.items.join(", "));
    }
    if (Object.keys(this.exits).length > 0) {
      console.log("Exits:", Object.keys(this.exits).join(", "));
    }
  }

  addItem(item: string): void {
    this.items.push(item);
    console.log(`${item} added to the room.`);
  }

  removeItem(item: string): void {
    const index = this.items.indexOf(item);
    if (index > -1) {
      this.items.splice(index, 1);
      console.log(`${item} removed from the room.`);
    }
  }
}

export default Room;

In this code:

  • The Room class has properties like name, description, exits (a dictionary of available exits), and items.
  • The displayRoom method prints the room’s details.
  • The addItem and removeItem methods manage items within the room.

Game Logic and Main Game Loop

Now, let’s create the main game logic and a simple game loop. This involves creating the rooms, the player, and handling player input.


// src/game.ts
import * as readline from 'readline';
import Player from './player';
import Room from './room';
import Enemy from './enemy';

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

// Create rooms
const rooms: { [key: string]: Room } = {
  'start': new Room(
    'The Entrance', 
    'You are standing at the entrance of a dark cave. There is a flickering torch on the wall.',
    { 'north': 'cave', 'east': 'forest' },
    ['torch']
  ),
  'cave': new Room(
    'The Dark Cave', 
    'You are in a damp cave. The air is cold.',
    { 'south': 'start', 'east': 'treasure_room' },
    ['sword']
  ),
  'treasure_room': new Room(
    'The Treasure Room', 
    'You are in a room filled with treasure!',
    { 'west': 'cave' },
    ['gold']
  ),
  'forest': new Room(
    'The Forest', 
    'You are in a dense forest. Sunlight barely reaches the ground.',
    { 'west': 'start' },
    ['berries']
  ),
};

// Create enemies
const enemies: { [key: string]: Enemy } = {
  'goblin': new Enemy('Goblin', 30),
};

let currentPlayer: Player;
let currentRoom: Room;

function startGame(): void {
  rl.question('Enter your name: ', (name) => {
    currentPlayer = new Player(name);
    currentRoom = rooms['start'];
    console.log(`Welcome, ${currentPlayer.name}!`);
    gameLoop();
  });
}

function displayHealth(): void {
    console.log(`Health: ${currentPlayer.health}`);
}

function gameLoop(): void {
  currentRoom.displayRoom();
  displayHealth();
  rl.question('> ', (input) => {
    processInput(input);
  });
}

function processInput(input: string): void {
  const words = input.toLowerCase().split(' ');
  const command = words[0];

  switch (command) {
    case 'look':
      currentRoom.displayRoom();
      break;
    case 'go':
      const direction = words[1];
      if (direction && currentRoom.exits[direction]) {
        const nextRoomName = currentRoom.exits[direction];
        currentRoom = rooms[nextRoomName];
        console.log(`You go ${direction}.`);
        gameLoop();
      } else {
        console.log('Invalid direction.');
        gameLoop();
      }
      break;
    case 'take':
      const itemToTake = words.slice(1).join(' '); // Handle multi-word items
      if (itemToTake) {
        if (currentRoom.items.includes(itemToTake)) {
          currentPlayer.addItem(itemToTake);
          currentRoom.removeItem(itemToTake);
        } else {
          console.log(`There is no ${itemToTake} here.`);
        }
        gameLoop();
      }
      break;
    case 'inventory':
      currentPlayer.displayInventory();
      gameLoop();
      break;
    case 'attack':
        const targetName = words[1];
        if (targetName && enemies[targetName]) {
            const targetEnemy = enemies[targetName];
            if (targetEnemy) {
                currentPlayer.attack(targetEnemy);
                if (targetEnemy.health <= 0) {
                    console.log(`${targetEnemy.name} has been defeated!`);
                }
            }
        }
        gameLoop();
        break;
    case 'help':
      displayHelp();
      gameLoop();
      break;
    case 'quit':
      rl.close();
      return;
    default:
      console.log('Invalid command. Type "help" for a list of commands.');
      gameLoop();
  }
}

function displayHelp(): void {
    console.log('nAvailable commands:');
    console.log('  go [direction]  - Move to a different room (e.g., go north)');
    console.log('  look            - See the room description and items');
    console.log('  take [item]     - Take an item from the room');
    console.log('  inventory       - View your inventory');
    console.log('  attack [enemy]  - Attack an enemy');
    console.log('  help            - Display this help message');
    console.log('  quit            - Exit the gamen');
}

startGame();

Here’s a breakdown:

  • Import Statements: We import readline for handling user input, and the Player and Room classes we defined earlier.
  • Room Definitions: We create instances of the Room class to represent different rooms in our game. Each room has a name, description, exits, and a list of items.
  • Player Creation: We create a Player object to represent the player.
  • Game Loop (gameLoop()): This function is the heart of the game. It displays the current room’s description, prompts the player for input, and calls processInput() to handle the input.
  • Input Processing (processInput()): This function takes the player’s input, parses it, and executes the appropriate actions based on the command.
  • Command Handling: The processInput() function uses a switch statement to handle different commands such as go, look, take, inventory, and quit.
  • Game Start (startGame()): This function initializes the game by asking the player for their name and starting the game loop.

Running the Game

Now that we’ve written the code, let’s compile and run it. Open your terminal and navigate to your project directory. First, compile the TypeScript code using the TypeScript compiler:

tsc

This command will compile all your TypeScript files (.ts) into JavaScript files (.js) in the dist directory. Then, run the compiled JavaScript file using Node.js:

node dist/game.js

You should be prompted to enter your name. After entering your name, the game will start, and you will see the description of the starting room. You can then enter commands like go north, look, take torch, and inventory to interact with the game. Try exploring the rooms and see what you can find!

Adding More Features

This is a basic game, but you can expand it with many more features. Here are some ideas:

  • Combat System: Implement a combat system with enemies, damage, and health.
  • Items and Inventory: Add more items and allow the player to use them.
  • Quests and Puzzles: Introduce quests and puzzles to make the game more challenging.
  • More Rooms: Expand the map with more rooms and locations.
  • Save and Load: Allow the player to save and load their game progress.
  • User Interface: Consider using a library like React or Vue.js to create a graphical user interface (GUI) for your game.

Common Mistakes and How to Fix Them

When building a text-based adventure game in TypeScript, you might encounter some common issues. Here are some of them and how to fix them:

  • Incorrect Type Definitions: One of the advantages of TypeScript is its strong typing. Make sure your variables, function parameters, and return types are correctly defined. Incorrect types can lead to unexpected behavior and errors.
  • Scope Issues: Be mindful of variable scopes. Variables declared inside a function are only accessible within that function. If you need to access a variable from multiple functions, declare it outside of those functions.
  • Incorrect File Paths: When importing modules, ensure that the file paths are correct. TypeScript can’t find modules if the paths are wrong. Double-check your import statements and file structure.
  • Uninitialized Variables: Always initialize variables before using them. Uninitialized variables can have unpredictable values, leading to errors.
  • Missing Error Handling: Add error handling to your code. For example, check if a room exists before trying to access it. Handle potential errors gracefully to prevent the game from crashing.
  • Logic Errors: Debug your game logic carefully. Test different scenarios and inputs to identify and fix any logic errors. Use console logs to trace the flow of your game and identify where the problem occurs.

Key Takeaways

In this tutorial, we’ve built a simple text-based adventure game using TypeScript. We’ve covered the basics of object-oriented programming, classes, objects, and game logic. You’ve also learned how to use TypeScript’s features like types, interfaces, and modules. Building this game has hopefully given you a solid foundation for building more complex games and applications with TypeScript.

Remember to practice regularly, experiment with different features, and learn from your mistakes. The more you code, the better you’ll become! Consider this project a starting point for exploring the world of game development and TypeScript. You can extend this game by adding more features like combat, puzzles, and a more extensive world to explore. The possibilities are endless. Keep coding, keep learning, and have fun!