Case Study 2: Object-Oriented Crypts of Pascalia

"The strength of object-oriented programming is not in any single technique, but in the way it lets your code mirror the structure of your problem."


The Scenario

Crypts of Pascalia — our text adventure running example — has been growing chapter by chapter. In its procedural form, the game has rooms, items, a player with an inventory, and simple commands. It works, but our lead designer, Marcus, has been hitting walls.

"I want to add locked doors," Marcus explains. "And keys that unlock them. And potions that heal you. And traps that damage you. And NPCs you can talk to. Every time I add something new, I have to modify five different procedures. Last week I added torches, and I accidentally broke the PickUpItem procedure because torches have a BurnsOut flag that other items do not have."

Marcus's frustration points to a structural problem. In the procedural version, every item is represented by the same record type, with fields that may or may not be relevant depending on what kind of item it is. Every procedure that handles items must check what kind of item it is dealing with and branch accordingly. Adding a new item type means modifying every one of those procedures.

This is the exact problem that OOP was designed to solve. Let us redesign Crypts of Pascalia with classes.


The Procedural Version (Before)

Here is a simplified view of the procedural game:

type
  TItemKind = (ikGeneral, ikWeapon, ikPotion, ikKey, ikTorch);

  TItemRecord = record
    Name: string;
    Description: string;
    Kind: TItemKind;
    { Fields that only apply to certain kinds: }
    Damage: Integer;       { weapons only }
    HealAmount: Integer;   { potions only }
    KeyID: Integer;        { keys only }
    BurnsOut: Boolean;     { torches only }
    TurnsLeft: Integer;    { torches only }
  end;

  TRoomRecord = record
    Name: string;
    Description: string;
    Items: array[1..10] of TItemRecord;
    ItemCount: Integer;
    Exits: array[1..4] of Integer;  { N, S, E, W room indices }
  end;

  TPlayerRecord = record
    Name: string;
    Health: Integer;
    CurrentRoom: Integer;
    Inventory: array[1..20] of TItemRecord;
    InvCount: Integer;
  end;

procedure DescribeItem(const Item: TItemRecord);
begin
  Write(Item.Name, ': ', Item.Description);
  case Item.Kind of
    ikWeapon:  WriteLn(' [Damage: ', Item.Damage, ']');
    ikPotion:  WriteLn(' [Heals: ', Item.HealAmount, ' HP]');
    ikKey:     WriteLn(' [Key #', Item.KeyID, ']');
    ikTorch:   WriteLn(' [', Item.TurnsLeft, ' turns remaining]');
    else       WriteLn;
  end;
end;

