Building A Modern Text Adventure Game

Building A Modern Text Adventure Game

I call it NarrAItor

·

10 min read

Back then when computers started making their ways into our lives, we did not have GPUs. What we did have was text based adventure games and we would spend hours playing, drawing the maps that we explored and just had plain fun. Those games were something special - they made us use our imagination and problem-solving skills in ways that we could not imagine before.

With the age of AI, I thought it would be a perfect opportunity to try and create a simple text-based adventure game in Blazor WebAssembly and relive the experience. But I wanted to add a modern twist - what if each game could be unique, generated by AI?

I decided to call this game NarrAItor and come up with 6 themes. By using the OpenAI API, I could generate a completely different game each time someone plays. Pick fantasy today, sci-fi tomorrow - each time you'll get a fresh adventure. Even if you select the same theme twice, the adventures would not be the same.

The initial game screen where you select your theme. I even used ChatGPT to create the theme images - AI helping to build a game about AI!

The main play screen, where I tried to capture that classic text adventure feel. I spent quite a bit of time getting the font just right to give it that retro terminal look.

Why Build It This Way?

When I started planning this project, I had some key decisions to make. Here's what I went with and why:

  1. Blazor WebAssembly - I wanted the game to feel responsive, just like those old text adventures. Server-side rendering would introduce latency that would break the immersion. WebAssembly lets me handle all the game logic client-side, with the server only getting involved for AI operations.

  2. Tailwind CSS - Getting that retro look while keeping things modern required some careful styling. Tailwind's utility-first approach made it easy to experiment with different looks until I found the perfect balance between nostalgia and usability.

  3. Command Pattern - This was crucial for handling player input in a maintainable way. It lets me add new commands without touching existing code and makes testing much easier.

  4. OpenAI Integration - This is what makes each playthrough unique. Though I have to admit, getting it to behave was trickier than I expected.

The Architecture

I ended up with three main projects:

NarrAItor (Blazor WASM Frontend)

This is where the magic happens - the UI, command handling, and game state management. I implemented it as a Blazor WebAssembly application to get that snappy, desktop-like feel. The frontend maintains the current game state in memory and handles all the command processing locally.

NarrAItor.Api

This serves as our bridge to OpenAI. It handles all the AI communication and game generation, implementing retry logic and timeout handling. I structured it as a lightweight API that only does two things:

  • Generate new game content based on themes

  • Process custom commands that require AI interpretation

NarrAItor.Shared

This project defines our domain models and interfaces. It's crucial for maintaining consistency between the frontend and API. It contains:

  • Game state models (Rooms, Items, NPCs)

  • Command interfaces and base implementations

  • DTOs for API communication

  • Shared utilities and extensions

The Interesting Bits

Command Pattern Implementation

This is probably my favorite part of the code. Remember how in old text adventures you could type things like "go north" or just "n"? I wanted that flexibility.

💡
The Command Pattern is a well-known design pattern, not just in game programming but in software development in general. It's one of the 23 design patterns described in the famous "Gang of Four" (GoF) book "Design Patterns: Elements of Reusable Object-Oriented Software" published in 1994.

First, I needed the Command interface:

 namespace NarrAItor.Services.Commands;

public interface ICommand
{
    bool CanHandle(CommandContext context);
    Task ExecuteAsync(CommandContext context);
}

Then, a CommandContext record that encapsulates all the information needed to execute a command - I used a record type here to ensure the command context is immutable:

using NarrAItor.Shared.Models;

namespace NarrAItor.Services.Commands;

public record CommandContext(
    GameState GameState,
    string Command,
    string[] Arguments
)
{
    public Room CurrentRoom
    {
        get
        {
            if (GameState == null)
                throw new InvalidOperationException("GameState is null.");

            if (GameState.CurrentRoomId == null)
                throw new InvalidOperationException("CurrentRoomId is null.");

            if (!GameState.Rooms.ContainsKey(GameState.CurrentRoomId))
                throw new KeyNotFoundException($"Room with ID '{GameState.CurrentRoomId}' does not exist in GameState.Rooms.");

            return GameState.Rooms[GameState.CurrentRoomId];
        }
    }

    public string FullArgument => string.Join(" ", Arguments);

    public void AddToLog(string message)
    {
        GameState.AddToLog(message);
    }
}

Then put it all together in a Movement Command - there are other commands too by the way:

using NarrAItor.Shared.Models;

namespace NarrAItor.Services.Commands;

public class MovementCommand : ICommand
{
    private static readonly HashSet<string> MoveCommands = new(StringComparer.OrdinalIgnoreCase)
    {
        "go", "move", "walk", "run", "head"
    };

    private static readonly Dictionary<string, string> DirectionAliases = new(StringComparer.OrdinalIgnoreCase)
    {
        { "n", "north" }, { "s", "south" }, { "e", "east" }, { "w", "west" },
        { "u", "up" }, { "d", "down" },
        { "forward", "north" }, { "backward", "south" }, { "backwards", "south" },
        { "right", "east" }, { "left", "west" }
    };

