Skip to content

27. Jump

Every game so far fitted on one screen. In Jump the level is five times as wide as the screen, or three times as high, and the screen shows only the part around the player: a camera follows them. The player runs, jumps, falls and stands on things, takes coins, jumps on beetles and birds, and tries to reach the red flag at the end. Three levels, a meadow, caves and a climb into the clouds, were drawn in a level editor, Tiled, and the game reads its files.

The meadow: a jump over a beetle The heights: the camera has climbed with the player

The meadow (left): the hills behind move slower than the ground, the clouds slower still. The heights (right): a level three screens high, with the camera halfway up.

Platformers began on the arcade screen in 1981, scrolled sideways from 1984, and in 1985 one of them sold forty million copies. What made it last was not the graphics but the jump: how high it goes, how much it can be steered, how forgiving it is when a button comes a moment too early or too late. A jump is physics, a little cheating, and many small numbers. This chapter builds all of it, and the camera, and the background, and reads its levels from a real editor.

What you will learn

  • Maps from Tiled: the TMX format, tilesets, tile classes, animations and objects, read with SAX into records
  • Gravity and collision with tiles: moving one axis at a time, without tunnelling through floors, and platforms that hold only from above
  • A jump that feels right: its height from physics, a variable height, coyote time and a jump buffer
  • A camera: world and screen coordinates, a dead zone, smoothing that does not depend on the frame rate, and the edges of the world
  • Parallax: backgrounds that scroll slower than the world
  • Tiles without gaps at any scale, and cheaper sprites: measured, then halved
  • Proving a level can be finished: a search over every jump the player can make
  • Saving progress so that no crash can lose it

The complete game is in amade-jump-game.

The rules

  1. A level is a grid of tiles of 16 units: ground, wooden ledges that hold from above but can be jumped through from below and dropped through with down, spikes, water and coins. On the screen of 640 × 400 units, 40 × 25 tiles are visible.
  2. The player runs left and right and jumps. Holding the button jumps higher, four and a half tiles at most; a tap is a hop.
  3. Coins are taken by touching them.
  4. Beetles walk and turn at walls, some also at edges; birds fly back and forth. Landing on one from above beats it, and the player bounces off. Touching one in any other way is a fall.
  5. Spikes, water, enemies and pits are falls. There are no lives: the player starts again at the last green flag (a checkpoint) reached, the clock goes on, and the falls are counted.
  6. A spring throws whoever lands on it nine tiles up.
  7. The red flag ends the level. Time, coins and falls are shown; the best time and the most coins of each level are saved, and the next level opens.

Step 1: Levels from the Tiled editor

Maze and Blast kept their boards in text files of letters. That works for a board of 15 × 13; a level of 176 × 25 tiles with two layers and fifty things in it is no longer a text one wants to type. Level designers use editors, and the one most used by small studios and hobbyists is Tiled: free, open source, for Linux, Windows and macOS. You paint tiles with the mouse, place things as rectangles, give them names and properties, and save.

Tiled saves a map as TMX, an XML format, and a tileset (the picture with all tiles, and what Tiled knows about each tile) as TSX, next to it or inside the map:

amade-jump-game · levels/level1.tmx (shortened)
<map version="1.10" orientation="orthogonal" width="176" height="25" tilewidth="16" tileheight="16" infinite="0">
 <properties>
  <property name="name" value="Meadow"/>
  <property name="theme" value="meadow"/>
 </properties>
 <tileset firstgid="1" source="tiles.tsx"/>
 <layer id="1" name="back" width="176" height="25">
  <data encoding="csv">0,0,0,0,0,...</data>
 </layer>
 <layer id="2" name="ground" width="176" height="25">
  <data encoding="csv">...,1,1,1,2,0,0,0,1,1,...</data>
 </layer>
 <objectgroup id="3" name="things">
  <object id="1" type="start" x="48" y="304" width="16" height="16"/>
  <object id="3" type="beetle" x="1088" y="304" width="16" height="16">
   <properties><property name="careful" type="bool" value="true"/></properties>
  </object>
  <object id="11" type="goal" x="2656" y="272" width="16" height="48"/>
 </objectgroup>
</map>
amade-jump-game · levels/tiles.tsx (shortened)
<tileset name="tiles" tilewidth="16" tileheight="16" tilecount="32" columns="8">
 <image source="tiles.png" width="128" height="64"/>
 <tile id="0" type="solid"/>
 <tile id="8" type="platform"/>
 <tile id="11" type="spikes"/>
 <tile id="12" type="coin">
  <animation>
   <frame tileid="12" duration="120"/>
   <frame tileid="13" duration="120"/>
   ...
  </animation>
 </tile>
</tileset>

A few ideas of the format:

  • A layer is a grid of numbers, the gids (global tile ids). 0 is empty; the tileset that starts at firstgid 1 has the gids 1 to 32, a second one would start at 33. Tiled writes the grid as CSV, or as Base64, compressed with zlib or gzip, or (in very old files) as one XML element per tile.
  • The top three bits of a gid say whether the tile is flipped horizontally, vertically or diagonally. A gid of 2147483649 is tile 1, mirrored.
  • Tiles can have a class (type) and properties, and an animation: frames of other tiles with their durations.
  • An object layer holds rectangles with a class, a name and properties. Tiled leaves their meaning to the game.

The engine's TiledMap reads all of that into records. The game never sees XML:

amade-game-engine · tiled/TiledMap.java
public final class TiledMap {
    /** The top bits of a gid say whether the tile is flipped; the rest is the tile. */
    public static final int FLIPPED_HORIZONTALLY = 0x80000000;
    public static final int FLIPPED_VERTICALLY = 0x40000000;
    public static final int FLIPPED_DIAGONALLY = 0x20000000;
    private static final int TILE_BITS = 0x0FFFFFFF;

    /** One frame of a tile's animation: which tile of the set, for how long. */
    public record Frame(int tileId, double seconds) {
    }

    /** What the tileset says about one of its tiles: its class, properties and animation. */
    public record TileInfo(int id, String type, TiledProperties properties, List<Frame> animation) {
        public TileInfo {
            animation = List.copyOf(animation);
        }
    }

    /**
     * A tileset: the image {@code image} (a path relative to the map), cut into {@code columns} columns of
     * {@code tileWidth} × {@code tileHeight} pixels. Its tiles have the gids {@code firstGid} to
     * {@code firstGid + tileCount - 1}.
     */
    public record Tileset(int firstGid, String name, int tileWidth, int tileHeight, int tileCount, int columns,
                          String image, int imageWidth, int imageHeight, Map<Integer, TileInfo> tiles) {
        public Tileset {
            tiles = Map.copyOf(tiles);
        }

        public boolean contains(int gid) {
            return gid >= firstGid && gid < firstGid + tileCount;
        }

        public int rows() {
            return (tileCount + columns - 1) / columns;
        }
    }

    /** A grid of gids, row by row. The gids keep their flip bits; {@link #gid} takes them off. */
    public record TileLayer(String name, int width, int height, int[] gids, boolean visible, TiledProperties properties) {
        /** The tile at {@code col}, {@code row}, without flip bits; 0 for none. */
        public int gid(int col, int row) {
            return gids[row * width + col] & TILE_BITS;
        }

        /** The flip bits at {@code col}, {@code row}: {@link #FLIPPED_HORIZONTALLY} and the others. */
        public int flips(int col, int row) {
            return gids[row * width + col] & ~TILE_BITS;
        }
    }

    /** An object layer. */
    public record ObjectGroup(String name, List<TiledObject> objects, boolean visible, TiledProperties properties) {
        public ObjectGroup {
            objects = List.copyOf(objects);
        }
    }

    private final String source;
    private final int width;
    private final int height;
    private final int tileWidth;
    private final int tileHeight;
    private final TiledProperties properties;
    private final List<Tileset> tilesets;
    private final List<TileLayer> layers;
    private final List<ObjectGroup> objectGroups;

    TiledMap(String source, int width, int height, int tileWidth, int tileHeight, TiledProperties properties,
             List<Tileset> tilesets, List<TileLayer> layers, List<ObjectGroup> objectGroups) {
        this.source = source;
        this.width = width;
        this.height = height;
        this.tileWidth = tileWidth;
        this.tileHeight = tileHeight;
        this.properties = properties;
        this.tilesets = List.copyOf(tilesets);
        this.layers = List.copyOf(layers);
        this.objectGroups = List.copyOf(objectGroups);
    }

    /**
     * Reads a map among the classes of {@code owner}, e.g. {@code "/levels/level1.tmx"}; tilesets in their
     * own files are looked for next to it.
     */
    public static TiledMap load(Class<?> owner, String resource) {
        int slash = resource.lastIndexOf('/');
        String folder = resource.substring(0, slash + 1);
        InputStream in = owner.getResourceAsStream(resource);
        if (in == null) throw new IllegalArgumentException("no map " + resource);
        return parse(resource.substring(slash + 1), in, file -> owner.getResourceAsStream(folder + file));
    }

    /**
     * Reads a map from a stream; {@code source} names it in errors, {@code files} opens the files it
     * refers to, by their path relative to the map (null if there is none).
     */
    public static TiledMap parse(String source, InputStream in, Function<String, InputStream> files) {
        return new TmxReader(source, files).read(in);
    }

The reader is a SAX handler: it gets the elements one by one as the parser meets them, which is quick and, more importantly, knows the line of each one. An error in a level names the file and the line, as TextData has done since Swarm, and it is the same exception. Decoding a layer covers the three encodings:

amade-game-engine · tiled/TmxReader.java
    /** The gids of a layer's {@code <data>}, in one of Tiled's encodings. */
    private int[] decode(String data) {
        int count = width * height;
        int[] result = new int[count];
        if (encoding == null) {
            // Old Tiled: one <tile gid="..."/> element per cell.
            if (xmlTiles.size() != count) throw error(xmlTiles.size() + " tiles in the layer, the map has " + count);
            for (int i = 0; i < count; i++) result[i] = xmlTiles.get(i);
        } else if (encoding.equals("csv")) {
            String[] cells = data.split("\\s*,\\s*");
            if (cells.length != count) throw error(cells.length + " tiles in the layer, the map has " + count);
            for (int i = 0; i < count; i++) {
                try {
                    // Up to 2³² - 1: the flip bits make many gids too large for an int, not for a long.
                    result[i] = (int) Long.parseLong(cells[i]);
                } catch (NumberFormatException e) {
                    throw error("\"" + cells[i] + "\" is not a tile number");
                }
            }
        } else if (encoding.equals("base64")) {
            byte[] bytes = inflate(Base64.getMimeDecoder().decode(data));
            if (bytes.length != count * 4) throw error(bytes.length / 4 + " tiles in the layer, the map has " + count);
            // Four bytes per tile, the lowest first.
            ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asIntBuffer().get(result);
        } else {
            throw error("the encoding " + encoding + " cannot be read; choose CSV under Map → Map Properties");
        }
        return result;
    }

The gids are parsed as long and then cut to int: a flipped tile's number is larger than Integer.MAX_VALUE, and its bits must survive. TileLayer.gid masks the flip bits off, flips keeps only them.

Objects need one correction. Tiled places a tile object (an object that shows a tile) by its bottom left corner, every other object by its top left. The reader turns them all into boxes with their top left corner:

amade-game-engine · tiled/TmxReader.java
    private TiledObject object(TiledProperties properties) {
        int raw = (int) Long.parseLong(object.getValue("gid") == null ? "0" : object.getValue("gid"));
        int gid = raw & 0x0FFFFFFF;
        double x = number(object, "x", 0);
        double y = number(object, "y", 0);
        double w = number(object, "width", 0);
        double h = number(object, "height", 0);
        if (gid != 0) {
            // A tile object: Tiled places it by its bottom left corner, and without a size it is the tile's.
            Tileset set = tilesets.stream().filter(s -> s.contains(gid)).findFirst()
                    .orElseThrow(() -> new DataException(source + ":" + objectLine + ": the object shows tile "
                            + gid + ", which is in no tileset"));
            if (w == 0) w = set.tileWidth();
            if (h == 0) h = set.tileHeight();
            y -= h;
        }
        String name = object.getValue("name");
        return new TiledObject(integer(object, "id", 0), name == null ? "" : name, typeOf(object), x, y, w, h,
                gid, properties);
    }
amade-game-engine · test/tiled/TiledMapTest.java
    @Test
    void aMapIsReadWithItsLayersTilesetsAndProperties() {
        TiledMap map = TiledMap.load(TiledMapTest.class, "small.tmx");
        assertEquals(4, map.width());
        assertEquals(3, map.height());
        assertEquals(16, map.tileWidth());
        assertEquals("Test Meadow", map.properties().string("name", ""));
        assertEquals(90, map.properties().integer("time", 0));

        TiledMap.TileLayer ground = map.layer("ground");
        assertEquals(2, ground.gid(2, 0));
        assertEquals("solid", map.type(ground.gid(0, 2)));
        assertEquals("coin", map.type(ground.gid(2, 0)));
        assertEquals("", map.type(0));
        // 2147483649 is tile 1, flipped horizontally: the top bit.
        assertEquals(1, ground.gid(2, 2));
        assertEquals(TiledMap.FLIPPED_HORIZONTALLY, ground.flips(2, 2));

        TiledMap.TileInfo coin = map.info(2).orElseThrow();
        assertEquals(10, coin.properties().integer("points", 0));
        assertEquals(List.of(new TiledMap.Frame(1, 0.15), new TiledMap.Frame(3, 0.15)), coin.animation());
    }

