Skip to content

33. Town

Colony (chapter 32) was a game about goods: every plank was carried, every loaf had a way to go. Town turns the camera and the economy around. The player no longer builds houses but says where houses may stand: a highway, a river and green land; roads from the highway, zones for homes, shops and industry, power and water, and the town grows by itself, lot by lot, where people want to live and work. Homes need jobs and shops, shops need people, industry starts a town but its smoke lowers the land value around it. Coal plants and wind turbines make the power, a pump house on the shore and water towers bring the water; schools, a library, a clinic, a fire station, the town hall and parks make people happier and the land worth more. Taxes pay for the upkeep, and the budget must hold.

The town close up The same town from further away

Mission 1 after a year and a half (left): row houses on lawns, the water tower, the school with its sports field, cars on both lanes and people on the pavements. The wheel shows the same district from further away (right): the town hall on its plaza, the clinic, shops on blue lots.

It is also the first game in this handbook that is drawn isometric: the map is still a grid of square tiles, but the player looks at it from a corner and from above, so every tile becomes a diamond and every house shows two walls and its roof. That is a step for the engine more than for the game. A flat map is drawn tile after tile, row after row; an isometric one needs a projection that turns the world into a picture and the mouse back into the world, an order in which buildings of different sizes cover each other correctly, a minimap turned like the view, and a way to draw a big map that is quick enough for a large window.

What you will learn

  • The isometric projection: a flat, square world turned into a picture of diamonds, walls and roofs drawn through transforms, the mouse back to its tile
  • Drawing order: the painter's algorithm for things of any size, a sort on footprints instead of rows
  • Zones instead of buildings: the player says where, demand decides when, and a lot grows, waits or falls back
  • Networks of tiles: power that flows from plants through lines and buildings, served from the nearest load outward
  • Reach: services and water towers that serve a circle, and land value made from what is near
  • Demand, mood and the budget: three numbers that pull on each other, taxes that push everything
  • A view that zooms, and a ground that is painted only where it changed

The complete game is in amade-town-game.

The rules

  1. A map is 64 × 64 tiles of 16 units: meadow, forest, water and a highway that comes in from the edge. The view shows the map in a picture of diamonds 32 units wide and 16 high; the panel on the right takes 128 of the 640 units, the bar at the top 20.
  2. Roads are dragged from tile to tile. Only roads joined to the highway bring people. A road over water is a bridge; it must run straight.
  3. Zones are dragged as rectangles: homes (R), shops (C) or industry (I). A zoned tile is a lot. A lot grows a building when its zone is wanted, a road joined to the highway is within two tiles, and power reaches it. From the second level it needs water; homes reach the third only near a school. Each level holds more people or jobs.
  4. Power comes from coal plants (much, with smoke) and wind turbines (a little, clean). It flows through power lines, zones and buildings that touch along an edge. When there is not enough, the lots furthest from the plants go dark.
  5. Water comes from pump houses on the shore and reaches the lots within the circle of a water tower. When there is too little, some lots go dry, always the same ones.
  6. Services (school, library, clinic, fire station, town hall) and parks serve a circle. They make people happier and the land worth more; smoke makes it worth less. A lot without power or water for two months falls back a level.
  7. Demand for each zone moves every month: people come for jobs, shops and industry come for people, high taxes put everyone off, a happy town draws people. The budget is taxes on people and jobs against the upkeep of roads, lines and buildings.
  8. On missions with fire, a lot catches fire now and then and may set its neighbours alight. A fire station in reach sends its truck; otherwise the building burns down and leaves rubble.
  9. Each mission has a goal: a number of people, sometimes with a share of them happy and a number of years. Below -$5,000 the mission is lost. The fastest win of each mission is kept, and winning opens the next.

Step 1: The isometric view

A flat map is drawn by looking down on it: a tile at column c, row r is a square at (c · 16, r · 16). The isometric view looks at the same map from the corner where x and y are largest, from above at an angle. A tile becomes a diamond twice as wide as high, the classic 2:1 of city builders: one step along x goes half a tile right and a quarter tile down in the picture, one step along y half a tile left and a quarter tile down.

The important decision is what not to change: the rules stay flat. A tile is still 16 units a side, x still runs along the columns, a building still stands at a column and a row. Only the drawing turns the world. The engine's IsoProjection does the turning, in both directions:

amade-game-engine · iso/IsoProjection.java
public final class IsoProjection {
    private final double tile;
    private final double halfWidth;
    private final double halfHeight;
    private final double originX;
    private final double originY;
    private final int columns;
    private final int rows;
    private final double above;

    private IsoProjection(double tile, double tileWidth, double tileHeight, double originX, double originY,
                          int columns, int rows, double above) {
        if (tile <= 0 || tileWidth <= 0 || tileHeight <= 0) throw new IllegalArgumentException("empty tile");
        this.tile = tile;
        this.halfWidth = tileWidth / 2;
        this.halfHeight = tileHeight / 2;
        this.originX = originX;
        this.originY = originY;
        this.columns = columns;
        this.rows = rows;
        this.above = above;
    }

    /**
     * The projection of a map of {@code columns} × {@code rows} tiles of {@code tile} world units, each
     * drawn as a diamond of {@code tileWidth} × {@code tileHeight}, placed so that the whole map lies in the
     * picture from (0, 0) to ({@link #width()}, {@link #height()}). The top corner of the map, tile (0, 0),
     * sits {@code above} units below the top, so that the tallest building on it still fits.
     */
    public static IsoProjection forMap(int columns, int rows, double tile, double tileWidth, double tileHeight,
                                       double above) {
        if (columns <= 0 || rows <= 0) throw new IllegalArgumentException("empty map");
        return new IsoProjection(tile, tileWidth, tileHeight, rows * tileWidth / 2, above, columns, rows, above);
    }

    /**
     * A projection with its world origin at {@code originX}, {@code originY} of the picture and no map
     * around it: for drawing one building into a picture of its own, with (0, 0) at its top corner.
     */
    public static IsoProjection at(double tile, double tileWidth, double tileHeight, double originX, double originY) {
        return new IsoProjection(tile, tileWidth, tileHeight, originX, originY, 0, 0, 0);
    }

    /** Where a point of the world, {@code height} above the ground, lies in the picture. */
    public double pictureX(double x, double y) {
        return originX + (x - y) * halfWidth / tile;
    }

    public double pictureY(double x, double y, double height) {
        return originY + (x + y) * halfHeight / tile - height;
    }

    /** The point of the ground that lies at a point of the picture: the way back, for the mouse. */
    public double worldX(double pictureX, double pictureY) {
        double across = (pictureX - originX) / halfWidth * tile;   // x - y
        double down = (pictureY - originY) / halfHeight * tile;    // x + y
        return (down + across) / 2;
    }

    public double worldY(double pictureX, double pictureY) {
        double across = (pictureX - originX) / halfWidth * tile;
        double down = (pictureY - originY) / halfHeight * tile;
        return (down - across) / 2;
    }

    /** The column and row of the tile whose diamond holds a point of the picture; may lie off the map. */
    public int col(double pictureX, double pictureY) {
        return (int) Math.floor(worldX(pictureX, pictureY) / tile);
    }

    public int row(double pictureX, double pictureY) {
        return (int) Math.floor(worldY(pictureX, pictureY) / tile);
    }

pictureX and pictureY take a point of the world, with a height above the ground, to the picture, the plane in which the whole map is drawn; the camera then moves over that picture like over any big world. The height is in the picture's units, so a wall of 20 is 20 units tall on the screen however large the tiles are. The way back, worldX and worldY, works for points on the ground, and that is all the mouse needs: the tile under the mouse is col and row of the picture point under it.

amade-game-engine · test/iso/IsoProjectionTest.java
    @Test
    void aPointOfTheGroundGoesToThePictureAndBack() {
        // 40 × 40 tiles of 16 units, diamonds of 32 × 16, 100 units of room above the map.
        IsoProjection iso = IsoProjection.forMap(40, 40, 16, 32, 16, 100);
        assertEquals(40 * 32, iso.width());
        assertEquals(40 * 16 + 100, iso.height());
        // The top corner of the map is in the middle at the top, below the room for towers.
        assertEquals(640, iso.pictureX(0, 0));
        assertEquals(100, iso.pictureY(0, 0, 0));
        // One tile along x: half a diamond to the right and down; along y: to the left and down.
        assertEquals(656, iso.pictureX(16, 0));
        assertEquals(108, iso.pictureY(16, 0, 0));
        assertEquals(624, iso.pictureX(0, 16));
        // A height lifts the point straight up.
        assertEquals(108 - 30, iso.pictureY(16, 0, 30));
        // And back: the middle of tile (5, 7) is found in the middle of its diamond.
        double x = iso.pictureX(5.5 * 16, 7.5 * 16);
        double y = iso.pictureY(5.5 * 16, 7.5 * 16, 0);
        assertEquals(5.5 * 16, iso.worldX(x, y), 1e-9);
        assertEquals(7.5 * 16, iso.worldY(x, y), 1e-9);
        assertEquals(5, iso.col(x, y));
        assertEquals(7, iso.row(x, y));
    }

Walls and roofs through transforms

Drawing a house point by point through pictureX and pictureY works, but it is tedious: every window becomes four projected corners. The projection is also an affine map, so it can be put on the graphics as a transform. ground(h) turns the plane at height h into the picture: a rectangle of 16 × 16 drawn under it comes out as a diamond, a roof drawn as a rectangle comes out as the roof seen from above. frontWall and sideWall turn the plane of a wall: the game draws the wall as if it faced the viewer, with its windows, its door and its bricks, and Java2D shears all of it into place.

amade-game-engine · iso/IsoProjection.java
    /**
     * Draws the world flat at {@code height} above the ground: a rectangle in world units becomes a diamond.
     * Floors, lawns, roofs and the tops of boxes.
     */
    public AffineTransform ground(double height) {
        return new AffineTransform(halfWidth / tile, halfHeight / tile, -halfWidth / tile, halfHeight / tile,
                originX, originY - height);
    }

    /**
     * Draws the wall that runs along {@code x} and faces the viewer on the left, from the ground corner
     * ({@code x}, {@code y}) onwards. In the wall's own coordinates {@code u} is the distance along the wall
     * in world units and {@code v} the height in picture units, negative upwards: the wall is the rectangle
     * (0, -height) to (length, 0), drawn as if seen from the front.
     */
    public AffineTransform frontWall(double x, double y) {
        return new AffineTransform(halfWidth / tile, halfHeight / tile, 0, 1, pictureX(x, y), pictureY(x, y, 0));
    }

