Skip to content

46. Paths, Groups and Decisions

How things find their way, and how the computer decides what to do. The searches of this chapter work on any grid the game describes with one function, "may this tile be entered?"; the decisions are small tools that keep a character's modes in order, give the computer a picture of the battle, and let consequences spread in a fixed order. Choosing units with the mouse is here too, because it is the start of every order in a strategy game.

What may be entered: Passable

Package path · introduced in 25. Maze · source: Passable

Every search asks the game, not the map, whether a tile can be entered: (col, row) -> boolean. The same map gives different answers to different walkers. A ghost flying home may pass the door of its house, which is a wall to everyone else; a vehicle of Sands drives on sand and rock, the burrower only on sand; a tower that is only planned closes its tile for the question "would the way stay open?". So the searches never need to know what a tile is.

The question Use Cost
The way from everywhere to one place, in four directions: the ghosts' way home, the nearest safe tile DistanceMap One breadth-first search for every tile at once
The cheapest way from here to there, with costs per tile: a computer player that avoids danger AStar One search per question, looking first towards the goal
The way from everywhere to one place, in eight directions, for many units walking freely FlowField One search for every tile; each unit only looks up an arrow
Many such goals, one per order FieldCache A flow field per goal, kept until the map changes
The next road for a ware on a network of roads and flags RoadNetwork.next One search from the goal over the flags, kept until the network changes
Where each unit of a group stops Formation.slots and assign A small search around the target
May a unit drive straight from here to there? Formation.clearLine One walk along the line

The rule of thumb: when many things want to reach one place, search once from that place, outwards (DistanceMap, FlowField). When one thing wants to reach a place of its own, search from the thing towards the goal (AStar).

One goal, every tile: DistanceMap

Package path · introduced in 25. Maze · source: DistanceMap

What it is for. For every tile of a map, the number of steps to the nearest of one or more goals, found by breadth-first search: the goals have distance 0, their open neighbours 1, the neighbours of those 2, a wave that spreads through the corridors until it has filled them. downhill(col, row) is then the first step on a shortest way from anywhere. Search from where one stands instead, and the distances of the dots say which is the nearest of many things.

How it works. A queue in an int array, four directions in the order of Direction, steps over an edge of the map arriving on the far side as in TileMap.step. Of equally good steps downhill, the first direction wins, so the result never depends on chance. visited() is what the search cost. Maze sends its eaten ghosts home this way, and Blast's computer players find the nearest tile no flame will reach (chapter 26). The map need not be a level of tiles: Hover Cab's autopilot lays a grid of possible cab positions four units apart over a free-form scene, marks each open or not with a pixel mask, and flies down the distances to its pad (chapter 28).

amade-game-engine · path/DistanceMap.java
public final class DistanceMap {
    /** The distance of a tile no way leads to. */
    public static final int UNREACHABLE = Integer.MAX_VALUE;

    private final int width;
    private final int height;
    private final int[] distance;
    private int visited;

    private DistanceMap(int width, int height) {
        this.width = width;
        this.height = height;
        this.distance = new int[width * height];
        Arrays.fill(distance, UNREACHABLE);
    }