    @Test
    void aLayerBecomesATileMapOfTheGamesOwnTiles() {
        TiledMap map = TiledMap.load(TiledMapTest.class, "small.tmx");
        TileMap<Tile> tiles = map.tiles("ground", gid -> switch (map.type(gid)) {
            case "solid" -> Tile.GROUND;
            case "coin" -> Tile.COIN;
            default -> Tile.AIR;
        });
        assertEquals(Tile.COIN, tiles.get(2, 0));
        assertEquals(4, tiles.count(t -> t == Tile.GROUND));
    }
amade-game-engine · test/tiled/TiledMapTest.java
    @Test
    void objectsHaveAClassABoxAndProperties() {
        TiledMap map = TiledMap.load(TiledMapTest.class, "small.tmx");
        List<TiledObject> things = map.objects("things");
        assertEquals(3, things.size(), "the coin tile's collision box is no object of the map");
        TiledObject start = things.get(0);
        assertEquals("start", start.type());
        assertEquals(16, start.centerX(), 1e-9);
        TiledObject walker = things.get(1);
        assertEquals("walker", walker.type(), "class, as Tiled 1.9 wrote it");
        assertEquals("left", walker.properties().string("direction", "right"));
        assertEquals("two\nlines", walker.properties().string("note", ""));
        TiledObject coin = things.get(2);
        assertEquals(2, coin.gid());
        assertEquals(16, coin.y(), 1e-9, "a tile object stands on its y: turned into the top");
    }

What the engine cannot read, it refuses, and says what to change in Tiled:

amade-game-engine · test/tiled/TiledMapTest.java
    @Test
    void whatCannotBeReadIsAnErrorNamingTheFileAndLine() {
        var infinite = assertThrows(DataException.class, () -> parse("""
                <?xml version="1.0" encoding="UTF-8"?>
                <map orientation="orthogonal" width="4" height="3" tilewidth="16" tileheight="16" infinite="1">
                </map>
                """));
        assertEquals("level.tmx:2: infinite maps cannot be read: untick Infinite under Map → Map Properties",
                infinite.getMessage());
        var short_ = assertThrows(DataException.class, () -> parse("""
                <?xml version="1.0" encoding="UTF-8"?>
                <map orientation="orthogonal" width="2" height="2" tilewidth="16" tileheight="16">
                 <layer name="ground" width="2" height="2">
                  <data encoding="csv">1,1,1</data>
                 </layer>
                </map>
                """));
        assertEquals("level.tmx:4: 3 tiles in the layer, the map has 4", short_.getMessage());
        var missing = assertThrows(DataException.class, () -> TiledMap.load(TiledMapTest.class, "small.tmx").layer("back"));
        assertEquals("small.tmx: no tile layer \"back\"", missing.getMessage());
    }

Which Tiled

Tiled 1.9 renamed an object's type to class; Tiled 1.10 went back to type for objects and tiles. The reader takes both. Infinite maps (made of chunks), maps of hexagons or isometric tiles, and object templates are refused.

From map to rules

The rules want their own tiles, not gids. The tileset's classes say which is which, and TiledMap.tiles turns a layer into the TileMap of chapter 25, with a function from gid to the game's tile:

amade-jump-game · domain/Tile.java
public enum Tile {
    AIR(Solidity.EMPTY), SOLID(Solidity.SOLID), PLATFORM(Solidity.PLATFORM),
    SPIKES(Solidity.EMPTY), WATER(Solidity.EMPTY), COIN(Solidity.EMPTY);

    public final Solidity solidity;

    Tile(Solidity solidity) {
        this.solidity = solidity;
    }

    /** The tile a gid of the map stands for, by its class; an unknown class is an error naming the map. */
    static Tile of(TiledMap map, int gid) {
        return switch (map.type(gid)) {
            case "" -> AIR;
            case "solid" -> SOLID;
            case "platform" -> PLATFORM;
            case "spikes" -> SPIKES;
            case "water" -> WATER;
            case "coin" -> COIN;
            default -> throw new DataException(map.source() + ": the tile class \"" + map.type(gid)
                    + "\" means nothing to the game; use solid, platform, spikes, water or coin");
        };
    }
}
amade-jump-game · domain/Level.java
    /** The rules' view of a map: its ground as tiles, its things by their class. */
    public static Level read(int number, TiledMap map) {
        TileMap<Tile> tiles = map.tiles("ground", gid -> Tile.of(map, gid));
        Box start = null;
        Box goal = null;
        List<Box> checkpoints = new ArrayList<>();
        List<Box> springs = new ArrayList<>();
        List<Spawn> enemies = new ArrayList<>();
        for (TiledObject thing : map.objects("things")) {
            Box box = new Box(thing.x(), thing.y(), Math.max(1, thing.width()), Math.max(1, thing.height()));
            switch (thing.type()) {
                case "start" -> start = once(map, thing, start, box);
                case "goal" -> goal = once(map, thing, goal, box);
                case "checkpoint" -> checkpoints.add(box);
                case "spring" -> springs.add(box);
                case "beetle" -> enemies.add(new Spawn(Enemy.Kind.BEETLE, box.centerX(), box.bottom(), thing.properties()));
                case "bird" -> enemies.add(new Spawn(Enemy.Kind.BIRD, box.centerX(), box.centerY(), thing.properties()));
                default -> throw error(map, thing, "the class \"" + thing.type()
                        + "\" means nothing to the game; use start, goal, checkpoint, spring, beetle or bird");
            }
        }
        if (start == null) throw new DataException(map.source() + ": no object of the class start");
        if (goal == null) throw new DataException(map.source() + ": no object of the class goal");
        checkpoints.sort((a, b) -> Double.compare(a.x(), b.x()));
        return new Level(number, map, tiles, start, goal, checkpoints, springs, enemies);
    }

Only the layer ground matters to the rules. Every other tile layer is decoration, and every tile without a class too: flowers, fences, a sign. The level designer can paint whatever they like without the rules noticing.

The tests do not need Tiled. A helper draws small levels as text, # for ground, o for a coin, P for the start, and turns them into the TMX that Tiled would save, so every test goes through the real reader.

Step 2: Falling and standing

Everything that moves and stands on the ground is a Body: a box with a speed. Gravity is an acceleration, so each step it adds to the downward speed, up to a limit, the terminal speed, beyond which a fall would be too fast to follow:

amade-game-engine · physics/Body.java
public final class Body {
    public double x;
    public double y;
    public final double width;
    public final double height;
    /** Velocity in units per second; y grows downwards, as on the screen. */
    public double vx;
    public double vy;
    /** What the last move touched. */
    public boolean onGround;
    public boolean hitCeiling;
    public boolean hitLeft;
    public boolean hitRight;
    /** Standing on a platform that can be jumped through from below and dropped through from above. */
    public boolean onPlatform;
    private double previousX;
    private double previousY;

    public Body(double x, double y, double width, double height) {
        if (width <= 0 || height <= 0) throw new IllegalArgumentException("empty body");
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
        previousX = x;
        previousY = y;
    }

    /**
     * Gravity: the vertical speed grows by {@code gravity} units per second each second, up to
     * {@code maxFall}. Real things fall faster and faster; a game's things stop at a speed the eye can follow
     * and the tiles can catch.
     */
    public void fall(double gravity, double maxFall, double seconds) {
        vy = Math.min(vy + gravity * seconds, maxFall);
    }

Moving a body through tiles is where many first platformers go wrong. The body falls diagonally onto the corner of a tile: did it hit a wall or land on the floor? Moved in one go, it cannot tell. The answer of every platformer since the 1980s: one axis at a time. First sideways, and if a wall is in the way, stop at it. Then up or down, and if the floor is in the way, stand on it. Each move has one answer.

The second trap is speed. A player falling at 480 units a second moves 4 units per step at 120 steps a second, less than a tile, but a bullet or a spring can be faster, and a body that jumps over a floor in one step falls through it: tunnelling. So the collider does not look at where the body ends up. It looks at every row the feet pass and every column the side passes, the nearest first:

amade-game-engine · physics/TileCollider.java
public final class TileCollider {
    /** What the tile at {@code col}, {@code row} is; asked also for tiles outside the map. */
    @FunctionalInterface
    public interface Tiles {
        Solidity at(int col, int row);
    }

    /** Visits one tile. */
    @FunctionalInterface
    public interface CellVisitor {
        void visit(int col, int row);
    }

    /** Edges that lie exactly on a tile border belong to the tile before it, not to the one after. */
    private static final double EPSILON = 1e-6;

    private final Tiles tiles;
    private final double tileWidth;
    private final double tileHeight;

    public TileCollider(Tiles tiles, double tileWidth, double tileHeight) {
        this.tiles = tiles;
        this.tileWidth = tileWidth;
        this.tileHeight = tileHeight;
    }

    /**
     * Moves the body by its velocity for {@code seconds}: sideways, then up or down. Afterwards its contact
     * flags say what it touched, and the velocity towards what it touched is 0.
     *
     * @param dropThrough true to fall through one-way platforms, e.g. while the player holds down
     */
    public void move(Body body, double seconds, boolean dropThrough) {
        body.remember();
        body.onGround = body.hitCeiling = body.hitLeft = body.hitRight = body.onPlatform = false;
        moveX(body, body.vx * seconds);
        moveY(body, body.vy * seconds, dropThrough);
        if (!body.onGround && body.vy >= 0) standing(body, dropThrough);
    }

    private void moveX(Body body, double dx) {
        int top = row(body.y);
        int bottom = row(body.bottom() - EPSILON);
        if (dx > 0) {
            // Every column the right edge enters, nearest first.
            for (int col = col(body.right() - EPSILON) + 1; col <= col(body.right() + dx - EPSILON); col++) {
                if (solidInColumn(col, top, bottom)) {
                    body.x = col * tileWidth - body.width;
                    body.vx = 0;
                    body.hitRight = true;
                    return;
                }
            }
        } else if (dx < 0) {
            for (int col = col(body.x) - 1; col >= col(body.x + dx); col--) {
                if (solidInColumn(col, top, bottom)) {
                    body.x = (col + 1) * tileWidth;
                    body.vx = 0;
                    body.hitLeft = true;
                    return;
                }
            }
        }
        body.x += dx;
    }

    private void moveY(Body body, double dy, boolean dropThrough) {
        int left = col(body.x);
        int right = col(body.right() - EPSILON);
        if (dy > 0) {
            // Every row the feet enter. A platform holds only from above, and the feet come from above
            // whenever they enter its row from the row before.
            for (int row = row(body.bottom() - EPSILON) + 1; row <= row(body.bottom() + dy - EPSILON); row++) {
                boolean solid = solidInRow(row, left, right);
                boolean platform = !dropThrough && platformInRow(row, left, right);
                if (solid || platform) {
                    body.y = row * tileHeight - body.height;
                    body.vy = 0;
                    body.onGround = true;
                    body.onPlatform = !solid;
                    return;
                }
            }
        } else if (dy < 0) {
            for (int row = row(body.y) - 1; row >= row(body.y + dy); row--) {
                if (solidInRow(row, left, right)) {
                    body.y = (row + 1) * tileHeight;
                    body.vy = 0;
                    body.hitCeiling = true;
                    return;
                }
            }
        }
        body.y += dy;
    }

Three details:

  • Edges on tile borders. A body 16 units high whose bottom is at exactly 320 stands on row 20; it is not in it. EPSILON makes an edge that lies on a border belong to the tile before it.
  • Platforms. A ledge holds the feet only when they come from above: when they enter its row from the row before. Jumping up through it, the head passes; standing on it, down (dropThrough) lets the player fall.
  • Standing still. A body that did not move down this step may still stand: standing checks the row right under the feet. Without it, a player standing still would not be onGround and could not jump.

The collider asks the game what a tile is, through a small interface, so it works with any map. Jump says that left and right of the level are walls, and above and below is air: a pit leads out of the world.

amade-jump-game · domain/Game.java
        // Walls left and right of the level, air above and below it: a pit is a way out of the world.
        this.world = new TileCollider((col, row) -> {
            if (col < 0 || col >= tiles.width()) return Solidity.SOLID;
            if (row < 0 || row >= tiles.height()) return Solidity.EMPTY;
            return tiles.get(col, row).solidity;
        }, Field.TILE, Field.TILE);
amade-game-engine · test/physics/TileColliderTest.java
    @Test
    void aFallingBodyLandsOnTheGroundExactly() {
        TileCollider world = world(
                "......",
                "......",
                "......",
                "XXXXXX");
        Body body = new Body(20, 0, 10, 14);
        for (int i = 0; i < 120 && !body.onGround; i++) {
            body.fall(1300, 420, STEP);
            world.move(body, STEP, false);
        }
        assertTrue(body.onGround);
        assertEquals(48 - 14, body.y, 1e-9, "the feet on top of row 3");
        assertEquals(0, body.vy, 1e-9);
    }