    /**
     * Draws the wall that runs along {@code y} and faces the viewer on the right, from its front ground
     * corner ({@code x}, {@code y}) back towards smaller {@code y}: {@code u} runs from the front corner
     * away from the viewer, {@code v} as for {@link #frontWall}.
     */
    public AffineTransform sideWall(double x, double y) {
        return new AffineTransform(halfWidth / tile, -halfHeight / tile, 0, 1, pictureX(x, y), pictureY(x, y, 0));
    }

That is how every building of the game is drawn: a box is two walls and a roof, each drawn flat through its transform. One method draws a whole building, walls with rows of windows, a door, a flat or gabled roof:

amade-town-game · ui/game/Art.java
    /** A flat roofed building: the front wall faces +y (left on the screen), the side wall +x (right). */
    static void building(Graphics2D g, IsoProjection p, double x0, double y0, double x1, double y1, double h,
                         int wallC, int roofC, Style style, int seed) {
        double len = x1 - x0, dep = y1 - y0;
        with(g, p.frontWall(x0, y1), f -> wall(f, len, h, wallC, style, new Random(seed), true));
        with(g, p.sideWall(x1, y1), s -> {
            wall(s, dep, h, wallC, style, new Random(seed + 1), false);
            s.setColor(new Color(0, 0, 20, 70));
            s.fill(rect(0, -h, dep, h));
        });
        g.setColor(new Color(255, 255, 255, 50));
        g.setStroke(new BasicStroke(0.4f));
        g.draw(new Line2D.Double(at(p, x1, y1, 0), at(p, x1, y1, h)));
        with(g, p.ground(h), r -> roof(r, x0, y0, len, dep, roofC, style, new Random(seed + 2)));
    }

Only what the view shows

A flat map draws the tiles from the first visible column and row to the last. In an isometric picture the visible part of the map is a diamond cut by the view's rectangle, and a tall building whose foot lies below the view can still reach into it. visible works out the range of columns and rows to look at, with a margin of rows below for the tallest tower:

amade-game-engine · iso/IsoProjection.java
    /**
     * The tiles whose diamonds may show in a rectangle of the picture, as the smallest and largest column
     * and row, kept on the map: {@code {colMin, rowMin, colMax, rowMax}}. A frame draws only these. The box
     * reaches {@code margin} tiles further down, for things that stand on tiles below the view but are tall
     * enough to reach into it.
     */
    public int[] visible(double left, double top, double width, double height, int margin) {
        double right = left + width;
        double bottom = top + height + margin * 2 * halfHeight;
        int colMin = (int) Math.floor(worldX(left, top) / tile);
        int colMax = (int) Math.floor(worldX(right, bottom) / tile);
        int rowMin = (int) Math.floor(worldY(right, top) / tile);
        int rowMax = (int) Math.floor(worldY(left, bottom) / tile);
        return new int[]{Math.max(0, colMin - 1), Math.max(0, rowMin - 1),
                Math.min(columns - 1, colMax + 1), Math.min(rows - 1, rowMax + 1)};
    }

Step 2: What covers what

On a flat map, nothing covers anything. In the isometric view, a house covers the lawn behind it, a tree covers the street behind it, a car disappears behind a factory. The painter's algorithm draws everything from back to front, and for things of one tile "back" is easy: a smaller x + y is further back. A building of three tiles by two breaks that rule. A tree on the tile behind its right end has a larger x + y than the building's middle, and sorted by x + y it would be drawn over the building's wall.

The engine's DepthSort sorts on footprints, rectangles of the ground: A lies behind B if A ends before B begins along x, or along y. That relation only orders things that touch in the picture, so the sort compares only things whose boxes in the picture overlap, found by a sweep along the picture's x, and draws them in topological order, the nearest x + y first among those that are free:

amade-game-engine · iso/DepthSort.java
public final class DepthSort<T> {
    private static final class Item<T> {
        double x0, y0, x1, y1;
        double left, top, right, bottom;
        double key;
        int index;
        T thing;
        int[] after = new int[4];   // the items that must be drawn after this one
        int afterCount;
        int before;                 // how many items must be drawn before this one
    }

    private final List<Item<T>> items = new ArrayList<>();
    private final List<Item<T>> spare = new ArrayList<>();
    private Item<T>[] byLeft;
    private final List<T> order = new ArrayList<>();
    private final PriorityQueue<Item<T>> free =
            new PriorityQueue<>(Comparator.<Item<T>>comparingDouble(i -> i.key).thenComparingInt(i -> i.index));

    /**
     * A thing with its footprint from ({@code x0}, {@code y0}) to ({@code x1}, {@code y1}) in world units,
     * and the box it covers in the picture, from {@code left}, {@code top} to {@code right}, {@code bottom}.
     */
    public void add(double x0, double y0, double x1, double y1, double left, double top, double right,
                    double bottom, T thing) {
        Item<T> item = spare.isEmpty() ? new Item<>() : spare.removeLast();
        item.x0 = x0;
        item.y0 = y0;
        item.x1 = x1;
        item.y1 = y1;
        item.left = left;
        item.top = top;
        item.right = right;
        item.bottom = bottom;
        item.key = x0 + x1 + y0 + y1;
        item.index = items.size();
        item.thing = thing;
        item.afterCount = 0;
        item.before = 0;
        items.add(item);
    }

    /** How many things wait to be sorted. */
    public int size() {
        return items.size();
    }

    /** True if footprint A must be drawn before footprint B: A ends before B begins along x or along y. */
    public static boolean behind(double ax0, double ay0, double ax1, double ay1,
                                 double bx0, double by0, double bx1, double by1) {
        boolean aFirst = ax1 <= bx0 || ay1 <= by0;
        boolean bFirst = bx1 <= ax0 || by1 <= ay0;
        return aFirst && !bFirst;
    }

    /**
     * The things in the order to draw them, back to front. The sort is empty afterwards; the list is reused
     * by the next call.
     */
    @SuppressWarnings("unchecked")
    public List<T> sorted() {
        order.clear();
        int n = items.size();
        if (byLeft == null || byLeft.length < n) byLeft = new Item[Math.max(16, n * 2)];
        for (int i = 0; i < n; i++) byLeft[i] = items.get(i);
        Arrays.sort(byLeft, 0, n, Comparator.comparingDouble(i -> i.left));
        // Only things whose boxes overlap in the picture can cover each other.
        for (int i = 0; i < n; i++) {
            Item<T> a = byLeft[i];
            for (int j = i + 1; j < n && byLeft[j].left < a.right; j++) {
                Item<T> b = byLeft[j];
                if (b.top >= a.bottom || a.top >= b.bottom) continue;
                if (behind(a.x0, a.y0, a.x1, a.y1, b.x0, b.y0, b.x1, b.y1)) link(a, b);
                else if (behind(b.x0, b.y0, b.x1, b.y1, a.x0, a.y0, a.x1, a.y1)) link(b, a);
            }
        }
        for (Item<T> item : items) if (item.before == 0) free.add(item);
        boolean[] done = new boolean[n];
        int drawn = 0;
        while (drawn < n) {
            Item<T> next = free.poll();
            if (next == null) {
                // A cycle: draw the nearest-to-the-back of what is left and go on.
                for (Item<T> item : items) {
                    if (!done[item.index] && (next == null || item.key < next.key)) next = item;
                }
            }
            if (done[next.index]) continue;
            done[next.index] = true;
            drawn++;
            order.add(next.thing);
            for (int k = 0; k < next.afterCount; k++) {
                Item<T> later = items.get(next.after[k]);
                if (--later.before == 0 && !done[later.index]) free.add(later);
            }
        }
        free.clear();
        for (Item<T> item : items) item.thing = null;
        spare.addAll(items);
        items.clear();
        return order;
    }
amade-game-engine · test/iso/DepthSortTest.java
    @Test
    void aTreeBehindTheEndOfALongBuildingIsDrawnBeforeIt() {
        // A hall of six tiles by two; a tree on the tile behind its right end, at a larger x + y than the
        // hall's middle. Sorting by x + y alone would draw the tree over the hall's wall.
        DepthSort<String> sort = new DepthSort<>();
        add(sort, 10, 10, 16, 12, 30, "hall");
        add(sort, 15, 9, 16, 10, 20, "tree");
        assertTrue(15 + 16 + 9 + 10 > 10 + 16 + 10 + 12, "the tree's x + y is the larger");
        assertEquals(List.of("tree", "hall"), sort.sorted());
    }

Town puts everything that stands into one sort each frame: the lots with their buildings, the plants and services, trees, pylons, cars, people, rubble and the zone signs. A building with several parts, a factory with its chimney, is one item, its parts ordered inside its own picture. Flat things, lawns, car parks, the yard of the coal plant, are not sorted at all: they are drawn with the ground, before everything that stands.

amade-town-game · ui/game/TownPainter.java
    private void things(Graphics2D g, Game game, IsoProjection iso, int[] v, View view, Traffic traffic, double time) {
        for (int row = v[1]; row <= v[3]; row++) {
            for (int col = v[0]; col <= v[2]; col++) {
                double x = tileX(iso, col, row);
                double y = tileY(iso, col, row);
                if (x + 17 < view.left() || x - 17 > view.right() || y > view.bottom()) continue;
                double tx = col * Field.TILE;
                double ty = row * Field.TILE;
                Zone zone = game.zone(col, row);
                int level = game.lot(col, row);
                if (zone != null && level > 0) {
                    double tall = Lots.tall(zone, level);
                    sort.add(tx, ty, tx + Field.TILE, ty + Field.TILE, x - 17, y - tall - 1, x + 17, y + 17,
                            new Thing(LOT, col, row, 0, 0, null));
                } else if (game.rubble(col, row)) {
                    sort.add(tx + 2, ty + 2, tx + 14, ty + 14, x - 17, y - 7, x + 17, y + 17, new Thing(RUBBLE, col, row, 0, 0, null));
                } else if (zone != null && Art.hash(col, row, 71) < 0.12) {
                    double sx = tx + 11, sy = ty + 12;
                    double px = iso.pictureX(sx, sy), py = iso.pictureY(sx, sy, 0);
                    sort.add(sx - 0.5, sy - 0.5, sx + 0.5, sy + 0.5, px - 2, py - 11, px + 8, py + 2, new Thing(SIGN, col, row, sx, sy, zone));
                } else if (game.ground(col, row) == Ground.FOREST && game.buildingAt(col, row) == null && !game.road(col, row)) {
                    for (int i = 0; i < trees(col, row); i++) {
                        double[] t = tree(col, row, i);
                        double px = iso.pictureX(t[0], t[1]), py = iso.pictureY(t[0], t[1], 0);
                        sort.add(t[0] - 1, t[1] - 1, t[0] + 1, t[1] + 1, px - Pictures.TREE_X, py - Pictures.TREE_Y,
                                px + Pictures.TREE_X, py + 4, new Thing(TREE, col, row, t[0], t[1], t));
                    }
                }
                if (pylon(game, col, row)) {
                    double px = iso.pictureX(tx + 2, ty + 2), py = iso.pictureY(tx + 2, ty + 2, 0);
                    sort.add(tx + 1.5, ty + 1.5, tx + 2.5, ty + 2.5, px - 8, py - 21, px + 8, py + 3,
                            new Thing(PYLON, col, row, tx + 2, ty + 2, null));
                }
            }
        }
        for (Building b : game.buildings()) {
            Structures.Look look = pictures.look(b.type().work());
            if (!sees(iso, view, b, look.tall())) continue;
            double x = tileX(iso, b.col(), b.row());
            double y = tileY(iso, b.col(), b.row());
            int across = b.width() + b.height();
            sort.add(b.col() * Field.TILE, b.row() * Field.TILE, (b.col() + b.width()) * Field.TILE, (b.row() + b.height()) * Field.TILE,
                    x - b.height() * 16.0 - Pictures.STRUCTURE_SIDE, y - look.tall() - 2,
                    x + b.width() * 16.0 + Pictures.STRUCTURE_SIDE, y + across * 8.0 + 2, new Thing(BUILDING, b.col(), b.row(), 0, 0, b));
        }
        for (Traffic.Car car : traffic.cars()) {
            double px = iso.pictureX(car.x(), car.y()), py = iso.pictureY(car.x(), car.y(), 0);
            if (!view.sees(px - 12, py - 12, px + 12, py + 6)) continue;
            boolean alongX = car.facing() < 2;
            double half = car.kind() >= Pictures.BUS ? 5.5 : 3.2;
            double x0 = alongX ? car.x() - half : car.x() - 1.6, x1 = alongX ? car.x() + half : car.x() + 1.6;
            double y0 = alongX ? car.y() - 1.6 : car.y() - half, y1 = alongX ? car.y() + 1.6 : car.y() + half;
            sort.add(x0, y0, x1, y1, px - Pictures.CAR_X, py - Pictures.CAR_Y, px + Pictures.CAR_X, py + 5,
                    new Thing(CAR, 0, 0, car.x(), car.y(), car));
        }
        for (Walkers.Walker w : traffic.walkers()) {
            double px = iso.pictureX(w.x(), w.y()), py = iso.pictureY(w.x(), w.y(), 0);
            if (!view.sees(px - 3, py - 8, px + 3, py + 2)) continue;
            sort.add(w.x() - 0.4, w.y() - 0.4, w.x() + 0.4, w.y() + 0.4, px - Pictures.PERSON_X, py - Pictures.PERSON_Y,
                    px + Pictures.PERSON_X, py + 1, new Thing(WALKER, 0, 0, w.x(), w.y(), w));
        }
        for (Thing thing : sort.sorted()) draw(g, game, iso, thing, time);
    }

Step 3: Zones, lots and growth

In Colony the player built every house. In a city builder the player only zones land, and the town decides what grows. A zoned tile is a lot of level 0, empty land with a sign. The rules visit every lot once a month, in eight slices so the work is spread over the month, and decide: grow, wait, or fall back.

amade-town-game · domain/Game.java
    /** Every lot whose number falls in this slice of the month: grows, waits or falls back. */
    private void grow(int part) {
        double spare = power.supply() - power.served();
        for (int i = part; i < width * height; i += Field.SLICES) {
            Zone z = zone[i];
            if (z == null || fire[i] > 0) continue;
            if (rubble[i] > 0) continue;
            int col = i % width;
            int row = i / width;
            int now = lot[i];
            double demand = demand(z);
            boolean street = street(col, row);
            if (now == 0) {
                // An empty lot: a road to the highway, power to spare and people who want to come.
                if (!street || !power.connected(col, row) || spare < Field.LOT_POWER[1] || demand <= 0) continue;
                if (random.nextDouble() < 0.08 + 0.3 * demand) {
                    lot[i] = 1;
                    spare -= Field.LOT_POWER[1];
                    events.add(new Event.Grew(col, row, 1));
                }
                continue;
            }
            boolean lit = power.supplied(col, row);
            boolean wet = watered[i];
            if (!lit || (now >= 2 && !wet) || !street) {
                if (++lack[i] > Field.PATIENCE) {
                    lack[i] = 0;
                    fall(i, col, row);
                }
                continue;
            }
            lack[i] = 0;
            if (demand < -0.4 && random.nextDouble() < 0.1) {
                fall(i, col, row);
                continue;
            }
            if (now == 3 || !wet || demand <= 0.1 * now) continue;
            int next = now + 1;
            if (value[i] < needed(z, next)) continue;
            if (z == Zone.RESIDENTIAL && next == 3 && !services.get(Work.SCHOOL).covered(col, row)) continue;
            double more = Field.LOT_POWER[next] - Field.LOT_POWER[now];
            if (spare < more) continue;
            if (random.nextDouble() < 0.04 + 0.2 * demand) {
                lot[i] = next;
                spare -= more;
                events.add(new Event.Grew(col, row, next));
            }
        }
    }

Each condition is something the player can see and fix: no road to the highway (the sign stays empty), no power (a crossed-out bolt blinks over the lot), no water from the second level (a crossed-out drop), not enough land value for the next level, no school for tall homes. The chance to grow rises with demand, so a wanted zone fills quickly and a zone nobody wants fills slowly or not at all. A lot that lacks power or water for two months falls back; a zone that is badly over-built falls back too, now and then. The random numbers come from the game's seeded random, so a town grows the same way every time it is played the same way.

Zoning is a rectangle dragged with the mouse. The rules keep what can be zoned, free land or an empty lot of another zone, and the cost per tile:

amade-town-game · domain/Game.java
    /** The tiles of a rectangle that can be zoned: free land, or an empty lot of another zone. */
    public List<Cell> zonable(Zone z, int col, int row, int toCol, int toRow) {
        List<Cell> cells = new ArrayList<>();
        for (int r = Math.min(row, toRow); r <= Math.max(row, toRow); r++) {
            for (int c = Math.min(col, toCol); c <= Math.max(col, toCol); c++) {
                if (!contains(c, r)) continue;
                int i = index(c, r);
                if (ground[i] == Ground.WATER || road[i] || line[i] || built[i] != 0) continue;
                if (zone[i] == z || (zone[i] != null && lot[i] > 0)) continue;
                cells.add(new Cell(c, r));
            }
        }
        return cells;
    }