    /** The distances to the nearest of {@code goals} over the tiles {@code open} lets one enter. */
    public static DistanceMap search(TileMap<?> map, Passable open, Cell... goals) {
        DistanceMap result = new DistanceMap(map.width(), map.height());
        int[] queue = new int[map.width() * map.height()];
        int head = 0;
        int tail = 0;
        for (Cell goal : goals) {
            int index = goal.row() * map.width() + goal.col();
            if (result.distance[index] == UNREACHABLE) {
                result.distance[index] = 0;
                queue[tail++] = index;
            }
        }
        while (head < tail) {
            int index = queue[head++];
            int col = index % map.width();
            int row = index / map.width();
            for (Direction d : Direction.values()) {
                Cell next = map.step(col, row, d);
                int n = next.row() * map.width() + next.col();
                if (result.distance[n] != UNREACHABLE || !open.test(next.col(), next.row())) continue;
                result.distance[n] = result.distance[index] + 1;
                queue[tail++] = n;
            }
        }
        result.visited = tail;
        return result;
    }

The cheapest way for one: AStar

Package path · introduced in 25. Maze · source: AStar

What it is for. The cheapest way from one tile to another when tiles cost differently to enter: a way past a ghost can cost more than a longer way round it. AStar.find(map, from, to, cost) returns a Path: the cells, the steps, the cost and how many tiles the search looked at; found() and first() for the step to take now.

How it works. Breadth-first search spreads evenly in all directions and counts steps. A* adds a cost per tile and looks first at the tiles that seem to lead to the goal: of all tiles at its border it takes the one with the smallest g + h, the cost so far plus a guess of the cost still to come. The guess is the number of steps without walls, across and along, the short way round over the edges. As long as every tile costs at least 1, the guess is never too high, and the way found is always one of the cheapest. A tile one cannot enter costs Double.POSITIVE_INFINITY.

amade-game-engine · path/AStar.java
    public static Path find(TileMap<?> map, Cell from, Cell to, Cost cost) {
        int width = map.width();
        int size = width * map.height();
        double[] g = new double[size];
        Arrays.fill(g, Double.POSITIVE_INFINITY);
        int[] cameFrom = new int[size];
        Direction[] cameBy = new Direction[size];
        boolean[] closed = new boolean[size];
        // The border of the search, cheapest g + h first; of equal ones, the one nearer the goal.
        PriorityQueue<Open> border = new PriorityQueue<>((a, b) -> a.f() != b.f()
                ? Double.compare(a.f(), b.f()) : Double.compare(a.h(), b.h()));
        int start = from.row() * width + from.col();
        int goal = to.row() * width + to.col();
        g[start] = 0;
        border.add(new Open(start, guess(map, from, to), guess(map, from, to)));
        int visited = 0;
        while (!border.isEmpty()) {
            int index = border.poll().index();
            if (closed[index]) continue;            // an older, dearer entry of a tile seen again
            closed[index] = true;
            visited++;
            if (index == goal) return path(map, cameFrom, cameBy, start, goal, g[goal], visited);
            int col = index % width;
            int row = index / width;
            for (Direction d : Direction.values()) {
                Cell next = map.step(col, row, d);
                int n = next.row() * width + next.col();
                if (closed[n]) continue;
                double through = g[index] + cost.enter(next.col(), next.row());
                if (through >= g[n]) continue;      // no better than a way known already (or a wall)
                g[n] = through;
                cameFrom[n] = index;
                cameBy[n] = d;
                double h = guess(map, next, to);
                border.add(new Open(n, through + h, h));
            }
        }
        return new Path(List.of(), List.of(), Double.POSITIVE_INFINITY, visited);
    }

    /** The steps without walls, across and along, the short way round over the edges. Never too many. */
    static double guess(TileMap<?> map, Cell a, Cell b) {
        int dx = Math.abs(a.col() - b.col());
        int dy = Math.abs(a.row() - b.row());
        return Math.min(dx, map.width() - dx) + Math.min(dy, map.height() - dy);
    }

The two searches work well together. The pilot of Maze, the computer player, plans again on every new tile: a DistanceMap from where it stands finds the nearest dot, and A* finds the way there, with costs that rise near the ghosts (chapter 25).

Many units, one goal: FlowField

Package path · introduced in 30. Towers · source: FlowField

What it is for. For every tile of a map, which way leads to the goal: a field of arrows that any number of units follow at once. A hundred orcs cost one search, not a hundred, and a new tower costs one new search, however many orcs must find a new way round it. The field also answers whether every tile still reaches the goal, which is how Towers refuses a tower that would close the way (chapter 30).

How it works. Units walk in eight directions. A step to a side costs 10, a diagonal one 14 (√2 × 10), so the distances are those of a real walk and not of a chess king. Because the steps differ, the search is Dijkstra's, with a queue sorted by distance, a binary heap of longs with the distance above and the tile below. A diagonal step is allowed only where both tiles beside it are open: no unit squeezes between two walls that touch at a corner. Unlike DistanceMap, a flow field has edges.

amade-game-engine · path/FlowField.java
    /**
     * Dijkstra's search: the tile nearest to a goal that has not been settled yet is settled next, and its
     * neighbours learn a shorter distance through it. Breadth-first search (chapter 25) is the same with
     * equal steps; here diagonal steps cost more, so the queue must be sorted by distance.
     */
    private void search(Passable open, Cell... goals) {
        Heap queue = new Heap(width * height);
        for (Cell goal : goals) {
            if (!contains(goal.col(), goal.row())) throw new IllegalArgumentException(goal + " is outside the field");
            int index = goal.row() * width + goal.col();
            distance[index] = 0;
            queue.push(0, index);
        }
        while (!queue.isEmpty()) {
            long entry = queue.pop();
            int index = (int) entry;
            int here = (int) (entry >>> 32);
            if (here > distance[index]) continue;     // settled already by a shorter way
            visited++;
            int col = index % width;
            int row = index / width;
            for (int d = 0; d < 8; d++) {
                int c = col + DX[d];
                int r = row + DY[d];
                if (!step(open, col, row, d)) continue;
                int n = r * width + c;
                int through = here + (d < 4 ? STRAIGHT : DIAGONAL);
                if (through < distance[n]) {
                    distance[n] = through;
                    // The way from n leads back the way the search came: to the opposite neighbour.
                    next[n] = (byte) opposite(d);
                    queue.push(through, n);
                }
            }
        }
    }

