Building a Text Adventure Game

Learn how to create an interactive text adventure with object-oriented design in ShitRust.

Introduction

In this tutorial, we'll build a text adventure game called "The Enchanted Forest Adventure". This project will demonstrate several advanced ShitRust features:

  • Object-oriented programming with structs and methods
  • Working with collections (Vector, HashMap)
  • Game state management
  • User input parsing
  • Control flow with pattern matching
  • Dynamic game world creation

Our game will feature:

  • Multiple rooms to explore
  • Items to examine, take, drop, and use
  • An inventory system
  • Interactive puzzles
  • Random encounters
  • Health system

By the end of this tutorial, you'll have a complete text adventure game and understand how to expand it with your own ideas.

📌 Note

This tutorial assumes you have completed the Calculator and Todo List tutorials or have equivalent experience with ShitRust.

Game Design

Before diving into coding, let's outline the structure of our game:

Core Components

  • Item: Objects that can be examined, taken, and used
  • Room: Locations that contain items and connections to other rooms
  • Player: The user's avatar with inventory and health
  • Game: The main engine that manages rooms, the player, and game logic

Game Map

Our adventure will take place in an enchanted forest with the following locations:

  • Forest Entrance: The starting point
  • Forest Clearing: Contains a mysterious pedestal
  • Winding Path: Leads deeper into the forest
  • Dark Cave: A mysterious cave with hidden treasures
  • Abandoned Cabin: Contains useful items and clues
  • Hidden Treasure Room: A secret room that can be unlocked

Let's start implementing these components one by one.

Items Implementation

First, let's set up our imports and define the Item struct:

// Import required modules
import { stdin, stdout } from "std/io";
import { Vector, HashMap } from "std/collections";
import { Random } from "std/math";

// Define Item struct
struct Item {
    name: string,
    description: string,
    can_be_taken: bool,
    
    // Constructor for creating a new item
    static fn new(name: string, description: string, can_be_taken: bool) -> Item {
        return Item {
            name: name,
            description: description,
            can_be_taken: can_be_taken
        };
    }
    
    // Display the item description
    fn examine() -> void {
        println(self.description);
    }
}

The Item struct has three fields:

  • name: The name of the item (e.g., "key", "book")
  • description: A detailed description of the item
  • can_be_taken: A boolean indicating if the player can pick up the item

We've also defined two methods:

  • new: A static method (constructor) to create a new item
  • examine: A method to display the item's description

Rooms Implementation

Next, let's define the Room struct:

// Define Room struct
struct Room {
    id: string,
    name: string,
    description: string,
    exits: HashMap,  // direction -> room_id
    items: Vector,
    
    // Constructor for creating a new room
    static fn new(id: string, name: string, description: string) -> Room {
        return Room {
            id: id,
            name: name,
            description: description,
            exits: HashMap::new(),
            items: Vector::new()
        };
    }
    
    // Add an exit to the room
    fn add_exit(direction: string, room_id: string) -> void {
        self.exits.insert(direction, room_id);
    }
    
    // Add an item to the room
    fn add_item(item: Item) -> void {
        self.items.push(item);
    }
    
    // Remove an item from the room
    fn remove_item(item_name: string) -> result {
        for i in 0..self.items.length() {
            if self.items[i].name.to_lowercase() == item_name.to_lowercase() {
                let item = self.items[i];
                if !item.can_be_taken {
                    return err("You can't take the " + item_name + ".");
                }
                self.items.remove(i);
                return ok(item);
            }
        }
        return err("There is no " + item_name + " here.");
    }
    
    // Check if a room has an exit in a given direction
    fn has_exit(direction: string) -> bool {
        return self.exits.contains_key(direction);
    }
    
    // Get the ID of the room in a given direction
    fn get_exit(direction: string) -> string {
        return self.exits[direction];
    }
    
    // Display the room description and available exits
    fn describe() -> void {
        println(self.name);
        println(self.description);
        
        // List items in the room
        if self.items.length() > 0 {
            println("\nYou can see:");
            for item in self.items {
                println("- " + item.name);
            }
        }
        
        // List available exits
        println("\nExits:");
        let directions = self.exits.keys();
        for direction in directions {
            println("- " + direction);
        }
    }
}

The Room struct has five fields:

  • id: A unique identifier for the room (e.g., "cave", "entrance")
  • name: The display name of the room
  • description: A detailed description of the room
  • exits: A HashMap mapping directions to room IDs
  • items: A Vector of items in the room

The Room struct has several methods for managing exits, items, and displaying information.

Player Implementation

Now, let's define the Player struct:

// Define Player struct
struct Player {
    current_room_id: string,
    inventory: Vector,
    health: int,
    
    // Constructor for creating a new player
    static fn new(starting_room_id: string) -> Player {
        return Player {
            current_room_id: starting_room_id,
            inventory: Vector::new(),
            health: 100
        };
    }
    
    // Add an item to the player's inventory
    fn add_item(item: Item) -> void {
        self.inventory.push(item);
    }
    
    // Remove an item from the player's inventory
    fn remove_item(item_name: string) -> result {
        for i in 0..self.inventory.length() {
            if self.inventory[i].name.to_lowercase() == item_name.to_lowercase() {
                let item = self.inventory[i];
                self.inventory.remove(i);
                return ok(item);
            }
        }
        return err("You don't have a " + item_name + ".");
    }
    
    // Check if the player has a specific item
    fn has_item(item_name: string) -> bool {
        for item in self.inventory {
            if item.name.to_lowercase() == item_name.to_lowercase() {
                return true;
            }
        }
        return false;
    }
    