    public int zoneCost(List<Cell> cells, Zone z) {
        int cost = 0;
        for (Cell cell : cells) cost += z.cost() + (ground(cell.col(), cell.row()) == Ground.FOREST ? Field.TREE : 0);
        return cost;
    }

    public Result zone(Zone z, int col, int row, int toCol, int toRow) {
        List<Cell> cells = zonable(z, col, row, toCol, toRow);
        if (cells.isEmpty()) return Result.NOTHING;
        int cost = zoneCost(cells, z);
        if (cost > money) return Result.MONEY;
        for (Cell cell : cells) {
            int i = index(cell.col(), cell.row());
            zone[i] = z;
            lot[i] = 0;
            lack[i] = 0;
            if (ground[i] == Ground.FOREST) ground[i] = Ground.GRASS;
        }
        money -= cost;
        events.add(new Event.Laid("zone." + z.letter(), cells.size(), cost));
        survey();
        return Result.OK;
    }

Roads are dragged too, from tile to tile, along the columns first and then the rows. Only roads joined to the highway count: a flood from the highway along the roads marks them once per survey, and a lot looks for a marked road within two tiles.

amade-town-game · domain/Game.java
    /** What a road along {@code cells} costs, or -1 if it cannot be built. A bridge must run straight. */
    public int roadCost(List<Cell> cells) {
        int cost = 0;
        for (int k = 0; k < cells.size(); k++) {
            Cell cell = cells.get(k);
            if (!contains(cell.col(), cell.row())) return -1;
            int i = index(cell.col(), cell.row());
            if (road[i]) continue;
            if (built[i] != 0 || (zone[i] != null && lot[i] > 0)) return -1;
            if (ground[i] == Ground.WATER) {
                if (!straight(cells, k)) return -1;
                cost += Field.BRIDGE;
            } else {
                cost += Field.ROAD + (ground[i] == Ground.FOREST ? Field.TREE : 0);
            }
        }
        return cost;
    }

    /** Whether the tile {@code k} of a path has its neighbours in the path on opposite sides, or is an end. */
    private boolean straight(List<Cell> cells, int k) {
        Cell here = cells.get(k);
        Cell before = k > 0 ? cells.get(k - 1) : null;
        Cell after = k < cells.size() - 1 ? cells.get(k + 1) : null;
        if (before == null || after == null) return cells.size() > 1;
        return before.col() == after.col() && here.col() == before.col()
                || before.row() == after.row() && here.row() == before.row();
    }

    public Result road(List<Cell> cells) {
        int cost = roadCost(cells);
        if (cost < 0) return Result.BLOCKED;
        if (cost == 0) return Result.NOTHING;
        if (cost > money) return Result.MONEY;
        int laid = 0;
        for (Cell cell : cells) {
            int i = index(cell.col(), cell.row());
            if (road[i]) continue;
            road[i] = true;
            zone[i] = null;
            if (ground[i] == Ground.FOREST) ground[i] = Ground.GRASS;
            laid++;
        }
        money -= cost;
        events.add(new Event.Laid("road", laid, cost));
        survey();
        return Result.OK;
    }

Step 4: Power through the town

Power is a network. A coal plant puts 120 MW into it, every lot and building takes some out, and in between it flows through power lines and through everything else that is built: zones and buildings that touch along an edge carry it to each other. A block of homes therefore needs one line to it, not a line to every house; a strip of meadow between two blocks needs a line across it.

The engine's SupplyGrid solves such a network. Tiles that carry are conductors; sources put an amount in, loads take an amount out. solve finds the networks, groups of conductors that touch along an edge, and serves the loads of each network in the order a flood from its sources reaches them, until the sources run dry:

amade-game-engine · tile/SupplyGrid.java
public final class SupplyGrid {
    private final int cols;
    private final int rows;
    private final boolean[] conducts;
    private final double[] sources;
    private final double[] loads;
    private final int[] network;
    private final boolean[] supplied;
    private final int[] queue;
    private double supply;
    private double demand;
    private double served;
    private int networks;
    /** The networks with a source come first: numbers below this one. */
    private int fed;

    public SupplyGrid(int cols, int rows) {
        if (cols <= 0 || rows <= 0) throw new IllegalArgumentException("empty map");
        this.cols = cols;
        this.rows = rows;
        int n = cols * rows;
        conducts = new boolean[n];
        sources = new double[n];
        loads = new double[n];
        network = new int[n];
        supplied = new boolean[n];
        queue = new int[n];
    }

    /** Forgets every conductor, source and load. */
    public void clear() {
        Arrays.fill(conducts, false);
        Arrays.fill(sources, 0);
        Arrays.fill(loads, 0);
        Arrays.fill(network, -1);
        Arrays.fill(supplied, false);
        supply = demand = served = 0;
        networks = fed = 0;
    }

    /** A tile that carries. */
    public void conductor(int col, int row) {
        conducts[row * cols + col] = true;
    }

    /** A tile that puts {@code amount} into its network; added to what the tile puts in already. */
    public void source(int col, int row, double amount) {
        int i = row * cols + col;
        conducts[i] = true;
        sources[i] += amount;
    }

    /** A tile that takes {@code amount} out of its network; added to what the tile takes already. */
    public void load(int col, int row, double amount) {
        int i = row * cols + col;
        conducts[i] = true;
        loads[i] += amount;
    }