    /** Whether a unit may step from a tile to its neighbour {@code d}: open, and no corner cut. */
    private boolean step(Passable open, int col, int row, int d) {
        int c = col + DX[d];
        int r = row + DY[d];
        if (!contains(c, r) || !open.test(c, r)) return false;
        if (d < 4) return true;
        return open.test(c, row) && open.test(col, r);
    }

Walking it. heading(x, y), positions in tiles (2.5 is the middle of column 2), is the direction, length 1, towards the middle of the next tile; the zero vector at the goal's middle or where no way leads. A unit that walks this way never touches a closed tile, and that holds for any point of the next tile, so a unit may aim a little to the side of the middle to walk beside the others instead of behind them (chapter 30).

Method What it does
FlowField.toward(width, height, open, goals...) The field towards the nearest goal
heading(x, y) Where a unit at x, y (in tiles) walks
next(col, row) The next tile on a shortest way; null at a goal and where no way leads
distance(col, row), reaches(col, row), isGoal(col, row) The cost, 10 per tile; whether a way leads to a goal
visited() How many tiles the search settled
Test: a wall in the way
amade-game-engine · test/path/FlowFieldTest.java
    @Test
    void aNewWallCanCutTheWayAndTheFieldSaysSo() {
        TileMap<Character> map = map("""
                row ......
                row XX.XXX
                row ......
                """);
        Cell goal = new Cell(5, 2);
        assertTrue(field(map, goal).reaches(0, 0));
        map.set(2, 1, 'X');      // a tower in the only gap
        FlowField cut = field(map, goal);
        assertFalse(cut.reaches(0, 0));
        assertEquals(FlowField.UNREACHABLE, cut.distance(0, 0));
        assertEquals(Vector.ZERO, cut.heading(0.5, 0.5), "nowhere to go");
        assertTrue(cut.reaches(0, 2));
    }

Many fields: FieldCache

Package path · introduced in 31. Sands · source: FieldCache

In a strategy game every order has a goal of its own. toward(goal) gives the field towards a tile, from the cache or searched now: a group shares one field, two groups sent to the same place share it too, and the harvesters, all driving home to the same refinery, share theirs for the whole game. When a building goes up or comes down, invalidate() forgets every field, and each is searched again when it is next asked for. Only the 32 fields used last are kept, an access-ordered LinkedHashMap that drops its eldest entry, because a field of a map of 64 × 64 is 20 KB. searches() counts what the cache did not save: a test proves that six tanks with one order cost one search.

Roads between flags: RoadNetwork

Package road · introduced in 32. Colony · source: RoadNetwork

What it is for. Roads that the player lays from flag to flag, and the way of a ware over them. A road joins two flags and runs over the cells between; a new flag on a road makes two roads of it. A ware at a flag asks which road it takes next to reach the flag of the house it is meant for.

When to choose it. When things travel on a network the player builds, in hops between fixed points: the roads of a settlement, rails between stations, pipes between pumps. Things that walk freely over the map use the searches above.

How it works. The flags are the nodes of a graph and the roads its edges, each as long as its steps. The way to a goal is found by Dijkstra's search from the goal outwards, once, which gives every flag at once its distance and the road it takes: next is then a lookup. The searches are kept for the 64 goals used most recently and forgotten when a flag or road is added or removed. Ties go to the lower number, so the answer never depends on the order of a hash map, and a loaded game goes on exactly. Flags and roads may be added with a given number, and restore sets the counters, for loading.

amade-game-engine · road/RoadNetwork.java
    /**
     * The road a ware at flag {@code from} takes next on a shortest way to flag {@code to}; {@code null}
     * when it is there already or no way leads there.
     */
    public Road next(int from, int to) {
        if (from == to) return null;
        return search(to).next().get(from);
    }