    @Test
    void evenAVeryFastBodyCannotFallThroughAFloor() {
        TileCollider world = world(
                "......",
                "......",
                "XXXXXX",
                "......",
                "......");
        Body body = new Body(20, 0, 10, 14);
        body.vy = 30_000;                       // 250 units, almost 16 tiles, in one step
        world.move(body, STEP, false);
        assertTrue(body.onGround);
        assertEquals(32 - 14, body.y, 1e-9);
    }
amade-game-engine · test/physics/TileColliderTest.java
    @Test
    void sidewaysFirstThenDownAWallStopsTheRunButNotTheFall() {
        TileCollider world = world(
                "...X..",
                "...X..",
                "...X..",
                "XXXXXX");
        Body body = new Body(30, 10, 10, 14);
        body.vx = 2400;                         // 20 units in this step, 12 of them free
        body.vy = 300;
        world.move(body, STEP, false);
        assertTrue(body.hitRight);
        assertEquals(48 - 10, body.x, 1e-9, "against the wall");
        assertEquals(0, body.vx, 1e-9);
        assertEquals(12.5, body.y, 1e-9, "and still falling along it");
        assertFalse(body.onGround);
    }
amade-game-engine · test/physics/TileColliderTest.java
    @Test
    void aPlatformHoldsFromAboveAndLetsThroughFromBelow() {
        TileCollider world = world(
                "......",
                "......",
                "==....",
                "......",
                "XXXXXX");
        Body body = new Body(8, 50, 10, 14);    // under the platform, on the ground
        body.vy = -600;
        for (int i = 0; i < 8; i++) world.move(body, STEP, false);
        assertFalse(body.hitCeiling, "through the platform from below");
        assertEquals(10, body.y, 1e-9, "the feet above it now");
        body.vy = 300;                          // now down again: the feet come from above
        for (int i = 0; i < 6 && !body.onGround; i++) world.move(body, STEP, false);
        assertTrue(body.onGround);
        assertTrue(body.onPlatform);
        assertEquals(32 - 14, body.y, 1e-9);
    }

    @Test
    void holdingDownDropsThroughAPlatform() {
        TileCollider world = world(
                "......",
                "==....",
                "......",
                "XXXXXX");
        Body body = new Body(8, 2, 10, 14);     // standing on the platform
        body.vy = 0;
        world.move(body, STEP, false);
        assertTrue(body.onGround, "standing still on it");
        body.vy = 100;
        world.move(body, STEP, true);
        assertFalse(body.onGround);
        assertTrue(body.y > 2);
    }

Step 3: Running and jumping

All the numbers of the feel are in one place. They are the result of playing, changing one, and playing again:

amade-jump-game · domain/Field.java
    /** Gravity, and the speed at which falling stops getting faster: not quite a tile per step at 120 steps. */
    public static final double GRAVITY = 1400;
    public static final double MAX_FALL = 480;
    /** Running: the top speed, how fast it is reached, and how fast the player stops on the ground and in the air. */
    public static final double RUN_SPEED = 140;
    public static final double GROUND_ACCELERATION = 1000;
    public static final double AIR_ACCELERATION = 700;
    public static final double GROUND_FRICTION = 1400;
    public static final double AIR_FRICTION = 250;
    /** A full jump is four and a half tiles high: walls of four tiles can be jumped onto, five cannot. */
    public static final double JUMP_HEIGHT = 4.5 * TILE;
    public static final double JUMP_SPEED = Jump.speedFor(JUMP_HEIGHT, GRAVITY);
    public static final double COYOTE_SECONDS = 0.08;
    public static final double BUFFER_SECONDS = 0.1;
    /** Letting go of jump while rising keeps this part of the speed. */
    public static final double JUMP_CUT = 0.45;
    /** Off a stomped beetle: four tiles high with jump held, much less without. */
    public static final double STOMP_SPEED = Jump.speedFor(4 * TILE, GRAVITY);
    /** A spring throws the player nine tiles high, held or not. */
    public static final double SPRING_SPEED = Jump.speedFor(9 * TILE, GRAVITY);

Running is not a speed but a change of speed. The player accelerates towards the running speed, slows down when the keys are let go, and both happen more slowly in the air: a jump can be steered, but not turned round on the spot.

amade-jump-game · domain/Player.java
    /** Speeds up towards the wanted running speed, or slows down without one; slower in the air. */
    void run(int move, double seconds) {
        double target = move * Field.RUN_SPEED;
        double rate;
        if (move != 0) rate = body.onGround ? Field.GROUND_ACCELERATION : Field.AIR_ACCELERATION;
        else rate = body.onGround ? Field.GROUND_FRICTION : Field.AIR_FRICTION;
        double change = rate * seconds;
        body.vx = body.vx < target ? Math.min(target, body.vx + change) : Math.max(target, body.vx - change);
        if (move != 0) facingLeft = move < 0;
        if (body.onGround) ran += Math.abs(body.vx) * seconds;
    }

Jumping sets the upward speed at once. Which speed? The one that reaches the height the levels are built for. Rising at speed v against gravity g, a body stops after v/g seconds, at the height v² / 2g. So the speed for a height h is √(2gh), and Field says "four and a half tiles" instead of a speed found by trying:

amade-game-engine · test/physics/JumpTest.java
    @Test
    void theSpeedForAHeightReachesThatHeight() {
        double height = 4.5 * 16;
        Jump jump = new Jump(Jump.speedFor(height, GRAVITY), 0.08, 0.1, 0.4);
        Body body = standing(20);
        double ground = body.y;
        double highest = ground;
        step(jump, body, FLAT, true, true);
        for (int i = 0; i < 120; i++) {
            step(jump, body, FLAT, false, true);
            highest = Math.min(highest, body.y);
        }
        // The steps lose half a step's way, v · dt / 2: 1.8 units of 72.
        double lost = Jump.speedFor(height, GRAVITY) * STEP / 2;
        assertEquals(height - lost, ground - highest, 0.1);
        assertTrue(body.onGround, "and back on the ground");
    }

    @Test
    void lettingGoEarlyMakesAHop() {
        Jump jump = new Jump(Jump.speedFor(72, GRAVITY), 0.08, 0.1, 0.4);
        Body body = standing(20);
        double ground = body.y;
        double highest = ground;
        step(jump, body, FLAT, true, true);
        for (int i = 0; i < 120; i++) {
            step(jump, body, FLAT, false, i < 6);      // held for 1/20 second
            highest = Math.min(highest, body.y);
        }
        assertTrue(ground - highest < 72 / 2.0, "a hop: " + (ground - highest));
    }

The test allows half a step's way of difference: the game moves in steps of 1/120 second, and adding up steps is not quite the integral.

Exact physics alone feels wrong, though. Three small kindnesses make a jump feel right, and the engine's Jump has all three:

  • A variable height. Letting go of the button while still rising cuts the rising speed, here to 45 %. A tap is a hop, a held button the full jump. One button, every height.
  • Coyote time. A player who presses jump a few hundredths of a second after running off a ledge still jumps, like the cartoon coyote who keeps running on air until he looks down. The eye sees the feet still at the edge; the rules already let them fall.
  • A jump buffer. A press a moment before landing is kept and carried out on landing. Without it, a press four hundredths of a second too early does nothing, and the game feels deaf.
amade-game-engine · physics/Jump.java
public final class Jump {
    /** Steps of 1/120 s add up with a remainder; a nanosecond of tolerance, as in the Ticker. */
    private static final double EPSILON = 1e-9;

    private final double speed;
    private final double coyote;
    private final double buffer;
    private final double cut;
    /** Seconds since the body last stood on the ground, and since the button was last pressed. */
    private double sinceGround = Double.POSITIVE_INFINITY;
    private double sinceRequest = Double.POSITIVE_INFINITY;
    /** Rising from a jump, while letting go may still cut it. */
    private boolean rising;

    /**
     * @param speed  the upward speed a jump starts with, units per second
     * @param coyote seconds after leaving the ground in which a jump is still allowed
     * @param buffer seconds a press is kept before landing
     * @param cut    the part of the rising speed kept when the button is let go, 0 … 1; 1 for a fixed height
     */
    public Jump(double speed, double coyote, double buffer, double cut) {
        this.speed = speed;
        this.coyote = coyote;
        this.buffer = buffer;
        this.cut = cut;
    }

    /** The starting speed that reaches {@code height} against {@code gravity}: v = √(2gh). */
    public static double speedFor(double height, double gravity) {
        return Math.sqrt(2 * gravity * height);
    }

    /** The height a starting speed reaches: h = v² / 2g. */
    public static double heightOf(double speed, double gravity) {
        return speed * speed / (2 * gravity);
    }

    /**
     * One update step, after the last move (so the body knows whether it stands) and before this step's
     * fall and move. Starts a jump if one is due and returns true in that step.
     *
     * @param pressed the button went down in this step
     * @param held    the button is down
     */
    public boolean update(Body body, boolean pressed, boolean held, double seconds) {
        sinceGround = body.onGround ? 0 : sinceGround + seconds;
        sinceRequest = pressed ? 0 : sinceRequest + seconds;
        if (body.onGround) rising = false;
        if (sinceRequest <= buffer + EPSILON && sinceGround <= coyote + EPSILON) {
            body.vy = -speed;
            // Both used up: no second jump from the same ground or the same press.
            sinceGround = Double.POSITIVE_INFINITY;
            sinceRequest = Double.POSITIVE_INFINITY;
            rising = true;
            return true;
        }
        if (rising && body.vy >= 0) rising = false;      // the top is reached
        if (rising && !held) {
            body.vy *= cut;
            rising = false;
        }
        return false;
    }

Both windows are used up by a jump, so a press cannot jump twice, and a jump in the air does not count as standing.

amade-game-engine · test/physics/JumpTest.java
    @Test
    void aJumpPressedJustAfterRunningOffALedgeStillJumps() {
        Jump jump = new Jump(400, 0.08, 0.1, 0.4);
        Body body = standing(50);                     // the right edge at 60, the ledge ends at 64
        body.vx = 120;
        int air = 0;
        while (air < 6) {                             // 6 steps, 0.05 s, in the air
            step(jump, body, LEDGE, false, false);
            if (!body.onGround) air++;
            body.vx = 120;
        }
        assertTrue(step(jump, body, LEDGE, true, true), "coyote time: the jump counts");
        assertTrue(body.vy < 0);
    }

    @Test
    void tooLateAfterTheLedgeIsTooLate() {
        Jump jump = new Jump(400, 0.08, 0.1, 0.4);
        Body body = standing(50);
        body.vx = 120;
        int air = 0;
        while (air < 12) {                            // 0.1 s in the air
            step(jump, body, LEDGE, false, false);
            if (!body.onGround) air++;
            body.vx = 120;
        }
        assertFalse(step(jump, body, LEDGE, true, true));
    }

    @Test
    void aPressJustBeforeLandingJumpsOnLanding() {
        Jump jump = new Jump(400, 0.08, 0.1, 0.4);
        Body body = new Body(20, 100, 10, 14);        // falling, 46 units above the ground
        boolean pressed = false;
        boolean jumped = false;
        for (int i = 0; i < 120 && !jumped; i++) {
            // Press once, when the ground is less than 10 units away: a few steps too early.
            boolean press = !pressed && 160 - body.bottom() < 10;
            pressed |= press;
            jumped = step(jump, body, FLAT, press, true);
        }
        assertTrue(pressed);
        assertTrue(jumped, "the buffered press jumps as soon as the feet are down");
    }

A step of the game puts these together in a fixed order: the jump first (it needs to know whether the body stood after the last step), then running, gravity, the move, and then what the body touches:

amade-jump-game · domain/Game.java
    /** One step of {@code seconds} with what the player wants in it. */
    public void update(double seconds, Input input) {
        events.clear();
        phaseTime += seconds;
        for (int i = 0; i < springs.length; i++) springs[i] = Math.max(0, springs[i] - seconds);
        double bottom = tiles.height() * Field.TILE + Field.FALL_OUT;
        for (Enemy enemy : enemies) enemy.update(seconds, world, bottom);
        switch (phase) {
            case PLAYING -> play(seconds, input);
            case DYING -> tumble(seconds);
            case CLEARED -> player.body.remember();
        }
    }