    /** Finds the networks and serves the loads, nearest to a source first. */
    public void solve() {
        Arrays.fill(network, -1);
        Arrays.fill(supplied, false);
        supply = demand = served = 0;
        networks = fed = 0;
        int n = cols * rows;
        for (int i = 0; i < n; i++) {
            supply += sources[i];
            demand += loads[i];
        }
        // Each network with a source: flood from all its sources at once, serving loads as they are reached.
        for (int start = 0; start < n; start++) {
            if (sources[start] <= 0 || network[start] >= 0) continue;
            int id = networks++;
            double left = 0;
            int head = 0;
            int tail = 0;
            // First the whole network, to know what its sources make...
            queue[tail++] = start;
            network[start] = id;
            while (head < tail) {
                int i = queue[head++];
                left += sources[i];
                tail = spread(i, id, tail);
            }
            // ...then from the sources outwards, in breadth-first order of distance from the nearest source.
            int[] order = breadthFromSources(id, tail);
            for (int k = 0; k < tail; k++) {
                int i = order[k];
                if (loads[i] <= 0) continue;
                if (loads[i] <= left + 1e-9) {
                    left -= loads[i];
                    supplied[i] = true;
                    served += loads[i];
                }
            }
        }
        fed = networks;
        // Networks without a source are networks too, for network().
        for (int start = 0; start < n; start++) {
            if (!conducts[start] || network[start] >= 0) continue;
            int id = networks++;
            int head = 0;
            int tail = 0;
            queue[tail++] = start;
            network[start] = id;
            while (head < tail) tail = spread(queue[head++], id, tail);
        }
    }

Serving from the nearest load outward is a decision about what the player sees. A town that builds more than its plant can feed loses power at the far end, in the newest streets, where the player is looking anyway, and not here and there at random. A load is served wholly or not at all, so a lot is lit or dark, never dim.

amade-game-engine · test/tile/SupplyGridTest.java
    @Test
    void thePlantFeedsTheNearestHomesFirst() {
        // A plant of 5 at the left end of a line of ten tiles, a home of 2 on every other tile.
        SupplyGrid power = new SupplyGrid(12, 3);
        power.clear();
        for (int col = 0; col < 10; col++) power.conductor(col, 1);
        power.source(0, 1, 5);
        for (int col = 2; col < 10; col += 2) power.load(col, 1, 2);
        power.solve();
        assertTrue(power.supplied(2, 1));
        assertTrue(power.supplied(4, 1));
        assertFalse(power.supplied(6, 1), "5 feeds two homes of 2, not three");
        assertFalse(power.supplied(8, 1));
        assertTrue(power.connected(8, 1), "connected, but the plant is too small");
        assertEquals(5, power.supply());
        assertEquals(8, power.demand());
        assertEquals(4, power.served());
    }

Once a slice the game surveys the whole town: which roads reach the highway, the power network, the water, the reach of the services, pollution and land value. For a map of 64 × 64 tiles that is a few thousand small loops, nothing against a frame.

amade-town-game · domain/Game.java
    /** Looks at the whole town: which roads reach the highway, power, water, services, pollution, land value. */
    void survey() {
        linkRoads();
        power.clear();
        for (int i = 0; i < width * height; i++) {
            int col = i % width;
            int row = i / width;
            if (line[i] || zone[i] != null || built[i] != 0) power.conductor(col, row);
            if (zone[i] != null && lot[i] > 0 && fire[i] <= 0) power.load(col, row, Field.LOT_POWER[lot[i]]);
        }
        for (Building b : buildings.values()) {
            if (b.type().plant()) power.source(b.col(), b.row(), b.type().power());
            else if (b.type().uses() > 0) power.load(b.col(), b.row(), b.type().uses());
        }
        power.solve();
        for (Building b : buildings.values()) b.powered = b.type().plant() || power.supplied(b.col(), b.row());

        towers.clear();
        for (Coverage coverage : services.values()) coverage.clear();
        waterSupply = 0;
        for (Building b : buildings.values()) {
            if (!b.working()) continue;
            Work work = b.type().work();
            if (work == Work.PUMP) waterSupply += b.type().water();
            else if (work == Work.TOWER) towers.add(b.centerCol(), b.centerRow(), b.type().reach());
            else if (services.containsKey(work)) services.get(work).add(b.centerCol(), b.centerRow(), b.type().reach());
        }
        waterDemand = 0;
        for (int i = 0; i < width * height; i++) {
            if (zone[i] != null && lot[i] > 0 && towers.covered(i % width, i / width)) waterDemand += Field.LOT_WATER[lot[i]];
        }
        // Too little water: the same lots go dry every time, those whose number comes up last.
        double share = waterDemand <= 0 ? 1 : Math.min(1, waterSupply / waterDemand);
        for (int i = 0; i < width * height; i++) {
            watered[i] = towers.covered(i % width, i / width) && waterSupply > 0 && (share >= 1 || spread(i) < share);
        }
        pollute();
        appraise();
        count();
    }

Step 5: Reach, water and land value

Water towers, schools, the library, the clinic, the fire station, the town hall and parks all serve a circle. The engine's Coverage adds the circles: a tile knows how many places reach it, and how close the nearest one is, as a strength from 1 on the place itself down to almost 0 at the edge of its reach.

amade-game-engine · tile/Coverage.java
public final class Coverage {
    private final int cols;
    private final int rows;
    private final int[] counts;
    private final float[] strengths;

    public Coverage(int cols, int rows) {
        if (cols <= 0 || rows <= 0) throw new IllegalArgumentException("empty map");
        this.cols = cols;
        this.rows = rows;
        this.counts = new int[cols * rows];
        this.strengths = new float[cols * rows];
    }

    /** Forgets every place. */
    public void clear() {
        Arrays.fill(counts, 0);
        Arrays.fill(strengths, 0);
    }

    /** A place on tile {@code col}, {@code row} that reaches {@code radius} tiles around. */
    public void add(int col, int row, double radius) {
        add(col + 0.5, row + 0.5, radius);
    }

    /**
     * A place whose middle is at {@code col}, {@code row} in tiles, which need not be the middle of a
     * tile: a building of two tiles by two has its middle on the corner between them.
     */
    public void add(double col, double row, double radius) {
        int r = (int) Math.ceil(radius);
        int c0 = Math.max(0, (int) Math.floor(col - r));
        int c1 = Math.min(cols - 1, (int) Math.ceil(col + r));
        int r0 = Math.max(0, (int) Math.floor(row - r));
        int r1 = Math.min(rows - 1, (int) Math.ceil(row + r));
        for (int y = r0; y <= r1; y++) {
            for (int x = c0; x <= c1; x++) {
                double distance = Math.hypot(x + 0.5 - col, y + 0.5 - row);
                if (distance > radius) continue;
                int i = y * cols + x;
                counts[i]++;
                float strength = (float) (1 - distance / (radius + 1));
                if (strength > strengths[i]) strengths[i] = strength;
            }
        }
    }

    /** How many places reach a tile; 0 off the map. */
    public int count(int col, int row) {
        return contains(col, row) ? counts[row * cols + col] : 0;
    }

    /** True if any place reaches a tile. */
    public boolean covered(int col, int row) {
        return count(col, row) > 0;
    }

    /** How close the nearest place is: 1 on it, falling towards 0 at the edge of its reach, 0 out of reach. */
    public double strength(int col, int row) {
        return contains(col, row) ? strengths[row * cols + col] : 0;
    }

A strength cannot be taken back out of a sum, so the game does not keep the coverage up to date place by place: it clears it and adds every place again in each survey. That is the simplest correct thing, and for a few dozen places it costs nothing.

Water is the tower's circle and the pumps' supply. When the pumps give less than the lots in reach of the towers ask for, a share of the lots goes dry, and it is always the same ones: each tile has a fixed number between 0 and 1, and those whose number lies above the share go without. Lots that went dry at random in every survey would flicker between wet and dry and never grow.

Land value is made from what is near: parks most, then school, town hall, clinic, library and fire station, a little for the riverside, minus the smoke.

amade-town-game · domain/Game.java
    /** What the land is worth: parks, services and the riverside raise it, smoke lowers it. */
    private void appraise() {
        Coverage parks = services.get(Work.PARK);
        for (int i = 0; i < width * height; i++) {
            int col = i % width;
            int row = i / width;
            double worth = 22
                    + 26 * parks.strength(col, row)
                    + 14 * services.get(Work.SCHOOL).strength(col, row)
                    + 8 * services.get(Work.LIBRARY).strength(col, row)
                    + 10 * services.get(Work.CLINIC).strength(col, row)
                    + 6 * services.get(Work.FIRE).strength(col, row)
                    + 10 * services.get(Work.HALL).strength(col, row)
                    + (shore[i] == 0 ? 0 : 4 * (4 - shore[i]))
                    - 0.8 * smoke[i];
            value[i] = (int) Math.max(0, Math.min(100, Math.round(worth)));
        }
    }

Smoke comes from industrial lots, more with every level, and from the coal plant, falling off with the distance; forests swallow some of it.

amade-town-game · domain/Game.java
    /** Smoke from industry and the coal plant, falling off with the distance. */
    private void pollute() {
        Arrays.fill(smoke, 0);
        for (int i = 0; i < width * height; i++) {
            if (zone[i] == Zone.INDUSTRIAL && lot[i] > 0) puff(i % width + 0.5, i / width + 0.5, Field.SMOKE[lot[i]], Field.SMOKE_REACH);
        }
        for (Building b : buildings.values()) {
            if (b.type().smoke() > 0 && b.working()) puff(b.centerCol(), b.centerRow(), b.type().smoke(), b.type().reach());
        }
        for (int i = 0; i < width * height; i++) {
            if (ground[i] == Ground.FOREST) smoke[i] = Math.max(0, smoke[i] - 6);
            smoke[i] = Math.min(100, smoke[i]);
        }
    }

