Session 10 · Programming Technology
Sprite games with images and level files
Arkanoid shows three techniques for the third assignment – sprites from images, key bindings and levels from text files – and the Diver sample shows where they belong: a Swing-free model, levels in resources checked by a playability test, patrolling enemies on a Timer, and a checklist for submitting.
The last session gives you the handholds for the third assignment. Two programs: Arkanoid is a small, classic brick breaker that shows three techniques clearly – sprites drawn from images, the keyboard through key bindings, and levels from text files. Its code is deliberately simple: game state and drawing live in one class, so the techniques stand out. Diver is the opposite: a complete sample assignment – a grid game with levels from files, WASD, patrolling enemies with a field of view, lives, a timer and a leaderboard in a database – built in model–view layers, with tests, UML and documentation.
Take the techniques from Arkanoid, the structure from Diver. Arkanoid shows how to draw an image, bind a key and read a level; Diver shows where each of those belongs so the rules can be tested.
Arkanoid: a small sprite game
Left arrow or A starts the paddle moving left, right arrow or D moves it right, down arrow or S stops it, Escape pauses. The ball bounces off the bricks, the walls and the paddle; if it falls out at the bottom, the game starts over. When no bricks are left, the next level comes.
| Class | Job |
|---|---|
Arkanoid | entry point |
ArkanoidGUI | the window; its only component is the GameEngine |
Sprite | position, size, image; draw(Graphics) with drawImage; collides by rectangle intersection |
Ball, Paddle, Brick | the ball (velocity, bounces at the edges), the paddle (velocity, stops at the edges), a brick (doesn't move) |
Level | the bricks from a file; a hit brick disappears; isOver when none are left |
GameEngine | the playfield (JPanel): images, key bindings, the Timer, the game loop newFrame, paintComponent |
The whole game is one loop. A Timer fires roughly 240 times a second and calls newFrame, which moves everything one step, applies the rules, and asks for a repaint. Moving the ball in two separate half-steps – first horizontally, then vertically – is a cheap trick worth knowing: a collision after the horizontal step means "bounce sideways", after the vertical step "bounce up or down".
GameEngine.newFrame()
private void newFrame() {
if (!paused) {
ball.moveX();
if (level.collides(ball)) { // hit a brick sideways: bounce horizontally
ball.invertVelX();
}
if (!ball.moveY()) { // fell out at the bottom: start over
levelNum = 0;
restart();
}
if (level.collides(ball)) {
ball.invertVelY();
}
if (paddle.collides(ball)) {
ball.invertVelY();
}
paddle.move();
}
if (level.isOver()) { // no bricks left: next level
levelNum = (levelNum + 1) % LEVEL_COUNT;
restart();
}
repaint();
}Three techniques worth taking
1. Sprites from images
Each image is loaded once, and drawImage draws it at the sprite's position, stretched to the sprite's size. The images come from a free tile pack (CC0 licence); any PNG works for your own game. The size is decided by the code, not by the file.
// GameEngine constructor – each image is loaded once
background = new ImageIcon("data/images/background.jpg").getImage();
paddleImage = new ImageIcon("data/images/paddle.png").getImage();
ballImage = new ImageIcon("data/images/ball.png").getImage();
// Sprite.draw – the image is stretched to the sprite's size
g.drawImage(image, x, y, width, height, null);Game art often comes as a sprite sheet: one big image with many tiles or animation frames on it, plus an atlas file that says where each one is. The tile pack in the Arkanoid folder ships both forms – separate PNGs and a sheet with an XML atlas. The example uses the separate files; with a sheet you load one image and cut rectangles out of it.
Breakout_Tile_Free.xml – the atlas (shortened)
<TextureAtlas imagePath="Breakout_Tile_Free.png">
<SubTexture name="02-Breakout-Tiles.png" x="0" y="0" width="384" height="128"/>
<SubTexture name="03-Breakout-Tiles.png" x="0" y="130" width="384" height="128"/>
...
</TextureAtlas>2. Key bindings
The InputMap maps a key to a name, the ActionMap maps the name to an action. With WHEN_IN_FOCUSED_WINDOW the panel doesn't need the keyboard focus – it keeps working after you used the menu, which is exactly where a KeyListener fails. A tiny bind helper keeps it readable:
GameEngine – bindings
bind("LEFT", "left", () -> paddle.setVelX(-PADDLE_MOVEMENT));
bind("A", "left", () -> paddle.setVelX(-PADDLE_MOVEMENT));
bind("DOWN", "stop", () -> paddle.setVelX(0));
bind("ESCAPE", "pause", () -> paused = !paused);
private void bind(String key, String actionName, Runnable action) {
getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(key), actionName);
getActionMap().put(actionName, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
action.run();
}
});
}In Arkanoid a key press sets a velocity; the paddle keeps going until you stop it. That is one of three usual styles, and the three example games use one each:
| Style | Example | What the key does | Fits |
|---|---|---|---|
| velocity | Arkanoid | sets velX; the loop moves every frame | smooth movement, "keep going" |
| one cell per press | Diver | game.move(direction) once | grid games: one press, one step |
| press / release | Dodger | adds / removes the direction in a Set; tick moves | move while held, diagonals |
3. Levels from text files
A level is a text grid: one line per row of bricks, a digit is the kind of brick, a space is an empty place. The Level constructor reads it line by line and computes each brick's pixel position from the character's position.
data/levels/level00.txt (top rows)
0000000000000000
1111111111111111
2222222222222222
3333333333333333 Level – from characters to bricks
int row = 0;
String line;
while ((line = reader.readLine()) != null) {
for (int col = 0; col < line.length(); col++) {
char blockType = line.charAt(col);
if (Character.isDigit(blockType)) {
Image image = new ImageIcon("data/images/brick0" + blockType + ".png").getImage();
bricks.add(new Brick(col * BRICK_WIDTH, row * BRICK_HEIGHT,
BRICK_WIDTH, BRICK_HEIGHT, image));
}
}
row++;
}What not to copy
GameEngine is model (ball, paddle, bricks, rules), view (paintComponent) and input handling at once; Sprite holds its image and draws itself. For a game this size it fits in one class – but no test can be written for it. "If the ball falls out, start over" can only be checked by playing, and the playfield size (800 × 600) is known by several classes.
- No
Imagein the model. The model has numbers (position, size, kind); the view picks the picture. - No
draw(Graphics)in model classes. The view reads the model and draws it. - One owner for the playfield size – the model – and everyone asks it.
- The rules in a method like
tick()ormove()that a test can call.
Diver: the sample third assignment
A diver collects pearls in a rocky underwater cave. The cave is a grid of rock (impassable) and water; some water cells hold a pearl. The diver starts at the boat (the start cell) and moves one cell per W/A/S/D press (or arrow). A pearl is worth one point. Sharks patrol in straight lines, horizontally or vertically, turning at rock and at the edge. A shark sees its own cell and the one in front of it; if the diver is in a shark's field of view, a life is lost and the diver goes back to the boat. With all pearls collected the next level comes – there are 10, in files. When the 3 lives are gone, the program asks for a name, saves the score to the database and shows the top 10.
The rules, and where they live
| # | Rule | Where |
|---|---|---|
| 1 | The level is a grid of rock, water and pearls; start and shark positions come from the file | Tile, Level.parse |
| 2 | 10 levels from files, strictly checked (equal lines, exactly one start, at least one pearl, known symbols) | Level.load, Level.parse |
| 3 | Every level is playable: every pearl can be reached from the start | Level.isPlayable (BFS), a test over all 10 |
| 4 | The diver moves one cell in four directions; not onto rock, not off the board | Game.move |
| 5 | Stepping on a pearl collects it: +1 point, the cell becomes water | Game.move |
| 6 | A shark steps every SHARK_PERIOD-th tick; at rock or the edge it turns and does not step | Shark.patrol, Game.tick |
| 7 | A shark sees its own cell and VISION = 1 cell ahead, not through rock | Shark.sees |
| 8 | Diver seen (after a move or a shark step): −1 life, back to the start | Game.checkSharks |
| 9 | 0 lives: game over; after that move and tick do nothing | Game.move, Game.tick |
| 10 | After the last pearl the next level; after the last level the first; score and lives stay | Game.move → startLevel |
| 11 | Time since the start, counted in ticks; stops at game over | Game.getElapsedMillis |
| 12 | At the end: name, save, top 10; new game from the menu | DiverGUI.gameOver, Leaderboard |
Two questions the task leaves open, and the sample answers explicitly in its documentation – yours should do the same for its open questions:
- What is "one unit"? One grid cell. The field of view is a constant (
Shark.VISION = 1); a larger one is a single number to change (plus the tests). - When do we check for being caught? After every diver move and every shark step – nothing else can change the situation in between. The levels are drawn so that no shark sees the start, and a test checks it.
The structure
Three packages, dependencies pointing one way, as in the Dodger of the previous session: diver.view (Swing) calls diver.model (no Swing, no AWT, no SQL), and diver.db implements the model's HighScoreRepository with JDBC. main wires them together: new DiverGUI(new Leaderboard(new JdbcHighScoreRepository())).
One design decision is worth copying directly: the Level never changes during play; the Game works on copies. A collected pearl becomes water in the game's own tile array, and the sharks are fresh objects. So the same Level can be restarted any number of times, and the 10 levels are loaded only once.
Game.startLevel
private void startLevel(int index) {
levelIndex = index;
level = levels.get(index);
tiles = level.copyTiles(); // the Game's own copy: a collected pearl becomes water here
diver = level.getStart();
sharks = level.newSharks(); // fresh Shark objects at their start positions
pearlsLeft = level.getPearlCount();
}Levels from files, checked by a test
src/main/resources/levels/level01.txt
############
#D.........#
#.###.###..#
#.#P....P#.#
#.#.####.#.#
#.P..H...P.#
#..........#
############The parser is strict and reports every problem with its line number: unequal line lengths, an unknown symbol, a missing or second start, no pearl. A broken level file shows up while you develop, not on the player's machine.
Level.parse – the heart of it
switch (c) {
case '#' -> tiles[row][col] = Tile.ROCK;
case '.' -> tiles[row][col] = Tile.WATER;
case 'P' -> { tiles[row][col] = Tile.PEARL; pearls++; }
case 'D' -> { /* exactly one start, water underneath */ }
case 'H' -> { sharks.add(new Shark(position, Direction.RIGHT)); tiles[row][col] = Tile.WATER; }
case 'V' -> { sharks.add(new Shark(position, Direction.DOWN)); tiles[row][col] = Tile.WATER; }
default -> throw new IllegalArgumentException("Line " + (row + 1) + ", character "
+ (col + 1) + ": unknown symbol '" + c + "'");
}The task requires every level to be playable. Don't judge that by eye – let the program check it. isPlayable() runs a breadth-first search from the start over passable cells and then asks whether every pearl was visited.
Level.isPlayable – the search
Set<Position> visited = new HashSet<>();
Queue<Position> queue = new ArrayDeque<>();
queue.add(start);
visited.add(start);
while (!queue.isEmpty()) {
Position current = queue.remove();
for (Direction direction : Direction.values()) {
Position next = current.step(direction);
if (contains(next) && getTile(next).isPassable() && visited.add(next)) {
queue.add(next);
}
}
}
// playable if every pearl's position is in visitedLevelTest – every built-in level
@Test
public void allBuiltInLevelsLoadAndArePlayable() {
List<Level> levels = Level.loadAll();
assertEquals(Level.LEVEL_COUNT, levels.size());
for (int i = 0; i < levels.size(); i++) {
assertTrue("level " + (i + 1) + ": is playable", levels.get(i).isPlayable());
// ... has pearls, has sharks, no shark sees the start
}
}Sharks, moves and ticks
A shark's state is two values: its position and its direction. patrol is one step; turning "costs" the step – at rock the shark stops, turns, and leaves the next time. Simple, and the player can see it happen.
Shark.patrol
void patrol(Level level) {
Position next = position.step(direction);
if (level.contains(next) && level.getTile(next).isPassable()) {
position = next;
} else {
direction = direction.opposite(); // turning costs this step
}
}sees walks forward cell by cell up to VISION, stopping at rock or the edge, so the constant can grow without rewriting the method:
Shark.sees
boolean sees(Position target, Level level) {
if (position.equals(target)) {
return true;
}
Position ahead = position;
for (int distance = 1; distance <= VISION; distance++) {
ahead = ahead.step(direction);
if (!level.contains(ahead) || !level.getTile(ahead).isPassable()) {
return false; // rock or edge: the view stops here
}
if (ahead.equals(target)) {
return true;
}
}
return false;
}The game has two clocks. The diver moves when the player presses a key – that doesn't depend on time. The sharks move with time: the view's Timer calls tick every 50 ms, and every 10th tick (half a second) the sharks take a step. Both paths end in checkSharks.
Game.tick
public void tick() {
if (over) {
return;
}
ticks++;
if (ticks % SHARK_PERIOD == 0) {
for (Shark shark : sharks) {
shark.patrol(level);
}
checkSharks();
}
}GamePanel – one cell per key press
getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
.put(KeyStroke.getKeyStroke("pressed " + key), "move-" + key);
getActionMap().put("move-" + key, new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
game.move(direction); // the model decides: rock means no step
repaint();
}
});The elapsed time is ticks × TICK_MS, not the system clock. So in a test time "passes" when you call tick; it stops at game over; and while a modal dialog is open (the Timer is stopped) the time stops too.
Events and tests
| Event | Source | Handler | Action |
|---|---|---|---|
| W / A / S / D (and arrows) pressed | GamePanel key binding | AbstractAction in bindKeys | game.move(direction), repaint() |
| Timer fires (50 ms) | DiverGUI.timer | tick() | game.tick(); pack() on a new level; repaint; status bar; at the end timer.stop(), gameOver() |
| Game ▸ New game (Ctrl+N) | JMenuItem | newGame() | new Game, gamePanel.setGame, pack(), timer.restart() |
| Game ▸ High scores (Ctrl+H) | JMenuItem | showHighScores() | timer stops; leaderboard.top(10) → dialog; error → message; timer starts |
| Game ▸ Exit (Ctrl+Q) | JMenuItem | lambda | System.exit(0) |
| Game over (from tick) | – | gameOver() | askName(), leaderboard.save, showHighScores(), newGame() |
53 JUnit tests in four classes, all without Swing, the game tests without a database:
| Class | Tests | What it checks |
|---|---|---|
LevelTest | 12 | parsing (tiles, start, sharks, water under markers); copies independent of the Level; broken levels rejected with the line; isPlayable true and false; all 10 built-in levels load, are playable, no shark sees the start |
GameTest | 21 | movement (water, rock, edge); collecting pearls; next level and wrap-around; sharks step on ticks, turn at rock and edge, swim over pearls; caught by stepping in front or by a shark stepping next to you; no view through rock or sideways; game over; time in ticks |
LeaderboardTest | 11 | the leaderboard backend with an in-memory repository |
JdbcHighScoreRepositoryTest | 9 | the JDBC repository on a temporary SQLite file |
The game tests give their levels as text, right in the test. You see the situation at a glance, and no file is needed:
GameTest – a level as five strings
@Test
public void walkingInFrontOfASharkCostsALifeAndRespawns() {
Game game = game(
"#####",
"#D..#",
"#.H.#", // the shark faces right: it sees (2, 3)
"#.P.#",
"#####");
game.move(Direction.RIGHT);
game.move(Direction.RIGHT); // (1, 3): safe
game.move(Direction.DOWN); // (2, 3): in front of the shark
assertEquals(Game.LIVES - 1, game.getLives());
assertEquals(new Position(1, 1), game.getDiver());
}A helper sharkStep(game) calls tick() SHARK_PERIOD times, so "the shark moves one cell" reads as one line.
The third assignment: checklist
Go through this before you submit – the right column shows where the sample solves it.
| Requirement | In the sample |
|---|---|
| Basic graphics; the player's sprite moves with WASD | GamePanel.paintComponent; key binding W/A/S/D → Game.move |
| Unless an algorithm generates the playfield: at least 10 levels in files, all playable | levels/level01..10.txt; Level.isPlayable; the all-levels test |
| A timer showing the time since the start | Game.getElapsedMillis (from ticks); the status bar |
| At the end a pop-up asks for the name; the score goes into a database | DiverGUI.gameOver, askName, Leaderboard.save |
| A menu item for the top 10 | Game ▸ High scores: Leaderboard.top(10), HighScoresDialog |
| New game from another menu item | Game ▸ New game |
| Object-oriented; layered (recommended) | model / db / view packages |
| User-friendly | status bar, shortcuts, the sharks' field of view drawn, a database error is a message, not a crash |
| Documentation: description, analysis, structure with a UML class diagram, implementation chapter, event–handler pairs | the sample's README, chapters 1–5 |
| Tests (asked at the presentation) | LevelTest, GameTest, LeaderboardTest, JdbcHighScoreRepositoryTest |
Practice
Task 1
Arkanoid levels from resources
Move Arkanoid's level files to src/main/resources/levels/ and load them with getResourceAsStream, so the jar runs from any folder. Do the same for the images.
Approach
Level needs a constructor that takes the lines (or a BufferedReader) instead of a Path. Remember that getResourceAsStream returns null for a missing file – turn that into a clear exception. For images, ImageIO.read(getClass().getResource("/images/ball.png")).
// instead of Path.of("data", "levels", ...) and Files.newBufferedReader:
String name = String.format("/levels/level%02d.txt", levelNum);
InputStream in = GameEngine.class.getResourceAsStream(name); // null if missing!Task 2
Cut a sprite sheet
Load Breakout_Tile_Free.png once and draw the bricks with tiles cut out of it, using the coordinates in the XML atlas, instead of the separate PNG files.
Approach
Cut every frame once at start-up into an array, not in paintComponent. You can copy the few coordinates you need into a constant table rather than parsing the XML.
BufferedImage sheet = ImageIO.read(getClass().getResource("/images/Breakout_Tile_Free.png"));
BufferedImage tile = sheet.getSubimage(0, 130, 384, 128); // x, y, width, height from the XMLTask 3
The all-levels-playable test for your format
Write the test that loads all levels of your own assignment and checks that each one can be finished. Then break one level file on purpose and watch the test fail with a message naming the level.
Approach
Breadth-first search from the start over passable cells (a Set of visited positions and an ArrayDeque as the queue), then check that every collectable cell is in the visited set. Put the level number into the assertion message.
Task 4
Pull Arkanoid's rules out of the panel
Create a Swing-free model for Arkanoid: a Game with ball, paddle and bricks as plain numbers, a tick() with the rules of newFrame, and queries for the view. Then write a test for "if the ball falls out, the level restarts".
Approach
Model classes keep only positions, sizes and velocities – no Image, no Graphics, java.awt.Rectangle replaced by your own intersection check. The panel keeps the images and draws by asking the model. The playfield size lives in the model only.
// a Swing-free ball: numbers only, no Image, no Graphics
public final class Ball {
private int x, y, velX = 1, velY = 1;
// move, bounce ... the view draws it with drawImage at (getX(), getY())
}Task 5
A sharper-eyed shark
Set Shark.VISION to 2. Which tests in GameTest and LevelTest change their outcome, and why? Does the view's pale red area follow automatically?
Where to look
The "diver two cells away is safe" situations now get caught; levels where a shark is two cells from the start fail the "no shark sees the start" check. The view draws the field of view with its own loop – check whether it uses VISION or a literal 1.
Summary
- Sprite
- a moving picture in the game; in the model a rectangle, in the view an image
- Sprite sheet
- one image with many frames; an atlas lists their rectangles, getSubimage cuts them
- Key binding
- InputMap (key → name) + ActionMap (name → action); works without focus
- Resource
- a file packed into the jar, read with
getResourceAsStream– independent of the working directory - BFS
- breadth-first search: visits everything reachable from a start, nearest first
- Load each image once; drawImage stretches it to the sprite's size. A sprite sheet is cut with getSubimage.
- Choose the key style that fits: velocity, one step per press, or a set of held directions.
- Levels are text grids in src/main/resources, parsed strictly, with a test that every level is playable.
- Arkanoid shows the techniques; its one-class structure is not something to copy.
- Diver is the shape of a finished third assignment: model / db / view, rules in move and tick, 53 tests, DOCX or PDF documentation.