    private void play(double seconds, Input input) {
        time += seconds;
        Body body = player.body;
        if (player.jump.update(body, input.jump(), input.held(), seconds)) {
            events.add(new Event.Jumped(body.centerX(), body.bottom()));
        }
        player.run(input.move(), seconds);
        body.fall(Field.GRAVITY, Field.MAX_FALL, seconds);
        double falling = body.vy;
        boolean flying = !body.onGround;
        world.move(body, seconds, input.down());
        if (flying && body.onGround) events.add(new Event.Landed(body.centerX(), body.bottom(), falling));
        springs(falling);
        if (touchTiles() || enemies(falling)) return;
        checkpoints();
        if (body.overlaps(asBody(level.goal()))) clear();
        else if (body.y > tiles.height() * Field.TILE + Field.FALL_OUT) die(Event.Cause.FALL);
    }

Step 4: Coins, spikes and enemies

Tiles that do something when touched are visited with forEachCell, every tile the player's box overlaps. Not every tile is deadly to its edge, though: the spikes are only the lower half of their tile, and water starts below its waves. A player who touches a pixel of a tile they cannot see as dangerous feels cheated:

amade-jump-game · domain/Game.java
    /** Coins, spikes and water: what lies in the tiles the player overlaps. True if it was deadly. */
    private boolean touchTiles() {
        Body body = player.body;
        Event.Cause[] death = {null};
        world.forEachCell(body, (col, row) -> {
            if (!tiles.contains(col, row) || death[0] != null) return;
            double x = col * Field.TILE;
            double y = row * Field.TILE;
            switch (tiles.get(col, row)) {
                case COIN -> {
                    if (overlaps(body, x + 3, y + 3, 10, 10)) {
                        tiles.set(col, row, Tile.AIR);
                        coins++;
                        events.add(new Event.CoinTaken(col, row, coins));
                    }
                }
                // The points of the spikes are the lower half of the tile; water starts below its waves.
                case SPIKES -> {
                    if (overlaps(body, x + 2, y + 8, 12, 8)) death[0] = Event.Cause.SPIKES;
                }
                case WATER -> {
                    if (overlaps(body, x, y + 6, 16, 10)) death[0] = Event.Cause.WATER;
                }
                default -> {
                }
            }
        });
        if (death[0] == null) return false;
        die(death[0]);
        return true;
    }
amade-jump-game · test/domain/GameTest.java
    @Test
    void runningThroughCoinsTakesThem() {
        Game game = new Game(Levels.of(
                "...........",
                ".P.ooo...G.",
                "###########"));
        List<Event> events = run(game, Input.run(1), 1.5);
        assertEquals(3, game.coins());
        assertEquals(List.of(3, 4, 5), of(events, Event.CoinTaken.class).stream().map(Event.CoinTaken::col).toList());
        assertEquals(Tile.AIR, game.tiles().get(4, 1), "the coin is gone from the game's tiles");
        assertEquals(Tile.COIN, game.level().tile(4, 1), "but not from the level's");
    }

A taken coin becomes air in the game's tiles but stays in the level's: the next attempt has all coins again.

Enemies are bodies too. A beetle walks with the same gravity and the same collider as the player, so it falls into pits and stops at walls without a line of its own for that. A careful beetle looks one unit ahead and below its feet and turns at an edge. A bird ignores gravity and flies along a sine, sideways or up and down:

amade-jump-game · domain/Enemy.java
    void update(double seconds, TileCollider world, double bottom) {
        if (gone) return;
        if (squashed) {
            body.remember();
            squashedTime += seconds;
            if (squashedTime >= Field.SQUASHED_SECONDS) gone = true;
            return;
        }
        time += seconds;
        if (kind == Kind.BIRD) {
            body.remember();
            double offset = range * Math.sin(2 * Math.PI * time / period);
            double before = vertical ? body.y : body.x;
            if (vertical) body.y = originY + offset;
            else body.x = originX + offset;
            double moved = (vertical ? body.y : body.x) - before;
            if (!vertical && moved != 0) direction = Math.signum(moved);
            return;
        }
        // A beetle walks: the same gravity and collision as the player's.
        double ahead = direction > 0 ? body.right() + 1 : body.x - 1;
        if (careful && body.onGround && world.at(ahead, body.bottom() + 1) == Solidity.EMPTY) direction = -direction;
        body.vx = direction * Field.BEETLE_SPEED;
        body.fall(Field.GRAVITY, Field.MAX_FALL, seconds);
        world.move(body, seconds, false);
        if (body.hitLeft || body.hitRight) direction = -direction;
        if (body.y > bottom) gone = true;
    }

The oldest rule of the genre decides who wins when the player touches an enemy: coming down from above, the player does. "From above" means: falling, and in the step before, the feet were no lower than a few units below the enemy's top. The margin makes a landing on the enemy's shoulder count; without it, half the stomps would be falls.

amade-jump-game · domain/Game.java
    /**
     * Enemies: whoever comes down onto one from above, with the feet no lower than a little below its top
     * in the step before, stomps it and bounces off; whoever touches one in any other way is lost.
     */
    private boolean enemies(double falling) {
        Body body = player.body;
        for (Enemy enemy : enemies) {
            if (!enemy.alive() || !body.overlaps(enemy.body)) continue;
            double feetBefore = body.previousY() + body.height;
            if (falling > 0 && feetBefore <= enemy.body.previousY() + Field.STOMP_MARGIN) {
                enemy.squash();
                body.y = enemy.body.y - body.height;
                player.jump.launch(body, Field.STOMP_SPEED, true);
                events.add(new Event.Stomped(enemy));
            } else {
                die(Event.Cause.ENEMY);
                return true;
            }
        }
        return false;
    }

The bounce off a stomped enemy is a jump without the button: Jump.launch. Holding the button while landing bounces higher, as it does in the classics, because the launch can be cut like a jump. A spring launches too, but uncut: it always throws nine tiles high.

amade-jump-game · test/domain/GameTest.java
    @Test
    void aBeetleIsStompedFromAboveAndDeadlyFromTheSide() {
        Level level = Levels.of(
                "..........",
                "..........",
                "..........",
                ".P.....b.G",
                "##########");
        Game side = new Game(level);
        List<Event> events = run(side, Input.run(1), 2);
        assertEquals(Event.Cause.ENEMY, of(events, Event.Died.class).getFirst().cause(), "walked into it");

        Game above = new Game(level);
        above.put(7 * 16 + 4, 16, 0);                // high above the beetle, falling onto it
        events = run(above, Input.NONE, 1);
        assertEquals(1, of(events, Event.Stomped.class).size());
        assertTrue(of(events, Event.Died.class).isEmpty());
        assertFalse(above.enemies().getFirst().alive());
    }

A fall throws the player up and out of the picture through everything, and after a moment they stand at the last green flag again:

amade-jump-game · test/domain/GameTest.java
    @Test
    void afterAFallThePlayerStartsAgainAtTheLastCheckpoint() {
        Game game = new Game(Levels.of(
                "...........",
                ".P..C..^..G",
                "###########"));
        List<Event> events = run(game, Input.run(1), 1.2);
        assertEquals(1, of(events, Event.CheckpointReached.class).size());
        assertEquals(Event.Cause.SPIKES, of(events, Event.Died.class).getFirst().cause());
        assertEquals(Game.Phase.DYING, game.phase());
        events = run(game, Input.NONE, Field.DYING_SECONDS);
        Event.Respawned back = of(events, Event.Respawned.class).getFirst();
        assertEquals(4 * 16 + 8, back.x(), 1e-9, "at the flag");
        assertEquals(1, game.deaths());
        assertEquals(Game.Phase.PLAYING, game.phase());
    }

    @Test
    void fallingOutOfTheLevelIsAFall() {
        Game game = new Game(Levels.of(
                "........",
                ".P......",
                "###...#G",
                "......##"));
        List<Event> events = run(game, Input.run(1), 2);
        assertEquals(Event.Cause.FALL, of(events, Event.Died.class).getFirst().cause());
    }
amade-jump-game · test/domain/GameTest.java
    @Test
    void theGoalEndsTheLevelWithItsResult() {
        Game game = new Game(Levels.of(
                ".........",
                ".P.o.o.G.",
                "#########"));
        List<Event> events = run(game, Input.run(1), 2);
        LevelResult result = of(events, Event.Cleared.class).getFirst().result();
        assertEquals(2, result.coins());
        assertEquals(2, result.totalCoins());
        assertTrue(result.allCoins());
        assertEquals(0, result.deaths());
        assertTrue(result.seconds() > 0.5 && result.seconds() < 1, "about 90 units at up to 140 a second");
        double stopped = game.time();
        run(game, Input.run(1), 1);
        assertEquals(stopped, game.time(), "the clock stops at the goal");
    }

All of it reaches the user interface as events, as in Blast:

amade-jump-game · domain/Event.java
public sealed interface Event {
    record Jumped(double x, double y) implements Event {
    }

    /** The feet came down after a fall at {@code speed}: a puff of dust for a hard landing. */
    record Landed(double x, double y, double speed) implements Event {
    }

    record CoinTaken(int col, int row, int coins) implements Event {
    }

    record Stomped(Enemy enemy) implements Event {
    }

    record Sprung(double x, double y) implements Event {
    }

    record CheckpointReached(int index, double x, double y) implements Event {
    }

    /** The player is lost: spikes, water, an enemy, or a fall out of the level. */
    record Died(double x, double y, Cause cause) implements Event {
    }

    /** Back at the last checkpoint. */
    record Respawned(double x, double y) implements Event {
    }

    record Cleared(LevelResult result) implements Event {
    }

    enum Cause { SPIKES, WATER, ENEMY, FALL }
}

Step 5: Can the level be finished?

A level drawn in an editor can be broken in ways no unit test of the rules finds: a gap one tile too wide, a ledge one tile too high, a coin behind a wall. Playing every level after every change finds them, slowly. A test finds them at once.

The idea is Blast's board check, a breadth-first search, but over jumps instead of tiles. A place is a column and the row the player stands on. From each place, the search tries every kind of jump the player can make (standing or running, to the left, straight up or to the right, a hop or the full height, steering at once or only on the way down) and every step off an edge, each played with the game's own rules in a fresh Game. Wherever the player lands is a new place to try from. Every coin taken on the way is noted, and so is the goal:

amade-jump-game · test/domain/Reach.java
final class Reach {
    private static final double STEP = 1.0 / 120;

    /**
     * A place to stand: the column of the player's middle, and the row of the tile under the feet. Tries
     * start where the player first stood in it, {@code x}: the middle of the column may be off the edge.
     */
    record Spot(int col, int row) {
    }

    /**
     * A way to leave a place: the speed already there, where to steer and from when on, how long to hold
     * jump (0: no jump). Steering late is how a spring is ridden: straight up first, then to the side.
     */
    private record Try(double vx, int move, double steerFrom, double hold) {
    }

    private static final List<Try> TRIES = tries();

    final Map<Spot, Double> spots = new HashMap<>();
    final Set<Long> coins = new HashSet<>();
    final Level level;
    boolean goal;

    private Reach(Level level) {
        this.level = level;
    }

    static Reach of(Level level) {
        Reach reach = new Reach(level);
        Game first = new Game(level);
        Spot start = spot(first);
        Deque<Spot> queue = new ArrayDeque<>();
        reach.spots.put(start, first.player().body().centerX());
        queue.add(start);
        while (!queue.isEmpty()) {
            Spot from = queue.poll();
            for (Try t : TRIES) {
                Game game = reach.attempt(from, t);
                if (game == null) continue;
                Spot to = spot(game);
                if (reach.spots.putIfAbsent(to, game.player().body().centerX()) == null) queue.add(to);
            }
        }
        return reach;
    }

    /** Plays one try; the game with the player standing somewhere afterwards, or null if lost. */
    private Game attempt(Spot from, Try t) {
        Game game = new Game(level);
        game.withoutEnemies();
        game.put(spots.get(from), from.row() * Field.TILE, t.vx());
        if (!game.player().onGround()) return null;
        boolean airborne = false;
        for (double time = 0; time < 3; time += STEP) {
            boolean held = time < t.hold();
            int move = time >= t.steerFrom() ? t.move() : 0;
            game.update(STEP, new Input(move, t.hold() > 0 && time == 0, held, false));
            for (Event event : game.events()) {
                if (event instanceof Event.CoinTaken coin) coins.add(((long) coin.col() << 32) | coin.row());
            }
            if (game.phase() == Game.Phase.CLEARED) goal = true;
            if (game.phase() != Game.Phase.PLAYING) return null;
            if (!game.player().onGround()) airborne = true;
            else if (airborne) return game;
            else if (t.hold() == 0 && time > 0.12) return game;      // a step along the ground
        }
        return null;
    }

Enemies are left out: they can be stomped or waited for. The physics are not approximated: the search uses Game.update, so a change of JUMP_HEIGHT that makes a gap impossible fails the build.

amade-jump-game · test/domain/LevelTest.java
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3})
    void theGoalAndEveryCoinCanBeReached(int number) {
        Level level = Level.load(number);
        Reach reach = Reach.of(level);
        assertTrue(reach.goal, () -> level.name() + ": the goal cannot be reached\n" + reach.picture());
        assertEquals(level.coins(), reach.coins.size(), () -> level.name() + ": coins out of reach (?)\n" + reach.picture());
    }

When it fails, the message is the level as text, with every place reached marked * and the coins out of reach as ?. While the levels of this chapter were made, it failed four times: a ledge one tile too high, a gap one tile too wide in the caves, a coin that could only be reached by a jump nobody would try, and a climb in the heights that needed a spring the test did not yet know how to ride (it now steers late).

Step 6: The camera

The level is 2816 units wide; the screen shows 640. Everything so far lives in world coordinates: the player is at x = 1700. Drawing needs screen coordinates: where on the screen that is. A camera is the difference between the two: the world's position of the screen's top left corner. Drawing the world shifted by it, everything lands in the right place:

screenX = worldX - camera.x()

Where should the camera be? The obvious answer, "the player in the middle", is the wrong one: every step of the player shakes the whole world, every jump moves the ground up and down. Two rules make it calm:

  • A dead zone: a box in the middle of the screen, here 48 × 96 units, in which the player moves without the camera moving at all. Running back and forth a little, or jumping on the spot, leaves the world still.
  • Smoothing: once the player leaves the dead zone, the camera does not jump after them but closes a part of the distance each second.

The camera's dead zone

The smoothing must not depend on the step length, or the camera would move differently at 60 and at 120 steps a second. "Close 99.99 % of the distance in one second" means that after t seconds, (1 − 0.9999)^t of the distance is left, whatever the steps:

amade-game-engine · camera/Camera.java
public final class Camera {
    private double viewWidth;
    private double viewHeight;
    private final double worldWidth;
    private final double worldHeight;
    private double deadWidth;
    private double deadHeight;
    /** The part of the remaining distance the camera closes per second, 0 … 1; 1 follows at once. */
    private double catchUp = 0.99;
    private double x;
    private double y;
    private double previousX;
    private double previousY;
    /** Where the view's top left corner lies on the screen: (0, 0) unless a status bar takes the top. */
    private double screenLeft;
    private double screenTop;

    public Camera(double viewWidth, double viewHeight, double worldWidth, double worldHeight) {
        if (viewWidth <= 0 || viewHeight <= 0 || worldWidth <= 0 || worldHeight <= 0) {
            throw new IllegalArgumentException("empty view or world");
        }
        this.viewWidth = viewWidth;
        this.viewHeight = viewHeight;
        this.worldWidth = worldWidth;
        this.worldHeight = worldHeight;
    }

    /** The box in the middle of the view in which the target moves without the camera following. */
    public Camera deadZone(double width, double height) {
        deadWidth = Math.clamp(width, 0, viewWidth);
        deadHeight = Math.clamp(height, 0, viewHeight);
        return this;
    }

    /**
     * How fast the camera catches up once the target has left the dead zone: the part of the distance it
     * closes in one second. 0.99 leaves 1 % after a second, 0.5 is a slow drift; 1 sticks to the target.
     */
    public Camera catchUp(double partPerSecond) {
        catchUp = Math.clamp(partPerSecond, 0, 1);
        return this;
    }

    /** Puts the target in the middle of the view at once, without scrolling: at the start, after a jump. */
    public void jumpTo(double targetX, double targetY) {
        x = clampX(targetX - viewWidth / 2);
        y = clampY(targetY - viewHeight / 2);
        previousX = x;
        previousY = y;
    }

    /** One update step: moves towards the target if it has left the dead zone. */
    public void follow(double targetX, double targetY, double seconds) {
        previousX = x;
        previousY = y;
        double wantedX = x + outside(targetX - (x + viewWidth / 2), deadWidth / 2);
        double wantedY = y + outside(targetY - (y + viewHeight / 2), deadHeight / 2);
        // The part left after this step is (1 - catchUp) per second, so (1 - catchUp)^seconds of it:
        // the same speed at 60 or at 120 steps a second.
        double part = catchUp >= 1 ? 1 : 1 - Math.pow(1 - catchUp, seconds);
        x = clampX(x + (wantedX - x) * part);
        y = clampY(y + (wantedY - y) * part);
    }

    /** How far {@code offset} lies beyond {@code half} on either side of the middle; 0 inside. */
    private static double outside(double offset, double half) {
        if (offset > half) return offset - half;
        if (offset < -half) return offset + half;
        return 0;
    }

    private double clampX(double left) {
        if (worldWidth <= viewWidth) return (worldWidth - viewWidth) / 2;
        return Math.clamp(left, 0, worldWidth - viewWidth);
    }

    private double clampY(double top) {
        if (worldHeight <= viewHeight) return (worldHeight - viewHeight) / 2;
        return Math.clamp(top, 0, worldHeight - viewHeight);
    }

At the edges of the world the camera stops, and the player walks towards the edge of the screen. A world narrower than the screen is centred.

amade-game-engine · test/camera/CameraTest.java
    @Test
    void theCameraStaysWhileTheTargetMovesInsideTheDeadZone() {
        Camera camera = new Camera(640, 400, 3200, 400).deadZone(64, 80).catchUp(1);
        camera.jumpTo(1000, 200);
        assertEquals(680, camera.x(), 1e-9, "the target in the middle: 1000 - 320");
        camera.follow(1030, 200, STEP);         // 30 to the right: still inside the 32 of the half zone
        assertEquals(680, camera.x(), 1e-9);
        camera.follow(1050, 200, STEP);         // 50: 18 beyond the zone, and the camera follows by 18
        assertEquals(698, camera.x(), 1e-9);
    }

    @Test
    void theCameraStopsAtTheEdgesOfTheWorld() {
        Camera camera = new Camera(640, 400, 3200, 400).catchUp(1);
        camera.jumpTo(100, 200);
        assertEquals(0, camera.x(), 1e-9, "not left of the world, so the target is left of the middle");
        camera.jumpTo(3150, 200);
        assertEquals(2560, camera.x(), 1e-9, "the right edge of the view at the right edge of the world");
        assertEquals(0, camera.y(), 1e-9, "a world as high as the view never scrolls up or down");
    }
amade-game-engine · test/camera/CameraTest.java
    @Test
    void smoothingClosesTheSamePartPerSecondAtAnyStepLength() {
        Camera fast = new Camera(640, 400, 10_000, 400).catchUp(0.9);
        Camera slow = new Camera(640, 400, 10_000, 400).catchUp(0.9);
        fast.jumpTo(320, 200);
        slow.jumpTo(320, 200);
        for (int i = 0; i < 120; i++) fast.follow(1320, 200, 1.0 / 120);     // one second in 120 steps
        for (int i = 0; i < 30; i++) slow.follow(1320, 200, 1.0 / 30);       // and in 30
        assertEquals(900, fast.x(), 1e-6, "90 % of the 1000 after one second");
        assertEquals(fast.x(), slow.x(), 1e-6);
    }

The camera keeps its position from the step before, like the bodies, so drawing can interpolate between steps with alpha (chapter 20). A camera that jumps in whole steps while the player is drawn in between makes the player shiver.

The play scene owns the camera. It follows the player in every step, except while they tumble out of the picture after a fall: the camera stays where the fall happened.

amade-jump-game · ui/game/JumpGame.java
    /** A level. The camera follows the player, but not into the depths after a fall. */
    final class PlayScene implements Scene {
        private final Game game;
        private final Camera camera;
        private double sinceCheckpoint = Double.MAX_VALUE;
        private Progress.Record record;

        PlayScene(Level level) {
            game = new Game(level);
            camera = new Camera(Field.WIDTH, Field.HEIGHT, level.width() * Field.TILE, level.height() * Field.TILE)
                    .deadZone(DEAD_ZONE_WIDTH, DEAD_ZONE_HEIGHT).catchUp(CATCH_UP);
            Body body = game.player().body();
            camera.jumpTo(body.centerX(), body.centerY());
            effects.clear();
            effects.music(level.theme());
        }

        @Override
        public void update(double seconds) {
            if (game.phase() == Game.Phase.CLEARED && game.phaseTime() >= CHEER_SECONDS) {
                scenes.push(new ClearedScene(this));
                return;
            }
            if (controls.wasPressed(Action.PAUSE)) {
                scenes.push(new PauseScene());
                return;
            }
            Input input = new Input(controls.axis(Action.LEFT, Action.RIGHT), controls.wasPressed(Action.JUMP),
                    controls.isDown(Action.JUMP), controls.isDown(Action.DOWN));
            step(seconds, input);
        }

        /** One step of the level: the rules, what they did, and the camera. */
        void step(double seconds, Input input) {
            game.update(seconds, input);
            effects.react(game.events(), true);
            effects.update(seconds);
            sinceCheckpoint += seconds;
            for (Event event : game.events()) {
                switch (event) {
                    case Event.CheckpointReached reached -> sinceCheckpoint = 0;
                    case Event.Cleared(var result) -> {
                        record = progress.finished(result, levels.size());
                        chosen = Math.min(levels.size(), result.level() + 1);
                    }
                    default -> {
                    }
                }
            }
            Body body = game.player().body();
            if (game.phase() != Game.Phase.DYING) camera.follow(body.centerX(), body.centerY(), seconds);
        }

How far behind the camera stays at a steady run follows from the same formula: the speed divided by the rate, −ln(1 − 0.9999) ≈ 9.2 per second. At 140 units a second, 15 units beyond the dead zone's edge. The test says exactly that:

amade-jump-game · test/ui/game/JumpGameTest.java
    @Test
    void theCameraFollowsOnlyOnceThePlayerLeavesTheDeadZone() {
        JumpGame.PlayScene play = game.play(1);
        Stage.withoutEnemies(play.game());
        Stage.put(play.game(), 560, 320, 0);           // a flat stretch of the meadow
        Body body = play.game().player().body();
        play.camera().jumpTo(body.centerX(), body.centerY());
        double start = play.camera().x();
        keyboard.pressed(KeyEvent.VK_RIGHT, now[0]);
        seconds(0.1);
        assertEquals(start, play.camera().x(), "a few steps stay inside the dead zone");
        seconds(1.7);
        double player = play.game().player().body().centerX();
        double middle = play.camera().x() + Field.WIDTH / 2;
        assertTrue(play.camera().x() > start + 100, "the camera scrolled along");
        // At a steady run the camera trails the dead zone's edge by the speed over its rate, -ln(1 - catchUp).
        double lag = Field.RUN_SPEED / -Math.log(1 - JumpGame.CATCH_UP);
        assertEquals(JumpGame.DEAD_ZONE_WIDTH / 2 + lag, player - middle, 1);
    }

Step 7: Drawing a level larger than the screen

Only what the camera sees

A level of 176 × 25 tiles in two layers has 8800 tiles. Of these, 41 × 26 per layer can be seen. The painter works out the first and last column and row the camera sees and draws only those:

amade-jump-game · ui/game/JumpPainter.java
    /**
     * The level as the camera sees it, {@code alpha} of the way between the last two steps.
     *
     * @param time the clock of the animations, which runs on when the game's clock stops at the goal
     */
    void paint(Graphics2D g, Game game, Camera camera, Effects effects, double time, boolean player, double alpha) {
        Object smoothing = g.getRenderingHint(RenderingHints.KEY_ANTIALIASING);
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
        Scenery scenery = scenery(game.level());
        double cx = camera.x(alpha);
        double cy = camera.y(alpha);
        scenery.backdrop.draw(g, cx, cy);
        AffineTransform t = g.getTransform();
        g.translate(-cx, -cy);
        for (TileLayer layer : scenery.layers) {
            if (!layer.visible()) continue;
            if (layer.name().equals("ground")) things(g, game, time);
            tiles(g, scenery, layer, game, camera, cx, cy, time);
        }
        enemies(g, game.enemies(), alpha);
        if (player) player(g, game.player(), time, alpha);
        effects.render(g, alpha);
        g.setTransform(t);
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, smoothing);
    }

    /** The tiles of a layer that the camera sees, and no others: 41 × 26 of a map of thousands. */
    private void tiles(Graphics2D g, Scenery scenery, TileLayer layer, Game game, Camera camera, double cx,
                       double cy, double time) {
        boolean ground = layer.name().equals("ground");
        int firstCol = Math.max(0, (int) Math.floor(cx / TILE));
        int lastCol = Math.min(layer.width() - 1, (int) Math.floor((cx + camera.viewWidth()) / TILE));
        int firstRow = Math.max(0, (int) Math.floor(cy / TILE));
        int lastRow = Math.min(layer.height() - 1, (int) Math.floor((cy + camera.viewHeight()) / TILE));
        for (int row = firstRow; row <= lastRow; row++) {
            for (int col = firstCol; col <= lastCol; col++) {
                int gid = layer.gid(col, row);
                if (gid == 0 || gid >= scenery.pictures.length) continue;
                // A coin that was taken is air for the rules; the map still has it.
                if (ground && game.tiles().get(col, row) == Tile.AIR && game.level().tile(col, row) == Tile.COIN) {
                    continue;
                }
                // Each tile's animation runs a little behind the one to its left: a row of coins turns
                // like a wave instead of blinking in step.
                scenery.picture(gid, time + col * WAVE).drawSeamless(g, col * TILE, row * TILE);
            }
        }
    }

The painter draws the map, not the rules' tiles: the picture of each tile, and its animation, come from the tileset, which the level designer controls. What a level needs is prepared once: a sprite for each gid, and the frames of each animated one:

amade-jump-game · ui/game/JumpPainter.java
    private static final class Scenery {
        final List<TileLayer> layers;
        /** The picture of each gid; null for none. */
        final Sprite[] pictures;
        /** The frames of each animated gid and their seconds; null for a tile that stands still. */
        final Sprite[][] frames;
        final double[][] seconds;
        final Parallax backdrop;