    /** Steps on the shortest way from one flag to another; {@link Integer#MAX_VALUE} without a way. */
    public int distance(int from, int to) {
        if (!flags.containsKey(from) || !flags.containsKey(to)) return Integer.MAX_VALUE;
        return search(to).distance().getOrDefault(from, Integer.MAX_VALUE);
    }

    public boolean connected(int from, int to) {
        return distance(from, to) != Integer.MAX_VALUE;
    }

    /** Dijkstra's search from the goal outwards, kept until the network changes. */
    private Search search(int goal) {
        Search search = searches.get(goal);
        if (search != null) return search;
        Map<Integer, Integer> distance = new HashMap<>();
        Map<Integer, Road> next = new HashMap<>();
        // Ties go to the lower flag number, so the answer never depends on the order of a hash map.
        PriorityQueue<int[]> open = new PriorityQueue<>((a, b) -> a[0] != b[0] ? Integer.compare(a[0], b[0])
                : Integer.compare(a[1], b[1]));
        if (flags.containsKey(goal)) {
            distance.put(goal, 0);
            open.add(new int[]{0, goal});
        }
        while (!open.isEmpty()) {
            int[] head = open.poll();
            int flag = head[1];
            if (head[0] > distance.get(flag)) continue;     // an older, longer entry of a flag settled since
            for (Road road : touching.get(flag)) {
                int other = road.other(flag);
                int d = head[0] + road.length();
                Integer known = distance.get(other);
                if (known == null || d < known || d == known && road.id() < next.get(other).id()) {
                    distance.put(other, d);
                    next.put(other, road);
                    open.add(new int[]{d, other});
                }
            }
        }
        search = new Search(distance, next);
        searches.put(goal, search);
        searched++;
        return search;
    }
Method What it does
addFlag(cell), removeFlag(flag) A new flag and its number; removes one with its roads
connect(from, to, cells), remove(road) A road over the cells from one flag to another, checked; removes one
split(cell) A flag in the middle of a road, two roads of one; the new flag
next(from, to), distance(from, to), connected(from, to), way(from, to) The next road on the shortest way, its length, whether there is one, all of its roads
flagAt(cell), roadAt(cell), cell(flag), road(id) What is where, for the mouse and the drawing
flags(), roads(), roads(flag) Everything, and the roads at one flag
version(), searches() Counts changes for the drawing; counts searches for tests
nextFlag(), nextRoad(), restore(nextFlag, nextRoad) The counters, for a save file

Groups on the move: Formation

Package path · introduced in 31. Sands · source: Formation

What it is for. Where a group of units stands when it arrives. Five tanks sent to one tile cannot all stand on it: each needs a slot of its own near the tile the player clicked, and each must take the slot that suits it, or they cross each other's way at the end and jostle.

How it works. In three static methods:

