Teach students how to manage multiple moving enemies using ArrayList and a custom enemy class.
ArrayList?An ArrayList can grow and shrink at runtime. It is ideal for storing enemies, bullets, and collectibles.
Reference: W3Schools — Java ArrayList
private class Enemy {
double x;
double y;
double size = 32;
double speed = 2;
double dx;
double dy;
Enemy(double x, double y) {
this.x = x;
this.y = y;
chooseDirection();
}
void chooseDirection() {
int direction = random.nextInt(4);
dx = 0;
dy = 0;
if (direction == 0) dx = speed;
else if (direction == 1) dx = -speed;
else if (direction == 2) dy = speed;
else dy = -speed;
}
void update() {
x += dx;
y += dy;
// Add wall collision or bounds checks here.
}
}
List<Enemy> enemies = new ArrayList<>();
void createEnemies() {
enemies.add(new Enemy(100, 100));
enemies.add(new Enemy(300, 200));
enemies.add(new Enemy(500, 120));
}
for (Enemy enemy : enemies) {
enemy.update();
}
for (Enemy enemy : enemies) {
gc.setFill(Color.RED);
gc.fillOval(enemy.x, enemy.y, enemy.size, enemy.size);
}
Use the same rectangle overlap function for player and enemies:
boolean intersects(double x1, double y1, double w1, double h1,
double x2, double y2, double w2, double h2) {
return x1 < x2 + w2 &&
x1 + w1 > x2 &&
y1 < y2 + h2 &&
y1 + h1 > y2;
}
Then check each enemy:
for (Enemy enemy : enemies) {
if (intersects(playerX, playerY, playerSize, playerSize,
enemy.x, enemy.y, enemy.size, enemy.size)) {
lives--;
resetPlayer();
break;
}
}
Pellet Pursuit uses the same ArrayList pattern, but the enemies are more
sophisticated because each ghost has a different personality.
In GameApp.java, the ghosts are stored exactly like the example above:
List<Ghost> ghosts = new ArrayList<>();
ghosts.add(new Shadow());
ghosts.add(new Patrol());
ghosts.add(new Shy());
ghosts.add(new Ambush());
ListvsArrayList: You’ve usedArrayList<Ghost>before.Listis a more general type — it describes any list, andArrayListis one implementation of it. DeclaringList<Ghost> ghostsjust means “a list of ghosts”; the actual list is still anArrayList. You’ll seeListused as the declared type in professional Java because it keeps the code flexible.
Each frame they are updated and checked for collisions in a loop, just like
the simple Enemy example.
The simple Enemy class above works when all enemies behave the same way.
When enemies need different behaviors, an abstract class is a better
tool:
Reference: W3Schools — Java Abstract Classes
Ghost (abstract — handles movement, navigation, drawing)
└── Shadow — overrides chooseTarget() to chase directly
└── Patrol — overrides chooseTarget() to patrol a corner
└── Shy — overrides chooseTarget() to flee until cornered
└── Ambush — overrides chooseTarget() to cut off the player
Ghost handles everything the ghosts share (movement, wall avoidance, the
frightened/dead states). Each subclass only needs to answer one question:
“given where the player is, which tile should I head toward?”
That one method — chooseTarget() — is what you implement.
chooseTarget() returns a tile coordinate — just an int[] with a column
and a row. The engine in Ghost.java then automatically figures out how to
get there: it explores the maze outward from the ghost’s position, tile by
tile, until it finds the target, then takes the first step of that route.
You don’t need to know how this works. What matters is:
Return the tile you want the ghost to head toward, and the engine will navigate there. Return the player’s tile → the ghost chases. Return a corner → the ghost retreats. Return a point ahead of the player → the ghost tries to cut them off.
Read Shadow.java to see this in action before writing your own.
Ghost.collidesWith(Player p) uses distance-based collision rather than
rectangle overlap. This is a different strategy from tile collision (isWall):
instead of looking up a grid cell, you measure the pixel distance between two
sprite centers and check if it is less than a threshold.
This is also a stub — you implement it in Phase 2 (see Your task below).
1. Implement Ghost.collidesWith(Player p) in Ghost.java
The method should return true when the ghost is close enough to the player
to count as a collision. distanceTo() is already implemented in Sprite —
it returns the straight-line pixel distance between two center points.
Use it to compare the distance between the ghost and the player against a
threshold based on size. Consider: should the threshold be exactly size,
slightly less, or slightly more? Think about what each choice would feel like
to a player — a threshold that is too large means dying when a ghost barely
clips the edge of your tile; one that is too small means ghosts walk through
you.
This method uses pixel distance rather than tile distance. Add a short comment in your implementation explaining why checking which tile each sprite is on would be less accurate than measuring the pixel gap between their centers.
Without this, ghosts pass through the player with no effect — no death, no eating frightened ghosts.
2. Implement the ghost personalities — one at a time
Each ghost file (Patrol.java, Shy.java, Ambush.java) has a chooseTarget()
stub with TODO comments. Read Shadow.java first — it is the worked example.
Work through them one at a time. When a ghost’s chooseTarget() is working,
add it to the ghost list in GameApp.java:
ghosts = new ArrayList<>(List.of(
new Shadow(map),
new Patrol(map), // add after Patrol works
new Shy(map), // add after Shy works
new Ambush(map) // add after Ambush works
));
List.of(...)creates a list from a fixed set of elements in one line — it’s a shorthand for callingadd()four times. The outernew ArrayList<>(...)wraps it in a mutable list so the game can modify it at runtime.
Each ghost you add should feel visibly different from Shadow — that is how you know the personality is working.
Once your ghosts have distinct personalities, the next step is making the game get harder as the level number rises.
LevelConfig.forLevel(int level) is called once at the start of each level
and returns four values the game loop reads directly:
| Field | What it controls |
|---|---|
ghostSpeedMultiplier |
How fast all ghosts move (1.0 = normal) |
frightenDuration |
Seconds ghosts stay blue after a power pellet |
spawnDelay |
Seconds between each ghost leaving the house |
bonusThreshold |
Dots eaten before a bonus item appears |
public static LevelConfig forLevel(int level) {
double speed = 1.0 + (level - 1) * 0.08; // 1.0, 1.08, 1.16 ...
double frighten = 6.0 - (level - 1) * 0.4; // 6.0, 5.6, 5.2 ...
double spawn = 3.0 - (level - 1) * 0.2; // 3.0, 2.8, 2.6 ...
int bonus = 70;
return new LevelConfig(speed, frighten, spawn, bonus);
}
Each value changes linearly per level and is clamped so it never goes out of
range. The field guide comment at the top of LevelConfig.java shows the
safe min/max for each field.
Edit forLevel() so that:
Concrete targets to aim for:
| Value | Level 1 | Level 3 |
|---|---|---|
ghostSpeedMultiplier |
≤ 1.1 | ≤ 1.5 |
frightenDuration |
≥ 5 s | ≥ 2 s |
spawnDelay |
≥ 2.5 s | ≥ 1 s |
Test by playing level 1 after each change — you should be able to survive for at least 30 seconds without eating a power pellet.
When you’re ready: Phase 3 — Bonus Items & File I/O