        Scenery(Level level) {
            TiledMap map = level.map();
            layers = map.layers();
            int gids = 1;
            for (Tileset set : map.tilesets()) gids = Math.max(gids, set.firstGid() + set.tileCount());
            pictures = new Sprite[gids];
            frames = new Sprite[gids][];
            seconds = new double[gids][];
            for (Tileset set : map.tilesets()) {
                SpriteSheet sheet = SpriteSheet.load(JumpPainter.class, Level.FOLDER + set.image(), set.columns(),
                        set.rows(), TILE, TILE).hardEdges();
                for (int id = 0; id < set.tileCount(); id++) pictures[set.firstGid() + id] = sheet.frame(id);
                set.tiles().forEach((id, info) -> {
                    List<Frame> animation = info.animation();
                    if (animation.isEmpty()) return;
                    int gid = set.firstGid() + id;
                    frames[gid] = new Sprite[animation.size()];
                    seconds[gid] = new double[animation.size()];
                    for (int i = 0; i < animation.size(); i++) {
                        frames[gid][i] = sheet.frame(animation.get(i).tileId());
                        seconds[gid][i] = animation.get(i).seconds();
                    }
                });
            }
            backdrop = Backdrop.of(level.theme(), level.height() * TILE);
        }

        /** The picture of {@code gid} at {@code time}: the frame of its animation, or its own. */
        Sprite picture(int gid, double time) {
            Sprite[] animation = frames[gid];
            if (animation == null) return pictures[gid];
            double[] lengths = seconds[gid];
            double total = 0;
            for (double length : lengths) total += length;
            double at = time % total;
            for (int i = 0; i < animation.length; i++) {
                at -= lengths[i];
                if (at < 0) return animation[i];
            }
            return animation[animation.length - 1];
        }
    }

Tiles without gaps

The first version of the painter drew the tiles with Sprite.draw, and in a window of 1728 × 1080 pixels a thin dark line ran through the ground every few tiles and flickered while it scrolled. At that size a tile of 16 units is 43.2 pixels wide. The sprite caches one picture of 43 pixels and copies it to the rounded position of each tile: the tiles start at 0, 43.2, 86.4, 129.6, 172.8, rounded 0, 43, 86, 130, 173. Between 86 + 43 = 129 and 130, one column of pixels stays empty.

Tiles drawn with draw and with drawSeamless

drawSeamless rounds both edges of each tile to the nearest pixel and draws the picture exactly that wide: 43 or 44 pixels. Each tile ends where the next one starts. A sprite keeps a few such pictures, one per width:

amade-game-engine · sprite/Sprite.java
    /**
     * Draws the sprite so that it covers exactly the screen pixels from {@code x} to {@code x + width} and
     * from {@code y} to {@code y + height}, each edge rounded to the nearest pixel. Tiles drawn side by side
     * this way meet without a gap and without overlapping, whatever the scale.
     *
     * <p>{@link #draw} copies one cached picture of the rounded size, and that is not enough for tiles: at
     * 2.7 screen pixels per unit, a tile of 16 units is 43.2 pixels wide. Copied 43 pixels wide at rounded
     * positions, every fifth tile leaves a gap of one pixel, a line that flickers through the scrolling
     * ground. So here the width is worked out from both rounded edges, 43 or 44 pixels, and both pictures
     * are cached.
     */
    public void drawSeamless(Graphics2D g, double x, double y) {
        AffineTransform t = g.getTransform();
        int rotated = AffineTransform.TYPE_MASK_ROTATION | AffineTransform.TYPE_GENERAL_TRANSFORM;
        if (UNCACHED || (t.getType() & rotated) != 0) {
            drawScaled(g, x, y);
            return;
        }
        long left = Math.round(t.getTranslateX() + x * t.getScaleX());
        long right = Math.round(t.getTranslateX() + (x + width) * t.getScaleX());
        long top = Math.round(t.getTranslateY() + y * t.getScaleY());
        long bottom = Math.round(t.getTranslateY() + (y + height) * t.getScaleY());
        int w = (int) Math.max(1, right - left);
        int h = (int) Math.max(1, bottom - top);
        boolean smooth = !hard && g.getRenderingHint(RenderingHints.KEY_INTERPOLATION)
                != RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR;
        BufferedImage image = null;
        for (int i = 0; i < seamless.length; i++) {
            BufferedImage candidate = seamless[i];
            if (candidate != null && candidate.getWidth() == w && candidate.getHeight() == h
                    && seamlessSmooth[i] == smooth) {
                image = candidate;
                break;
            }
        }
        if (image == null) {
            image = render(g.getDeviceConfiguration(), w, h, smooth);
            seamless[nextSeamless] = image;
            seamlessSmooth[nextSeamless] = smooth;
            nextSeamless = (nextSeamless + 1) % seamless.length;
        }
        g.setTransform(AffineTransform.getTranslateInstance(left, top));
        g.drawImage(image, 0, 0, null);
        g.setTransform(t);
    }
amade-game-engine · test/sprite/SpriteTest.java
    @Test
    void tilesDrawnSeamlesslyLeaveNoGapsAtAnyScale() {
        Sprite tile = Sprite.drawn(16, 16, (g, w, h) -> {
            g.setColor(Color.GREEN);
            g.fill(new java.awt.geom.Rectangle2D.Double(0, 0, w, h));
        });
        BufferedImage plain = row(tile, false);
        BufferedImage seamless = row(tile, true);
        int gaps = 0;
        for (int x = 0; x < 20 * 43; x++) {
            if (plain.getRGB(x, 20) == 0) gaps++;
            assertEquals(Color.GREEN.getRGB(), seamless.getRGB(x, 20), "pixel " + x);
        }
        assertEquals(4, gaps, "draw: a pixel lost at every fifth tile");
        assertEquals(2, tile.seamlessCount(), "43 and 44 pixels wide, 43 high");
    }