  • slots(target, count, width, height, open): the open tiles nearest the target, counted in steps from it by a breadth-first search in eight directions, so a slot never lies behind a wall. A closed target, such as a click on a rock, is replaced by the nearest open tile.
  • assign(xs, ys, slots): which slot each unit takes. The closest pair of unit and slot first, then the closest of what is left. That is the greedy assignment, not the best one there is (the Hungarian method), but it is close for groups of a dozen, it is a few lines, and the paths it gives seldom cross.
  • clearLine(x0, y0, x1, y1, width, height, open): whether a unit can drive straight from one point to another. It walks the line through the grid tile by tile, as a ray is traced through a grid, and refuses to cut a corner. A unit follows the group's flow field until it is near the goal and then drives straight to its own slot (chapter 31).
amade-game-engine · path/Formation.java
    /**
     * Which slot each unit takes: {@code result[unit]} is an index into {@code slots}, or -1 for a unit
     * left without one when there are fewer slots than units. Positions are in tiles, 2.5 being the middle
     * of column 2. The closest pair of unit and slot is taken first, then the closest of the rest.
     */
    public static int[] assign(double[] xs, double[] ys, List<Cell> slots) {
        int units = xs.length;
        int[] slotOf = new int[units];
        Arrays.fill(slotOf, -1);
        int pairs = units * slots.size();
        double[] distance = new double[pairs];
        for (int u = 0; u < units; u++) {
            for (int s = 0; s < slots.size(); s++) {
                int pair = u * slots.size() + s;
                distance[pair] = Math.hypot(slots.get(s).col() + 0.5 - xs[u], slots.get(s).row() + 0.5 - ys[u]);
            }
        }
        // Sort the pairs by distance; ties keep the order of the units, then of the slots.
        Integer[] sorted = new Integer[pairs];
        for (int i = 0; i < pairs; i++) sorted[i] = i;
        Arrays.sort(sorted, (a, b) -> {
            int nearer = Double.compare(distance[a], distance[b]);
            return nearer != 0 ? nearer : Integer.compare(a, b);
        });
        boolean[] taken = new boolean[slots.size()];
        int left = Math.min(units, slots.size());
        for (int i = 0; i < pairs && left > 0; i++) {
            int u = sorted[i] / slots.size();
            int s = sorted[i] % slots.size();
            if (slotOf[u] >= 0 || taken[s]) continue;
            slotOf[u] = s;
            taken[s] = true;
            left--;
        }
        return slotOf;
    }

Slots keep a group apart at the end; on the way, the game pushes overlapping units apart itself, with a Grid.

Choosing units: Selection and BoxSelect

Package select · introduced in 31. Sands · source: Selection, BoxSelect

What it is for. The conventions of strategy games since the nineties. A click picks one thing, a drag opens a box and picks all in it, Shift adds to the choice (and a Shift-click on something already picked takes it out), Ctrl and a number keep the choice as a group that the number alone calls back. Selection keeps what is picked and the nine groups; BoxSelect tells a click from a box.

How it works. BoxSelect is arithmetic in screen or world coordinates: press, move and release with the mouse's position, in the update step. A hand that clicks moves the mouse a little, so the press only becomes a box after more than 4 units of movement (SLOP). release returns a sealed Result, a Click or a Box with sorted edges, so the game's switch must handle both, and Java checks that it does. While dragging(), the game draws the box.

amade-game-engine · select/BoxSelect.java
public final class BoxSelect {
    /** How far the mouse may move between press and release and still be a click, in logical units. */
    public static final double SLOP = 4;

    /** What a press and a release came to. */
    public sealed interface Result {
    }

    /** The button went down and up on nearly the same spot. */
    public record Click(double x, double y) implements Result {
    }

    /** A box dragged open, its edges sorted: left is less than right, top less than bottom. */
    public record Box(double left, double top, double right, double bottom) implements Result {
        public boolean contains(double x, double y) {
            return x >= left && x <= right && y >= top && y <= bottom;
        }
    }

    private boolean pressed;
    private boolean dragging;
    private double startX;
    private double startY;
    private double x;
    private double y;

    /** The left button went down here. */
    public void press(double px, double py) {
        pressed = true;
        dragging = false;
        startX = x = px;
        startY = y = py;
    }

    /** The mouse is here now; once it has gone further than {@link #SLOP} from the press, it drags a box. */
    public void move(double px, double py) {
        if (!pressed || Double.isNaN(px)) return;
        x = px;
        y = py;
        if (Math.abs(x - startX) > SLOP || Math.abs(y - startY) > SLOP) dragging = true;
    }

    /** The button came up here: a click or a box; {@code null} if it was never pressed. */
    public Result release(double px, double py) {
        if (!pressed) return null;
        move(px, py);
        pressed = false;
        if (!dragging) return new Click(startX, startY);
        dragging = false;
        return box();
    }

Selection keeps the picked things in a LinkedHashSet: none twice, in the order of picking, so the panel shows the first picked first. The game decides what may be picked: in Sands only the player's own vehicles, while a click on an enemy only shows it.

Method What it does
pick(things, adding), click(thing, adding), clear() Picks; with adding, as with Shift
store(number), recall(number), group(number) Groups 1 to 9
keepOnly(alive) Takes the destroyed out of the selection and every group: call it after every step
all(), contains, size, isEmpty What is picked
version() Changes with the selection: a panel redraws only then

One state at a time: StateMachine

Package ai · introduced in 25. Maze · source: StateMachine

What it is for. Something that is always in exactly one of a few states, an enum, and changes between them only along ways allowed in advance: a ghost that waits, hunts, flees or flies home; a door; the phases of a round.

When to choose it. When the rules of a thing depend on its mode and the modes are few. The table of allowed changes is the point: the classic bug is a combination nobody meant, a ghost eaten while it already flies home, and with a table such a change is an IllegalStateException the moment it happens, naming both states, instead of a strange sight minutes later.

How it works. allow(from, to...) fills the table, to(state) changes and calls the onChange listener with the old and the new state (to start a sound, turn round, reset something), update(seconds) lets time pass and seconds() says how long the thing has been in its state. reset(state) goes back without asking the table, for a new level.

amade-game-engine · ai/StateMachine.java
public final class StateMachine<S extends Enum<S>> {
    private final Map<S, EnumSet<S>> allowed;
    private S state;
    private S previous;
    private double seconds;
    private BiConsumer<S, S> onChange = (from, to) -> { };

