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.

20 min readdrawImage and sprite sheetsKey binding stylesLevel files as resourcesPlayability check with BFSMoves vs ticksThird-assignment checklist

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.

ClassJob
Arkanoidentry point
ArkanoidGUIthe window; its only component is the GameEngine
Spriteposition, size, image; draw(Graphics) with drawImage; collides by rectangle intersection
Ball, Paddle, Brickthe ball (velocity, bounces at the edges), the paddle (velocity, stops at the edges), a brick (doesn't move)
Levelthe bricks from a file; a hit brick disappears; isOver when none are left
GameEnginethe 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".

Timer firesabout 240 times a secondskipped while pausedball.moveX()level.collides(ball)?ball.invertVelX()ball.moveY()false: restart()back to level 0level.collides(ball)?ball.invertVelY()paddle.collides(ball)?ball.invertVelY()paddle.move()level.isOver()?next level: restart()repaint()paintComponentbackground, bricks, paddle, ball
Fig. 1 · One frame of Arkanoid. Every rule of the game is somewhere on this path – and all of it runs inside a JPanel.

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>
the sheet – one PNGTextureAtlas XMLx=0 y=130 width=384 height=128getSubimage(…)a BufferedImage of its owng.drawImage(tile, …)The Arkanoid example loads one PNG per brick (brick00.png …) instead – the tile pack ships both forms.
Fig. 2 · A sprite sheet plus its coordinates gives you frames. For an animation, cut several frames once at load time and draw a different one on every few ticks.

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:

StyleExampleWhat the key doesFits
velocityArkanoidsets velX; the loop moves every framesmooth movement, "keep going"
one cell per pressDivergame.move(direction) oncegrid games: one press, one step
press / releaseDodgeradds / removes the direction in a Set; tick movesmove 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++;
}
level file – one character per brick·········000000··111111···2··2··· = space: no brickthe panel – bricks of 40 × 20 pixels00000011111122column 3, row 2 → x = 3 × BRICK_WIDTH = 120, y = 2 × BRICK_HEIGHT = 40The digit picks the image: '1' → data/images/brick01.png. The file says where; the code says how big.
Fig. 3 · Row and column in the file become y and x on the screen, multiplied by the tile size. The same mapping draws any grid game.

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.

Arkanoid – GameEngine does it allRules: ball, paddle, bricks, restartDrawing: paintComponentInput: key bindingsGame loop: Timer, newFrame()Sprite keeps its Image, draws itself800 × 600 known by several classesDiver – three packagesview – SwingDiverGUI, GamePanel, HighScoresDialogmodel – no Swing, no SQLGame, Level, Shark, Leaderboard, HighScore …db – the only SQLJdbcHighScoreRepository, Databasecallsimplements
Fig. 4 · The same kind of game, organised two ways. The left one is fine for showing a technique; the right one is what the assignment is graded against.
  • No Image in 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() or move() 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

#RuleWhere
1The level is a grid of rock, water and pearls; start and shark positions come from the fileTile, Level.parse
210 levels from files, strictly checked (equal lines, exactly one start, at least one pearl, known symbols)Level.load, Level.parse
3Every level is playable: every pearl can be reached from the startLevel.isPlayable (BFS), a test over all 10
4The diver moves one cell in four directions; not onto rock, not off the boardGame.move
5Stepping on a pearl collects it: +1 point, the cell becomes waterGame.move
6A shark steps every SHARK_PERIOD-th tick; at rock or the edge it turns and does not stepShark.patrol, Game.tick
7A shark sees its own cell and VISION = 1 cell ahead, not through rockShark.sees
8Diver seen (after a move or a shark step): −1 life, back to the startGame.checkSharks
90 lives: game over; after that move and tick do nothingGame.move, Game.tick
10After the last pearl the next level; after the last level the first; score and lives stayGame.move → startLevel
11Time since the start, counted in ticks; stops at game overGame.getElapsedMillis
12At the end: name, save, top 10; new game from the menuDiverGUI.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.#
#..........#
############
level01.txt#############D.........##.###.###..##.#P....P#.##.#.####.#.##.P..H...P.##..........#############parseTile[][] + start + sharkstiles: 8 × 12start: (1, 1)pearls: 4shark (5, 5) →# rock . water P pearl D start H shark, starts right V shark, starts downUnder D, H and V there is water. Unequal lines, an unknown symbol, no D or two, no P: an error naming the line.
Fig. 5 · The parser turns characters into three things: a grid of tiles, a start position and a list of sharks. D, H and V are markers on top of water.

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.

D12345613567234345Blue: reached from D – number = steps away.Both pearls in the blue area: collectable.Red: a pearl walled in by rock.It is never visited, soisPlayable() == falseThe unnumbered water cell on the rightis unreachable too – harmless, no pearl.
Fig. 6 · Breadth-first search spreads from the start one step at a time. Whatever it never touches can't be reached by the player either.

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 visited

LevelTest – 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;
}
own cell + 1 aheaddiver 2 cells away: saferock in front#the rock stops the viewpatrol: before#next cell is rock …patrol: after#… so it turns, no step
Fig. 7 · Red cells are what the shark sees. The view draws exactly this area in pale red, so the player understands why they were caught.

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.

Timer: tick() every 50 mssharks stepsharks stepkey pressesDDSmove(direction)a key press – any timetick()every 10th: sharks patrol()checkSharks()seen: −1 life, back to startElapsed time = ticks × TICK_MS. While a dialog is open the Timer stops – and so does the time.
Fig. 8 · One step = one move(), one beat = one tick(). The model is where the two meet, so a test can drive either one.

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

EventSourceHandlerAction
W / A / S / D (and arrows) pressedGamePanel key bindingAbstractAction in bindKeysgame.move(direction), repaint()
Timer fires (50 ms)DiverGUI.timertick()game.tick(); pack() on a new level; repaint; status bar; at the end timer.stop(), gameOver()
Game ▸ New game (Ctrl+N)JMenuItemnewGame()new Game, gamePanel.setGame, pack(), timer.restart()
Game ▸ High scores (Ctrl+H)JMenuItemshowHighScores()timer stops; leaderboard.top(10) → dialog; error → message; timer starts
Game ▸ Exit (Ctrl+Q)JMenuItemlambdaSystem.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:

ClassTestsWhat it checks
LevelTest12parsing (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
GameTest21movement (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
LeaderboardTest11the leaderboard backend with an in-memory repository
JdbcHighScoreRepositoryTest9the 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.

RequirementIn the sample
Basic graphics; the player's sprite moves with WASDGamePanel.paintComponent; key binding W/A/S/D → Game.move
Unless an algorithm generates the playfield: at least 10 levels in files, all playablelevels/level01..10.txt; Level.isPlayable; the all-levels test
A timer showing the time since the startGame.getElapsedMillis (from ticks); the status bar
At the end a pop-up asks for the name; the score goes into a databaseDiverGUI.gameOver, askName, Leaderboard.save
A menu item for the top 10Game ▸ High scores: Leaderboard.top(10), HighScoresDialog
New game from another menu itemGame ▸ New game
Object-oriented; layered (recommended)model / db / view packages
User-friendlystatus 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 pairsthe 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 XML

Task 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.