    private void puff(double col, double row, int amount, int reach) {
        for (int y = (int) Math.floor(row - reach); y <= (int) Math.ceil(row + reach); y++) {
            for (int x = (int) Math.floor(col - reach); x <= (int) Math.ceil(col + reach); x++) {
                if (!contains(x, y)) continue;
                double distance = Math.hypot(x + 0.5 - col, y + 0.5 - row);
                if (distance > reach) continue;
                smoke[index(x, y)] += (int) Math.round(amount * (1 - distance / (reach + 1)));
            }
        }
    }

Step 6: Demand, mood and the budget

Three numbers, one per zone, from -1 to 1, decide what the town wants. They pull on each other: homes are wanted when there are jobs for more people, shops when there are people to buy, industry when there are people to work and, because industry also sells out of town, from the start. That last part is what starts a town at all: with no people, nobody wants shops or homes, but industry is wanted, and its jobs then call for homes.

amade-town-game · domain/Game.java
    /**
     * Demand for each zone, from -1 to 1. People come for jobs; shops and industry come for people, and
     * industry also sells out of town, so it starts the town. A grown town also wants offices, clean jobs
     * in the shop zone, and so a little less industry. Taxes above the usual rate put everyone off, a
     * happy town draws people.
     */
    private void weigh() {
        int jobs = shopJobs + workJobs;
        double workers = people * 0.3;
        double homes = (jobs + 16 - workers) / Math.max(24, people * 0.12);
        double offices = Math.max(0, people - Field.OFFICE_TOWN) * Field.OFFICE_JOBS;
        double shopTarget = people * 0.12 + 4 + offices;
        double shops = (shopTarget - shopJobs) / Math.max(8, shopTarget * 0.5);
        double workTarget = people * 0.17 + 10 - offices * 0.5;
        double works = (workTarget - workJobs) / Math.max(10, workTarget * 0.5);
        double taxes = (tax - Field.TAX) * 0.05;
        demandR = clamp(0.5 * demandR + 0.5 * (homes - taxes + (happiness - 50) / 150.0));
        demandC = clamp(0.5 * demandC + 0.5 * (shops - taxes));
        demandI = clamp(0.5 * demandI + 0.5 * (works - taxes));
    }

A grown town wants something more: offices. Above 300 people, every further person asks for a quarter of an office job, counted with the shops, because offices grow in the shop zone as its third level, the office block and the glass tower. They are clean jobs, so the town wants a little less industry for them. Shops alone would never get there: at 1,000 people the town has all the shops it buys from, and shop demand turns negative. Offices also ask for less land value than tall homes, 40 instead of 55, so they rise by the parks, the clinic and the town hall.

Offices downtown

Mission 1 played on to its fifth year: two glass towers and office blocks between the shops, by the clinic and its park. The computer's plan built more wind turbines, a second coal plant and a third pump after the win, since power, not demand, is what holds a grown town back.

Each number moves halfway to its new value every month, so demand does not jump when a block grows at once. Taxes above the usual 9 % push every zone down, and a happy town pulls people in. How happy the people are is worked out from what reaches their homes: services, parks, clean air, power, water, enough jobs and fair taxes, each weighed by how many people live on the lot.

amade-town-game · domain/Game.java
    /** How happy the people are: services, parks, clean air, power, water, jobs and fair taxes. */
    private void mood() {
        if (people == 0) {
            happiness = 50;
            return;
        }
        double school = 0, library = 0, clinic = 0, station = 0, parks = 0, air = 0, dark = 0, dry = 0;
        for (int i = 0; i < width * height; i++) {
            if (zone[i] != Zone.RESIDENTIAL || lot[i] == 0) continue;
            int col = i % width;
            int row = i / width;
            double w = Field.PEOPLE[lot[i]];
            if (services.get(Work.SCHOOL).covered(col, row)) school += w;
            if (services.get(Work.LIBRARY).covered(col, row)) library += w;
            if (services.get(Work.CLINIC).covered(col, row)) clinic += w;
            if (services.get(Work.FIRE).covered(col, row)) station += w;
            parks += w * services.get(Work.PARK).strength(col, row);
            air += w * smoke[i];
            if (!power.supplied(col, row)) dark += w;
            if (lot[i] >= 2 && !watered[i]) dry += w;
        }
        double p = people;
        double target = 36 + 16 * school / p + 8 * library / p + 12 * clinic / p + 8 * station / p
                + Math.min(12, 24 * parks / p) - 0.5 * air / p - 3 * (tax - Field.TAX) - 30 * dark / p - 15 * dry / p
                + (shopJobs + workJobs >= people * 0.3 ? 6 : -6);
        target = Math.max(0, Math.min(100, target));
        happiness = (int) Math.round(0.5 * happiness + 0.5 * target);
    }

At the end of a month the budget is drawn up: taxes on people and jobs, upkeep for every road, line and building. Then fires, advice and whether the mission is over:

amade-town-game · domain/Game.java
    /** The end of a month: demand, the budget, the people's mood, fires, advice, and whether the mission is over. */
    private void month() {
        months++;
        weigh();
        double upkeep = 0;
        int roads = 0;
        int lines = 0;
        for (int i = 0; i < width * height; i++) {
            if (road[i] && !level.highway(i % width, i / width)) roads++;
            if (line[i]) lines++;
            if (rubble[i] > 0) rubble[i]--;
        }
        upkeep += roads * Field.ROAD_UPKEEP + lines * Field.LINE_UPKEEP;
        for (Building b : buildings.values()) upkeep += b.type().upkeep();
        income = tax / 100.0 * (people * 7 + (shopJobs + workJobs) * 5);
        expenses = upkeep;
        money += income - expenses;
        mood();
        if (level.mission().fire() && random.nextDouble() < Field.FIRE_CHANCE) ignite();
        events.add(new Event.Month(months % 12, months / 12 + 1));
        while (milestone < MILESTONES.length && people >= MILESTONES[milestone]) {
            events.add(new Event.Milestone(MILESTONES[milestone]));
            milestone++;
        }
        advise();
        Mission mission = level.mission();
        if (endless) return;
        if (people >= mission.goal() && happiness >= mission.happiness()) {
            phase = Phase.WON;
            events.add(new Event.Over(true));
        } else if (money < Field.DEBT || (mission.years() > 0 && months >= mission.years() * 12)) {
            phase = Phase.LOST;
            events.add(new Event.Over(false));
        }
    }

The budget over the land value

The budget in the panel, with the tax rate and its buttons, over the land-value overlay: red near the coal plant and the industry, green in the homes around the school and the parks.

Advice is a city builder's way of teaching: the town says what it lacks. A town without a road to the highway, without power, without water, with homes but no jobs, with smoke over the homes, with the money running low: each has its hint, shown in the message line: at most one a month, the most urgent first, and the same one not again within a year.

Step 7: Fire and the truck

On missions with fire, a lot catches fire now and then. Halfway through, the fire reaches for the lots next door. A fire station in reach sends its truck, and the truck puts the fire out a few seconds later; without one, the building burns down and leaves rubble for two months.

amade-town-game · domain/Game.java
    private void fires(double seconds) {
        for (int i = 0; i < width * height; i++) {
            if (fire[i] <= 0) continue;
            int col = i % width;
            int row = i / width;
            if (douse[i] >= 0) {
                douse[i] -= seconds;
                if (douse[i] < 0) {
                    fire[i] = 0;
                    douse[i] = -1;
                    events.add(new Event.Doused(col, row));
                    continue;
                }
            }
            double before = fire[i];
            fire[i] -= seconds;
            // Halfway through, the fire reaches for the lots next door.
            if (before > Field.BURNS / 2 && fire[i] <= Field.BURNS / 2) {
                int[][] next = {{col - 1, row}, {col + 1, row}, {col, row - 1}, {col, row + 1}};
                for (int[] c : next) {
                    if (!contains(c[0], c[1])) continue;
                    int j = index(c[0], c[1]);
                    if (zone[j] != null && lot[j] > 0 && fire[j] <= 0 && random.nextDouble() < Field.SPREAD) light(j);
                }
            }
            if (fire[i] <= 0) {
                fire[i] = 0;
                lot[i] = 0;
                rubble[i] = Field.RUBBLE_MONTHS;
                events.add(new Event.Burned(col, row));
            }
        }
    }

The rules only say that a truck was sent. The drawing sends a real one: a red truck leaves the station, finds its way along the roads with a breadth-first search, and drives with a siren to the fire, where it parks until the fire is out. The town's traffic is the same idea without a goal: cars, buses and lorries, one for every 25 people, drive the roads on the right lane, turning at random at crossings, and come in from the highway.

amade-town-game · ui/game/Traffic.java
    /** Moves a car along; false when it has left the roads or the map, or a truck's fire is out. */
    private boolean drive(Car car, double seconds) {
        if (car.truck()) {
            if (!game.burning(car.fireCol, car.fireRow)) return false;
            if (car.parked) return true;
        }
        car.along += car.speed * seconds;
        while (car.along >= car.length) {
            car.along -= car.length;
            car.col += DX[car.out];
            car.row += DY[car.out];
            car.in = car.out;
            if (car.truck()) {
                if (car.route.isEmpty()) {
                    car.parked = true;
                    car.along = car.length / 2;
                    car.col -= DX[car.out];
                    car.row -= DY[car.out];
                    break;
                }
                car.route.removeFirst();
                Cell next = car.route.peekFirst();
                car.out = next == null ? car.in : direction(new Cell(car.col, car.row), next);
            } else {
                if (!game.contains(car.col, car.row) || !game.road(car.col, car.row)) return false;
                car.out = choose(car.col, car.row, car.in);
            }
            enter(car);
        }
        if (!car.truck() && !game.road(car.col, car.row)) return false;
        place(car);
        return true;
    }

People walk on the pavements, one for every 12 people, up to 120. A walker goes from corner to corner of the road tiles, a metre in from the edge: on along the pavement, round the corner of a block, to the touching corner of the next road tile, and now and then across the street. A corner is pavement unless the roads on both its sides and the road between them meet there; the highway has none.

amade-town-game · ui/game/Walkers.java
    /** Whether a corner of a road tile is pavement: not where the roads on both its sides and between them meet. */
    boolean pavement(int col, int row, int corner) {
        if (!road(col, row)) return false;
        int dx = corner == 1 || corner == 2 ? 1 : -1;
        int dy = corner >= 2 ? 1 : -1;
        return !(game.road(col + dx, row) && game.road(col, row + dy) && game.road(col + dx, row + dy));
    }

    /**
     * Chooses where a walker goes from its corner: to the next corner of the same tile, which is along the
     * pavement or across a street, or to the touching corner of a neighbouring road tile. It does not turn
     * back the way it came, unless that is the only way.
     */
    private boolean next(Walker w, int fromCol, int fromRow, int fromCorner) {
        ways.clear();
        int c = w.col, r = w.row, k = w.corner;
        for (int turn : new int[]{1, 3}) {
            int other = (k + turn) % 4;
            // The side of the tile between the two corners: 0 is -y, 1 is +x, 2 is +y, 3 is -x.
            int side = turn == 1 ? k : other;
            int sx = side == 1 ? 1 : side == 3 ? -1 : 0;
            int sy = side == 2 ? 1 : side == 0 ? -1 : 0;
            boolean across = game.road(c + sx, r + sy);
            if (pavement(c, r, other) && (!across || random.nextDouble() < CROSS)) ways.add(new int[]{c, r, other});
        }
        // The touching corners of the neighbours: the same corner mirrored across the shared edge.
        int dx = k == 1 || k == 2 ? 1 : -1;
        int dy = k >= 2 ? 1 : -1;
        int mirrorX = k ^ 1;
        int mirrorY = 3 - k;
        if (pavement(c + dx, r, mirrorX)) ways.add(new int[]{c + dx, r, mirrorX});
        if (pavement(c, r + dy, mirrorY)) ways.add(new int[]{c, r + dy, mirrorY});
        int[] back = null;
        for (Iterator<int[]> it = ways.iterator(); it.hasNext(); ) {
            int[] way = it.next();
            if (way[0] == fromCol && way[1] == fromRow && way[2] == fromCorner) {
                back = way;
                it.remove();
            }
        }
        if (ways.isEmpty() && back != null) ways.add(back);
        if (ways.isEmpty()) return false;
        int[] way = ways.get(random.nextInt(ways.size()));
        w.toCol = way[0];
        w.toRow = way[1];
        w.toCorner = way[2];
        w.length = Math.max(0.01, Math.hypot(cornerX(w.toCol, w.toCorner) - cornerX(w.col, w.corner),
                cornerY(w.toRow, w.toCorner) - cornerY(w.row, w.corner)));
        return true;
    }

The test lets the computer build a town and then follows the walkers for twenty seconds: none of them may leave the street or step into the middle of the road.

amade-town-game · WalkersTest.java
    @Test
    void peopleWalkOnlyOnThePavementOfTheStreets() {
        Game game = new Game(Level.load(1, RULES), RULES, 7);
        Planner planner = new Planner(RULES.plan());
        for (int i = 0; i < 120 * 150; i++) {
            planner.update(game, STEP);
            game.update(STEP);
        }
        Traffic traffic = new Traffic();
        for (int i = 0; i < 120 * 20; i++) {
            traffic.update(STEP, game);
            for (Walkers.Walker w : traffic.walkers()) {
                int col = (int) Math.floor(w.x() / Field.TILE);
                int row = (int) Math.floor(w.y() / Field.TILE);
                assertTrue(game.road(col, row) && !game.highway(col, row), "a walker off the street at " + col + ", " + row);
                double x = w.x() - col * Field.TILE;
                double y = w.y() - row * Field.TILE;
                boolean edge = x < 2 || x > 14 || y < 2 || y > 14;
                assertTrue(edge, "a walker in the middle of the road");
            }
        }
        assertFalse(traffic.walkers().isEmpty(), "a town of " + game.people() + " has people on its streets");
    }

A fire in the shops Zoning homes

A shop on fire in the middle of the town (left), the fire truck on its way from the station at the left edge. Zoning homes (right): a rectangle of 5 × 3 dragged next to a road, its size and cost beside the mouse.

Step 8: The computer builds

The title screen shows a town the computer builds, and GameTest lets it build mission 1 until it is won. As in Colony the plan is a text file, one step every few seconds, as a player would make them with the mouse: lay a road, zone a block, build a plant, run a line, build a pump. A step the town cannot pay for waits until the money is there. After the win, when the player could keep building, the plan adds power and water, so the title screen's town keeps growing into offices.

amade-town-game · domain/Planner.java
    public void update(Game game, double seconds) {
        timer += seconds;
        while (timer >= plan.every() && next < plan.steps().size()) {
            timer -= plan.every();
            Game.Result result = apply(game, plan.steps().get(next));
            if (result == Game.Result.MONEY) return;
            next++;
        }
    }

That makes the plan a test of the balance. If a change to demand, growth or the budget makes mission 1 stall, the town no longer reaches 1,000 people within two years and the build fails:

amade-town-game · test/domain/GameTest.java
    @Test
    void thePlanWinsTheFirstMission() {
        Game game = played(1, 60 * 8);
        assertEquals(Game.Phase.WON, game.phase(), "people " + game.people() + " happy " + game.happiness());
        assertTrue(game.people() >= game.mission().goal());
        assertTrue(game.months() <= 24, "won in month " + game.months());
    }

Step 9: Dragging with the mouse

Every tool is either dragged or clicked. Roads, power lines, zones and the bulldozer are dragged: the left button goes down on a tile, the mouse moves, and the painter shows the path or rectangle with its cost; letting go builds it. Buildings are clicked: the building follows the mouse, its middle under it, green where it fits and red where not.

amade-town-game · ui/game/TownGame.java
        /** The left button on the map: a drag begins, or a building is put down. */
        private void press(Cell cell) {
            if (tool == null) return;
            if (tool.drags(choice)) {
                from = cell;
                to = cell;
                return;
            }
            BuildingType type = building();
            if (type == null || place == null) return;
            Game.Result result = game.build(type, place.col(), place.row());
            if (result != Game.Result.OK) refuse(result);
        }

