Case Study 2: Crypts of Pascalia Entity System

The Scenario

In earlier chapters, Crypts of Pascalia used records and arrays to represent rooms, items, and simple game state. Now it is time to bring object-oriented design to the game engine. We need a flexible entity system that can handle players, monsters, NPCs, and — crucially — any new entity type we might invent in the future, without rewriting the core game loop.

The design goal: a single game loop that processes any entity polymorphically, calling virtual methods like Update, TakeDamage, and Describe without knowing or caring about the concrete type.

The Entity Hierarchy

TGameEntity (base)
├── TPlayer     (human-controlled, levels up, has inventory)
├── TMonster    (AI-controlled, attacks on sight, drops loot)
└── TNPC        (dialogue-driven, gives quests, cannot be killed)

Complete Implementation

program CryptsEntitySystem;

{$mode objfpc}{$H+}

uses
  SysUtils, Classes;

type
  { ================================================================ }
  { Base class: everything in the game world }
  { ================================================================ }
  TGameEntity = class
  protected
    FName: string;
    FX, FY: Integer;
    FHitPoints: Integer;
    FMaxHitPoints: Integer;
  public
    constructor Create(AName: string; AX, AY, AHP: Integer);
    destructor Destroy; override;

    { Movement — same for all entities }
    procedure MoveTo(NewX, NewY: Integer);
    function DistanceTo(Other: TGameEntity): Double;
    function IsAlive: Boolean;

    { Virtual methods — each entity type customizes these }
    procedure TakeDamage(Amount: Integer); virtual;
    procedure Update; virtual; abstract;
    procedure Describe; virtual;

    property Name: string read FName;
    property X: Integer read FX;
    property Y: Integer read FY;
    property HitPoints: Integer read FHitPoints;
    property MaxHitPoints: Integer read FMaxHitPoints;
  end;

  { ================================================================ }
  { Player: human-controlled entity }
  { ================================================================ }
  TPlayer = class(TGameEntity)
  private
    FLevel: Integer;
    FExperience: Integer;
    FInventory: TStringList;
    FDefense: Integer;
  public
    constructor Create(AName: string; AX, AY: Integer);
    destructor Destroy; override;

    procedure TakeDamage(Amount: Integer); override;
    procedure Update; override;
    procedure Describe; override;

    procedure GainExperience(Amount: Integer);
    procedure AddItem(ItemName: string);
    function HasItem(ItemName: string): Boolean;
    procedure ShowInventory;

    property Level: Integer read FLevel;
    property Experience: Integer read FExperience;
    property Defense: Integer read FDefense write FDefense;
  end;

  { ================================================================ }
  { Monster: AI-controlled hostile entity }
  { ================================================================ }
  TMonsterState = (msIdle, msPatrolling, msChasing, msAttacking, msFleeing);

  TMonster = class(TGameEntity)
  private
    FDamage: Integer;
    FState: TMonsterState;
    FLootTable: TStringList;
    FAggroRange: Integer;
  public
    constructor Create(AName: string; AX, AY, AHP, ADamage: Integer;
                       AAggroRange: Integer);
    destructor Destroy; override;

    procedure TakeDamage(Amount: Integer); override;
    procedure Update; override;
    procedure Describe; override;

    procedure AddLoot(ItemName: string);
    function DropLoot: string;
    function StateToStr: string;

    property Damage: Integer read FDamage;
    property State: TMonsterState read FState;
    property AggroRange: Integer read FAggroRange;
  end;

  { ================================================================ }
  { NPC: non-hostile, dialogue-driven entity }
  { ================================================================ }
  TNPC = class(TGameEntity)
  private
    FDialogueLines: TStringList;
    FCurrentLine: Integer;
    FQuestID: Integer;
    FHasQuest: Boolean;
  public
    constructor Create(AName: string; AX, AY: Integer; AQuestID: Integer);
    destructor Destroy; override;

    procedure TakeDamage(Amount: Integer); override;
    procedure Update; override;
    procedure Describe; override;

    procedure AddDialogue(Line: string);
    function Talk: string;
    procedure ResetDialogue;

    property QuestID: Integer read FQuestID;
    property HasQuest: Boolean read FHasQuest write FHasQuest;
  end;

{ ================================================================ }
{ TGameEntity Implementation }
{ ================================================================ }

constructor TGameEntity.Create(AName: string; AX, AY, AHP: Integer);
begin
  inherited Create;
  FName := AName;
  FX := AX;
  FY := AY;
  FHitPoints := AHP;
  FMaxHitPoints := AHP;
end;

