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
-
Wasted memory. Every item has fields for weapons, potions, keys, and torches — even though most items use only one or two of these fields.
-
Fragile
casestatements. Every procedure that handles items has acase Item.Kind ofblock. Adding a new item kind means finding and updating every single one of these blocks. Miss one, and you get bugs. -
No behavioral differences. A potion and a sword are the same type of data. The only thing that distinguishes them is a
Kindfield and scatteredcasestatements. There is no way to give a potion special behavior that a sword does not have without adding yet anothercasebranch. -
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
-
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?
-
The
ProcessCommandmethod uses a chain ofif-elsestatements. How might this be redesigned using OOP techniques? (Think: could commands themselves be objects?) -
If we wanted to add a "save game" feature, which classes would need a
SaveToFilemethod? Would it be better to addSave/Loadto each class, or to create a separateTGameSaverclass that knows how to serialize the game state? -
The
TRoom.Describemethod checksFIsLitto 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.