        /** The left button let go: the road, line, zone or bulldozing dragged out is done. */
        private void release() {
            Cell a = from;
            Cell b = to;
            from = null;
            to = null;
            if (a == null || b == null) return;
            Game.Result result;
            if (tool == Tool.ROAD) result = game.road(Game.path(a.col(), a.row(), b.col(), b.row()));
            else if (tool == Tool.POWER) result = game.line(Game.path(a.col(), a.row(), b.col(), b.row()));
            else if (tool == Tool.BULLDOZE) result = game.bulldoze(a.col(), a.row(), b.col(), b.row());
            else result = game.zone(tool.zone(), a.col(), a.row(), b.col(), b.row());
            if (result != Game.Result.OK && result != Game.Result.NOTHING) refuse(result);
        }

The tile under the mouse goes through two conversions: from the screen to the picture by the camera, then from the picture to the world by the projection.

amade-town-game · ui/game/TownGame.java
        private void input(double seconds) {
            double mx = mouse.x();
            double my = mouse.y();
            boolean onMap = !Double.isNaN(mx) && mx < viewWidth() && my >= Field.BAR;
            // The panel is laid out for the narrowest screen and moved to the right edge: so is the mouse.
            double panelX = mx - extra();
            hover = null;
            if (onMap) {
                double px = camera.worldX(mx / zoom);
                double py = camera.worldY(my / zoom);
                Cell cell = new Cell(iso.col(px, py), iso.row(px, py));
                if (game.contains(cell.col(), cell.row())) hover = cell;
            }
            if (controls.wasPressed(Action.BACK)) back();
            if (controls.wasPressed(Action.SAVE)) {
                slot.save(game);
                say(msg.get("message.saved"), Hud.CHALK);
                effects.tick();
            }
            if (controls.wasPressed(Action.FASTER)) faster(1);
            if (controls.wasPressed(Action.SLOWER)) faster(-1);
            if (controls.wasPressed(Action.COUNTS)) counts = !counts;
            if (controls.wasPressed(Action.OVERLAY)) nextOverlay();
            if (controls.wasPressed(Action.ZOOM)) zoom(zoom == ZOOMS[0] ? 1 : -1, viewWidth() / 2, Field.BAR + Field.VIEW_HEIGHT / 2);
            if (onMap && mouse.wheel() != 0) zoom(-mouse.wheel(), mx, my);
            if (controls.wasPressed(Action.BUDGET)) info = info == Hud.Info.BUDGET ? Hud.Info.TOOL : Hud.Info.BUDGET;
            for (Tool t : Tool.values()) {
                if (controls.wasPressed(Action.valueOf("TOOL_" + (t.ordinal() + 1)))) choose(t);
            }

            placing();
            if (mouse.wasPressed(MouseEvent.BUTTON1)) {
                if (onMap && hover != null) press(hover);
                else if (!Double.isNaN(mx) && panelX >= Field.VIEW_WIDTH) panelClick(panelX, my);
            }
            if (from != null && hover != null) to = hover;
            if (mouse.wasReleased(MouseEvent.BUTTON1) && from != null) release();
            if (minimapDrag && mouse.isDown(MouseEvent.BUTTON1) && !Double.isNaN(mx)) jumpMinimap(panelX, my);
            if (!mouse.isDown(MouseEvent.BUTTON1)) minimapDrag = false;
            if (mouse.wasPressed(MouseEvent.BUTTON3)) {
                pressX = mx;
                pressY = my;
                panned = false;
            }
            if (mouse.wasReleased(MouseEvent.BUTTON3) && !panned && onMap) back();
            pan(seconds, mx, my);
        }

A school looking for its place The power overlay

A school following the mouse (left), see-through over the meadow, its footprint green where it fits; the panel lists what the Services tool builds, with prices. The power overlay (right): yellow where power reaches, the lines along the side streets, the coal plant and two wind turbines at the left.

Step 10: A view that zooms

The preview of this game was drawn close up, with houses large enough to see their windows and fences; a city builder also needs the whole district at a glance. Town has two zooms, close up (as the game begins) and twice as far, switched with the mouse wheel or Z.

The engine's Mouse learned the wheel for it: wheel() is the number of notches turned between the last two polls, positive towards the player.

The camera did not have to learn anything. It works in the picture's units, so a zoomed view is simply a camera with a smaller view, drawn scaled up: at zoom 2 the camera shows 256 × 190 units of the picture, and the graphics are scaled by 2 before the town is drawn. The mouse is divided by the zoom before the camera converts it. To keep the point under the mouse in place, the new camera is centred so that point lands where the mouse is:

amade-town-game · ui/game/TownGame.java
        /**
         * The wheel or Z: the map drawn larger or smaller by {@code by} steps. The camera works in picture units,
         * so a zoomed view is a camera with a smaller view, drawn scaled up; the point under the mouse stays put.
         */
        private void zoom(int by, double mx, double my) {
            int i = 0;
            while (ZOOMS[i] != zoom) i++;
            int next = ZOOMS[Math.clamp(i + by, 0, ZOOMS.length - 1)];
            if (next == zoom) return;
            double px = camera.worldX(mx / zoom);
            double py = camera.worldY(my / zoom);
            zoom = next;
            camera = view(iso, zoom);
            camera.jumpTo(px - (mx - viewWidth() / 2) / zoom, py - (my - Field.BAR - Field.VIEW_HEIGHT / 2) / zoom);
            effects.tick();
        }

        private Camera view(IsoProjection iso, int zoom) {
            return new Camera(viewWidth() / zoom, Field.VIEW_HEIGHT / zoom, iso.width(), iso.height())
                    .placedAt(0, Field.BAR / zoom);
        }
amade-town-game · test/ui/game/TownGameTest.java
    @Test
    void theWheelZoomsAndKeepsTheTileUnderTheMouse() {
        TownGame.PlayScene play = game.play(1);
        Cell start = meadow(play, 4);
        assertEquals(2, play.zoom(), "the game begins close up");
        over(play, start.col() + 1, start.row());
        Cell before = play.hover();
        mouse.wheeled(1);
        steps(1);
        assertEquals(1, play.zoom());
        steps(1);
        assertEquals(before, play.hover());
        mouse.wheeled(-1);
        steps(2);
        assertEquals(2, play.zoom());
        assertEquals(before, play.hover());
        tap(KeyEvent.VK_Z);
        assertEquals(1, play.zoom());
    }

On a wide screen the view is wider, as in Sands and Colony: viewWidth() is 512 units plus whatever the canvas widened by, the camera is resized to it divided by the zoom in every step, and the zoom keeps the point under the mouse with the middle of the wider view. The cards of the mission choice, each with its map, are drawn for 640 and moved to the middle of the screen.

Because every sprite scales itself once to its size on the screen (chapter 42), the close view is not a blown-up picture: the houses are drawn again, at twice the resolution, the first time they are shown that large.

Step 11: Drawing the town

The ground

The ground is computed pixel by pixel, as in Sands and Colony, but here every tile is a diamond. Each floor (meadow, forest, water, lawn, paving, concrete, yard, rubble) is drawn as tile pictures of 34 × 18 units, a little larger than the diamond so neighbours overlap without a gap. Noise that repeats every 8 tiles makes the tiles seamless: tile (c, r) uses picture (c mod 8, r mod 8), so 64 pictures per floor cover the whole map without a visible pattern.

amade-town-game · ui/game/Pictures.java
    /**
     * A ground tile, worked out pixel by pixel at the size it has on the screen: each pixel of the sprite is
     * taken back to its point of the tile and coloured by {@code shader}. Pixels outside the diamond and its
     * overlap stay clear.
     */
    static Sprite floor(Shader shader) {
        return Sprite.drawn(FLOOR_WIDTH, FLOOR_HEIGHT, (g, w, h) -> {
            AffineTransform t = g.getTransform();
            double sx = t.getScaleX();
            double sy = t.getScaleY();
            int pw = Math.max(1, (int) Math.round(w * sx));
            int ph = Math.max(1, (int) Math.round(h * sy));
            BufferedImage image = new BufferedImage(pw, ph, BufferedImage.TYPE_INT_ARGB);
            double half = Field.TILE / (double) Field.TILE_WIDTH * 2;
            for (int py = 0; py < ph; py++) {
                for (int px = 0; px < pw; px++) {
                    double dx = (px + 0.5) / sx - FLOOR_X;
                    double dy = (py + 0.5) / sy - FLOOR_Y;
                    // The way back from the picture to the ground: x - y = dx, x + y = 2 dy, in world units.
                    double x = (dy * 2 + dx) / 2 * half;
                    double y = (dy * 2 - dx) / 2 * half;
                    if (x < -OVERLAP || y < -OVERLAP || x > Field.TILE + OVERLAP || y > Field.TILE + OVERLAP) continue;
                    image.setRGB(px, py, shader.argb(Math.clamp(x, 0, Field.TILE - 1e-6), Math.clamp(y, 0, Field.TILE - 1e-6)));
                }
            }
            g.transform(AffineTransform.getScaleInstance(1 / sx, 1 / sy));
            g.drawImage(image, 0, 0, null);
            g.setTransform(t);
        });
    }

On top come the roads, one picture for each combination of neighbours (16 for streets, 16 for the highway), river banks and corners of pavement. All shadows are one path, filled once under the ground transform, so where two shadows overlap the ground is not darker. A tree's shadow has its shape: the trunk's from the foot along +x, then a round crown's, or for a conifer a shadow that narrows to the point of its tip.

amade-town-game · ui/game/TownPainter.java
    /**
     * All shadows in one path, filled once: where two overlap, the ground is not darker, and nothing that
     * stands is drawn yet, so no shadow falls on a roof. Each is its footprint stretched along +x by its height.
     */
    private void shadows(Graphics2D g, Game game, IsoProjection iso, int[] v) {
        shadows.reset();
        treeShadows.reset();
        for (int row = v[1]; row <= v[3]; row++) {
            for (int col = v[0]; col <= v[2]; col++) {
                int level = game.lot(col, row);
                Zone zone = game.zone(col, row);
                if (zone != null && level > 0) {
                    for (double[] s : pictures.look(zone, level, variant(col, row)).shadows()) shadow(col * Field.TILE, row * Field.TILE, s);
                } else if (game.ground(col, row) == Ground.FOREST && game.buildingAt(col, row) == null) {
                    for (int i = 0; i < trees(col, row); i++) {
                        double[] t = tree(col, row, i);
                        treeShadows.append(Art.treeShadow(t[0], t[1], t[4] > 0, Pictures.TREE_SIZES[(int) t[2]]), false);
                    }
                }
                if (pylon(game, col, row)) shadow(col * Field.TILE, row * Field.TILE, new double[]{1.6, 1.6, 2.4, 2.4, Art.PYLON});
            }
        }
        for (Building b : game.buildings()) {
            if (b.col() + b.width() < v[0] || b.row() + b.height() < v[1] || b.col() > v[2] || b.row() > v[3]) continue;
            for (double[] s : pictures.look(b.type().work()).shadows()) shadow(b.col() * Field.TILE, b.row() * Field.TILE, s);
        }
        AffineTransform before = g.getTransform();
        g.transform(iso.ground(0));
        g.setColor(SHADOW);
        g.fill(shadows);
        g.setColor(TREE_SHADOW);
        g.fill(treeShadows);
        g.setTransform(before);
    }