destructor TGameEntity.Destroy;
begin
  inherited Destroy;
end;

procedure TGameEntity.MoveTo(NewX, NewY: Integer);
begin
  FX := NewX;
  FY := NewY;
end;

function TGameEntity.DistanceTo(Other: TGameEntity): Double;
begin
  Result := Sqrt(Sqr(FX - Other.FX) + Sqr(FY - Other.FY));
end;

function TGameEntity.IsAlive: Boolean;
begin
  Result := FHitPoints > 0;
end;

procedure TGameEntity.TakeDamage(Amount: Integer);
begin
  FHitPoints := FHitPoints - Amount;
  if FHitPoints < 0 then
    FHitPoints := 0;
  WriteLn(FName, ' takes ', Amount, ' damage! (HP: ', FHitPoints,
          '/', FMaxHitPoints, ')');
end;

procedure TGameEntity.Describe;
begin
  WriteLn(Format('[%s] %s at (%d,%d) - HP: %d/%d',
    [ClassName, FName, FX, FY, FHitPoints, FMaxHitPoints]));
end;

{ ================================================================ }
{ TPlayer Implementation }
{ ================================================================ }

constructor TPlayer.Create(AName: string; AX, AY: Integer);
begin
  inherited Create(AName, AX, AY, 100);   { Players start with 100 HP }
  FLevel := 1;
  FExperience := 0;
  FDefense := 5;
  FInventory := TStringList.Create;
  FInventory.Add('Rusty Sword');
  FInventory.Add('Torch');
end;

destructor TPlayer.Destroy;
begin
  FInventory.Free;
  inherited Destroy;
end;

procedure TPlayer.TakeDamage(Amount: Integer);
var
  Reduced: Integer;