procedure UseItem(var Player: TPlayerRecord; ItemIndex: Integer);
begin
  case Player.Inventory[ItemIndex].Kind of
    ikPotion:
    begin
      Player.Health := Player.Health + Player.Inventory[ItemIndex].HealAmount;
      WriteLn('You drink the ', Player.Inventory[ItemIndex].Name,
              '. Health restored to ', Player.Health, '.');
      { Remove potion from inventory — it's consumed }
      { ... messy array shifting code ... }
    end;
    ikTorch:
    begin
      if Player.Inventory[ItemIndex].BurnsOut then
        Dec(Player.Inventory[ItemIndex].TurnsLeft);
      WriteLn('The ', Player.Inventory[ItemIndex].Name, ' flickers.');
    end;
    else
      WriteLn('You cannot use that item.');
  end;
end;

The Problems

  1. Wasted memory. Every item has fields for weapons, potions, keys, and torches — even though most items use only one or two of these fields.

  2. Fragile case statements. Every procedure that handles items has a case Item.Kind of block. Adding a new item kind means finding and updating every single one of these blocks. Miss one, and you get bugs.

  3. No behavioral differences. A potion and a sword are the same type of data. The only thing that distinguishes them is a Kind field and scattered case statements. There is no way to give a potion special behavior that a sword does not have without adding yet another case branch.

  4. Rooms and players are tightly coupled to the item representation. If we change TItemRecord, we must recompile everything.


The OOP Design

We ask our OOP question: What objects exist in this game world?

  • Game items — things the player can pick up, use, examine.
  • Rooms — locations the player can be in, containing items and exits.
  • The player — has health, inventory, current location.
  • The game world — the collection of rooms, the game loop.

And crucially, different kinds of items have different behavior. A potion heals when used. A key unlocks a door. A torch provides light. A weapon does damage. This suggests a class per item type — but all items share some common behavior (they have a name, a description, they can be picked up and dropped).

For now, we will use a single TGameItem class with subtype-specific fields managed through properties. In Chapter 17, we will refactor this into an inheritance hierarchy with TWeapon, TPotion, and TKey as subclasses. The design we build here is a solid starting point that will evolve.

The Classes

{$mode objfpc}{$H+}
uses SysUtils, Classes;

type
  TGameItem = class
  private
    FName: string;
    FDescription: string;
    FCanPickUp: Boolean;
    FIsUsable: Boolean;
    FUseMessage: string;
  public
    constructor Create(const AName, ADesc: string);
    destructor Destroy; override;
    procedure Describe;
    function Use: string; virtual;
    property Name: string read FName;
    property Description: string read FDescription;
    property CanPickUp: Boolean read FCanPickUp write FCanPickUp;
    property IsUsable: Boolean read FIsUsable write FIsUsable;
  end;

  TRoom = class
  private
    FName: string;
    FDescription: string;
    FItems: array of TGameItem;
    FItemCount: Integer;
    FExits: array[0..3] of TRoom;  { 0=North, 1=South, 2=East, 3=West }
    FIsLit: Boolean;
  public
    constructor Create(const AName, ADesc: string);
    destructor Destroy; override;
    procedure AddItem(AItem: TGameItem);
    function RemoveItem(const AItemName: string): TGameItem;
    function FindItem(const AItemName: string): TGameItem;
    procedure SetExit(ADirection: Integer; ARoom: TRoom);
    function GetExit(ADirection: Integer): TRoom;
    procedure Describe;
    procedure ListItems;
    property Name: string read FName;
    property Description: string read FDescription;
    property IsLit: Boolean read FIsLit write FIsLit;
    property ItemCount: Integer read FItemCount;
  end;

  TPlayer = class
  private
    FName: string;
    FHealth: Integer;
    FMaxHealth: Integer;
    FCurrentRoom: TRoom;
    FInventory: array of TGameItem;
    FInvCount: Integer;
  public
    constructor Create(const AName: string; AMaxHealth: Integer);
    destructor Destroy; override;
    procedure PickUp(const AItemName: string);
    procedure Drop(const AItemName: string);
    procedure UseItem(const AItemName: string);
    function HasItem(const AItemName: string): Boolean;
    function FindInInventory(const AItemName: string): TGameItem;
    procedure TakeDamage(AAmount: Integer);
    procedure Heal(AAmount: Integer);
    procedure ShowInventory;
    procedure ShowStatus;
    procedure MoveTo(ARoom: TRoom);
    property Name: string read FName;
    property Health: Integer read FHealth;
    property MaxHealth: Integer read FMaxHealth;
    property CurrentRoom: TRoom read FCurrentRoom;
    property InventoryCount: Integer read FInvCount;
  end;

  TGameWorld = class
  private
    FRooms: array of TRoom;
    FRoomCount: Integer;
    FPlayer: TPlayer;
    FIsRunning: Boolean;
    procedure ProcessCommand(const ACommand: string);
    procedure DoLook;
    procedure DoGo(const ADirection: string);
    procedure DoPickUp(const AItemName: string);
    procedure DoDrop(const AItemName: string);
    procedure DoUse(const AItemName: string);
    procedure DoInventory;
    procedure DoHelp;
  public
    constructor Create(const APlayerName: string);
    destructor Destroy; override;
    function AddRoom(const AName, ADesc: string): TRoom;
    procedure SetStartRoom(ARoom: TRoom);
    procedure Run;
    property Player: TPlayer read FPlayer;
    property IsRunning: Boolean read FIsRunning;
  end;

Key Implementations

TGameItem: Self-Describing Objects

constructor TGameItem.Create(const AName, ADesc: string);
begin
  inherited Create;
  if AName = '' then
    raise Exception.Create('Item must have a name');
  FName := AName;
  FDescription := ADesc;
  FCanPickUp := True;
  FIsUsable := False;
  FUseMessage := 'Nothing happens.';
end;

procedure TGameItem.Describe;
begin
  WriteLn(FName, ': ', FDescription);
end;

function TGameItem.Use: string;
begin
  if FIsUsable then
    Result := FUseMessage
  else
    Result := 'You cannot use the ' + FName + '.';
end;

Notice that Use is declared virtual. This is a keyword we will explore fully in Chapter 17. For now, know that it means descendant classes can override this method to provide specialized behavior. A TPotion class could override Use to actually heal the player instead of just printing a message.

TRoom: A Container of Items

constructor TRoom.Create(const AName, ADesc: string);
var
  I: Integer;
begin
  inherited Create;
  FName := AName;
  FDescription := ADesc;
  FItemCount := 0;
  SetLength(FItems, 10);
  FIsLit := True;
  for I := 0 to 3 do
    FExits[I] := nil;
end;

destructor TRoom.Destroy;
var
  I: Integer;
begin
  { Free all items that are still in the room }
  for I := 0 to FItemCount - 1 do
    FItems[I].Free;
  inherited Destroy;
end;

procedure TRoom.AddItem(AItem: TGameItem);
begin
  if FItemCount >= Length(FItems) then
    SetLength(FItems, Length(FItems) * 2);
  FItems[FItemCount] := AItem;
  Inc(FItemCount);
end;

function TRoom.RemoveItem(const AItemName: string): TGameItem;
var
  I, J: Integer;
begin
  Result := nil;
  for I := 0 to FItemCount - 1 do
  begin
    if CompareText(FItems[I].Name, AItemName) = 0 then
    begin
      Result := FItems[I];
      { Shift remaining items down }
      for J := I to FItemCount - 2 do
        FItems[J] := FItems[J + 1];
      Dec(FItemCount);
      Exit;
    end;
  end;
end;

procedure TRoom.Describe;
var
  DirNames: array[0..3] of string = ('North', 'South', 'East', 'West');
  I: Integer;
begin
  WriteLn;
  WriteLn('=== ', FName, ' ===');
  if FIsLit then
  begin
    WriteLn(FDescription);
    WriteLn;
    if FItemCount > 0 then
    begin
      WriteLn('You see:');
      for I := 0 to FItemCount - 1 do
        WriteLn('  - ', FItems[I].Name);
    end;
    WriteLn;
    Write('Exits: ');
    for I := 0 to 3 do
      if FExits[I] <> nil then
        Write(DirNames[I], ' ');
    WriteLn;
  end
  else
    WriteLn('It is too dark to see anything.');
end;

The Describe method is a beautiful example of OOP encapsulation. The room knows how to describe itself. It knows its own name, its own items, its own exits. No external procedure needs to reach into the room's internals.

TPlayer: Inventory Management

procedure TPlayer.PickUp(const AItemName: string);
var
  Item: TGameItem;
begin
  if FCurrentRoom = nil then
  begin
    WriteLn('You are nowhere.');
    Exit;
  end;

  Item := FCurrentRoom.FindItem(AItemName);
  if Item = nil then
  begin
    WriteLn('There is no "', AItemName, '" here.');
    Exit;
  end;

  if not Item.CanPickUp then
  begin
    WriteLn('You cannot pick up the ', Item.Name, '.');
    Exit;
  end;

  { Transfer item from room to inventory }
  Item := FCurrentRoom.RemoveItem(AItemName);
  if FInvCount >= Length(FInventory) then
    SetLength(FInventory, Length(FInventory) * 2);
  FInventory[FInvCount] := Item;
  Inc(FInvCount);
  WriteLn('You pick up the ', Item.Name, '.');
end;

procedure TPlayer.UseItem(const AItemName: string);
var
  Item: TGameItem;
  Msg: string;
begin
  Item := FindInInventory(AItemName);
  if Item = nil then
  begin
    WriteLn('You do not have a "', AItemName, '".');
    Exit;
  end;
  Msg := Item.Use;
  WriteLn(Msg);
end;

Notice the clean flow of PickUp: check the room, check the item exists, check it is pickable, then transfer ownership from room to player. Each check produces a clear error message. The player asks the room to remove the item; it does not reach into the room's internal array.

TGameWorld: The Game Loop

constructor TGameWorld.Create(const APlayerName: string);
begin
  inherited Create;
  FPlayer := TPlayer.Create(APlayerName, 100);
  FRoomCount := 0;
  SetLength(FRooms, 20);
  FIsRunning := True;
end;

destructor TGameWorld.Destroy;
var
  I: Integer;
begin
  FPlayer.Free;
  for I := 0 to FRoomCount - 1 do
    FRooms[I].Free;    { Each room frees its own items }
  inherited Destroy;
end;

procedure TGameWorld.Run;
var
  Command: string;
begin
  WriteLn('Welcome to the Crypts of Pascalia!');
  WriteLn('Type "help" for a list of commands.');
  DoLook;

  while FIsRunning do
  begin
    WriteLn;
    Write('> ');
    ReadLn(Command);
    Command := Trim(LowerCase(Command));
    if Command = 'quit' then
      FIsRunning := False
    else
      ProcessCommand(Command);
  end;

  WriteLn('Thank you for playing Crypts of Pascalia!');
end;

procedure TGameWorld.ProcessCommand(const ACommand: string);
begin
  if ACommand = 'look' then
    DoLook
  else if ACommand = 'inventory' then
    DoInventory
  else if ACommand = 'help' then
    DoHelp
  else if (ACommand = 'north') or (ACommand = 'n') then
    DoGo('north')
  else if (ACommand = 'south') or (ACommand = 's') then
    DoGo('south')
  else if (ACommand = 'east') or (ACommand = 'e') then
    DoGo('east')
  else if (ACommand = 'west') or (ACommand = 'w') then
    DoGo('west')
  else if Copy(ACommand, 1, 7) = 'pick up' then
    DoPickUp(Trim(Copy(ACommand, 8, Length(ACommand))))
  else if Copy(ACommand, 1, 4) = 'take' then
    DoPickUp(Trim(Copy(ACommand, 5, Length(ACommand))))
  else if Copy(ACommand, 1, 4) = 'drop' then
    DoDrop(Trim(Copy(ACommand, 5, Length(ACommand))))
  else if Copy(ACommand, 1, 3) = 'use' then
    DoUse(Trim(Copy(ACommand, 4, Length(ACommand))))
  else
    WriteLn('I do not understand "', ACommand, '". Type "help" for commands.');
end;

A Complete Game Setup

var
  World: TGameWorld;
  Entrance, Corridor, TreasureRoom: TRoom;
  Torch, GoldCoin, OldKey, Potion: TGameItem;
begin
  Randomize;
  World := TGameWorld.Create('Adventurer');
  try
    { Create rooms }
    Entrance := World.AddRoom('Entrance Hall',
      'A grand hall with crumbling stone walls. Cobwebs hang from the ceiling. ' +
      'The air smells of dust and centuries.');
    Corridor := World.AddRoom('Dark Corridor',
      'A narrow corridor stretches into darkness. Strange markings cover the walls.');
    TreasureRoom := World.AddRoom('Treasure Room',
      'A small chamber glittering with ancient artifacts. A stone pedestal ' +
      'stands in the center.');

    { Connect rooms }
    Entrance.SetExit(0, Corridor);      { North from Entrance -> Corridor }
    Corridor.SetExit(1, Entrance);      { South from Corridor -> Entrance }
    Corridor.SetExit(2, TreasureRoom);  { East from Corridor -> Treasure Room }
    TreasureRoom.SetExit(3, Corridor);  { West from Treasure Room -> Corridor }

    { Create and place items }
    Torch := TGameItem.Create('Torch',
      'A wooden torch wrapped in oil-soaked cloth. It still burns faintly.');
    Torch.IsUsable := True;
    Entrance.AddItem(Torch);

    GoldCoin := TGameItem.Create('Gold Coin',
      'An ancient coin bearing the face of a forgotten king.');
    TreasureRoom.AddItem(GoldCoin);

    OldKey := TGameItem.Create('Rusty Key',
      'A heavy iron key, orange with rust but still solid.');
    Corridor.AddItem(OldKey);

    Potion := TGameItem.Create('Health Potion',
      'A small glass vial filled with a glowing red liquid.');
    Potion.IsUsable := True;
    TreasureRoom.AddItem(Potion);

    { Set starting room and run }
    World.SetStartRoom(Entrance);
    World.Run;
  finally
    World.Free;
  end;
end.

What OOP Gives Us

1. Self-Contained Objects

Each object knows how to describe itself, manage its contents, and validate its operations. The room describes itself. The player manages its own inventory. The item reports whether it can be used. No external code needs to know the internal structure of any object.

2. Clear Ownership

TGameWorld
  ├── owns TPlayer
  │     └── owns TGameItem[] (inventory — items transferred from rooms)
  └── owns TRoom[]
        ├── owns TGameItem[] (items in the room)
        └── references TRoom (exits — borrowed, not owned)

When TGameWorld is freed, it frees the player (who frees inventory items) and all rooms (each frees its own items). No item is freed twice because each item is always owned by exactly one container — either a room or the player's inventory.

3. Easy Extension Points

Want to add a new item type? In the current design, create the item with appropriate properties. In Chapter 17, create a new subclass like TWeapon or TPotion that overrides the Use method. No existing code needs to change.

Want to add NPCs? Create a TCharacter class, add an array of characters to TRoom, and add "talk" to the command processor. The existing room, item, and player classes are untouched.

Want to add combat? Add Attack and Defend methods to the player, create a TMonster class, and add encounter logic to room entry. Again, existing classes remain stable.

4. Testability

Each class can be tested in isolation:

{ Test TGameItem independently }
Item := TGameItem.Create('Test Item', 'A test');
Assert(Item.Name = 'Test Item');
Assert(Item.CanPickUp = True);
Assert(Item.IsUsable = False);
Item.Free;

{ Test TRoom independently }
Room := TRoom.Create('Test Room', 'A test room');
TestItem := TGameItem.Create('Sword', 'A sharp sword');
Room.AddItem(TestItem);
Assert(Room.ItemCount = 1);
Assert(Room.FindItem('Sword') <> nil);
Assert(Room.FindItem('Shield') = nil);
Room.Free;  { Also frees the sword }

This isolation is impossible in the procedural version, where everything depends on global state.


Looking Ahead

The current TGameItem class is a good start, but it has a limitation: all items use the same Use method. A potion and a torch have different use behaviors, but we handle that through FUseMessage rather than through genuinely different code paths.

In Chapter 17, we will refactor the item hierarchy using inheritance:

TGameItem (base class)
  ├── TWeapon (overrides Use to deal damage)
  ├── TPotion (overrides Use to heal, then removes itself)
  ├── TKey    (overrides Use to unlock a door)
  └── TTorch  (overrides Use to toggle light, tracks burn time)

Each subclass will override the Use method with its own specific behavior. The player's UseItem method will not need to change — it calls Item.Use, and the correct behavior happens automatically. This is polymorphism, and it is the most powerful idea in all of OOP.


Discussion Questions

  1. In the current design, when a player picks up an item, ownership transfers from the room to the player. What would happen if we forgot to remove the item from the room's array? What bug would this cause?

  2. The ProcessCommand method uses a chain of if-else statements. How might this be redesigned using OOP techniques? (Think: could commands themselves be objects?)

  3. If we wanted to add a "save game" feature, which classes would need a SaveToFile method? Would it be better to add Save/Load to each class, or to create a separate TGameSaver class that knows how to serialize the game state?

  4. The TRoom.Describe method checks FIsLit to decide whether to show room details. Is this the right place for this check? What if different items (like torches) should affect whether a room is lit?


Crypts of Pascalia will continue to evolve throughout Part III. In Chapter 17, item inheritance transforms the game. In Chapter 18, interfaces enable save/load and plugin systems. By Chapter 21, the game will demonstrate advanced Object Pascal patterns including generics and design patterns.