    private void shadow(double ox, double oy, double[] s) {
        shadows.append(new Rectangle2D.Double(ox + s[0], oy + s[1], s[2] - s[0] + s[4] * Art.SHADOW, s[3] - s[1]), false);
    }

A ground that is painted only where it changed

The first benchmark of the town was too slow: 23 ms a frame at 1920 × 1200, of which the ground took 8 and the shadows 6. Each frame drew more than a thousand tile pictures and filled a path of thousands of shadows, though in most frames nothing of it had changed. The engine's new ScrollingLayer keeps such a layer as one picture of the view in screen pixels:

amade-game-engine · sprite/ScrollingLayer.java
public final class ScrollingLayer {
    /** Paints the world from {@code left}, {@code top}, {@code width} × {@code height} world units. */
    public interface Painter {
        void paint(Graphics2D g, double left, double top, double width, double height);
    }

    private final double viewWidth;
    private final double viewHeight;
    private BufferedImage image;
    private double scaleX;
    private double scaleY;
    private long key;
    private long pixelLeft;    // the world position of the picture's first column, in screen pixels
    private long pixelTop;
    private boolean valid;
    private int painted;
    private RenderingHints hints;

    /** A layer for a view of {@code viewWidth} × {@code viewHeight} world units. */
    public ScrollingLayer(double viewWidth, double viewHeight) {
        this.viewWidth = viewWidth;
        this.viewHeight = viewHeight;
    }

    /** Forgets the picture: the next frame paints the whole view. */
    public void invalidate() {
        valid = false;
    }

    /**
     * Draws the view whose top left world point is {@code left}, {@code top} at {@code screenLeft},
     * {@code screenTop} of the graphics, painting first what is missing.
     */
    public void draw(Graphics2D g, double screenLeft, double screenTop, double left, double top, long key,
                     Painter painter) {
        AffineTransform t = g.getTransform();
        int rotated = AffineTransform.TYPE_MASK_ROTATION | AffineTransform.TYPE_GENERAL_TRANSFORM;
        if ((t.getType() & rotated) != 0) {
            // Rotated: no picture of screen pixels fits, paint directly.
            Graphics2D direct = (Graphics2D) g.create();
            direct.translate(screenLeft - left, screenTop - top);
            direct.clip(new Rectangle2D.Double(left, top, viewWidth, viewHeight));
            painter.paint(direct, left, top, viewWidth, viewHeight);
            direct.dispose();
            return;
        }
        double sx = t.getScaleX();
        double sy = t.getScaleY();
        hints = g.getRenderingHints();   // the painter draws as the frame does: smooth or in pixel mode
        int w = (int) Math.ceil(viewWidth * sx) + 1;
        int h = (int) Math.ceil(viewHeight * sy) + 1;
        long pl = Math.round(left * sx);
        long pt = Math.round(top * sy);
        if (image == null || image.getWidth() != w || image.getHeight() != h || sx != scaleX || sy != scaleY) {
            image = create(g.getDeviceConfiguration(), w, h);
            scaleX = sx;
            scaleY = sy;
            valid = false;
        }
        long dx = pl - pixelLeft;
        long dy = pt - pixelTop;
        if (!valid || key != this.key || Math.abs(dx) >= w || Math.abs(dy) >= h) {
            pixelLeft = pl;
            pixelTop = pt;
            this.key = key;
            paint(painter, 0, 0, w, h);
            valid = true;
        } else if (dx != 0 || dy != 0) {
            Graphics2D ig = image.createGraphics();
            ig.copyArea(0, 0, w, h, (int) -dx, (int) -dy);
            ig.dispose();
            pixelLeft = pl;
            pixelTop = pt;
            if (dx > 0) paint(painter, w - (int) dx, 0, (int) dx, h);
            if (dx < 0) paint(painter, 0, 0, (int) -dx, h);
            if (dy > 0) paint(painter, 0, h - (int) dy, w, (int) dy);
            if (dy < 0) paint(painter, 0, 0, w, (int) -dy);
        }
        // The picture's first column is world pixel pixelLeft; where that is on the screen, rounded.
        long x = Math.round(t.getTranslateX() + (screenLeft - left) * sx + pixelLeft);
        long y = Math.round(t.getTranslateY() + (screenTop - top) * sy + pixelTop);
        g.setTransform(AffineTransform.getTranslateInstance(x, y));
        g.drawImage(image, 0, 0, null);
        g.setTransform(t);
    }

    /** A world position rounded to the screen pixel the layer puts it on, at the given scale. */
    public static double snap(double world, double scale) {
        return Math.round(world * scale) / scale;
    }

    /** How many regions were painted so far: for tests and for measuring what the layer saves. */
    public int painted() {
        return painted;
    }

    private void paint(Painter painter, int x, int y, int w, int h) {
        Graphics2D ig = image.createGraphics();
        ig.setRenderingHints(hints);
        ig.clipRect(x, y, w, h);
        ig.translate(-pixelLeft, -pixelTop);
        ig.scale(scaleX, scaleY);
        painter.paint(ig, (pixelLeft + x) / scaleX, (pixelTop + y) / scaleY, w / scaleX, h / scaleY);
        ig.dispose();
        painted++;
    }

    private static BufferedImage create(GraphicsConfiguration gc, int w, int h) {
        return gc != null ? gc.createCompatibleImage(w, h, Transparency.OPAQUE)
                : new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
    }
}

While the view stands still and the world does not change, a frame copies one opaque image. When the view moves, the picture is shifted by whole pixels with copyArea, and only the strips that came into view are painted. When the ground changes, the game passes another key, and the whole view is painted again. Town folds every tile's floor, road, line, zone, lot and building into that key: a few thousand multiplications per frame, much cheaper than finding out what changed.

amade-town-game · ui/game/TownPainter.java
    /** The town as the camera sees it, and the marks of what the player is doing. */
    void paint(Graphics2D g, Game game, IsoProjection iso, Camera camera, Traffic traffic, Effects effects,
               Marks marks, double alpha) {
        Shape clip = g.getClip();
        AffineTransform screen = g.getTransform();
        rectangle.setRect(camera.screenLeft(), camera.screenTop(), camera.viewWidth(), camera.viewHeight());
        g.clip(rectangle);
        // Whole screen pixels, as the ground layer is drawn, so that nothing standing on it slides.
        double left = ScrollingLayer.snap(camera.x(alpha), screen.getScaleX());
        double top = ScrollingLayer.snap(camera.y(alpha), screen.getScaleY());
        if (layer == null || layerWidth != camera.viewWidth() || layerHeight != camera.viewHeight()) {
            layerWidth = camera.viewWidth();
            layerHeight = camera.viewHeight();
            layer = new ScrollingLayer(layerWidth, layerHeight);
        }
        layer.draw(g, camera.screenLeft(), camera.screenTop(), left, top, groundKey(game),
                (lg, l, t, w, h) -> {
                    lg.setColor(BACKGROUND);
                    lg.fill(new Rectangle2D.Double(l, t, w, h));
                    int[] lv = iso.visible(l, t, w, h, MARGIN);
                    ground(lg, game, iso, lv, new View(l, t, l + w, t + h));
                    shadows(lg, game, iso, lv);
                });
        g.translate(camera.screenLeft() - left, camera.screenTop() - top);
        int[] v = iso.visible(left, top, camera.viewWidth(), camera.viewHeight(), MARGIN);
        View view = new View(left, top, left + camera.viewWidth(), top + camera.viewHeight());
        double time = effects.time();

        flat(g, game, iso, v, view, marks);
        things(g, game, iso, v, view, traffic, time);
        wires(g, game, iso, v, view);
        signs(g, game, iso, v, view, time);
        placing(g, iso, marks);
        effects.render(g, alpha);
        g.setTransform(screen);
        g.setClip(clip);
    }