    /** Twenty tiles side by side at 2.7 pixels per unit, starting at a fraction of a unit. */
    private static BufferedImage row(Sprite tile, boolean seamless) {
        BufferedImage screen = new BufferedImage(900, 50, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g = screen.createGraphics();
        g.scale(2.7, 2.7);
        g.translate(-0.3, 0);
        for (int i = 0; i < 20; i++) {
            if (seamless) tile.drawSeamless(g, i * 16, 0);
            else tile.draw(g, i * 16, 0);
        }
        g.dispose();
        return screen;
    }

Use draw for things that move and drawSeamless for things that must meet: tiles, and the copies of a background picture.

Parallax

Behind the level lies a background in layers: the sky, clouds far away, blue hills, green hills nearer. Each moves by a part of the camera's way, its factor: the sky not at all (0), the far clouds a little (0.05), the green hills almost half (0.4), the ground fully (1). The eye reads the different speeds as depth. The effect is called parallax; arcade games used it from 1982 on.

The layers of the meadow's background

Each layer is a picture as wide as the screen, repeated side by side as far as the view needs, from a position that wraps round:

amade-game-engine · camera/Parallax.java
public final class Parallax {
    /**
     * One picture of the background.
     *
     * @param factor  the part of the camera's horizontal way the layer moves; 0 still, 1 with the world
     * @param factorY the same for the vertical way, usually smaller, so the horizon stays where it is
     * @param y       the layer's top on the screen while the camera is at the top of the world
     */
    public record Layer(Sprite picture, double factor, double factorY, double y) {
    }

    private final double viewWidth;
    private final double viewHeight;
    private final List<Layer> layers = new ArrayList<>();

    /** Backgrounds for a view of {@code viewWidth} × {@code viewHeight} logical units. */
    public Parallax(double viewWidth, double viewHeight) {
        this.viewWidth = viewWidth;
        this.viewHeight = viewHeight;
    }

    /** Adds a layer in front of those added before. */
    public Parallax layer(Sprite picture, double factor, double factorY, double y) {
        layers.add(new Layer(picture, factor, factorY, y));
        return this;
    }

    /** Draws all layers, the furthest first, for a camera whose top left corner is at {@code cameraX}, {@code cameraY}. */
    public void draw(Graphics2D g, double cameraX, double cameraY) {
        for (Layer layer : layers) {
            double width = layer.picture().width();
            double top = layer.y() - cameraY * layer.factorY();
            if (top >= viewHeight || top + layer.picture().height() <= 0) continue;
            // Where the first copy starts: the layer's own position, wrapped into one picture width left of 0.
            double shift = (cameraX * layer.factor()) % width;
            double left = shift < 0 ? -shift - width : -shift;
            for (int i = 0; left + i * width < viewWidth; i++) {
                layer.picture().drawSeamless(g, left + i * width, top);
            }
        }
    }
amade-game-engine · test/camera/ParallaxTest.java
    @Test
    void aLayerMovesByItsFactorOfTheCamerasWayAndRepeats() {
        Parallax back = new Parallax(300, 100).layer(STRIPE, 0.5, 0, 40);
        BufferedImage screen = draw(back, 0);
        assertEquals(Color.RED.getRGB(), screen.getRGB(5, 50));
        assertEquals(Color.RED.getRGB(), screen.getRGB(205, 50), "the third copy");
        screen = draw(back, 40);                 // the camera 40 to the right: the layer 20 to the left
        assertEquals(Color.BLUE.getRGB(), screen.getRGB(5, 50));
        assertEquals(Color.RED.getRGB(), screen.getRGB(85, 50), "the second copy now starts at 80");
        assertEquals(Color.RED.getRGB(), screen.getRGB(285, 50), "and a fourth comes in on the right");
    }

A layer may also move up and down by a factor of its own. In the heights, the clouds climb slower than the player, and their picture is drawn tall enough to cover the screen however high the camera goes. The pictures themselves are pixel art drawn in code, one pixel per unit, so they look like the tiles in front of them:

amade-jump-game · ui/game/Backdrop.java
    /** The background of a theme for a world {@code worldHeight} units high. */
    static Parallax of(String theme, double worldHeight) {
        double climb = Math.max(0, worldHeight - Field.HEIGHT);
        Parallax back = new Parallax(Field.WIDTH, Field.HEIGHT);
        switch (theme) {
            case "cave" -> {
                add(back, sky(0x0E0C1A, 0x241E3A), 0, 0);
                add(back, pillars(0x2E2848, 0x3A3358, 3, 0.2, climb), 0.2, 0.2);
                add(back, pillars(0x1A1628, 0x221D34, 4, 0.45, climb), 0.45, 0.45);
            }
            case "sky" -> {
                add(back, sky(0x2E7FD8, 0xBDE4FF), 0, 0);
                // Paler than the white cloud platforms in front: the eye must not try to stand on them.
                add(back, clouds(0x8EC0EE, 5, 0.1, climb), 0.1, 0.1);
                add(back, clouds(0xB4D6F6, 6, 0.3, climb), 0.3, 0.3);
            }
            default -> {
                add(back, sky(0x5AA8F0, 0xCFEBFF), 0, 0);
                add(back, clouds(0xFFFFFF, 1, 0.05, climb), 0.05, 0.05);
                add(back, hills(0x8FB0D8, 0x7FA0CA, 160, 70, 2), 0.15, 0);
                add(back, hills(0x5DA050, 0x4A8A42, 240, 50, 7), 0.4, 0);
            }
        }
        return back;
    }

    /**
     * Adds a picture as bands of rows: rows with nothing in them are left out, rows that are solid all the
     * way across become a picture of their own, and the rest, the rows with see-through pixels, another.
     * Copying a solid picture needs no test of each pixel for transparency, and an empty row costs nothing:
     * the hills cost a third of a full-screen picture, the stalactites half.
     */
    private static void add(Parallax back, BufferedImage image, double factor, double factorY) {
        int row = 0;
        while (row < image.getHeight()) {
            Rows kind = rows(image, row);
            int end = row + 1;
            while (end < image.getHeight() && same(image, kind, end)) end++;
            if (kind != Rows.EMPTY) {
                back.layer(picture(image.getSubimage(0, row, image.getWidth(), end - row)), factor, factorY, row);
            }
            row = end;
        }
    }

    private enum Rows { EMPTY, SOLID, SEE_THROUGH }

    /**
     * Whether a row belongs to the band of {@code kind}. A see-through band takes in short gaps of empty
     * rows, and solid rows above it: one band more costs more than copying a few rows for nothing.
     */
    private static boolean same(BufferedImage image, Rows kind, int row) {
        Rows here = rows(image, row);
        if (here == kind) return true;
        if (kind != Rows.SEE_THROUGH) return false;
        if (here == Rows.SOLID) return false;
        int gap = row;
        while (gap < image.getHeight() && gap < row + GAP && rows(image, gap) == Rows.EMPTY) gap++;
        return gap < image.getHeight() && gap < row + GAP;
    }

    /** Empty rows shorter than this between see-through rows are copied with them. */
    private static final int GAP = 24;

    private static Rows rows(BufferedImage image, int row) {
        boolean seen = false;
        boolean hidden = false;
        for (int x = 0; x < image.getWidth(); x++) {
            int alpha = image.getRGB(x, row) >>> 24;
            seen |= alpha != 0;
            hidden |= alpha != 255;
        }
        return !seen ? Rows.EMPTY : hidden ? Rows.SEE_THROUGH : Rows.SOLID;
    }

The function add is the result of the next section.

Measured, then halved

The appendix on Java2D says: measure first. Jump has ./gradlew benchmark, which draws each level in a window of 1280 × 800 and of 1920 × 1200 and prints the milliseconds per frame. On the computer this chapter was written on, which draws in software, the first version needed 14 ms per frame for the meadow at 1920 × 1200. At 60 frames a second there are 16.7 ms for everything. Too close.

The benchmark also measures the background alone, and the background was 11 of the 14 ms. Four pictures the size of the screen, every frame. And every one of them was cached by Sprite as a translucent picture, because a sprite cannot know what its picture holds. Copying a translucent picture blends every pixel with what lies below: read both, mix, write. The sky has no transparent pixel at all.

So Sprite now looks at its picture once, when it first caches it. Every pixel opaque: the cache is opaque, and copying it is a plain copy. Every pixel either fully seen or fully transparent, as in pixel art: the cache is a bitmask, and copying skips the invisible pixels without blending. Only pictures with half-transparent pixels, or pixel art scaled smoothly (which makes its edges half transparent), are blended:

amade-game-engine · sprite/Sprite.java
    /**
     * The least transparency the cached picture needs, because copying it costs accordingly: an opaque
     * picture is copied as it is, one whose pixels are either seen or not (a {@code BITMASK}) only skips
     * the unseen ones, and only a translucent one is blended with what lies below, pixel by pixel. A sky
     * the size of the screen costs a quarter as much when it is known to be opaque.
     *
     * <p>Pictures drawn in code have soft edges and stay translucent, unless they are {@link #opaque()}. An
     * image is looked at once: opaque if
     * every pixel is, a bitmask if every pixel is either opaque or invisible. Smooth scaling mixes the
     * edges of a bitmask with the invisible pixels around them, so then it is translucent too.
     */
    private int transparency(boolean smooth) {
        if (source == null) return opaque ? Transparency.OPAQUE : Transparency.TRANSLUCENT;
        if (sourceTransparency == 0) sourceTransparency = transparencyOf(source);
        if (sourceTransparency == Transparency.BITMASK && smooth) return Transparency.TRANSLUCENT;
        return sourceTransparency;
    }

    static int transparencyOf(BufferedImage image) {
        if (!image.getColorModel().hasAlpha()) return Transparency.OPAQUE;
        boolean seeThrough = false;
        for (int y = 0; y < image.getHeight(); y++) {
            for (int x = 0; x < image.getWidth(); x++) {
                int alpha = image.getRGB(x, y) >>> 24;
                if (alpha != 0 && alpha != 255) return Transparency.TRANSLUCENT;
                seeThrough |= alpha == 0;
            }
        }
        return seeThrough ? Transparency.BITMASK : Transparency.OPAQUE;
    }
amade-game-engine · test/sprite/SpriteTest.java
    @Test
    void opaqueAndHardEdgedPicturesAreCachedWithoutBlending() {
        BufferedImage screen = new BufferedImage(200, 100, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = screen.createGraphics();
        g.scale(2.5, 2.5);
        Sprite sky = Sprite.of(source(), 40, 16);                       // every pixel opaque
        sky.draw(g, 0, 0);
        assertEquals(Transparency.OPAQUE, sky.cachedTransparency());

        BufferedImage holes = source();
        holes.setRGB(3, 3, 0);                                          // one pixel invisible
        Sprite hill = Sprite.of(holes, 40, 16).hardEdges();
        hill.draw(g, 0, 0);
        assertEquals(Transparency.BITMASK, hill.cachedTransparency());
        // Scaled smoothly, the hole's edge is half seen: blending is needed again.
        Sprite smooth = Sprite.of(holes, 40, 16);
        smooth.draw(g, 0, 0);
        assertEquals(Transparency.TRANSLUCENT, smooth.cachedTransparency());

        holes.setRGB(5, 5, 0x80FF0000);                                 // and one half seen
        assertEquals(Transparency.TRANSLUCENT, Sprite.transparencyOf(holes));
    }

The sky went from 4.2 to 1.2 ms. The hills and clouds did not get faster: a bitmask still tests each pixel, and the hills' pictures were mostly empty sky above the hill and solid hill below it. So Backdrop.add cuts each picture into bands of rows: rows with nothing in them are left out, rows solid all the way across become an opaque picture, and only the rows in between, where the hill's outline is, are a bitmask. The meadow's hills became two pictures each, a third of the area to test.

The whole frame at 1920 × 1200 went from 14 to 7 ms; on a computer with a graphics card that Java2D uses, much less. The appendix has the rules.

Step 8: Saving progress

High scores (chapter 10) are a list that can be lost without much harm. Progress cannot: a player who has worked through ten levels and finds the game back at level one after a crash does not play again. So the engine's SaveFile writes with more care than anything before it:

  • Never half written. The new state goes into a temporary file next to the save, is forced onto the disk with force(true), and only then gets the save's name, in one atomic rename. A crash leaves the old file or the new one, never a mixture of both.
  • A backup. The file before the last save is kept as progress.save.bak. If the save itself is damaged, the backup is read: the player loses one level, not all.
  • A checksum. The last line is a CRC-32 of everything above it. A file cut short is recognised and not read as if it were whole.
  • A version. The first line names the format's version, so a later game can read, and convert, what an earlier one wrote.
amade-game-engine · save/SaveFile.java
public final class SaveFile {
    /** What was read: the version of the format it was written in, and the keys and values. */
    public record Saved(int version, Map<String, String> values) {
        public Saved {
            values = Map.copyOf(values);
        }

        public String get(String key, String fallback) {
            return values.getOrDefault(key, fallback);
        }
    }

    private static final Logger LOG = LoggerFactory.getLogger(SaveFile.class);
    private static final String HEADER = "amade-save ";
    private static final String CHECKSUM = "crc32 ";

    private final Path file;
    private final Path backup;

    public SaveFile(Path file) {
        this.file = file;
        this.backup = file.resolveSibling(file.getFileName() + ".bak");
    }

    /** The save {@code <name>.save} of a game in {@code $XDG_DATA_HOME/amadeos/games/<game>}. */
    public static SaveFile of(XdgDirectories xdg, String game, String name) {
        return new SaveFile(xdg.dataHome().resolve("amadeos").resolve("games").resolve(game).resolve(name + ".save"));
    }

    /**
     * The last state saved: the save itself, or the backup if the save is missing or damaged; empty if
     * neither can be read, as on the first start.
     */
    public Optional<Saved> load() {
        for (Path candidate : List.of(file, backup)) {
            try {
                return Optional.of(parse(Files.readString(candidate, StandardCharsets.UTF_8)));
            } catch (NoSuchFileException e) {
                // Nothing saved yet, or no backup yet.
            } catch (IOException | IllegalArgumentException e) {
                LOG.warn("Cannot read the save {}: {}", candidate, e.getMessage());
            }
        }
        return Optional.empty();
    }

    /** Writes a new state; the one before becomes the backup. */
    public void store(int version, Map<String, String> values) {
        String text = format(version, values);
        try {
            Files.createDirectories(file.getParent());
            Path temp = Files.createTempFile(file.getParent(), file.getFileName().toString(), ".tmp");
            try {
                try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
                    channel.write(ByteBuffer.wrap(text.getBytes(StandardCharsets.UTF_8)));
                    channel.force(true);            // on the disk, not only in the system's cache
                }
                if (Files.exists(file)) move(file, backup);
                move(temp, file);
            } finally {
                Files.deleteIfExists(temp);
            }
        } catch (IOException e) {
            throw new UncheckedIOException("Cannot write " + file, e);
        }
    }

    private static void move(Path from, Path to) throws IOException {
        try {
            Files.move(from, to, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
        } catch (AtomicMoveNotSupportedException | AccessDeniedException e) {
            Files.move(from, to, StandardCopyOption.REPLACE_EXISTING);
        }
    }

    /** The text of a save: the version, the sorted keys and values, the checksum of all that. */
    static String format(int version, Map<String, String> values) {
        StringBuilder text = new StringBuilder(HEADER).append(version).append('\n');
        for (Map.Entry<String, String> entry : new TreeMap<>(values).entrySet()) {
            text.append(escape(entry.getKey(), true)).append('=').append(escape(entry.getValue(), false)).append('\n');
        }
        String sum = crc(text);
        return text.append(CHECKSUM).append(sum).append('\n').toString();
    }

    /** Reads a save's text; an {@link IllegalArgumentException} if it is not whole. */
    static Saved parse(String text) {
        int last = text.lastIndexOf(CHECKSUM);
        if (!text.startsWith(HEADER) || last < 0 || !text.endsWith("\n")) {
            throw new IllegalArgumentException("not a whole save");
        }
        String body = text.substring(0, last);
        String sum = text.substring(last + CHECKSUM.length()).strip();
        if (!sum.equals(crc(body))) throw new IllegalArgumentException("wrong checksum, the file is damaged");
        int newline = body.indexOf('\n');
        int version;
        try {
            version = Integer.parseInt(body.substring(HEADER.length(), newline));
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("no version");
        }
        Properties properties = new Properties();
        try {
            properties.load(new StringReader(body.substring(newline + 1)));
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
        Map<String, String> values = new TreeMap<>();
        for (String key : properties.stringPropertyNames()) values.put(key, properties.getProperty(key));
        return new Saved(version, values);
    }
amade-game-engine · test/save/SaveFileTest.java
    @Test
    void whatWasSavedIsReadBack() {
        SaveFile save = new SaveFile(folder.resolve("progress.save"));
        assertTrue(save.load().isEmpty(), "nothing saved yet");
        save.store(1, Map.of("unlocked", "2", "level.1.best", "71.25", "name", "Ada = the first: #1"));
        SaveFile.Saved saved = save.load().orElseThrow();
        assertEquals(1, saved.version());
        assertEquals("71.25", saved.get("level.1.best", ""));
        assertEquals("Ada = the first: #1", saved.get("name", ""), "any text survives");
    }

    @Test
    void theFileIsPlainSortedTextWithAChecksum() {
        String text = SaveFile.format(1, Map.of("unlocked", "2", "level.1.best", "71.25"));
        assertEquals("""
                amade-save 1
                level.1.best=71.25
                unlocked=2
                crc32 %s
                """.formatted(text.lines().toList().getLast().substring(6)), text);
    }

    @Test
    void aDamagedSaveIsNotReadAsIfItWereWhole() {
        String text = SaveFile.format(1, Map.of("unlocked", "2"));
        assertThrows(IllegalArgumentException.class, () -> SaveFile.parse(text.replace("unlocked=2", "unlocked=9")));
        assertThrows(IllegalArgumentException.class, () -> SaveFile.parse(text.substring(0, text.length() / 2)));
    }

    @Test
    void aDamagedSaveFallsBackToTheStateBefore() throws IOException {
        SaveFile save = new SaveFile(folder.resolve("progress.save"));
        save.store(1, Map.of("unlocked", "2"));
        save.store(1, Map.of("unlocked", "3"));
        assertEquals("unlocked=2", Files.readAllLines(save.backup()).get(1), "the state before is the backup");
        // The save is cut short, as by a full disk: the backup is read instead.
        Files.writeString(save.file(), "amade-save 1\nunlocked=", StandardCharsets.UTF_8);
        assertEquals("2", save.load().orElseThrow().get("unlocked", ""));
    }

The content is plain text, keys and values, sorted, so a person can read it and a diff can compare it. Jump's Progress keeps the open levels and, for each level, the best time and the most coins, and saves at once at the goal:

amade-jump-game · repository/Progress.java
public final class Progress {
    /** The version of the format; a later game that changes it can still read version 1. */
    static final int VERSION = 1;

    /** What a finished level brought: a new best time, more coins than before, a level opened. */
    public record Record(boolean bestTime, boolean mostCoins, boolean opened) {
    }

    private final SaveFile file;
    private final Map<String, String> values = new HashMap<>();

    @Inject
    public Progress(XdgDirectories xdg) {
        this(SaveFile.of(xdg, "jump", "progress"));
    }

    public Progress(SaveFile file) {
        this.file = file;
        file.load().ifPresent(saved -> values.putAll(saved.values()));
    }

    /** The highest level that may be played; at least the first. */
    public int open() {
        return Math.max(1, number("open", 1));
    }

    /** The best time of a level in seconds; empty if it was never finished. */
    public OptionalDouble best(int level) {
        String value = values.get("level." + level + ".best");
        if (value == null) return OptionalDouble.empty();
        try {
            return OptionalDouble.of(Double.parseDouble(value));
        } catch (NumberFormatException e) {
            return OptionalDouble.empty();
        }
    }

    /** The most coins ever brought to the goal of a level. */
    public int coins(int level) {
        return number("level." + level + ".coins", 0);
    }

    /** Enters a finished level, opens the next one of {@code levels}, and saves at once. */
    public Record finished(LevelResult result, int levels) {
        int level = result.level();
        OptionalDouble best = best(level);
        boolean bestTime = best.isEmpty() || result.seconds() < best.getAsDouble();
        boolean mostCoins = result.coins() > coins(level);
        boolean opened = level < levels && open() <= level;
        if (bestTime) values.put("level." + level + ".best", String.format(Locale.ROOT, "%.3f", result.seconds()));
        if (mostCoins) values.put("level." + level + ".coins", Integer.toString(result.coins()));
        if (opened) values.put("open", Integer.toString(level + 1));
        if (bestTime || mostCoins || opened) file.store(VERSION, values);
        return new Record(bestTime, mostCoins, opened);
    }
amade-jump-game · test/repository/ProgressTest.java
    @Test
    void aFinishedLevelOpensTheNextAndIsRememberedAfterARestart() {
        SaveFile file = new SaveFile(dir.resolve("progress.save"));
        Progress progress = new Progress(file);
        assertEquals(1, progress.open());
        assertTrue(progress.best(1).isEmpty());

        Progress.Record first = progress.finished(new LevelResult(1, 80.5, 30, 50, 2), 3);
        assertTrue(first.bestTime() && first.mostCoins() && first.opened());

        Progress later = new Progress(file);             // the next start of the game
        assertEquals(2, later.open());
        assertEquals(80.5, later.best(1).orElseThrow(), 1e-9);
        assertEquals(30, later.coins(1));

        Progress.Record slower = later.finished(new LevelResult(1, 95, 40, 50, 0), 3);
        assertFalse(slower.bestTime(), "slower is no record");
        assertTrue(slower.mostCoins(), "but more coins are");
        assertFalse(slower.opened(), "level 2 was open already");
        assertEquals(80.5, new Progress(file).best(1).orElseThrow(), 1e-9);
    }

The progress shows on the cards of the level choice, where a level opens once the one before it is finished:

The choice of a level The goal of the meadow

The choice of a level with two finished (left), and the goal (right).
amade-jump-game · ui/game/JumpGame.java
    /** Three cards, one for each level; a level opens when the one before it is finished. */
    final class ChoiceScene implements Scene {
        private double time;

        ChoiceScene() {
            effects.clear();
            chosen = Math.min(chosen, progress.open());
        }

        @Override
        public void update(double seconds) {
            time += seconds;
            if (controls.wasPressed(Action.BACK)) {
                scenes.replace(new TitleScene());
                return;
            }
            int before = chosen;
            if (controls.wasPressed(Action.LEFT)) chosen = Math.max(1, chosen - 1);
            if (controls.wasPressed(Action.RIGHT)) chosen = Math.min(Math.min(progress.open(), levels.size()), chosen + 1);
            if (chosen != before) effects.tick();
            if (controls.wasPressed(Action.OK) || controls.wasPressed(Action.JUMP)) {
                scenes.replace(new PlayScene(levels.get(chosen - 1)));
            }
        }
amade-jump-game · test/ui/game/JumpGameTest.java
    @Test
    void theGoalSavesTheTimeAndOpensTheNextLevel() {
        JumpGame.PlayScene play = game.play(1);
        Box goal = play.game().level().goal();
        Stage.put(play.game(), goal.x() - 24, goal.bottom(), 0);
        keyboard.pressed(KeyEvent.VK_RIGHT, now[0]);
        seconds(0.5);
        keyboard.released(KeyEvent.VK_RIGHT, now[0]);
        assertEquals(Game.Phase.CLEARED, play.game().phase());
        seconds(JumpGame.CHEER_SECONDS);
        assertInstanceOf(JumpGame.ClearedScene.class, game.top());
        assertEquals(2, progress.open());
        assertTrue(progress.best(1).isPresent());
        assertTrue(play.record().bestTime() && play.record().opened());
        seconds(1);
        tap(KeyEvent.VK_ENTER);
        JumpGame.PlayScene next = assertInstanceOf(JumpGame.PlayScene.class, game.top());
        assertEquals(2, next.game().level().number());
    }

Step 9: Everything else

Jump has one player, and any gamepad plays: the buttons of all four pads are bound to the same actions as the keys. The jump is on A and B, so either thumb position works:

amade-jump-game · ui/game/JumpGame.java
        controls = new Controls<>(Action.class, keyboard)
                .bind(Action.LEFT, settings.keys("left", KeyEvent.VK_LEFT, KeyEvent.VK_A))
                .bind(Action.RIGHT, settings.keys("right", KeyEvent.VK_RIGHT, KeyEvent.VK_D))
                .bind(Action.DOWN, settings.keys("down", KeyEvent.VK_DOWN, KeyEvent.VK_S))
                .bind(Action.JUMP, settings.keys("jump", KeyEvent.VK_SPACE, KeyEvent.VK_UP, KeyEvent.VK_W))
                .bind(Action.PAUSE, settings.keys("pause", KeyEvent.VK_P, KeyEvent.VK_ESCAPE))
                .bind(Action.BACK, KeyEvent.VK_ESCAPE)
                .bind(Action.OK, KeyEvent.VK_ENTER, KeyEvent.VK_SPACE);
        // One player, any gamepad: whichever is plugged in plays.
        for (Gamepad pad : gamepads.pads()) {
            controls.bind(Action.LEFT, pad, Gamepad.Button.LEFT)
                    .bind(Action.RIGHT, pad, Gamepad.Button.RIGHT)
                    .bind(Action.DOWN, pad, Gamepad.Button.DOWN)
                    .bind(Action.JUMP, pad, Gamepad.Button.A, Gamepad.Button.B)
                    .bind(Action.PAUSE, pad, Gamepad.Button.START)
                    .bind(Action.BACK, pad, Gamepad.Button.BACK)
                    .bind(Action.OK, pad, Gamepad.Button.A, Gamepad.Button.START);
        }

The player's picture follows what the body does: running (four frames, one per seven units run, so the feet match the ground), rising, falling, tumbling, and standing with a blink now and then:

amade-jump-game · ui/game/JumpPainter.java
    /** The player's picture: running, jumping, falling, tumbling, or standing and blinking now and then. */
    private void player(Graphics2D g, Player player, double time, double alpha) {
        Body body = player.body();
        int frame;
        if (player.dying()) frame = HURT;
        else if (!player.onGround()) frame = body.vy < 0 ? JUMP : FALL;
        else if (Math.abs(body.vx) > 5) frame = RUN[(int) (player.ran() / STRIDE) % RUN.length];
        else frame = time % 3 < 0.12 ? BLINK : STAND;
        double x = body.x(alpha) + body.width / 2 - TILE / 2;
        double y = body.y(alpha) + body.height - TILE;
        sprites.frame(frame, player.facingLeft() ? 1 : 0).draw(g, x, y);
    }

The events become dust under hard landings, glitter where a coin was, a splash, sounds, and for each theme a looping tune in MML:

amade-jump-game · ui/game/Effects.java
    /** Shows and, if {@code audible}, plays what happened in the last step. */
    void react(List<Event> events, boolean audible) {
        for (Event event : events) {
            switch (event) {
                case Event.Jumped(double x, double y) -> {
                    puff(x, y, 3, DUST);
                    if (audible) mixer.play(sounds.jump);
                }
                case Event.Landed(double x, double y, double speed) -> {
                    if (speed < HARD_LANDING) break;
                    puff(x, y, 6, DUST);
                    if (audible) mixer.play(sounds.land);
                }
                case Event.CoinTaken(int col, int row, int coins) -> {
                    glitters.add(new Glitter(col * Field.TILE, row * Field.TILE, time));
                    if (audible) mixer.play(sounds.coin);
                }
                case Event.Stomped stomped -> {
                    var body = stomped.enemy().body();
                    burst(body.centerX(), body.y, 8, WHITE, 60);
                    if (audible) mixer.play(sounds.stomp);
                }
                case Event.Sprung(double x, double y) -> {
                    puff(x, y, 4, WHITE);
                    if (audible) mixer.play(sounds.spring);
                }
                case Event.CheckpointReached(int index, double x, double y) -> {
                    burst(x, y + 6, 12, GOLD, 70);
                    if (audible) mixer.play(sounds.checkpoint);
                }
                case Event.Died(double x, double y, Event.Cause cause) -> {
                    if (cause == Event.Cause.WATER) burst(x, y, 16, WATER, 90);
                    else if (cause != Event.Cause.FALL) burst(x, y, 10, RED, 60);
                    if (audible) mixer.play(cause == Event.Cause.WATER ? sounds.splash : sounds.die);
                }
                case Event.Respawned(double x, double y) -> {
                    burst(x, y - 8, 10, WHITE, 40);
                    if (audible) mixer.play(sounds.respawn);
                }
                case Event.Cleared cleared -> {
                    if (audible) mixer.playMusic(music.cleared, false);
                }
            }
        }
    }

The overlay shows coins, clock and falls, and at the start the level's name:

amade-jump-game · ui/game/Hud.java
    /** Coins, clock and falls along the top; the level's name for a moment at its start. */
    void status(Graphics2D g, Game game, double sinceCheckpoint) {
        coin(g, 14, 10, 14);
        g.setFont(font(15, true));
        shadowed(g, game.coins() + " / " + game.level().coins(), 34, 23, CHALK);
        String clock = clock(game.time());
        shadowed(g, clock, (Field.WIDTH - g.getFontMetrics().stringWidth(clock)) / 2, 23, CHALK);
        String falls = msg.format("hud.falls", game.deaths());
        shadowed(g, falls, Field.WIDTH - 14 - g.getFontMetrics().stringWidth(falls), 23, CHALK);
        if (game.time() < BANNER_SECONDS) {
            int number = game.level().number();
            fadingText(g, msg.format("play.level", number, name(number)), 120, 26, GOLD, game.time());
        } else if (sinceCheckpoint < BANNER_SECONDS) {
            fadingText(g, msg.get("play.checkpoint"), 120, 22, GREEN, sinceCheckpoint);
        }
    }

The caves

The pixel art (the player, the beetle and bird, the flags, and the 32 tiles) is drawn as text, a letter per pixel, by tools/MakeSprites.java, and saved as two PNG files that the game and Tiled both use. Full screen (F11), the render modes, the frame times (F12), sound and the five languages work as in the earlier games.

What moved into the engine

From Jump Engine package Why
Reading TMX and TSX files: layers in every encoding, tilesets, classes, animations, objects eu.amadeos.gameengine.tiled.TiledMap, TiledObject, TiledProperties Levels and maps made in an editor: the strategy maps of Part III too
A camera with a dead zone and smoothing eu.amadeos.gameengine.camera.Camera Every world larger than the screen
Background layers that scroll slower eu.amadeos.gameengine.camera.Parallax Depth for every side view
Bodies, gravity, moving through tiles one axis at a time, platforms eu.amadeos.gameengine.physics.Body, TileCollider, Solidity Everything that falls and stands
A jump with variable height, coyote time and a buffer eu.amadeos.gameengine.physics.Jump Every game that jumps
Saves that survive crashes eu.amadeos.gameengine.save.SaveFile Progress, and Sands' whole game
Tiles without gaps; opaque and bitmask caches eu.amadeos.gameengine.sprite.Sprite Every tile map at every scale; every full-screen picture

What stays in the game: the tiles and their classes, the levels, the player, beetles and birds, the rules of stomping, springs and checkpoints, the backgrounds, the painter, the sounds and tunes, the progress and the scenes.

Exercises

  1. A level of your own. Install Tiled, open level1.tmx and add a fourth level: copy the file, change it, add it to Level.FILES and the texts. Make it impossible on purpose (a gap of seven tiles) and read what LevelTest says.
  2. Look ahead. The camera shows as much behind a running player as in front. Move the dead zone a little in the direction the player looks, smoothly, so more of what is coming can be seen. Does it feel better or worse?
  3. Moving platforms. A ledge that flies like a bird and carries the player. Where does the player's speed come from while standing on it, and what must the collider learn?
  4. Wall jumps. A player sliding down a wall (hitLeft or hitRight while falling) may jump away from it. Which numbers make it feel right?
  5. Flipped tiles. Tiled can mirror a tile with X. The reader keeps the flip bits; the painter ignores them. Draw flipped tiles mirrored, with a second sprite per flipped tile, made once.
  6. Ghost race. Record the inputs of the best run of a level with Replay (chapter 22) and save them with SaveFile. Show the best run as a see-through second player.

In the last game of Part II, the player no longer runs and jumps but flies: Hover Cab, a cab in the air between rooftops, with jets against gravity, landings that have to be gentle, and passengers who talk.