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:

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.

Items — things the player picks up and uses.

Rooms — nodes in the dungeon graph.

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:

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:

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:

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:

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:

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:

What you decide in this part:

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.