begin
  { Player has defense that reduces damage }
  Reduced := Amount - FDefense;
  if Reduced < 1 then
    Reduced := 1;   { Always take at least 1 damage }
  WriteLn(FName, '''s armor absorbs ', Amount - Reduced, ' damage.');
  inherited TakeDamage(Reduced);   { Call base to actually reduce HP }
end;

procedure TPlayer.Update;
begin
  { Player update: in a real game, this reads keyboard input.
    For this demonstration, we simulate. }
  if IsAlive then
    WriteLn(FName, ' awaits your command. (Level ', FLevel,
            ', XP: ', FExperience, ')');
end;

procedure TPlayer.Describe;
begin
  inherited Describe;   { Print base info first }
  WriteLn(Format('  Level: %d | XP: %d | Defense: %d | Items: %d',
    [FLevel, FExperience, FDefense, FInventory.Count]));
end;

procedure TPlayer.GainExperience(Amount: Integer);
begin
  FExperience := FExperience + Amount;
  WriteLn(FName, ' gains ', Amount, ' XP!');
  { Level up every 100 XP }
  while FExperience >= FLevel * 100 do
  begin
    FExperience := FExperience - FLevel * 100;
    Inc(FLevel);
    FMaxHitPoints := FMaxHitPoints + 10;
    FHitPoints := FMaxHitPoints;   { Full heal on level up }
    FDefense := FDefense + 2;
    WriteLn('*** LEVEL UP! ', FName, ' is now level ', FLevel, '! ***');
  end;
end;

procedure TPlayer.AddItem(ItemName: string);
begin
  FInventory.Add(ItemName);
  WriteLn(FName, ' picks up: ', ItemName);
end;

function TPlayer.HasItem(ItemName: string): Boolean;
begin
  Result := FInventory.IndexOf(ItemName) >= 0;
end;

procedure TPlayer.ShowInventory;
var
  i: Integer;
begin
  WriteLn(FName, '''s Inventory (', FInventory.Count, ' items):');
  for i := 0 to FInventory.Count - 1 do
    WriteLn('  ', i + 1, '. ', FInventory[i]);
end;

{ ================================================================ }
{ TMonster Implementation }
{ ================================================================ }

constructor TMonster.Create(AName: string; AX, AY, AHP, ADamage: Integer;
                            AAggroRange: Integer);
begin
  inherited Create(AName, AX, AY, AHP);
  FDamage := ADamage;
  FState := msIdle;
  FAggroRange := AAggroRange;
  FLootTable := TStringList.Create;
end;

destructor TMonster.Destroy;
begin
  FLootTable.Free;
  inherited Destroy;
end;

procedure TMonster.TakeDamage(Amount: Integer);
begin
  inherited TakeDamage(Amount);   { Apply damage via base class }
  { Monster-specific reaction: switch to fleeing if health is low }
  if IsAlive and (FHitPoints < FMaxHitPoints div 4) then
  begin
    FState := msFleeing;
    WriteLn(FName, ' is badly wounded and tries to flee!');
  end
  else if IsAlive then
  begin
    FState := msAttacking;
    WriteLn(FName, ' is enraged!');
  end
  else
    WriteLn(FName, ' has been slain!');
end;

procedure TMonster.Update;
begin
  if not IsAlive then Exit;

  case FState of
    msIdle:       WriteLn(FName, ' stands motionless, watching...');
    msPatrolling: WriteLn(FName, ' patrols its territory.');
    msChasing:    WriteLn(FName, ' charges toward the player!');
    msAttacking:  WriteLn(FName, ' attacks for ', FDamage, ' damage!');
    msFleeing:    WriteLn(FName, ' limps away, seeking escape...');
  end;
end;

procedure TMonster.Describe;
begin
  inherited Describe;
  WriteLn(Format('  Damage: %d | State: %s | Aggro Range: %d',
    [FDamage, StateToStr, FAggroRange]));
  if FLootTable.Count > 0 then
    WriteLn('  Loot: ', FLootTable.CommaText);
end;

procedure TMonster.AddLoot(ItemName: string);
begin
  FLootTable.Add(ItemName);
end;

function TMonster.DropLoot: string;
begin
  if FLootTable.Count > 0 then
  begin
    Result := FLootTable[Random(FLootTable.Count)];
    WriteLn(FName, ' drops: ', Result);
  end
  else
  begin
    Result := '';
    WriteLn(FName, ' drops nothing.');
  end;
end;

function TMonster.StateToStr: string;
begin
  case FState of
    msIdle:       Result := 'Idle';
    msPatrolling: Result := 'Patrolling';
    msChasing:    Result := 'Chasing';
    msAttacking:  Result := 'Attacking';
    msFleeing:    Result := 'Fleeing';
  end;
end;

{ ================================================================ }
{ TNPC Implementation }
{ ================================================================ }

constructor TNPC.Create(AName: string; AX, AY: Integer; AQuestID: Integer);
begin
  inherited Create(AName, AX, AY, 999);   { NPCs have high HP }
  FDialogueLines := TStringList.Create;
  FCurrentLine := 0;
  FQuestID := AQuestID;
  FHasQuest := AQuestID > 0;
end;

destructor TNPC.Destroy;
begin
  FDialogueLines.Free;
  inherited Destroy;
end;

procedure TNPC.TakeDamage(Amount: Integer);
begin
  { NPCs are invulnerable — damage is ignored }
  WriteLn(FName, ' glares at you disapprovingly.');
  WriteLn('"Violence is not the answer, adventurer."');
end;

procedure TNPC.Update;
begin
  if FHasQuest then
    WriteLn(FName, ' has a quest for you! (Quest #', FQuestID, ')')
  else
    WriteLn(FName, ' stands quietly, waiting to chat.');
end;

procedure TNPC.Describe;
begin
  inherited Describe;
  if FHasQuest then
    WriteLn('  [!] Quest available: #', FQuestID)
  else
    WriteLn('  [No quest]');
  WriteLn('  Dialogue lines: ', FDialogueLines.Count);
end;

procedure TNPC.AddDialogue(Line: string);
begin
  FDialogueLines.Add(Line);
end;

function TNPC.Talk: string;
begin
  if FDialogueLines.Count = 0 then
  begin
    Result := FName + ' has nothing to say.';
    Exit;
  end;

  Result := FName + ': "' + FDialogueLines[FCurrentLine] + '"';
  FCurrentLine := (FCurrentLine + 1) mod FDialogueLines.Count;
end;

procedure TNPC.ResetDialogue;
begin
  FCurrentLine := 0;
end;

{ ================================================================ }
{ Polymorphic Game Functions }
{ ================================================================ }

procedure GameTick(Entities: array of TGameEntity);
var
  i: Integer;
begin
  WriteLn('--- Game Tick ---');
  for i := 0 to High(Entities) do
  begin
    if Entities[i].IsAlive then
      Entities[i].Update;     { Polymorphic: each entity updates differently }
  end;
  WriteLn;
end;

procedure DescribeAll(Entities: array of TGameEntity);
var
  i: Integer;
begin
  WriteLn('=== Entity Report ===');
  for i := 0 to High(Entities) do
  begin
    Entities[i].Describe;     { Polymorphic: each entity describes differently }
    WriteLn;
  end;
end;

function FindNearest(Source: TGameEntity;
                     Entities: array of TGameEntity): TGameEntity;
var
  i: Integer;
  Dist, MinDist: Double;
begin
  Result := nil;
  MinDist := MaxInt;
  for i := 0 to High(Entities) do
  begin
    if (Entities[i] <> Source) and Entities[i].IsAlive then
    begin
      Dist := Source.DistanceTo(Entities[i]);
      if Dist < MinDist then
      begin
        MinDist := Dist;
        Result := Entities[i];
      end;
    end;
  end;
end;

procedure DamageAll(Entities: array of TGameEntity; Amount: Integer);
var
  i: Integer;
begin
  WriteLn('=== Area-of-Effect Damage: ', Amount, ' ===');
  for i := 0 to High(Entities) do
    Entities[i].TakeDamage(Amount);   { Polymorphic: each reacts differently }
  WriteLn;
end;

{ ================================================================ }
{ Main Game Demonstration }
{ ================================================================ }

var
  Entities: array[0..4] of TGameEntity;
  Player: TPlayer;
  Goblin: TMonster;
  Merchant: TNPC;
  Dragon: TMonster;
  Guide: TNPC;
  Loot: string;
  i: Integer;

begin
  Randomize;

  { Create entities — note: stored in both typed and base-class variables }
  Player := TPlayer.Create('Aldric', 5, 5);
  Goblin := TMonster.Create('Goblin Scout', 8, 6, 30, 8, 5);
  Goblin.AddLoot('Gold Coin');
  Goblin.AddLoot('Small Dagger');
  Merchant := TNPC.Create('Elara the Merchant', 3, 3, 0);
  Merchant.AddDialogue('Welcome to my shop, traveler!');
  Merchant.AddDialogue('I have the finest wares in all the Crypts.');
  Merchant.AddDialogue('Come back anytime!');
  Dragon := TMonster.Create('Ancient Dragon', 15, 15, 200, 35, 10);
  Dragon.AddLoot('Dragon Scale Armor');
  Dragon.AddLoot('Dragon''s Hoard (500 gold)');
  Guide := TNPC.Create('Old Marius', 4, 5, 42);
  Guide.AddDialogue('The dragon guards the deepest chamber.');
  Guide.AddDialogue('You will need the Crystal Key to proceed.');

  { Store in polymorphic array }
  Entities[0] := Player;
  Entities[1] := Goblin;
  Entities[2] := Merchant;
  Entities[3] := Dragon;
  Entities[4] := Guide;

  { --- Demonstrate polymorphic operations --- }

  WriteLn('========================================');
  WriteLn('   CRYPTS OF PASCALIA — Entity Demo');
  WriteLn('========================================');
  WriteLn;

  { 1. Describe all entities polymorphically }
  DescribeAll(Entities);

  { 2. Run a game tick — each entity updates differently }
  GameTick(Entities);

  { 3. Talk to NPCs (type-specific operation using 'is' and 'as') }
  WriteLn('=== Talking to NPCs ===');
  for i := 0 to High(Entities) do
  begin
    if Entities[i] is TNPC then
    begin
      WriteLn((Entities[i] as TNPC).Talk);
      WriteLn((Entities[i] as TNPC).Talk);
    end;
  end;
  WriteLn;

  { 4. Combat: player attacks goblin }
  WriteLn('=== Combat: Aldric vs Goblin Scout ===');
  WriteLn('Aldric swings his rusty sword!');
  Goblin.TakeDamage(15);
  WriteLn;
  WriteLn('The goblin retaliates!');
  Player.TakeDamage(Goblin.Damage);
  WriteLn;

  { 5. Area-of-effect damage — shows polymorphic TakeDamage }
  WriteLn('A magical trap triggers!');
  DamageAll(Entities, 10);

  { 6. Kill the goblin and loot }
  WriteLn('=== Finishing the Goblin ===');
  Goblin.TakeDamage(20);
  if not Goblin.IsAlive then
  begin
    Loot := Goblin.DropLoot;
    if Loot <> '' then
      Player.AddItem(Loot);
    Player.GainExperience(50);
  end;
  WriteLn;

  { 7. Show player inventory }
  Player.ShowInventory;
  WriteLn;

  { 8. Final state }
  WriteLn('=== Final Entity State ===');
  DescribeAll(Entities);

  { 9. Demonstrate FindNearest }
  WriteLn('=== Nearest Entity to Player ===');
  var Nearest: TGameEntity;
  Nearest := FindNearest(Player, Entities);
  if Nearest <> nil then
    WriteLn('Nearest to ', Player.Name, ': ', Nearest.Name,
            ' (', Nearest.ClassName, ')')
  else
    WriteLn('No nearby entities.');

  { Clean up }
  for i := 0 to High(Entities) do
    Entities[i].Free;
end.

Sample Output

========================================
   CRYPTS OF PASCALIA — Entity Demo
========================================

=== Entity Report ===
[TPlayer] Aldric at (5,5) - HP: 100/100
  Level: 1 | XP: 0 | Defense: 5 | Items: 2

[TMonster] Goblin Scout at (8,6) - HP: 30/30
  Damage: 8 | State: Idle | Aggro Range: 5
  Loot: Gold Coin,Small Dagger

[TNPC] Elara the Merchant at (3,3) - HP: 999/999
  [No quest]
  Dialogue lines: 3

[TMonster] Ancient Dragon at (15,15) - HP: 200/200
  Damage: 35 | State: Idle | Aggro Range: 10
  Loot: Dragon Scale Armor,Dragon's Hoard (500 gold)

[TNPC] Old Marius at (4,5) - HP: 999/999
  [!] Quest available: #42
  Dialogue lines: 2

--- Game Tick ---
Aldric awaits your command. (Level 1, XP: 0)
Goblin Scout stands motionless, watching...
Elara the Merchant stands quietly, waiting to chat.
Ancient Dragon stands motionless, watching...
Old Marius has a quest for you! (Quest #42)

=== Talking to NPCs ===
Elara the Merchant: "Welcome to my shop, traveler!"
Elara the Merchant: "I have the finest wares in all the Crypts."
Old Marius: "The dragon guards the deepest chamber."
Old Marius: "You will need the Crystal Key to proceed."

=== Combat: Aldric vs Goblin Scout ===
Aldric swings his rusty sword!
Goblin Scout takes 15 damage! (HP: 15/30)
Goblin Scout is enraged!

The goblin retaliates!
Aldric's armor absorbs 3 damage.
Aldric takes 3 damage! (HP: 97/100)

A magical trap triggers!
=== Area-of-Effect Damage: 10 ===
Aldric's armor absorbs 5 damage.
Aldric takes 5 damage! (HP: 92/100)
Goblin Scout takes 10 damage! (HP: 5/30)
Goblin Scout is badly wounded and tries to flee!
Elara the Merchant glares at you disapprovingly.
"Violence is not the answer, adventurer."
Ancient Dragon takes 10 damage! (HP: 190/200)
Ancient Dragon is enraged!
Old Marius glares at you disapprovingly.
"Violence is not the answer, adventurer."

Design Analysis

Why This Works

The core game functions — GameTick, DescribeAll, FindNearest, DamageAll — operate entirely through the TGameEntity interface. They never mention TPlayer, TMonster, or TNPC. Yet each entity type responds uniquely:

Operation TPlayer TMonster TNPC
TakeDamage Reduces by defense Enrages or flees Ignores damage, scolds
Update Waits for input Acts based on AI state Reports quest status
Describe Shows level, XP, items Shows damage, state, loot Shows quest, dialogue

Extending the System

To add a new entity type — say, TTrap — you would:

  1. Create TTrap = class(TGameEntity) with its own fields (damage, trigger condition, armed/disarmed state)
  2. Override TakeDamage (traps might be destroyed), Update (check if player is nearby), and Describe
  3. Add TTrap instances to the Entities array

The game loop functions require zero changes. This is the Open/Closed Principle realized through polymorphism.

The is/as Exception

The NPC dialogue section uses is and as because Talk is specific to TNPC — it is not part of the TGameEntity interface. This is acceptable because dialogue is genuinely NPC-specific. The alternative (adding a virtual Interact method to TGameEntity) would be worth considering if interaction becomes a universal behavior.

Discussion Questions

  1. The TPlayer.TakeDamage method calls inherited TakeDamage(Reduced) after calculating the reduced damage. What would happen if it called inherited TakeDamage(Amount) instead (passing the original amount)?

  2. TNPC.TakeDamage does not call inherited. Is this a violation of good design? What are the trade-offs?

  3. The monster has a state machine (TMonsterState). How would you extend this to make the monster actually chase the player? What additional information would Update need?

  4. Could TMonsterState be replaced with a class hierarchy of its own (a "State pattern")? When would that be worthwhile?

  5. FindNearest returns TGameEntity. What if you wanted to find the nearest monster specifically? How would you modify the function? Would you use is, or would you design a different approach?

  6. The entities are stored in a fixed-size array. What would change if you used a dynamic list (e.g., TList or a generic TList<TGameEntity>)? What advantages would that provide for a real game?