    // Display the player's inventory
    fn show_inventory() -> void {
        if self.inventory.length() == 0 {
            println("Your inventory is empty.");
            return;
        }
        
        println("Inventory:");
        for item in self.inventory {
            println("- " + item.name);
        }
    }
}

The Player struct has three fields:

  • current_room_id: The ID of the room the player is currently in
  • inventory: A Vector of items the player is carrying
  • health: The player's health points

The Player struct has methods for managing inventory and checking player status.

Game Engine Implementation

Finally, let's implement the Game struct, which ties everything together:

// Define Game struct
struct Game {
    rooms: HashMap,
    player: Player,
    running: bool,
    random: Random,
    
    // Constructor for creating a new game
    static fn new() -> Game {
        let game = Game {
            rooms: HashMap::new(),
            player: Player::new("entrance"),  // Start in the entrance
            running: true,
            random: Random::new()
        };
        
        // Initialize the game world
        game.setup_world();
        
        return game;
    }
    
    // Setup the game world with rooms and items
    fn setup_world() -> void {
        // Create rooms
        let entrance = Room::new("entrance", "Forest Entrance", 
            "You stand at the entrance to a dark, mysterious forest. " +
            "The trees loom overhead, their branches swaying in the gentle breeze.");
        entrance.add_exit("north", "clearing");
        entrance.add_exit("east", "path");
        entrance.add_item(Item::new("sign", "A wooden sign that reads: 'Beware of the forest creatures!'", false));
        
        let clearing = Room::new("clearing", "Forest Clearing", 
            "A small clearing in the forest. Sunlight filters through the canopy above. " +
            "There's a strange stone pedestal in the center.");
        clearing.add_exit("south", "entrance");
        clearing.add_exit("west", "cave");
        clearing.add_item(Item::new("flowers", "Beautiful wildflowers growing in patches.", true));
        clearing.add_item(Item::new("pedestal", "A stone pedestal with a slot that seems to fit something shiny.", false));
        
        // Add more rooms here...
        
        // Add rooms to the game
        self.rooms.insert("entrance", entrance);
        self.rooms.insert("clearing", clearing);
        // Add more room insertions here...
    }

The Game struct contains:

  • A HashMap of all rooms in the game
  • The player object
  • A flag indicating if the game is running
  • A random number generator for game events

The setup_world method creates all the rooms, adds exits and items, and builds the game world.

Next, we need to implement methods for handling player commands:

// Get the current room
fn current_room() -> Room {
    return self.rooms[self.player.current_room_id];
}

// Process a player command
fn process_command(command: string) -> void {
    let parts = command.to_lowercase().trim().split(" ");
    let action = parts[0];
    
    match action {
        "go" | "move" | "walk" => {
            if parts.length() < 2 {
                println("Go where?");
                return;
            }
            
            let direction = parts[1];
            self.move_player(direction);
        },
        "look" => {
            self.current_room().describe();
        },
        "examine" | "inspect" => {
            if parts.length() < 2 {
                println("Examine what?");
                return;
            }
            
            let item_name = parts[1];
            self.examine_item(item_name);
        },
        // More commands here...
        "help" => {
            self.show_help();
        },
        "quit" | "exit" => {
            println("Are you sure you want to quit? (y/n)");
            let answer = stdin.read_line().trim().to_lowercase();
            if answer == "y" || answer == "yes" {
                self.running = false;
            }
        },
        _ => {
            println("I don't understand that command. Type 'help' for a list of commands.");
        }
    }
}

The process_command method parses player input and calls the appropriate game methods based on the command.

Finally, we need a method to start the game:

// Start the game
fn start() -> void {
    println("=== The Enchanted Forest Adventure ===");
    println("Type 'help' for a list of commands.\n");
    
    // Show the initial room description
    self.current_room().describe();
    
    // Main game loop
    while self.running {
        print("\n> ");
        let command = stdin.read_line();
        self.process_command(command);
    }
    
    println("Thanks for playing!");
}

The start method displays the initial game information, shows the starting room description, and then enters the main game loop.

Running the Game

To run the game, we need a simple main function:

// Main function
fn main() -> void {
    let game = Game::new();
    game.start();
}

To play the game:

  1. Save the code in a file named adventure_game.sr
  2. Open a terminal and navigate to the directory containing your file
  3. Compile and run the program with the following command:
shitrust run adventure_game.sr

Game Commands

Here are the commands you can use in the game:

  • go [direction] - Move in a direction (north, south, east, west)
  • look - Look around the current location
  • examine [item] - Examine an item more closely
  • take [item] - Take an item from the room
  • drop [item] - Drop an item from your inventory
  • inventory (or inv, i) - Show your inventory
  • use [item] - Use an item in your inventory
  • help - Show this help message
  • quit - Quit the game

Extensions and Challenges

Now that you've built a basic text adventure, here are some ideas to extend and improve it:

  1. Add more rooms to expand the world
  2. Implement NPCs (non-player characters) to talk to
  3. Add a combat system for encounters with enemies
  4. Implement a quest system with objectives
  5. Add time-based events or a day/night cycle
  6. Create a save/load system to persist game state
  7. Add a magic system with spells and effects

These extensions will give you more practice with ShitRust and help you build a more complex and engaging adventure game.

Conclusion

Congratulations on completing this tutorial! You've built a text adventure game that demonstrates object-oriented programming, state management, and interactive gameplay.

The complete code for the Text Adventure Game is available in the adventure_game.sr file.

Check out our other tutorials to continue your ShitRust journey:

Or explore the Language Reference to learn more about ShitRust's features.