    public bool CanHandle(CommandContext context)
    {
        return MoveCommands.Contains(context.Command) || DirectionAliases.ContainsKey(context.Command);
    }

    public Task ExecuteAsync(CommandContext context)
    {
        var direction = context.Arguments.Length > 0 ? context.Arguments[0] : context.Command;

        if (DirectionAliases.TryGetValue(direction, out var normalizedDirection))
        {
            direction = normalizedDirection;
        }

        if (context.CurrentRoom.Exits.TryGetValue(direction, out var exit))
        {
            if (exit.Condition != "none" && !string.IsNullOrEmpty(exit.Condition) )
            {
                context.AddToLog($"You can't go that way: {exit.Condition}");
                return Task.CompletedTask;
            }

            context.GameState.CurrentRoomId = exit.TargetId;

            if (!string.IsNullOrEmpty(exit.Description))
            {
                context.AddToLog(exit.Description);
            }

            var newRoom = context.GameState.Rooms[exit.TargetId];
            context.AddToLog(newRoom.Description.Initial);
            DescribeRoomDetails(context, newRoom);
        }
        else
        {
            var availableExits = string.Join(", ", context.CurrentRoom.Exits.Keys);
            context.AddToLog(availableExits.Length > 0
                ? $"You can't go {direction} from here. Available exits are: {availableExits}"
                : "There are no visible exits.");
        }

        return Task.CompletedTask;
    }

    private void DescribeRoomDetails(CommandContext context, Room room)
    {
        // Describe atmosphere if present
        if (room.Atmosphere != null)
        {
            if (!string.IsNullOrEmpty(room.Atmosphere.Sights))
                context.AddToLog(room.Atmosphere.Sights);
            if (!string.IsNullOrEmpty(room.Atmosphere.Sounds))
                context.AddToLog(room.Atmosphere.Sounds);
            if (!string.IsNullOrEmpty(room.Atmosphere.Smells))
                context.AddToLog(room.Atmosphere.Smells);
        }

        // List exits
        if (room.Exits.Any())
        {
            context.AddToLog($"Exits: {string.Join(", ", room.Exits.Keys)}");
        }
        else
        {
            context.AddToLog("There are no visible exits.");
        }

        // List items
        if (room.Items.Any())
        {
            context.AddToLog($"You see: {string.Join(", ", room.Items.Select(i => i.Name))}");
        }

        // List NPCs
        if (room.NPCs.Any())
        {
            context.AddToLog($"Present here: {string.Join(", ", room.NPCs.Select(n => n.Name))}");
        }

        // Update room visited flag
        context.GameState.GameFlags[$"visited_{room.Id}"] = true;
    }
}

We needed to make sure that we have multiple verb support and comprehensive direction aliases for better user experience and more intuitive input. Static only collections give us better performance.

CanHandle method validates the command.

ExecuteAsync handles the movement execution and then provides a comprehensive room description after movement.

Here is a hypothetical implementation of how this would work:

// Example user input: "go north"
var input = "go north";
var context = new CommandContext(gameState, "go", new[] { "north" });
var command = new MovementCommand();

if (command.CanHandle(context))
{
    await command.ExecuteAsync(context);
}

AI Game Generation

Getting OpenAI to generate good game content was... interesting. Two main issues kept popping up:

Timeouts and Cancellation Token - AI can take its time thinking and API calls are much slower than for example chatting with ChatGPT, but players don't want to wait forever. Here's how I handled that:

public async Task<GameState> GenerateNewGame(string theme, CancellationToken cancellationToken = default)
    {
        var retryCount = 0;

        while (true)
        {
            try
            {
                using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
                linkedCts.CancelAfter(TimeSpan.FromSeconds(TimeoutSeconds));

                var response = await _httpClient.PostAsync(
                    $"/api/game/generate?theme={Uri.EscapeDataString(theme)}",
                    null,
                    linkedCts.Token);

                if (!response.IsSuccessStatusCode)
                {
                    var errorContent = await response.Content.ReadFromJsonAsync<ApiError>(
                        cancellationToken: linkedCts.Token);

                    _logger.LogError("Failed to generate new game. Status: {Status}, Error: {@Error}",
                        response.StatusCode, errorContent);

                    if (response.StatusCode == HttpStatusCode.ServiceUnavailable && retryCount < MaxRetries)
                    {
                        retryCount++;
                        _logger.LogInformation("Retrying game generation. Attempt {Attempt} of {MaxRetries}",
                            retryCount, MaxRetries);
                        await Task.Delay(RetryDelayMs * retryCount, cancellationToken);
                        continue;
                    }

                    if (response.StatusCode == HttpStatusCode.ServiceUnavailable)
                    {
                        throw new GameGenerationTimeoutException(
                            errorContent?.Error ?? "Generation timed out after multiple attempts");
                    }

                    throw new GameGenerationException(
                        errorContent?.Error ?? "Failed to generate game");
                }

                var gameState = await response.Content.ReadFromJsonAsync<GameState>(
                    cancellationToken: linkedCts.Token);

                // Validate and ensure every room has at least one exit
                if (gameState != null)
                {
                    EnsureRoomsHaveExits(gameState);
                }

                return gameState ?? throw new GameGenerationException("Failed to deserialize game state");
            }
            catch (OperationCanceledException) when (retryCount < MaxRetries)
            {
                retryCount++;
                _logger.LogWarning(
                    "Game generation attempt {Attempt} of {MaxRetries} timed out after {Timeout} seconds",
                    retryCount, MaxRetries, TimeoutSeconds);
                await Task.Delay(RetryDelayMs * retryCount, cancellationToken);
            }
            catch (OperationCanceledException)
            {
                _logger.LogWarning(
                    "Game generation failed after {Attempts} attempts, total time: {TotalTime} seconds",
                    retryCount + 1, (retryCount + 1) * TimeoutSeconds);
                throw new GameGenerationTimeoutException(
                    $"Request timed out after {(retryCount + 1) * TimeoutSeconds} seconds and {retryCount + 1} attempts. Please try again.");
            }
            catch (Exception ex) when (ex is not GameGenerationException && ex is not GameGenerationTimeoutException)
            {
                _logger.LogError(ex, "Failed to generate new game");
                throw new GameGenerationException("Failed to generate new game", ex);
            }
        }
    }

In the code above, we make 3 attempts to generate the game.

You will see that we are using something called CancellationToken in this class.

A CancellationToken is a mechanism in .NET that allows you to cancel long-running operations gracefully. In our code, it's particularly important for the OpenAI API calls which could take some time. Using it allows us to prevent hanging if OpenAI API is slow, allow retries for temporary failures, clean up resource if user cancels to name a few.

It is worth noting that, we connect to OpenAI API only at the beginning. After we create the game, we do not contact OpenAI API again.

Rooms Without Exits - Sometimes the AI would generate beautiful rooms... that you couldn't leave! I had to add a safety net by seeing if the room had an exit and if one did not exist then randomly assigning one to it:

public void EnsureRoomsHaveExits(GameState gameState)
    {
        var rooms = gameState.Rooms.Values.ToList();
        var random = new Random();

        for (int i = 0; i < rooms.Count; i++)
        {
            var room = rooms[i];

            if (room.Exits == null || room.Exits.Count == 0)
            {
                // Pick a random room to create an exit to
                var targetRoom = rooms[random.Next(rooms.Count)];

                // Ensure the target room is not the same as the current room
                while (targetRoom.Id == room.Id)
                {
                    targetRoom = rooms[random.Next(rooms.Count)];
                }

                // Get a random direction for the exit
                var direction = GetRandomDirection();

                // Create a new exit with appropriate arguments for targetId and optional parameters
                var newExit = new Exit(
                    targetId: targetRoom.Id,
                    description: $"A doorway leading {direction} to {targetRoom.Name}.",
                    condition: "none"
                );

                // Add the exit to the room's exits collection
                room.Exits = new Dictionary<string, Exit>
                {
                    { direction, newExit }
                };
            }
        }
    }

What I Learned

Building this game gave me a newfound respect for those early game developers. They created complex, engaging games with far fewer tools than we have today. No fancy APIs, no AI assistance - just clever code and creativity.

The AI integration was both exciting and challenging. While it can generate amazing content, it needs guardrails. You can't just let it run free - you need to guide it, handle its quirks, and plan for when it doesn't behave as expected.

Some specific lessons:

  1. Prompt Engineering is Crucial - The quality of AI-generated content depends heavily on how you structure your prompts. I found that giving explicit examples and constraints works better than abstract descriptions.

  2. Always Have Fallbacks - AI services can fail, be slow, or generate unusable content. Having fallback content ready ensures players can always play something.

  3. State Management is Key - In a Blazor WASM app, managing game state and keeping it in sync with the UI requires careful thought. I ended up using a combination of observable state and events to keep everything coordinated.

What's Next?

I have some ideas for improvements:

  1. More Complex Worlds - Currently, I'm limited by OpenAI API timeouts. With better prompting and maybe some caching, we could generate larger, more interconnected worlds.

  2. Better Natural Language Processing - Right now, the command parsing is pretty basic. I'd love to add more sophisticated natural language processing, possibly using AI to interpret more complex commands.

  3. Dynamic NPCs - The AI could generate more interesting characters with their own behaviors and storylines.

  4. Persistent Worlds - Adding the ability to save and return to games would make longer adventures possible.

The source code is available here: https://github.com/tjgokken/NarrAItor

But the most important thing? This project reminded me why those early text adventures were so special. Sometimes, less is more - a simple text interface and good storytelling can create an experience just as engaging as any modern 3D game.

Would you like to contribute or try it out? Feel free to grab the code and start your own adventure!