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:

  1. 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).
  2. Rooms — Locations in the dungeon, each with a description, exits to other rooms, and items that can be found there.
  3. 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 KeyID needed to unlock it). This is a simple encoding scheme that avoids needing yet another record type.
  • Item slots: Rooms do not contain TItem records 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

  1. Add a fifth room and connect it to the dungeon.
  2. Add an ikArmor variant to TItem with Defense: Integer and EquippedArmor to TPlayer. Modify UseItem to handle equipping armor.
  3. Implement the RemoveFromInventory procedure (shift remaining items down, decrement count).
  4. Add a PlayerHasKey function that checks whether the player's inventory contains a key with a given KeyID.
  5. 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.