Case Study 2: Crypts of Pascalia — Items and Rooms
Context
Crypts of Pascalia is our text adventure game that has been evolving throughout this course. The player explores a dungeon, collects items, and navigates between rooms. Until now, rooms and items have been represented with separate variables or simple string arrays. Records change everything — they let us model the game world as a set of coherent, interconnected data structures.
In this case study, we design record types for items, rooms, and the player, then build the data layer for a fully functional dungeon exploration system.
The Design Challenge
A text adventure game has three core entities:
- Items — Objects the player can find, pick up, and use. Different item types have different properties (weapons deal damage, potions heal, keys unlock doors, treasures have gold value).
- Rooms — Locations in the dungeon, each with a description, exits to other rooms, and items that can be found there.
- The Player — The adventurer, with an inventory of carried items, health points, and a current location.
The key challenge: items come in different kinds with different data. A sword has damage and durability; a health potion has a heal amount; a key has a door ID. This is a perfect use case for variant records.
Step 1: The Item Record (Variant)
type
TItemKind = (ikWeapon, ikPotion, ikKey, ikTreasure);
TItem = record
Name: string[30];
Description: string;
Weight: Real;
case Kind: TItemKind of
ikWeapon: (
Damage: Integer;
Durability: Integer
);
ikPotion: (
HealAmount: Integer;
Duration: Integer { Turns the effect lasts }
);
ikKey: (
KeyID: Integer { Matches a locked exit }
);
ikTreasure: (
GoldValue: Integer
);
end;
Why a variant record? All items share a name, description, and weight. But the meaningful data differs by kind. A variant record gives us a single TItem type that can represent any item, enabling a single items array and a single inventory system. The Kind tag field tells us which variant fields are valid.
Why string[30] for Name? In a game, item names are short. Using a short string saves memory when we have hundreds of items. The Description field stays as a full string since descriptions can be longer.
Step 2: The Room Record (Nested)
type
TExits = record
North: Integer; { Room index, 0 = no exit, -N = locked (KeyID = N) }
South: Integer;
East: Integer;
West: Integer;
end;
TRoom = record
Name: string[40];
Description: string;
Exits: TExits;
ItemSlots: array[1..10] of Integer; { Indices into global Items array }
NumItems: Integer;
Visited: Boolean;
IsDark: Boolean;
end;
Design decisions:
- Exits as a nested record: Each direction is an integer index pointing to another room. Zero means no exit. A negative value encodes a locked exit (the absolute value is the
KeyIDneeded to unlock it). This is a simple encoding scheme that avoids needing yet another record type. - Item slots: Rooms do not contain
TItemrecords directly. Instead, they store indices into a global items array. This way, when the player picks up an item, we remove its index from the room's slot list. The item itself stays in the global array — its data is not duplicated. - Visited flag: Allows us to give a shorter description when the player revisits a room.
- IsDark flag: A room might require a light source to see the description and items.
Step 3: The Player Record
type
TPlayer = record
Name: string[20];
Health: Integer;
MaxHealth: Integer;
Gold: Integer;
Location: Integer; { Index into rooms array }
Inventory: array[1..20] of Integer; { Indices into global Items array }
NumItems: Integer;
EquippedWeapon: Integer; { Index into global Items array, 0 = none }
end;
The player carries item indices, not items. EquippedWeapon tracks which weapon (if any) is actively wielded.
Step 4: The Game World
const
MAX_ITEMS = 200;
MAX_ROOMS = 50;
var
AllItems: array[1..MAX_ITEMS] of TItem;
NumItems: Integer;
Dungeon: array[1..MAX_ROOMS] of TRoom;
NumRooms: Integer;
Player: TPlayer;
All items exist in AllItems. Rooms and the player reference items by index. This is a simple but effective form of data normalization — each item exists in exactly one place.
Step 5: Helper Functions
Creating Items
Factory functions make item creation clean and consistent:
function MakeWeapon(AName: string; ADesc: string;
AWeight: Real; ADamage, ADurability: Integer): Integer;
begin
Inc(NumItems);
AllItems[NumItems].Name := AName;
AllItems[NumItems].Description := ADesc;
AllItems[NumItems].Weight := AWeight;
AllItems[NumItems].Kind := ikWeapon;
AllItems[NumItems].Damage := ADamage;
AllItems[NumItems].Durability := ADurability;
MakeWeapon := NumItems; { Return the index }
end;
function MakePotion(AName: string; ADesc: string;
AWeight: Real; AHeal, ADuration: Integer): Integer;
begin
Inc(NumItems);
AllItems[NumItems].Name := AName;
AllItems[NumItems].Description := ADesc;
AllItems[NumItems].Weight := AWeight;
AllItems[NumItems].Kind := ikPotion;
AllItems[NumItems].HealAmount := AHeal;
AllItems[NumItems].Duration := ADuration;
MakePotion := NumItems;
end;
function MakeKey(AName: string; ADesc: string;
AWeight: Real; AKeyID: Integer): Integer;
begin
Inc(NumItems);
AllItems[NumItems].Name := AName;
AllItems[NumItems].Description := ADesc;
AllItems[NumItems].Weight := AWeight;
AllItems[NumItems].Kind := ikKey;
AllItems[NumItems].KeyID := AKeyID;
MakeKey := NumItems;
end;
function MakeTreasure(AName: string; ADesc: string;
AWeight: Real; AGold: Integer): Integer;
begin
Inc(NumItems);
AllItems[NumItems].Name := AName;
AllItems[NumItems].Description := ADesc;
AllItems[NumItems].Weight := AWeight;
AllItems[NumItems].Kind := ikTreasure;
AllItems[NumItems].GoldValue := AGold;
MakeTreasure := NumItems;
end;
Each function returns the index of the newly created item. Notice how every function sets Kind before setting variant-specific fields — this is the critical rule for variant records.
Describing Items
procedure DescribeItem(Index: Integer);
begin
with AllItems[Index] do
begin
WriteLn(Name, ' — ', Description);
Write(' Weight: ', Weight:0:1, ' lbs. ');
case Kind of
ikWeapon:
WriteLn('Damage: ', Damage, ', Durability: ', Durability);
ikPotion:
WriteLn('Heals: ', HealAmount, ' HP, Duration: ', Duration, ' turns');
ikKey:
WriteLn('Key #', KeyID);
ikTreasure:
WriteLn('Value: ', GoldValue, ' gold');
end;
end;
end;
The case Kind of pattern appears every time we process an item. This is the fundamental pattern of working with variant records: dispatch on the tag field.
Step 6: Player Actions
Looking Around
procedure LookAround;
var
Room: TRoom;
I: Integer;
begin
Room := Dungeon[Player.Location];
WriteLn;
WriteLn('=== ', Room.Name, ' ===');
if Room.IsDark then
begin
WriteLn('It is pitch black. You cannot see anything.');
Exit;
end;
WriteLn(Room.Description);
WriteLn;
{ Show exits }
Write('Exits: ');
if Room.Exits.North <> 0 then Write('North ');
if Room.Exits.South <> 0 then Write('South ');
if Room.Exits.East <> 0 then Write('East ');
if Room.Exits.West <> 0 then Write('West ');
WriteLn;
{ Show items on the ground }
if Room.NumItems > 0 then
begin
WriteLn('You see:');
for I := 1 to Room.NumItems do
WriteLn(' - ', AllItems[Room.ItemSlots[I]].Name);
end;
end;
Picking Up Items
function PickUpItem(const ItemName: string): Boolean;
var
RoomIdx, I, ItemIdx: Integer;
begin
PickUpItem := False;
RoomIdx := Player.Location;
{ Find the item in the room }
ItemIdx := 0;
for I := 1 to Dungeon[RoomIdx].NumItems do
if AllItems[Dungeon[RoomIdx].ItemSlots[I]].Name = ItemName then
begin
ItemIdx := I;
Break;
end;
if ItemIdx = 0 then
begin
WriteLn('There is no "', ItemName, '" here.');
Exit;
end;
{ Check inventory space }
if Player.NumItems >= 20 then
begin
WriteLn('Your inventory is full!');
Exit;
end;
{ Move item from room to player }
Inc(Player.NumItems);
Player.Inventory[Player.NumItems] := Dungeon[RoomIdx].ItemSlots[ItemIdx];
{ Remove from room by shifting remaining items down }
for I := ItemIdx to Dungeon[RoomIdx].NumItems - 1 do
Dungeon[RoomIdx].ItemSlots[I] := Dungeon[RoomIdx].ItemSlots[I + 1];
Dec(Dungeon[RoomIdx].NumItems);
WriteLn('You pick up the ', ItemName, '.');
PickUpItem := True;
end;
Using Items
This is where variant records shine — each item kind has completely different behavior:
procedure UseItem(InvSlot: Integer);
var
ItemIdx: Integer;
begin
if (InvSlot < 1) or (InvSlot > Player.NumItems) then
begin
WriteLn('Invalid item slot.');
Exit;
end;
ItemIdx := Player.Inventory[InvSlot];
case AllItems[ItemIdx].Kind of
ikWeapon:
begin
Player.EquippedWeapon := ItemIdx;
WriteLn('You equip the ', AllItems[ItemIdx].Name,
' (Damage: ', AllItems[ItemIdx].Damage, ').');
end;
ikPotion:
begin
Player.Health := Player.Health + AllItems[ItemIdx].HealAmount;
if Player.Health > Player.MaxHealth then
Player.Health := Player.MaxHealth;
WriteLn('You drink the ', AllItems[ItemIdx].Name,
'. Health restored to ', Player.Health, '/', Player.MaxHealth, '.');
{ Remove consumed potion from inventory }
RemoveFromInventory(InvSlot);
end;
ikKey:
WriteLn('You cannot "use" a key directly. Walk toward a locked door.');
ikTreasure:
begin
Player.Gold := Player.Gold + AllItems[ItemIdx].GoldValue;
WriteLn('You appraise the ', AllItems[ItemIdx].Name,
' and add ', AllItems[ItemIdx].GoldValue, ' gold to your purse.');
RemoveFromInventory(InvSlot);
end;
end;
end;
Moving Between Rooms
procedure MovePlayer(const Direction: string);
var
DestRoom: Integer;
ExitVal: Integer;
begin
ExitVal := 0;
if Direction = 'north' then ExitVal := Dungeon[Player.Location].Exits.North
else if Direction = 'south' then ExitVal := Dungeon[Player.Location].Exits.South
else if Direction = 'east' then ExitVal := Dungeon[Player.Location].Exits.East
else if Direction = 'west' then ExitVal := Dungeon[Player.Location].Exits.West
else
begin
WriteLn('Unknown direction. Try: north, south, east, west.');
Exit;
end;
if ExitVal = 0 then
begin
WriteLn('There is no exit in that direction.');
Exit;
end;
if ExitVal < 0 then
begin
WriteLn('That way is locked. You need a key.');
{ Check if player has the right key }
if PlayerHasKey(Abs(ExitVal)) then
begin
WriteLn('Your key fits! The door swings open.');
{ Unlock permanently by changing the exit to positive }
DestRoom := Abs(ExitVal);
// In a full implementation, we would store the actual
// destination separately from the lock encoding.
end
else
Exit;
end
else
DestRoom := ExitVal;
Player.Location := DestRoom;
Dungeon[DestRoom].Visited := True;
LookAround;
end;
Step 7: Building the Dungeon
procedure InitializeDungeon;
var
SwordIdx, PotionIdx, KeyIdx, GoldIdx: Integer;
begin
NumItems := 0;
NumRooms := 0;
{ Create items }
SwordIdx := MakeWeapon('Iron Sword', 'A sturdy iron blade.', 3.5, 8, 100);
PotionIdx := MakePotion('Health Potion', 'A bubbling red liquid.', 0.5, 25, 0);
KeyIdx := MakeKey('Brass Key', 'An old brass key with a skull emblem.', 0.1, 1);
GoldIdx := MakeTreasure('Gold Chalice', 'An ornate golden chalice.', 2.0, 50);
{ Room 1: Entrance Hall }
Inc(NumRooms);
Dungeon[1].Name := 'Entrance Hall';
Dungeon[1].Description :=
'A torch-lit stone chamber. The air is damp and cold. ' +
'Passages lead north and east. The dungeon entrance behind you ' +
'has sealed shut.';
Dungeon[1].Exits.North := 2;
Dungeon[1].Exits.South := 0;
Dungeon[1].Exits.East := 3;
Dungeon[1].Exits.West := 0;
Dungeon[1].NumItems := 1;
Dungeon[1].ItemSlots[1] := SwordIdx;
Dungeon[1].Visited := True;
Dungeon[1].IsDark := False;
{ Room 2: Armory }
Inc(NumRooms);
Dungeon[2].Name := 'The Armory';
Dungeon[2].Description :=
'Rusted weapons line the walls. Most are beyond use, but ' +
'a few items catch your eye. A passage leads south.';
Dungeon[2].Exits.North := 0;
Dungeon[2].Exits.South := 1;
Dungeon[2].Exits.East := 0;
Dungeon[2].Exits.West := 0;
Dungeon[2].NumItems := 1;
Dungeon[2].ItemSlots[1] := PotionIdx;
Dungeon[2].Visited := False;
Dungeon[2].IsDark := False;
{ Room 3: Guard Room }
Inc(NumRooms);
Dungeon[3].Name := 'Guard Room';
Dungeon[3].Description :=
'A small room with a broken table and chairs. A brass key ' +
'glints on the floor. The passage continues east through ' +
'a heavy locked door.';
Dungeon[3].Exits.North := 0;
Dungeon[3].Exits.South := 0;
Dungeon[3].Exits.East := -1; { Locked! Needs KeyID 1 }
Dungeon[3].Exits.West := 1;
Dungeon[3].NumItems := 1;
Dungeon[3].ItemSlots[1] := KeyIdx;
Dungeon[3].Visited := False;
Dungeon[3].IsDark := False;
{ Room 4: Treasure Chamber }
Inc(NumRooms);
Dungeon[4].Name := 'Treasure Chamber';
Dungeon[4].Description :=
'A glittering chamber filled with ancient riches. A golden ' +
'chalice sits on a stone pedestal in the center.';
Dungeon[4].Exits.North := 0;
Dungeon[4].Exits.South := 0;
Dungeon[4].Exits.East := 0;
Dungeon[4].Exits.West := 3;
Dungeon[4].NumItems := 1;
Dungeon[4].ItemSlots[1] := GoldIdx;
Dungeon[4].Visited := False;
Dungeon[4].IsDark := False;
{ Initialize player }
Player.Name := 'Adventurer';
Player.Health := 100;
Player.MaxHealth := 100;
Player.Gold := 0;
Player.Location := 1;
Player.NumItems := 0;
Player.EquippedWeapon := 0;
end;
How Records Made This Possible
Without records, this game would require: - Separate arrays for item names, descriptions, weights, damage values, heal amounts, key IDs, and gold values — with most entries wasted (a potion does not need a damage field). - Separate arrays for room names, descriptions, north exits, south exits, east exits, west exits, item lists... - A tangled web of indices with no compile-time safety.
With records:
- Each item is a self-contained TItem with only the fields relevant to its kind (via variants).
- Each room is a self-contained TRoom with its exits neatly organized in a nested TExits record.
- The player is a coherent TPlayer with clear inventory management.
- Factory functions (MakeWeapon, MakePotion, etc.) ensure variant fields are always set correctly.
- The case Kind of pattern provides a clean, exhaustive dispatch mechanism.
Your Turn
- Add a fifth room and connect it to the dungeon.
- Add an
ikArmorvariant toTItemwithDefense: IntegerandEquippedArmortoTPlayer. ModifyUseItemto handle equipping armor. - Implement the
RemoveFromInventoryprocedure (shift remaining items down, decrement count). - Add a
PlayerHasKeyfunction that checks whether the player's inventory contains a key with a givenKeyID. - Challenge: Add a simple combat system where the player encounters a monster in a room. Use the equipped weapon's damage and the monster's health (stored in yet another record type) to resolve the fight.