    /**
     * What the ground layer shows, folded into one number: the town, and of every tile what decides its
     * floor, its road and the shadows on it. When it changes, the layer paints the whole view again.
     */
    static long groundKey(Game game) {
        long key = System.identityHashCode(game);
        for (int row = 0; row < game.height(); row++) {
            for (int col = 0; col < game.width(); col++) {
                Zone zone = game.zone(col, row);
                Building b = game.buildingAt(col, row);
                long tile = game.ground(col, row).ordinal()
                        | (game.road(col, row) ? 4 : 0) | (game.line(col, row) ? 8 : 0) | (game.rubble(col, row) ? 16 : 0)
                        | (zone == null ? 0 : zone.ordinal() + 1L) << 5 | (long) game.lot(col, row) << 8
                        | (b == null ? 0 : b.id() + 1L) << 12;
                key = key * 31 + tile;
            }
        }
        return key;
    }

The layer puts the view at whole screen pixels, so the rest of the town is drawn at ScrollingLayer.snapped positions too; otherwise a house would slide half a pixel against its lawn while the view scrolls. With the layer, a frame takes 12 ms at 1920 × 1200 while the view moves and less while it stands.

Lots and buildings

Every lot has four looks for each level of each zone, chosen by its tile so a street is not a row of copies: cottages and row houses with hedges, apartment blocks; a corner shop, shops with flats above them, an office block and a glass tower; workshops with tanks, a brick factory with its chimney, a works with a banded stack. All of them are drawn from boxes, gabled roofs, windows and awnings through the projection's transforms. The order inside a picture matters as much as between pictures: a chimney stands on the far slope of its roof, so it is drawn before the near slope, which hides its foot, and its top again after the ridge's line.

A lot's front faces +y, but its street may run along any side. A car parked in the garden, or a lorry in the yard, would then stand behind a fence or between two houses with no way out. So the lots that park one are drawn twice, with the car and a drive through a gap in the fence, and without, and the painter takes the first only where a road runs along the front.

amade-town-game · ui/game/Lots.java
    private static void home(Graphics2D g, IsoProjection p, int level, int variant, boolean parks) {
        double dx = (variant % 3 - 1) * 0.6;
        boolean shutters = variant % 2 == 0;
        // The path from the door to the street, and a pool or a bed of flowers behind.
        Art.with(g, p.ground(0), f -> {
            f.setColor(new Color(0xC8C0AC));
            f.fill(Art.rect(7 + dx, 10, 2, 6));
            if (parks) {
                // The drive from the street to the car, through a gap in the fence.
                f.setColor(new Color(0xA8A49C));
                f.fill(Art.rect(11.6, 10.6, 3.6, 5.4));
            }
            if (level == 1 && variant == 2) {
                f.setColor(new Color(0xE8E4DA));
                f.fill(new RoundRectangle2D.Double(12.2, 1.6, 2.8, 5.8, 1, 1));
                f.setPaint(new GradientPaint(12.5f, 2, new Color(0x3A9AC8), 15, 7, new Color(0x8AD8F0)));
                f.fill(Art.rect(12.5, 2, 2.2, 5));
            }
        });
        if (level == 1) {
            Art.gabled(g, p, 3 + dx, 3.5, 12.5 + dx, 10, 6, 4.5, wall(variant, 0), roof(variant, 1), variant % 3 == 0, shutters);
        } else if (level == 2) {
            if (variant % 2 == 0) {
                Art.gabled(g, p, 1.6, 2.5, 14.4, 10.5, 10, 5.5, wall(variant, 2), roof(variant, 0), true, shutters);
            } else {
                Art.gabled(g, p, 1.4, 3, 7.8, 10.5, 9, 5, wall(variant, 1), roof(variant, 2), false, false);
                Art.gabled(g, p, 8.2, 3, 14.6, 10.5, 9, 5, wall(variant, 3), roof(variant, 2), false, true);
            }
        } else {
            Art.building(g, p, 2, 2, 14, 13, apartment(variant), wall(variant, 4), 0x86847E, Art.Style.APARTMENT,
                    40 + variant);
        }
        // A hedge along the side, a car on the drive or a tree in the garden, the fence at the front.
        Art.box(g, p, 15.2, 1, 16, 15, 0, 2, 0x3E7A2E, 0x2E5E22, 0x24481A);
        if (parks) Art.car(g, p, 13.4, 13, 3, Art.CAR_COLOURS[(variant * 3 + level) % Art.CAR_COLOURS.length]);
        if (variant == 0 || variant == 3 || level == 3) {
            Art.tree(g, p, 3, 13, false, level == 3 ? 0.42 : 0.38, variant * 7L + level);
        }
        if (level == 3) {
            Art.box(g, p, 1, 14.4, 7, 15.4, 0, 1.6, 0x3E7A2E, 0x2E5E22, 0x24481A);
            Art.box(g, p, 9, 14.4, 15, 15.4, 0, 1.6, 0x3E7A2E, 0x2E5E22, 0x24481A);
        } else {
            Art.garden(g, p, 0.8, 6.4 + dx, 15.2, false);
            Art.garden(g, p, 9.6 + dx, parks ? 11.4 : 15, 15.2, false);
        }
    }

Power lines are pylons with wires that sag between them. A pylon on every tile of a long line looked like a fence, so a straight run gets one on every other tile, and the wires span two tiles:

amade-town-game · ui/game/TownPainter.java
    /**
     * Whether a tile of a power line carries a pylon: at its ends, corners and branches, and along a straight
     * run only on every other tile, so a long line reads as a line and not as a fence.
     */
    static boolean pylon(Game game, int col, int row) {
        if (!game.line(col, row)) return false;
        boolean x = game.line(col - 1, row) && game.line(col + 1, row);
        boolean y = game.line(col, row - 1) && game.line(col, row + 1);
        boolean sideX = game.line(col - 1, row) || game.line(col + 1, row);
        boolean sideY = game.line(col, row - 1) || game.line(col, row + 1);
        if (x && !sideY) return col % 2 == 0;
        if (y && !sideX) return row % 2 == 0;
        return true;
    }

    /** The wires from each pylon to the next one along x and along y, over the tiles between without one. */
    private void wires(Graphics2D g, Game game, IsoProjection iso, int[] v, View view) {
        for (int row = v[1] - 2; row <= v[3]; row++) {
            for (int col = v[0] - 2; col <= v[2]; col++) {
                if (!pylon(game, col, row)) continue;
                double x = col * Field.TILE + 2;
                double y = row * Field.TILE + 2;
                int c = col + 1;
                while (game.line(c, row) && !pylon(game, c, row)) c++;
                if (game.line(c, row)) Art.wires(g, iso, x, y, x + (c - col) * Field.TILE, y, true);
                int r = row + 1;
                while (game.line(col, r) && !pylon(game, col, r)) r++;
                if (game.line(col, r)) Art.wires(g, iso, x, y, x, y + (r - row) * Field.TILE, false);
            }
        }
    }

Step 12: Everything else

  • The minimap. The engine's DiamondMinimap: the picture of the projection, scaled down, so it is turned like the view, the camera's view is a plain box on it, and a click on it is a point of the picture to move the camera to. One pixel per tile, drawn through a transform that turns each pixel into a little diamond.
  • The panel. Nine tools with little isometric pictures, the demand for each zone as three bars, and a box that explains the tool, the tile under the mouse, the budget with the tax rate, or the mission.
  • Overlays. O colours the map by power, water, land value or pollution.
  • Events. Grew, declined, built, laid, fire, truck, doused, burned, milestone, advice, month, over. Effects turns them into dust, smoke from chimneys, steam from the cooling tower, sparks and sounds; the message line shows the ones the player must know about.
  • Music. A street tune in F major, and short ones for winning and losing.
  • Scenes. Title (over a town the computer builds), the choice of a mission (each card with a diamond preview of its map), the mission, the pause (continue, save, load, leave), the result with people, jobs, happiness, buildings and fires. After a win, C keeps building the same town.

The title over the computer's town The choice of a mission

The title (left), over a town the computer builds, and the choice of a mission (right): each with a diamond preview of its map, its goal, the share of happy people, the years, and whether there are fires.
amade-town-game · ui/game/Effects.java
    /** Shows what happened in the last step, and plays it if {@code audible}, as far as {@code camera} sees it. */
    void react(Game game, IsoProjection iso, Camera camera, Traffic traffic, boolean audible) {
        for (Event event : game.events()) {
            switch (event) {
                case Event.Laid(String what, int tiles, int cost) -> {
                    if (!audible) break;
                    if (what.equals("road")) play(sounds.paved);
                    else if (what.equals("line")) play(sounds.wired);
                    else if (what.equals("bulldoze")) play(sounds.bulldozed);
                    else play(sounds.zoned);
                }
                case Event.Built(Building b) -> {
                    double x = b.centerCol() * Field.TILE;
                    double y = b.centerRow() * Field.TILE;
                    dust(iso, b.col(), b.row(), b.width(), b.height(), 18);
                    say(iso.pictureX(x, y), iso.pictureY(x, y, 24), "-$" + b.type().cost(), Hud.RED);
                    if (audible) play(sounds.built);
                }
                case Event.Grew(int col, int row, int level) -> {
                    dust(iso, col, row, 1, 1, 5);
                    if (heard(iso, camera, audible, col, row)) playSeldom(sounds.grew, GROW_GAP);
                }
                case Event.Declined(int col, int row, int level) -> dust(iso, col, row, 1, 1, 4);
                case Event.Fire(int col, int row) -> {
                    if (audible) play(sounds.alarm);
                }
                case Event.Truck(Building station, int col, int row) -> {
                    traffic.sendTruck(station, col, row);
                    if (audible) play(sounds.siren);
                }
                case Event.Doused(int col, int row) -> {
                    double x = (col + 0.5) * Field.TILE;
                    double y = (row + 0.5) * Field.TILE;
                    sparkle(iso.pictureX(x, y), iso.pictureY(x, y, 8), 20, WATER, 30, 0);
                    puff(iso.pictureX(x, y), iso.pictureY(x, y, 12), 3, 2);
                    if (audible) play(sounds.doused);
                }
                case Event.Burned(int col, int row) -> {
                    dust(iso, col, row, 1, 1, 20);
                    double x = (col + 0.5) * Field.TILE;
                    double y = (row + 0.5) * Field.TILE;
                    for (int i = 0; i < 4; i++) puff(iso.pictureX(x, y) + (random.nextDouble() - 0.5) * 16, iso.pictureY(x, y, 6), 3, 1);
                    if (audible) play(sounds.collapse);
                }
                case Event.Milestone(int people) -> {
                    if (audible) play(sounds.milestone);
                }
                case Event.Advice(Event.Hint hint) -> {
                    if (audible) play(sounds.advice);
                }
                case Event.Over(boolean won) -> {
                    if (audible) mixer.playMusic(won ? music.won : music.lost, false);
                }
                case Event.Month month -> {
                }
            }
        }
    }

What moved into the engine

Package What For
iso IsoProjection the world turned into an isometric picture and the mouse back, transforms for the ground and the walls, the tiles in view
iso DepthSort the order of drawing for things of any size, from their footprints
iso DiamondMinimap the minimap of an isometric map, turned like the view
tile SupplyGrid a network of tiles that carries power or water from sources to loads, nearest first
tile Coverage how many places reach a tile, and how strongly, in circles
sprite ScrollingLayer a layer that is painted only where the view moved or the world changed
input Mouse.wheel the notches the wheel turned

None of them knows of zones or taxes. IsoProjection and DepthSort work for any isometric game, a strategy game or a role-playing game; SupplyGrid carries water through pipes as well as power; ScrollingLayer keeps any ground that rarely changes, flat or isometric.

Exercises

  1. Three zooms. Add a zoom of 3 for a really close look. What does the ground layer cost then, and why does the benchmark of zoom 2 already tell you?
  2. Turning the view. Many city builders turn the map by 90°. What must change in IsoProjection, and what in DepthSort? Which of the game's pictures can stay as they are?
  3. Water pipes. Replace the towers' circles by pipes under the streets, carried by a second SupplyGrid. Where does the water network differ from the power network?
  4. Density by the player. Let the player zone light or dense homes, and let dense homes grow only near a school and with high land value. What does the rule of growth need, and what the panel?
  5. Commuters. Count for each home the jobs within a distance along the roads, with a DistanceMap from the job lots. Let demand for homes depend on jobs nearby instead of jobs anywhere.
  6. A smarter key. The ground's key is worked out over the whole map in every frame. Give the game a counter that goes up whenever a tile changes, and use it as the key. What must every method that changes a tile do then?