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:
- Create
TTrap = class(TGameEntity)with its own fields (damage, trigger condition, armed/disarmed state) - Override
TakeDamage(traps might be destroyed),Update(check if player is nearby), andDescribe - Add
TTrapinstances to theEntitiesarray
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
-
The
TPlayer.TakeDamagemethod callsinherited TakeDamage(Reduced)after calculating the reduced damage. What would happen if it calledinherited TakeDamage(Amount)instead (passing the original amount)? -
TNPC.TakeDamagedoes not callinherited. Is this a violation of good design? What are the trade-offs? -
The monster has a state machine (
TMonsterState). How would you extend this to make the monster actually chase the player? What additional information wouldUpdateneed? -
Could
TMonsterStatebe replaced with a class hierarchy of its own (a "State pattern")? When would that be worthwhile? -
FindNearestreturnsTGameEntity. What if you wanted to find the nearest monster specifically? How would you modify the function? Would you useis, or would you design a different approach? -
The entities are stored in a fixed-size array. What would change if you used a dynamic list (e.g.,
TListor a genericTList<TGameEntity>)? What advantages would that provide for a real game?