Dungeon Crawler
Terminal Utility · Java
The Idea
You're building a text-based dungeon crawler — a game where the player moves through a network of rooms, encounters enemies, picks up items, and tries to survive long enough to reach the end.
The terminal output will look something like this:
== The Damp Corridor ==
A narrow passage. Torches flicker on the walls.
Exits: north, east
Enemies: Skeleton (12/20 hp), Rat (5/5 hp)
Items: Rusty Sword
> go north
> attack skeleton
> pick up rusty sword
Simple on the surface — but underneath it's a system of interacting objects that all need to be modelled carefully. A Room is connected to other rooms. An Enemy has its own AI behaviour and responds to being attacked. An Item has an effect when picked up or used. The player has stats that change as the game progresses.
The interesting design challenge is that rooms, enemies, and items are all quite different — but they all need to participate in the same game loop. How do you model that cleanly?
What you must build by the end of the project:
- A dungeon of at least six rooms connected in a non-linear map (some branching, not just a corridor)
- At least three distinct enemy types with different behaviours
- At least three item types with different effects
- A player who can move, fight, pick up items, and die
- A win condition — reaching a final room, defeating a boss, collecting a key, your choice
- A Swing GUI that the player can interact with
Procedural generation is not required — you can design your dungeon by hand. Two optional extensions are available if you want more of a challenge:
Optional extension A — Data-driven dungeon loading Instead of hardcoding your rooms in Java, define your dungeon in a JSON or plain-text file and write a loader that reads it at startup. This separates your game data from your game logic — a principle used in virtually every real game engine. A room in your file might look something like this:
{
"id": "crypt",
"name": "The Crypt",
"description": "Cold stone and the smell of decay.",
"exits": { "north": "great_hall", "east": "armory" },
"enemies": ["skeleton", "skeleton"],
"items": ["rusty_sword"]
}
Your loader reads the file, constructs the right objects, and wires the rooms together by their IDs. Changing your dungeon then requires no recompilation — just editing the file.
Optional extension B — Procedural generation Write a generator that creates a dungeon at runtime: place rooms, connect them, and populate them with enemies and items according to rules you define. Even a basic generator that guarantees a connected path from start to finish is a worthwhile challenge.
OOP Requirements
This project must clearly demonstrate all four OOP topics as well as two design patterns. Here is exactly what is required for each.
Classes & Subclasses
There are three distinct hierarchies in this project. Each models something that varies in type but shares a common structure.
Entities — everything that lives in the dungeon and can act or be acted upon.
- An abstract base class
Entitywith shared fields (name,maxHp,currentHp,attackPower,defence) and shared concrete methods (takeDamage(int amount),isAlive(),getHpBar()). The method that defines how an entity acts on its turn —takeTurn(Game game)— must be declared abstract. - A
Playersubclass. Holds an inventory and a reference to the current room. - At least three
Enemysubclasses, each with a mechanically distincttakeTurn(). How you implement that distinctness is up to you in Part 2 — but by Part 3 the behaviour must be driven by the State pattern.
Items — things the player picks up and uses.
- An abstract base class
Itemwith aname, adescription, anduse(Player p)declared abstract. - At least three concrete subclasses: a healing item, a weapon or damage-boosting item, and one more of your choice — a buff potion, a key, a torch, whatever fits your dungeon.
Rooms — nodes in the dungeon graph.
- A
Roomclass with a name, a description, aHashMap<String, Room>of exits, aList<Enemy>of enemies present, and aList<Item>of items on the floor. - A
Dungeonclass that holds all rooms, the starting room, and the goal room.
Inheritance & Polymorphism
The game loop must never check what type of entity or item it is dealing with. When it is an enemy's turn, the loop calls takeTurn() on each enemy in the room — and the right behaviour happens automatically:
for (Enemy e : room.getEnemies()) {
if (e.isAlive()) e.takeTurn(game);
}
No instanceof, no casting, no if (enemy is Troll). Each subclass owns its own logic entirely. The same rule applies to items — use(player) is called on whatever item the player selects, and the item handles the rest.
Containers
Three containers are required, each serving a distinct purpose:
HashMap<String, Room> exits— held in eachRoom, maps direction names ("north","down","through the mirror") to connected rooms. Using aHashMapmeans any string can be a valid exit direction, not just the four cardinal points.List<Enemy> enemiesandList<Item> items— held in eachRoom. Enemies are removed from the list when defeated; items are removed when picked up.List<Item> inventory— held inPlayer, tracks what the player is carrying.
Interfaces
Two interfaces must be defined:
interface Describable {
String describe();
}
interface Interactable {
void interact(Player p);
}
Describable should be implemented by Room, Enemy, and Item — anything the game needs to print a description of. The room display code can then call describe() uniformly on everything it renders.
Interactable should be implemented by anything the player can directly act on — items (picking up or using), and optionally environmental objects like chests or levers if your dungeon has them. This keeps the command parser decoupled: when the player types interact chest, the parser doesn't need to know what a chest is — it just calls interact(player) on whatever object has that name.
Why interfaces and not abstract methods in the base classes? Room and Enemy have nothing else in common — there is no shared base class that covers both. Describable is a capability that cuts across unrelated types. That is exactly the right use case for an interface.
Design Pattern Requirements
State — Enemy AI
By Part 3, enemy behaviour must be driven by the State pattern. Each enemy holds a reference to its current state object, and delegates its takeTurn() to that state:
abstract class Enemy extends Entity {
protected EnemyState currentState;
@Override
public void takeTurn(Game game) {
currentState.handle(this, game);
}
}
At minimum, two concrete states must be defined and used — for example AggressiveState (attacks the player) and FleeingState (tries to escape when low on health). Each enemy subclass starts in an appropriate state and transitions between states based on conditions it defines itself.
Why the State pattern and not a chain of if statements? As enemy behaviour grows more complex, the if blocks grow with it and start to interact with each other in ways that are hard to reason about. The State pattern gives each behaviour its own class with its own logic, and makes transitions explicit. Adding a new behaviour means adding a new state class — nothing else changes.
MVC — Swing GUI
The Swing GUI built in Part 4 must follow the Model-View-Controller pattern. The boundaries between layers must be strict:
- Model — the game state: the dungeon, the player, active enemies, the current room. No GUI imports. Could run without a screen.
- View — the Swing window and all its components. Reads from the model to render, passes input to the controller. No game logic lives here.
- Controller — receives input (from the terminal parser or the Swing input field), validates it against the current game state, and updates the model in response.
This separation is not just good practice for Part 4 — you will be building toward it from Part 1. If you follow the structural guidance in the early parts, the transition to MVC will require almost no rewriting of your game logic.
Part by Part
Part 1 — The Object Model and the Game Loop
This part is about two things: building the object model that the rest of the project depends on, and establishing a game loop structure that will survive the transition to a GUI in Part 4.
The object model: Build the Entity hierarchy, the Item hierarchy, and the Room/Dungeon classes as described in the OOP requirements. isComplete() equivalents don't need real logic yet — stubs are fine. Wire up a small hand-authored dungeon of three or four rooms with enemies and items placed in them. Let the player move between rooms using the exits map and see a description of each room when they enter.
The game loop: Build a Game class that owns the main loop. The loop does three things on each iteration: get input, parse it into a command, and execute the command if it is valid given the current game state. A move command might be invalid if the player is in combat; an attack command might be invalid if there are no enemies in the room. How you represent game state and how you gate commands is your design decision — but think carefully about it, because this structure will matter in Part 3 when the State pattern is introduced.
Output and the logger: Rather than calling System.out.println directly anywhere in your code, route all output through a logging method on your Game class — something as simple as game.log(String message). This is a small discipline now that will save you a significant rewrite later. In Part 4, when you add a Swing GUI, you will replace this single method's implementation to write to a text area instead of the terminal. Every class that calls game.log() will work with the GUI automatically, without any changes.
This is not over-engineering — it is the minimum structural decision that makes Part 4 possible without pain. If you scatter System.out.println calls across your codebase, you will regret it.
What you decide in this part:
- Your dungeon's theme and layout
- What your three enemy types are and what will make each mechanically distinct
- What your win condition is
- How you represent the current game state in the loop, and how that gates commands
The hardest design question this week: Player and Enemy both extend Entity and both have a takeTurn() method — but Player.takeTurn() needs to wait for user input while Enemy.takeTurn() runs its AI immediately. How do you handle that split without making Entity know anything about the terminal or the scanner? There is more than one valid answer — be ready to explain yours.
Checkpoint: A small dungeon of at least three rooms exists. The player can move between rooms and see descriptions. All output goes through game.log(). The object model is structurally sound even if behaviour is stubbed.
Part 2 — Combat and Items
In this part the game becomes playable end-to-end. The player can fight enemies, use items, and either win or die.
Combat: Implement a working combat loop. The player can attack enemies; enemies take their turns using takeTurn(game). Defeated enemies are removed from the room. The player can die. How combat is structured — whether it is a strict turn sequence, whether the player can flee, whether there are special moves — is your design decision. A turn-based system where the player acts first and then each enemy acts in sequence is a natural starting point.
Items: Implement use(player) on each of your item subclasses. A healing item restores HP. A weapon item increases attack power. Your third type does whatever makes sense for your dungeon. Make sure picking up and using items works cleanly through the inventory system.
Attack strategies: Enemy attacks should use the Strategy pattern. Rather than hardcoding attack logic directly into each enemy subclass, define an AttackStrategy interface with an attack(Enemy attacker, Player target) method and implement at least two concrete strategies — for example a straightforward BasicAttack and a HeavyAttack that hits harder but could be used less often. Each enemy holds an AttackStrategy and delegates to it when attacking. This keeps attack behaviour composable and separate from the enemy's state logic, which you will add in Part 3.
What you decide in this part:
- How the combat sequence is ordered and what options the player has
- What your three item types do and how they interact with player stats
- Which attack strategies exist and which enemies use them
Checkpoint: The game is completable end-to-end. The player can move through the dungeon, fight enemies, use items, and reach a win or lose condition. All six required rooms exist.
Part 3 — Enemy AI and the State Pattern
In this part you replace any if-based enemy behaviour with the State pattern, and flesh out your enemies to feel meaningfully distinct.
The State pattern: Each enemy subclass must hold a current EnemyState and delegate its takeTurn() to that state as described in the Design Patterns section. Define at least two concrete state classes and make sure at least two of your enemy subclasses transition between states during gameplay — for example a Troll that switches from AggressiveState to FleeingState when its HP drops below 30%, or a Wizard that enters a HealingState when damaged.
If you wrote if (currentHp < maxHp * 0.3) { attackTwice(); } in Part 2, this is where you replace that with a proper state transition. You should find that the State pattern makes this logic cleaner and easier to extend — adding a new behaviour means adding a new state class, not editing existing ones.
Revisit the game loop: Now that enemies have richer behaviour, revisit the command gating in your Game loop. Does the current state representation still hold up? If you find yourself adding more and more conditions to decide what is valid, consider whether the game itself — not just the enemies — could benefit from explicit states.
What you decide in this part:
- What states each enemy type can be in and what triggers transitions
- Whether the game loop itself uses explicit states or another approach
- Any additional enemy polish: different descriptions at low health, special dialogue, boss behaviour
Checkpoint: All enemy subclasses use the State pattern. At least two enemies transition between states during gameplay. Enemy behaviour feels distinct and mechanical differences are observable in play.
Part 4 — Swing GUI and MVC
In this part you add a Swing GUI and restructure your code to follow the MVC pattern cleanly.
The GUI: Build a Swing window that the player can use to play the game. At minimum it should have a text area where game output appears and an input field where the player types commands — replacing or supplementing the terminal. Beyond that, the design is yours. A natural extension is a side panel showing the player's current stats (HP, inventory, current room) that updates as the game state changes. A map panel showing visited rooms is another possibility.
Because you have been routing all output through game.log() since Part 1, wiring the text area up should be straightforward — you replace the logger's implementation so it appends to a JTextArea instead of printing to the terminal. If you scattered System.out.println calls throughout your code, now is when that decision costs you.
MVC structure: Organise your code according to the MVC boundaries described in the Design Patterns section. Concretely:
- The Model is your existing game state —
Game,Dungeon,Player, the rooms and enemies. It should not need significant changes if your logic has been clean. - The View is the Swing window. It reads from the model to render the stats panel and calls
game.log()(now writing to the text area) for narrative output. - The Controller is what connects input to the model. Whether input comes from the terminal or the Swing input field, it flows through the same command parsing logic into the same
Gamemethods.
What you decide in this part:
- Whether the terminal remains available alongside the GUI or is replaced entirely
- What the GUI shows beyond the input/output area
- How the stats panel updates — on every action, on a timer, or on explicit refresh
Checkpoint: The game is fully playable through the Swing GUI. The MVC boundaries are clearly reflected in the code structure. The model contains no GUI imports. The view contains no game logic.