    public StateMachine(S initial) {
        this.allowed = new EnumMap<>(initial.getDeclaringClass());
        this.state = initial;
        this.previous = initial;
    }

    /** Allows the changes from {@code from} to each of {@code to}. */
    @SafeVarargs
    public final StateMachine<S> allow(S from, S... to) {
        EnumSet<S> targets = allowed.computeIfAbsent(from, s -> EnumSet.noneOf(s.getDeclaringClass()));
        for (S s : to) targets.add(s);
        return this;
    }

    /** Called after each change with the old and the new state: to start a sound, turn round, reset a timer. */
    public StateMachine<S> onChange(BiConsumer<S, S> listener) {
        this.onChange = listener;
        return this;
    }

    public boolean canChange(S to) {
        EnumSet<S> targets = allowed.get(state);
        return targets != null && targets.contains(to);
    }

    /** Changes to {@code to}, which must be allowed from the current state. */
    public void to(S to) {
        if (!canChange(to)) throw new IllegalStateException("not allowed: " + state + " → " + to);
        previous = state;
        state = to;
        seconds = 0;
        onChange.accept(previous, to);
    }

    /** Lets time pass in the current state. */
    public void update(double seconds) {
        this.seconds += seconds;
    }

Strength on the map: InfluenceMap

Package ai · introduced in 31. Sands · source: InfluenceMap

What it is for. The computer opponent's picture of the battle. Every unit and building spreads its strength around it, full on its own tile and less further away, nothing beyond its reach; added up, the map shows where a side is strong and where it is weak, without looking at single units. With one per side, an opponent's questions become arithmetic: where is the enemy strongest (strongest())? Which of its buildings is defended worst (weakest(candidates))? Is this crystal field safe for a harvester (at(col, row) is 0)?

How it works. clear(), then add(col, row, strength, reach) for each thing: the strength falls in a straight line to nothing at reach tiles, as the crow flies. Sands builds the map anew each time the computer chooses whom to attack, with the damage per second of each weapon as its strength. What an influence map decides can be seen: draw it over the map, as Sands does with F3, and the computer's choice is no longer a mystery.

Events that cause events: EventQueue

Package event · introduced in 26. Blast · source: EventQueue

What it is for. Consequences that spread: a bomb explodes, its flames reach a second bomb, which explodes too, and its flames reach a player. Each event is handled by one method of the rules, and the handler may post further events; process(handler) handles them all, in the order they were posted, until none is left.

When to choose it. When one event can cause others in the same step, and the order matters. Games in which an event only reports what happened (a brick broke, play a sound) need no queue: the rules collect such events in a list for the user interface, as every game does from Paddle on.

How it works. The order is first in, first out, as in breadth-first search: everything the first event caused is handled before anything those events caused. A chain reaction runs in waves, the bombs hit by the first blast before those hit by theirs, and the order never depends on how deep a handler happens to call itself (chapter 26). Every handled event stays in happened() until clear(), so the user interface can show and play what happened in a step. A chain without end would hang the game; after limit events in one process, 10,000 by default, the queue gives up with an exception that names the last event.

amade-game-engine · event/EventQueue.java
public final class EventQueue<E> {
    private final ArrayDeque<E> pending = new ArrayDeque<>();
    private final List<E> happened = new ArrayList<>();
    private final int limit;

    /** @param limit most events one {@link #process} may handle */
    public EventQueue(int limit) {
        if (limit < 1) throw new IllegalArgumentException("limit must be positive: " + limit);
        this.limit = limit;
    }

    public EventQueue() {
        this(10_000);
    }

    /** Adds an event to the end of the queue; also from within a handler. */
    public void post(E event) {
        pending.addLast(event);
    }

    /** Hands every waiting event to {@code handler}, also those it posts, oldest first, until none is left. */
    public void process(Consumer<? super E> handler) {
        int handled = 0;
        while (!pending.isEmpty()) {
            E event = pending.removeFirst();
            if (++handled > limit) {
                pending.clear();
                throw new IllegalStateException("more than " + limit + " events in one go, the last " + event);
            }
            happened.add(event);
            handler.accept(event);
        }
    }