24. Swarm¶
Rocks had a handful of things on the screen, all of the same kind. Swarm has forty insects of three kinds. They fly in along curves, one after the other, and take their places in a formation at the top. The formation sways and breathes, and now and then an insect leaves it, loops and dives at the fighter at the bottom, shooting as it comes. The fighter moves only left and right, and it has two shots in the air at most.

The original came from an arcade hall in 1981, and its insects were small pictures, drawn by hand in every direction they could fly. Ours are too. The game needs more new engine parts than any game before it: sprite sheets and animation, entities made from components, flight paths, stages read from a text file, a grid for collisions, and music. Most of them come back in every game that follows.
What you will learn
- Sprite sheets: many small pictures in one image, cut into cells, and animation as a list of cells over time
- Pixel art turned in advance, sixteen directions, and why that suits small pictures and did not suit Rocks
- Entities and components: a thing is what it has, and systems work on everything that has the right parts
- Flight paths: Bézier curves, and flying along them at an even speed
- Data files: the stages in a text file, with error messages that name the line
- A spatial grid for collisions, and when it pays off
- Music: tunes written as text, played by the mixer next to the sound effects
The complete game is in amade-swarm-game.
The rules¶
- The fighter flies along the bottom, left and right only. Space fires; at most two shots fly at a time.
- A stage begins with insects flying in, in groups along curved paths. Each one takes its own slot in the formation: four bosses at the top, sixteen butterflies, twenty bees.
- A bee is worth 50 points, a butterfly 80, a boss 150. Shot down while flying, not sitting, they are worth twice as much, a boss 400.
- A boss takes two hits. The first only changes its colour.
- From the formation, insects dive at the fighter and shoot. A diver that misses flies out at the bottom, comes back in from the top and returns to its slot. An insect that rams the fighter is lost with it.
- When all insects of a stage are gone, the next stage begins, a little faster. More insects dive at once, and more often.
- Every third stage is a challenge stage: forty insects fly through and leave again. Nobody shoots and nobody dives. Each hit is worth 100 points, and hitting all forty brings 10,000 more.
- There are three fighters, an extra one at 20,000 points and then every 60,000.
Step 1: A sprite sheet¶
Bricks had a few sprites, each drawn from code. Swarm has three insects, each with two wing positions, each in sixteen directions, plus a hit boss, the fighter and two explosions: 144 small pictures. Keeping them as 144 files would be tiresome; the classic answer is one image with all of them in a grid, a sprite sheet:

SpriteSheet cuts an image into cells of equal size. Each cell becomes a Sprite, with the cache from chapter 21:
public final class SpriteSheet {
private final Sprite[] frames;
private final int columns;
private SpriteSheet(Sprite[] frames, int columns) {
this.frames = frames;
this.columns = columns;
}
/**
* Cuts {@code image} into {@code columns} × {@code rows} cells of equal size; each is shown at
* {@code frameWidth} × {@code frameHeight} logical units.
*/
public static SpriteSheet of(BufferedImage image, int columns, int rows, double frameWidth, double frameHeight) {
if (columns < 1 || rows < 1 || image.getWidth() % columns != 0 || image.getHeight() % rows != 0) {
throw new IllegalArgumentException(image.getWidth() + " × " + image.getHeight()
+ " pixels do not make " + columns + " × " + rows + " equal cells");
}
int w = image.getWidth() / columns;
int h = image.getHeight() / rows;
Sprite[] frames = new Sprite[columns * rows];
for (int row = 0; row < rows; row++) {
for (int column = 0; column < columns; column++) {
// A view into the sheet, not a copy: the pixels stay in the one image.
BufferedImage cell = image.getSubimage(column * w, row * h, w, h);
frames[row * columns + column] = Sprite.of(cell, frameWidth, frameHeight);
}
}
return new SpriteSheet(frames, columns);
}
getSubimage makes no copy, only a view into the sheet's pixels; the copies come when each sprite is scaled to the screen, and only for the cells that are actually drawn. Frames are numbered row by row, so frame 17 of a sheet with 16 columns is column 1 of row 1. A sheet whose size does not divide evenly is an error at once, not a picture cut one pixel off in each cell:
@Test
void theCellsAreNumberedRowByRow() {
SpriteSheet sheet = SpriteSheet.of(sheet(), 3, 2, 8, 8);
assertEquals(6, sheet.frames());
BufferedImage screen = new BufferedImage(8, 8, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = screen.createGraphics();
sheet.frame(4).draw(g, 0, 0); // second row, second column
assertEquals(Color.CYAN.getRGB(), screen.getRGB(4, 4));
sheet.frame(2, 0).draw(g, 0, 0);
assertEquals(Color.BLUE.getRGB(), screen.getRGB(4, 4));
g.dispose();
}
@Test
void pixelArtKeepsHardEdgesInEveryRenderMode() {
SpriteSheet sheet = SpriteSheet.of(sheet(), 3, 2, 4, 4).hardEdges();
BufferedImage screen = new BufferedImage(12, 12, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = screen.createGraphics();
g.scale(3, 3); // smooth scaling would blur black and white into greys
sheet.frame(5).draw(g, 0, 0);
g.dispose();
Set<Integer> colours = new HashSet<>();
for (int y = 0; y < 12; y++) {
for (int x = 0; x < 12; x++) colours.add(screen.getRGB(x, y));
}
assertEquals(Set.of(Color.BLACK.getRGB(), Color.WHITE.getRGB()), colours);
assertEquals(Color.BLACK.getRGB(), screen.getRGB(2, 2), "each pixel a block of 3 × 3");
assertEquals(Color.WHITE.getRGB(), screen.getRGB(3, 2));
}
Pixel art needs hard edges¶
The insects are 16 × 16 pixels, and the screen shows them at three times that size on a 1920 × 1200 screen. Scaled smoothly, as the sprites of Bricks were, they blur: each pixel melts into its neighbours, and the sharp little pictures turn into coloured smudges. Pixel art must be scaled the other way, each source pixel becoming a square block of screen pixels. Pixel mode always does that, for the whole frame. A sprite now has its own say: hardEdges() makes a sprite that is always scaled in blocks, also in direct mode, where the lines and the text around it stay smooth.
private final BufferedImage image = Sprite.readImage(SwarmPainter.class, "/eu/amadeos/games/swarm/sprites.png");
/** 16 × 16 pixels per cell, shown at 16 × 16 logical units, with hard edges: pixel art. */
private final SpriteSheet sheet = SpriteSheet.of(image, 16, 9, 16, 16).hardEdges();
/** The same picture, cells shown twice as large: the fighter's explosion. */
private final SpriteSheet large = SpriteSheet.of(image, 16, 9, 32, 32).hardEdges();
/** The wings beat for all insects together, as in the arcade: row 0 and row 1 of each insect. */
private final Animation wings = new Animation(Animation.Mode.LOOP, 0.32, 0, 1);
private final Animation insectBoom = new Animation(Animation.Mode.ONCE, 0.07, 1, 2, 3, 4, 5);
private final Animation fighterBoom = new Animation(Animation.Mode.ONCE, 0.12, 6, 7, 8, 9, 10);
The second sheet shows the same image with cells twice as large: the fighter's explosion is bigger than an insect's. One image, two ways to cut it.
Turned in advance¶
The insects fly curves, and their noses point along them. In chapter 23, we drew the rocks as lines because turning pictures in advance would have cost about 90 megabytes. Here it costs almost nothing, and that is worth understanding: the rocks were large, 180 pixels on a 1920 screen, and smooth, so they needed 64 angles to look round. The insects are 16 pixels of pixel art. Sixteen directions are enough, because pixel art cannot show finer turns anyway; the original had exactly sixteen. A cell scaled to 48 × 48 screen pixels takes 9 KB, and all 144 cells together 1.3 MB.
The turning is done by a small program in the game's tools folder, once, when the pictures change: for every pixel of the turned picture, it turns the position back and takes the source pixel it lands on. That is the rotation formula from chapter 23, and nothing is interpolated, so the colours stay the colours of the palette. The game picks the column nearest to the insect's angle:
static int column(double angle) {
return Math.floorMod(Math.round(angle / (2 * Math.PI / DIRECTIONS)), DIRECTIONS);
}
Animation¶
An animation is a list of frame numbers and a time per frame. What happens at the end is the only real choice: start again (wings), stay on the last frame (an explosion), or run back (breathing):
public final class Animation {
/** What happens after the last frame. */
public enum Mode {
/** Starts again with the first: wings, flames, blinking lights. */
LOOP,
/** Stays on the last: an explosion, a door that opens. */
ONCE,
/** Runs back to the first and forth again, without showing the ends twice: breathing, swaying. */
PING_PONG
}
private final Mode mode;
private final double secondsPerFrame;
private final int[] frames;
/** @param frames the numbers of the frames in the sheet, in the order they are shown */
public Animation(Mode mode, double secondsPerFrame, int... frames) {
if (frames.length == 0 || secondsPerFrame <= 0) throw new IllegalArgumentException("empty animation");
this.mode = mode;
this.secondsPerFrame = secondsPerFrame;
this.frames = frames.clone();
}
/** The frame to show {@code seconds} after the animation started. */
public int frame(double seconds) {
int step = (int) Math.floor(Math.max(0, seconds) / secondsPerFrame);
int n = frames.length;
int index = switch (mode) {
case LOOP -> step % n;
case ONCE -> Math.min(step, n - 1);
case PING_PONG -> {
if (n == 1) yield 0;
int round = 2 * (n - 1); // 0 1 2 3 2 1 | 0 1 2 3 2 1 | ...
int at = step % round;
yield at < n ? at : round - at;
}
};
return frames[index];
}
@Test
void theThreeWaysToGoOn() {
assertArrayEquals(new int[]{4, 5, 6, 4, 5, 6, 4},
frames(new Animation(Animation.Mode.LOOP, 0.1, 4, 5, 6), 7));
assertArrayEquals(new int[]{4, 5, 6, 6, 6, 6, 6},
frames(new Animation(Animation.Mode.ONCE, 0.1, 4, 5, 6), 7));
assertArrayEquals(new int[]{4, 5, 6, 7, 6, 5, 4, 5, 6},
frames(new Animation(Animation.Mode.PING_PONG, 0.1, 4, 5, 6, 7), 9));
}
An animation keeps no time of its own. It answers "which frame, so many seconds after the start?", and whoever asks keeps the start. All insects beat their wings together, as in the arcade, so one clock does for all forty. An explosion asks with the seconds since it began.
Step 2: Entities and components¶
So far each game had a class per kind of thing: Ball, Brick, Rock. An insect in Swarm is harder to pin down. It flies in along a path, then flies towards its slot, then sits; later it dives along another path, shooting, then it flies home. A challenge insect flies along a path and leaves. With a class Insect holding fields for all of that, and a state field saying which of them count, every method starts with a switch, and every new behaviour touches all of them.
The alternative has a name: entities and components. An entity is nothing but an identity and a bag of parts. What it does follows from which parts it has right now:
public final class Entity {
private final long id;
private final Map<Class<?>, Object> components = new HashMap<>();
private boolean alive = true;
Entity(long id) {
this.id = id;
}
/** Adds or replaces the component of the component's class. */
public <T> Entity add(T component) {
components.put(component.getClass(), component);
return this;
}
/** The component of this class, or {@code null}. */
public <T> T get(Class<T> type) {
return type.cast(components.get(type));
}
public boolean has(Class<?> type) {
return components.containsKey(type);
}
/** Takes the component of this class away; returns it, or {@code null} if there was none. */
public <T> T remove(Class<T> type) {
return type.cast(components.remove(type));
}
/** Marks the entity for removal; it leaves its {@link Entities} at their next {@link Entities#flush()}. */
public void destroy() {
alive = false;
}
The parts of an insect are small and say one thing each:
| Component | Means |
|---|---|
Body |
where it is and which way it points, now and one step ago |
Insect |
its kind and the hits it can still take |
Slot |
its place in the formation; an insect without one never stays |
Flight |
it is flying along a path, so far along it |
Homing |
it is flying straight back to its slot |
Gun |
it will shoot, so many times, at these intervals |
public final class Body {
private Vector position;
private Vector previous;
private double angle;
private double previousAngle;
public Body(Vector position, double angle) {
jumpTo(position, angle);
}
/** Moves in one step: the old place becomes the previous one. */
void moveTo(Vector position, double angle) {
this.previous = this.position;
this.previousAngle = this.angle;
this.position = position;
this.angle = angle;
}
/** Appears somewhere else, without a step in between to draw. */
void jumpTo(Vector position, double angle) {
this.position = previous = position;
this.angle = previousAngle = angle;
}
A system is a piece of the rules that works on every entity with certain components. Entities holds the entities and offers exactly that: every entity that has an A, or an A and a B:
public final class Entities {
/** What a system does with an entity that has both components. */
@FunctionalInterface
public interface Each2<A, B> {
void accept(Entity entity, A a, B b);
}
private final List<Entity> entities = new ArrayList<>();
private final List<Entity> born = new ArrayList<>();
private long nextId = 1;
/** A new entity without components; it joins the others at the next {@link #flush()}. */
public Entity create() {
Entity entity = new Entity(nextId++);
born.add(entity);
return entity;
}
/** Takes in the entities made since the last flush and drops the destroyed ones. */
public void flush() {
entities.removeIf(entity -> !entity.alive());
for (Entity entity : born) {
if (entity.alive()) entities.add(entity);
}
born.clear();
}
/** Every living entity with a component of {@code type}, in the order they were made. */
public <A> void forEach(Class<A> type, BiConsumer<Entity, A> system) {
for (int i = 0; i < entities.size(); i++) {
Entity entity = entities.get(i);
if (!entity.alive()) continue;
A a = entity.get(type);
if (a != null) system.accept(entity, a);
}
}
/** Every living entity with components of both types. */
public <A, B> void forEach(Class<A> typeA, Class<B> typeB, Each2<A, B> system) {
for (int i = 0; i < entities.size(); i++) {
Entity entity = entities.get(i);
if (!entity.alive()) continue;
A a = entity.get(typeA);
if (a == null) continue;
B b = entity.get(typeB);
if (b != null) system.accept(entity, a, b);
}
}
One detail decides whether this is pleasant or a source of strange bugs: a system may create and destroy entities while forEach walks the list. A new entity therefore waits in born, and a destroyed one is only marked; both take effect at flush(), which the game calls once at the end of its step. Until then, the walk sees the same list it started with, and dead entities are skipped.
@Test
void aSystemSeesOnlyTheEntitiesWithItsComponents() {
Entities world = new Entities();
Entity bee = world.create().add(new Body(10, 10)).add(new Velocity(0, 5));
Entity rock = world.create().add(new Body(50, 50)); // has a body, but does not move
world.flush();
// The movement system: everything with a body and a velocity.
world.forEach(Body.class, Velocity.class, (entity, body, velocity) -> {
body.x += velocity.dx;
body.y += velocity.dy;
});
assertEquals(15, bee.get(Body.class).y);
assertEquals(50, rock.get(Body.class).y);
// The bee lands: without a velocity it is left alone, like the rock.
bee.remove(Velocity.class);
world.forEach(Body.class, Velocity.class, (entity, body, velocity) -> body.y += velocity.dy);
assertEquals(15, bee.get(Body.class).y);
assertEquals(2, world.count(Body.class));
}
@Test
void entitiesMadeOrDestroyedDuringAWalkChangeTheListOnlyAtTheFlush() {
Entities world = new Entities();
List<Entity> three = new ArrayList<>();
for (int i = 0; i < 3; i++) three.add(world.create().add(new Lives()));
world.flush();
List<Long> visited = new ArrayList<>();
world.forEach(Lives.class, (entity, lives) -> {
visited.add(entity.id());
if (entity.id() == 1) three.get(1).destroy(); // a shot hits the next one
world.create().add(new Lives()); // and each one leaves a copy behind
});
assertEquals(List.of(1L, 3L), visited, "the destroyed one is skipped, the new ones wait");
assertEquals(3, world.all().size(), "still in the list until the flush");
world.flush();
assertEquals(List.of(1L, 3L, 4L, 5L), world.all().stream().map(Entity::id).toList());
}
Now the systems of Swarm read like the rules. The flight system moves everything with a Flight along its path. At the end, the flight and the gun are taken away: with a Slot, the insect gets a Homing; without one, it is a challenge insect and goes. The homing system flies straight to the slot, and in it, the insect loses its Homing and so belongs to the formation system:
/** The flight system: along the path; at its end, to the slot, or away. */
private void fly(Entity insect, Body body, Flight flight, double seconds) {
boolean done = flight.advance(seconds);
FlightPath path = flight.path();
body.moveTo(path.at(flight.distance()), path.angleAt(flight.distance()));
if (!done) return;
insect.remove(Flight.class);
insect.remove(Gun.class);
Slot slot = insect.get(Slot.class);
if (slot == null) {
insect.destroy(); // a challenge insect: through the field and gone
return;
}
if (body.position().y() > Field.HEIGHT) {
// Dived past the fighter and out at the bottom: it comes back in from above.
body.jumpTo(new Vector(formation.slot(slot).x(), -16), Math.PI / 2);
}
insect.add(new Homing());
}
/** The homing system: straight towards the slot, which moves with the formation; there, it sits down. */
private void home(Entity insect, Body body, double seconds) {
Vector slot = formation.slot(insect.get(Slot.class));
Vector way = slot.minus(body.position());
double step = Field.HOMING_SPEED * Field.pace(stage) * seconds;
if (way.length() <= step) {
insect.remove(Homing.class);
body.moveTo(slot, Math.PI / 2);
} else {
body.moveTo(body.position().plus(way.normalized().times(step)), way.angle());
}
}
/** The formation system: an insect in its slot, neither flying nor homing, moves with the slot. */
private void sit(Entity insect, Body body, Slot slot) {
if (insect.has(Flight.class) || insect.has(Homing.class)) return;
body.moveTo(formation.slot(slot), Math.PI / 2);
}
A dive adds components, nothing else. The insect keeps its slot, so its place in the formation waits for it:
/**
* Now and then an insect leaves the formation and dives at the fighter: it gets a flight, made here
* for where it sits and where the fighter is, and a gun. Its slot stays its own.
*/
private void dive(double seconds) {
if (plan.challenge() || !arrivals.isEmpty() || phase != Phase.PLAYING || intro > 0) return;
untilDive -= seconds;
if (untilDive > 0 || flying() >= Field.maxDivers(stage)) return;
untilDive = Field.diveInterval(stage) * (0.6 + 0.8 * random.nextDouble());
List<Entity> sitting = new ArrayList<>();
insects.forEach(Slot.class, (insect, slot) -> {
if (!insect.has(Flight.class) && !insect.has(Homing.class)) sitting.add(insect);
});
if (sitting.isEmpty()) return;
Entity diver = sitting.get(random.nextInt(sitting.size()));
Vector from = diver.get(Body.class).position();
diver.add(new Flight(divePath(from, fighter.x()), Field.DIVE_SPEED * Field.pace(stage)));
diver.add(new Gun(2 + Math.min(stage / 2, 2), 0.2 + 0.4 * random.nextDouble()));
events.add(new Event.Dived(diver.get(Insect.class).kind(), from));
}
/** A half loop up and outwards, then down towards the fighter, past it and out at the bottom. */
static FlightPath divePath(Vector from, double target) {
double side = from.x() < Field.WIDTH / 2 ? -1 : 1;
double x = from.x();
double y = from.y();
return FlightPath.from(x, y)
.curveTo(x, y - 36, x + side * 50, y - 36, x + side * 50, y)
.curveTo(x + side * 50, y + 120, target - side * 90, 250, target, 310)
.curveTo(target + side * 50, 350, target + side * 40, 390, target + side * 20, Field.HEIGHT + 24)
.build();
}
@Test
void aDiverChangesItsComponentsAndComesBack() {
Game game = new Game(2);
Entity[] diver = {null};
runUntil(game, 60, Steering.NONE, () -> {
for (Event event : game.events()) {
if (event instanceof Event.Dived) {
game.insects().forEach(Gun.class, (insect, gun) -> {
if (insect.has(Slot.class)) diver[0] = insect;
});
}
}
return diver[0] != null;
});
Entity insect = diver[0];
assertNotNull(insect, "someone dived");
assertTrue(insect.has(Flight.class) && insect.has(Gun.class), "flies, armed");
runUntil(game, 20, Steering.NONE, () -> !insect.has(Flight.class) || !insect.alive());
assertTrue(insect.has(Homing.class) || !insect.alive(), "on its way back, or it rammed the fighter");
if (!insect.alive()) return;
assertFalse(insect.has(Gun.class), "the gun is only for the dive");
runUntil(game, 20, Steering.NONE, () -> !insect.has(Homing.class) || !insect.alive());
if (insect.alive()) {
assertEquals(game.formation().slot(insect.get(Slot.class)), insect.get(Body.class).position(),
"back in its slot, which was kept for it");
}
}
The game's step is the systems in their order:
/** One step of {@code seconds}: the fighter moves and shoots, the systems run, then what touches collides. */
public void update(double seconds, Steering steering) {
events.clear();
if (fighter != null) {
fighter.move(steering, seconds);
if (steering.fire()) fire();
}
arrive(seconds);
formation.update(seconds, arrivals.isEmpty() && flying() == 0);
insects.forEach(Body.class, Flight.class, (insect, body, flight) -> fly(insect, body, flight, seconds));
insects.forEach(Body.class, Homing.class, (insect, body, homing) -> home(insect, body, seconds));
insects.forEach(Body.class, Slot.class, this::sit);
dive(seconds);
insects.forEach(Body.class, Gun.class, (insect, body, gun) -> shoot(body, gun, seconds));
for (int i = 0; i < shots.size(); i++) shots.get(i).move(seconds);
for (int i = 0; i < insectShots.size(); i++) insectShots.get(i).move(seconds);
collide();
shots.releaseIf(Shot::expired);
insectShots.releaseIf(Shot::expired);
insects.flush();
waitForFighter(seconds);
nextStage(seconds);
}
Behind the scenes: is this a real entity–component system?
Big engines store components in arrays, one per type, so a system runs through memory in order and the processor's cache helps; that is worth it for tens of thousands of entities. Ours keeps a map per entity, which costs a lookup per component, and that is fine for forty insects: the whole step takes a few microseconds. The idea is what counts, and it does not change when the storage does: behaviour comes from which parts an entity has. The shots stay a Pool from chapter 23, because they have one kind of behaviour and a limit.
Step 3: Flight paths¶
The insects fly in along smooth curves, and the curves need to be easy to write down, to change and to mirror. The curve of choice in every drawing program, in fonts and in Java2D's own Path2D, is the cubic Bézier curve: a start, an end, and two control points that bend it. The curve leaves the start towards the first control point and arrives at the end coming from the second:

A point on the curve is a weighted mean of the four points. At a parameter t from 0 to 1, the weights are the Bernstein polynomials (1 − t)³, 3(1 − t)²t, 3(1 − t)t² and t³. At t = 0 all weight is on the start, at t = 1 on the end, and in between the control points pull. The direction of flight comes from the derivative, and with it the column of the sprite sheet:
/**
* x ({@code axis} 0) or y (1) at parameter {@code u}, the curve number plus <i>t</i>: the four points
* of the curve, weighted by the Bernstein polynomials of <i>t</i>.
*/
private double coordinate(double u, int axis) {
int curve = Math.min((int) u, curves - 1);
double t = u - curve;
double s = 1 - t;
int p = 6 * curve + axis;
return s * s * s * points[p] + 3 * s * s * t * points[p + 2] + 3 * s * t * t * points[p + 4]
+ t * t * t * points[p + 6];
}
/** The derivative: which way, and how fast in <i>t</i>, the curve goes at {@code u}. */
private double slope(double u, int axis) {
int curve = Math.min((int) u, curves - 1);
double t = u - curve;
double s = 1 - t;
int p = 6 * curve + axis;
return 3 * s * s * (points[p + 2] - points[p]) + 6 * s * t * (points[p + 4] - points[p + 2])
+ 3 * t * t * (points[p + 6] - points[p + 4]);
}
A path is several curves in a row, each starting where the last one ended. lineTo is a curve too, with its control points on the straight line, a third and two thirds of the way.
An even speed¶
Here is the catch. t is not distance. Where the control points are far apart the curve covers a lot of ground per step of t, and where they bunch up it covers little. An insect that increases t evenly races through some parts of its path and crawls through others. There is no formula for the length of a Bézier curve, so FlightPath measures it: it walks the curve in 48 small straight steps per curve and writes down the distance flown at each. To find where one is after a given distance, it looks the distance up in that table with a binary search and goes the rest of the way in a straight line between two neighbouring samples:
/** Walks the path in small straight steps and notes the distance flown at each. */
private double[] measure() {
double[] table = new double[curves * SAMPLES + 1];
double lastX = points[0];
double lastY = points[1];
for (int i = 1; i < table.length; i++) {
double u = i / (double) SAMPLES; // curve number plus t
double x = coordinate(u, 0);
double y = coordinate(u, 1);
table[i] = table[i - 1] + Math.hypot(x - lastX, y - lastY);
lastX = x;
lastY = y;
}
return table;
}
/** The parameter at {@code distance}: the curve number plus <i>t</i> within it. */
private double parameterAt(double distance) {
double d = Math.clamp(distance, 0, length());
int i = Arrays.binarySearch(distances, d);
if (i >= 0) return i / (double) SAMPLES;
int after = -i - 1; // the first measured point beyond d
int before = after - 1;
double part = (d - distances[before]) / (distances[after] - distances[before]);
return (before + part) / SAMPLES;
}
/** The length of the whole path, in logical units. */
public double length() {
return distances[distances.length - 1];
}
/** Where one is after flying {@code distance} along the path; before the start the start, after the end the end. */
public Vector at(double distance) {
double u = parameterAt(distance);
return new Vector(coordinate(u, 0), coordinate(u, 1));
}
/** The direction of flight after {@code distance}, as an angle like {@link Vector#angle()}. */
public double angleAt(double distance) {
double u = parameterAt(distance);
double dx = slope(u, 0);
double dy = slope(u, 1);
if (dx == 0 && dy == 0) {
// A control point on an end point: the curve stands still there for a moment. Look around it.
return at(distance + 0.5).minus(at(distance - 0.5)).angle();
}
return Math.atan2(dy, dx);
}
@Test
void aCurveIsFlownAtAnEvenSpeed() {
// A swoop from the top down and round to the left: sharp at the bottom, nearly straight at the top.
FlightPath swoop = FlightPath.from(320, 0).curveTo(320, 300, 0, 300, 100, 100).build();
double step = swoop.length() / 40;
double shortest = Double.MAX_VALUE;
double longest = 0;
for (int i = 0; i < 40; i++) {
double gap = swoop.at(i * step).distance(swoop.at((i + 1) * step));
shortest = Math.min(shortest, gap);
longest = Math.max(longest, gap);
}
// Equal distances along the curve: equal steps on the screen, to a hundredth.
assertEquals(step, shortest, step * 0.01);
assertEquals(step, longest, step * 0.01);
assertEquals(Math.PI / 2, swoop.angleAt(0), EPS, "starts straight down");
}
The test allows 1 percent. The straight steps are a little shorter than the curve, so the distances are not exact; 48 steps per curve keep the error below what anybody sees, and the table is made once per path, when the stage file is read. A dive path is made at the moment of the dive, from where the insect sits and where the fighter is, and the 145 samples of its three curves take a few microseconds.
With that, a Flight is a path, a speed and the distance flown so far:
public final class Flight {
private final FlightPath path;
private final double speed;
private double distance;
public Flight(FlightPath path, double speed) {
this.path = path;
this.speed = speed;
}
/** Flies on; true when the end of the path is reached. */
boolean advance(double seconds) {
distance += speed * seconds;
return distance >= path.length();
}
/** Speeds along flight paths, units per second, in the first stage; later stages are faster. */
public static final double ENTRY_SPEED = 210;
public static final double DIVE_SPEED = 170;
public static final double HOMING_SPEED = 150;
/** Seconds between two insects of a group on the same path: they follow each other in a line. */
public static final double SPACING = 0.16;
The formation¶
The slots are not fixed points either. While insects arrive, the formation sways left and right, which makes the insects' homing look alive; once all have arrived, it breathes instead, spreading and closing. Switching from one to the other at a random moment would make every insect jump, so the switch waits until the sway passes the middle:
public final class Formation {
private double time;
private boolean breathing;
void update(double seconds, boolean complete) {
double before = sway();
time += seconds;
// Switch only when the sway passes the middle, so no insect jumps.
if (!breathing && complete && Math.signum(sway()) != Math.signum(before)) {
breathing = true;
time = 0;
}
}
/** Back to swaying, for a new stage. */
void reset() {
time = 0;
breathing = false;
}
public Vector slot(Slot slot) {
double spread = breathing ? 1 + Field.BREATH * (1 - Math.cos(2 * Math.PI * time / Field.BREATH_SECONDS)) / 2 : 1;
double x = Field.WIDTH / 2 + sway() + (slot.column() - (Field.COLUMNS - 1) / 2.0) * Field.SLOT_WIDTH * spread;
double y = Field.FORMATION_TOP + slot.row() * Field.SLOT_HEIGHT * spread;
return new Vector(x, y);
}
private double sway() {
return breathing ? 0 : Field.SWAY * Math.sin(2 * Math.PI * time / Field.SWAY_SECONDS);
}
/** The formation: ten columns, five rows, around the top middle of the field. */
public static final int COLUMNS = 10;
public static final int ROWS = 5;
public static final double SLOT_WIDTH = 28;
public static final double SLOT_HEIGHT = 22;
public static final double FORMATION_TOP = 50;
/** While insects arrive, the formation sways left and right by this much, once in this many seconds. */
public static final double SWAY = 24;
public static final double SWAY_SECONDS = 5;
/** When all have arrived, it breathes instead: its rows and columns spread by up to this part, and back. */
public static final double BREATH = 0.14;
public static final double BREATH_SECONDS = 3.5;
Step 4: Stages as data¶
Forty insects per stage, each with a path, a slot and a moment to start: written as Java code, a stage is a page of new Group(...) that nobody but a programmer can change, and every change needs a compiler. A stage is data, and data belongs in a file. The format can be very simple, a line per thing, words separated by spaces:
path top-left
from 290 -16
curve 290 120 120 170 110 250
curve 100 330 250 340 260 260
curve 268 200 230 170 200 150
mirror top-right top-left
stage
group 0 top-left butterfly@4,1 butterfly@5,1 butterfly@4,2 butterfly@5,2
group 0 top-right bee@4,3 bee@5,3 bee@4,4 bee@5,4
group 3.5 side-left boss@3,0 butterfly@3,1 boss@4,0 butterfly@6,1 ...
mirror saves half the work: the paths on the right are those on the left, mirrored. The engine's TextData reads such files. It knows nothing about stages: it drops comments and empty lines, splits the rest into words, and remembers where each line came from:
public final class TextData {
/** A line with the words in it, and where it came from. */
public record Line(String source, int lineNumber, List<String> words) {
public Line {
words = List.copyOf(words);
}
/** The first word: what the line is. */
public String keyword() {
return words.getFirst();
}
public int size() {
return words.size();
}
/** Word {@code index}; an error naming the line if there is none. */
public String word(int index) {
if (index >= words.size()) throw error("expected " + (index + 1) + " words, found " + words.size());
return words.get(index);
}
/** Word {@code index} as a number, such as {@code 12}, {@code -0.5} or {@code 1e3}. */
public double number(int index) {
String word = word(index);
try {
return Double.parseDouble(word);
} catch (NumberFormatException e) {
throw error('"' + word + "\" is not a number");
}
}
/** Word {@code index} as a whole number. */
public int integer(int index) {
String word = word(index);
try {
return Integer.parseInt(word);
} catch (NumberFormatException e) {
throw error('"' + word + "\" is not a whole number");
}
}
/** An error at this line, to throw: {@code waves.txt:12: message}. */
public DataException error(String message) {
return new DataException(source + ":" + lineNumber + ": " + message);
}
}
/** Something in a data file the game cannot use. */
public static final class DataException extends RuntimeException {
public DataException(String message) {
super(message);
}
}
private TextData() {
}
/** Reads all lines that say something; {@code source} names the file in error messages. */
public static List<Line> parse(String source, Reader reader) {
List<Line> lines = new ArrayList<>();
try (BufferedReader in = new BufferedReader(reader)) {
int number = 0;
for (String text; (text = in.readLine()) != null; ) {
number++;
int comment = text.indexOf('#');
if (comment >= 0) text = text.substring(0, comment);
text = text.strip();
if (!text.isEmpty()) lines.add(new Line(source, number, Arrays.asList(text.split("\\s+"))));
}
} catch (IOException e) {
throw new UncheckedIOException("cannot read " + source, e);
}
return Collections.unmodifiableList(lines);
}
@Test
void linesBecomeWordsAndErrorsSayWhere() {
List<TextData.Line> lines = TextData.parse("waves.txt", new StringReader("""
# Stage 1
stage 1
group 0.5 swoop-left bee 1 3 # two bees
speed fast
"""));
assertEquals(3, lines.size());
TextData.Line group = lines.get(1);
assertEquals("group", group.keyword());
assertEquals(4, group.lineNumber());
assertEquals(0.5, group.number(1));
assertEquals(List.of("group", "0.5", "swoop-left", "bee", "1", "3"), group.words());
TextData.DataException error = assertThrows(TextData.DataException.class, () -> lines.get(2).number(1));
assertEquals("waves.txt:5: \"fast\" is not a number", error.getMessage());
}
Why not JSON, or XML? Both would work, and both need a library or a lot of brackets. A format of lines and words is what game files looked like for decades, and it is easy to write by hand, to compare in Git and to read in an error message. The game gives the words their meaning:
/** Reads the lines of a stage file; a mistake in it is an error naming its line. */
public Stages(List<Line> lines) {
String pathName = null;
FlightPath.Builder path = null;
Line pathLine = null;
List<Group> groups = null;
boolean challenge = false;
Set<Slot> taken = new HashSet<>();
for (Line line : lines) {
switch (line.keyword()) {
case "path" -> {
finishPath(pathName, path, pathLine);
pathName = line.word(1);
path = null;
pathLine = line;
}
case "from" -> path = FlightPath.from(line.number(1), line.number(2));
case "curve" -> pathBuilder(path, line).curveTo(line.number(1), line.number(2), line.number(3),
line.number(4), line.number(5), line.number(6));
case "line" -> pathBuilder(path, line).lineTo(line.number(1), line.number(2));
case "mirror" -> {
finishPath(pathName, path, pathLine);
pathName = null;
paths.put(line.word(1), path(line, line.word(2)).mirrored(Field.WIDTH));
}
case "stage" -> {
finishPath(pathName, path, pathLine);
pathName = null;
if (groups != null) plans.add(new Plan(challenge, List.copyOf(groups)));
groups = new ArrayList<>();
challenge = line.size() > 1 && line.word(1).equals("challenge");
taken.clear();
}
case "group" -> {
if (groups == null) throw line.error("a group belongs to a stage");
List<Member> members = new ArrayList<>();
for (int i = 3; i < line.size(); i++) members.add(member(line, line.word(i), taken));
if (members.isEmpty()) throw line.error("a group without insects");
groups.add(new Group(line.number(1), path(line, line.word(2)), List.copyOf(members)));
}
default -> throw line.error("unknown: \"" + line.keyword() + '"');
}
}
finishPath(pathName, path, pathLine);
if (groups != null) plans.add(new Plan(challenge, List.copyOf(groups)));
if (plans.isEmpty()) throw new TextData.DataException("no stages");
}
/** One insect: {@code bee} flies through, {@code bee@4,3} takes the slot in column 4, row 3. */
private static Member member(Line line, String word, Set<Slot> taken) {
String[] parts = word.split("@");
Kind kind = Kind.named(parts[0]);
if (kind == null) throw line.error('"' + parts[0] + "\" is not an insect");
if (parts.length == 1) return new Member(kind, null);
String[] place = parts[1].split(",");
try {
Slot slot = new Slot(Integer.parseInt(place[0]), Integer.parseInt(place[1]));
if (slot.column() < 0 || slot.column() >= Field.COLUMNS || slot.row() < 0 || slot.row() >= Field.ROWS) {
throw line.error("no slot " + parts[1] + " in the formation");
}
if (!taken.add(slot)) throw line.error("slot " + parts[1] + " is taken twice");
return new Member(kind, slot);
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
throw line.error('"' + word + "\" is not kind@column,row");
}
}
The most important thing about a data file is what happens when it is wrong. Somebody will type bee@10,0 in a formation with columns 0 to 9, or give two insects the same slot. Without care, that ends in an ArrayIndexOutOfBoundsException deep in the game, or worse, in an insect that never arrives. Every check here throws an error that names the file and the line, and the game reads the whole file at start, so a mistake shows at once:
@Test
void mistakesInTheFileNameTheirLine() {
String path = """
path down # line 1
from 0 0
line 10 10
stage
""";
assertEquals("stages.txt:5: \"swoop\" is not a path", error(path + "group 0 swoop bee@1,1"));
assertEquals("stages.txt:5: \"wasp\" is not an insect", error(path + "group 0 down wasp@1,1"));
assertEquals("stages.txt:5: no slot 10,0 in the formation", error(path + "group 0 down bee@10,0"));
assertEquals("stages.txt:6: slot 1,1 is taken twice",
error(path + "group 0 down bee@1,1\ngroup 1 down bee@1,1"));
assertEquals("stages.txt:5: \"soon\" is not a number", error(path + "group soon down bee"));
}
The game turns a stage's groups into a list of arrivals, sorted by time; insects of a group follow each other along the path at a short distance. Each arrival becomes an entity with the components it needs, and only some get a gun:
/** When every insect of the stage is gone, a short break, and the next stage. */
private void nextStage(double seconds) {
if (!arrivals.isEmpty() || insects.count(Insect.class) > 0 || intro > 0) return;
if (stageBreak == 0) {
events.add(new Event.StageCleared(stage));
if (plan.challenge()) {
int of = plan.insects();
int bonus = challengeHits == of ? Field.PERFECT_BONUS : 0;
events.add(new Event.ChallengeOver(challengeHits, of, bonus));
addPoints(bonus);
}
}
stageBreak += seconds;
if (stageBreak >= Field.STAGE_BREAK + (plan.challenge() ? 1.5 : 0)) startStage(stage + 1);
}
/** The stage's groups become a list of arrivals in time order; insects of a group follow each other. */
private void startStage(int number) {
stage = number;
plan = stages.plan(number);
stageTime = 0;
stageBreak = 0;
challengeHits = 0;
intro = Field.STAGE_INTRO;
untilDive = Field.diveInterval(number);
formation.reset();
arrivals.clear();
double pace = Field.pace(number);
for (Stages.Group group : plan.groups()) {
for (int i = 0; i < group.members().size(); i++) {
arrivals.add(new Arrival((group.time() + i * Field.SPACING) / pace, group.path(),
group.members().get(i)));
}
}
arrivals.sort((a, b) -> Double.compare(a.time(), b.time())); // stable: equal times keep their order
events.add(new Event.StageStarted(number, plan.challenge()));
}
/** Insects whose time has come appear at the start of their path; with a slot, they will stay. */
private void arrive(double seconds) {
if (intro > 0) {
intro -= seconds;
return;
}
stageTime += seconds;
while (!arrivals.isEmpty() && arrivals.getFirst().time() <= stageTime) {
Arrival arrival = arrivals.removeFirst();
FlightPath path = arrival.path();
Entity insect = insects.create()
.add(new Body(path.start(), path.angleAt(0)))
.add(new Insect(arrival.member().kind()))
.add(new Flight(path, Field.ENTRY_SPEED * Field.pace(stage)));
if (arrival.member().slot() != null) insect.add(arrival.member().slot());
// From the second stage on, some insects shoot on their way in.
if (!plan.challenge() && stage > 1 && random.nextDouble() < 0.3) {
insect.add(new Gun(1, 0.4 + random.nextDouble()));
}
}
}
@Test
void fortyInsectsFlyInAndTakeTheirPlaces() {
Game game = new Game(1);
assertTrue(game.inIntro());
run(game, Field.STAGE_INTRO + 0.6, Steering.NONE);
assertEquals(8, game.insects().count(Flight.class), "the first two groups, one after the other");
runUntil(game, 30, Steering.NONE, () -> sitting(game).size() == 40);
assertEquals(40, sitting(game).size());
assertEquals(0, game.waiting());
for (Entity insect : sitting(game)) {
Vector slot = game.formation().slot(insect.get(Slot.class));
assertEquals(slot, insect.get(Body.class).position(), "in its slot");
}
}
After the last stage in the file, the first comes again, faster: Field.pace adds 6 percent per stage. A challenge stage is a stage challenge line, with insects that have no slot:

@Test
void aChallengeStageFliesThroughAndCountsTheHits() {
Game game = new Game(5);
game.jumpToStage(3);
assertTrue(game.challenge());
List<Event> events = runUntil(game, 60, FIRE, () -> game.stage() == 4);
Event.ChallengeOver over = events.stream().filter(Event.ChallengeOver.class::isInstance)
.map(Event.ChallengeOver.class::cast).findFirst().orElseThrow();
assertEquals(40, over.of());
assertTrue(over.hits() > 0, "some flew into the shots");
assertEquals(0, count(events, Event.InsectFired.class), "nobody shoots");
assertEquals(0, count(events, Event.FighterLost.class));
assertEquals(over.hits() * Field.CHALLENGE_POINTS + over.bonus(), game.score());
assertEquals(over.hits() == 40 ? Field.PERFECT_BONUS : 0, over.bonus());
}
Step 5: Collisions on a grid¶
Two shots against forty insects, and the fighter against forty insects and eight insect shots: about a hundred circle tests per step, which the plain loop of chapter 23 does in well under a microsecond each. But consider a game with 2000 things that may all hit each other: two million tests per step. The cure is to test only things that are near each other, and the simplest way to know what is near is a grid. The field is cut into cells; each thing goes into every cell it touches; a question asks only the cells around a point.
public final class Grid<T> {
private final double cellSize;
private final int columns;
private final int rows;
/** Per cell, the first of its entries, or -1. */
private final int[] firstEntry;
/** Per entry, the next entry of the same cell, or -1, and the thing it stands for. */
private int[] nextEntry = new int[64];
private int[] entryItem = new int[64];
private int entries;
private Object[] items = new Object[32];
private int size;
/** Per thing, the number of the last question that visited it: no thing is visited twice. */
private int[] visited = new int[32];
private int question;
public Grid(double width, double height, double cellSize) {
if (width <= 0 || height <= 0 || cellSize <= 0) throw new IllegalArgumentException("empty grid");
this.cellSize = cellSize;
this.columns = (int) Math.ceil(width / cellSize);
this.rows = (int) Math.ceil(height / cellSize);
this.firstEntry = new int[columns * rows];
clear();
}
/** Empties all cells; the game calls it at the start of each step, before it adds the things anew. */
public void clear() {
Arrays.fill(firstEntry, -1);
Arrays.fill(items, 0, size, null);
entries = 0;
size = 0;
}
/** Puts a thing into every cell its area, a circle around {@code x}, {@code y}, touches. */
public void add(T item, double x, double y, double radius) {
if (size == items.length) {
items = Arrays.copyOf(items, 2 * size);
visited = Arrays.copyOf(visited, 2 * size);
}
int index = size++;
items[index] = item;
visited[index] = question;
for (int row = row(y - radius); row <= row(y + radius); row++) {
for (int column = column(x - radius); column <= column(x + radius); column++) {
if (entries == nextEntry.length) {
nextEntry = Arrays.copyOf(nextEntry, 2 * entries);
entryItem = Arrays.copyOf(entryItem, 2 * entries);
}
int cell = row * columns + column;
entryItem[entries] = index;
nextEntry[entries] = firstEntry[cell]; // in front of the cell's other entries
firstEntry[cell] = entries++;
}
}
}
/**
* Visits every thing in the cells that a circle around {@code x}, {@code y} touches, each once: the
* candidates. Whether a candidate really touches is the caller's test. Returns how many were visited.
*/
@SuppressWarnings("unchecked")
public int forEachNear(double x, double y, double radius, Consumer<? super T> visitor) {
question++;
int count = 0;
for (int row = row(y - radius); row <= row(y + radius); row++) {
for (int column = column(x - radius); column <= column(x + radius); column++) {
for (int e = firstEntry[row * columns + column]; e >= 0; e = nextEntry[e]) {
int index = entryItem[e];
if (visited[index] == question) continue; // already met in another cell
visited[index] = question;
count++;
visitor.accept((T) items[index]);
}
}
}
return count;
}
private int column(double x) {
return Math.clamp((long) Math.floor(x / cellSize), 0, columns - 1);
}
private int row(double y) {
return Math.clamp((long) Math.floor(y / cellSize), 0, rows - 1);
}
Three choices make it fast and keep it simple:
- It is rebuilt every step. Things move, and moving one between cells costs as much as adding it anew.
clearresets an array, no objects are freed. - The cells are linked lists, but made of
intarrays, not of objects:firstEntryper cell,nextEntryper entry. Adding a thing creates no garbage once the arrays have grown to size. - A thing that touches several cells is in several lists. So that a question meets it only once, each question gets a number, and each thing remembers the number of the last question that met it.
The grid answers candidates: things in cells nearby. Whether a candidate really touches is still the circle test:
/**
* Every insect goes into the grid; each shot then only meets the insects in its cell and the ones next
* to it, not all forty.
*/
private void collide() {
grid.clear();
insects.forEach(Body.class, (insect, body) ->
grid.add(insect, body.position().x(), body.position().y(), Field.INSECT_RADIUS));
for (int i = 0; i < shots.size(); i++) {
Shot shot = shots.get(i);
probe = new Circle(shot.position().x(), shot.position().y(), 0);
struck = null;
grid.forEachNear(probe.x(), probe.y(), 0, this::test);
if (struck != null) {
shot.spend();
hit(struck);
}
}
if (fighter == null) return;
Circle hull = new Circle(fighter.x(), Field.FIGHTER_Y, Field.FIGHTER_RADIUS);
for (int i = 0; i < insectShots.size(); i++) {
Shot shot = insectShots.get(i);
if (hull.contains(shot.position().x(), shot.position().y())) {
shot.spend();
loseFighter();
return;
}
}
probe = hull;
struck = null;
grid.forEachNear(hull.x(), hull.y(), hull.radius(), this::test);
if (struck != null) {
destroy(struck); // rammed: the insect is lost too, and still counts
loseFighter();
}
}
/** The grid's visitor: the first living insect that overlaps the probe is struck. */
private void test(Entity insect) {
if (struck != null || !insect.alive()) return;
Vector at = insect.get(Body.class).position();
if (probe.overlaps(new Circle(at.x(), at.y(), Field.INSECT_RADIUS))) struck = insect;
}
@Test
void theGridFindsEveryHitWithAFractionOfTheTests() {
SeededRandom random = new SeededRandom(24);
int n = 2000;
double[] x = new double[n];
double[] y = new double[n];
Grid<Integer> grid = new Grid<>(640, 400, 16);
for (int i = 0; i < n; i++) {
x[i] = random.nextDouble() * 640;
y[i] = random.nextDouble() * 400;
grid.add(i, x[i], y[i], 3);
}
// Every pair, the slow way: n × (n - 1) / 2 tests.
Set<Long> slow = new HashSet<>();
for (int a = 0; a < n; a++) {
for (int b = a + 1; b < n; b++) {
if (new Circle(x[a], y[a], 3).overlaps(new Circle(x[b], y[b], 3))) slow.add((long) a * n + b);
}
}
// With the grid: only the neighbours of each.
Set<Long> fast = new HashSet<>();
int[] tests = {0};
for (int a = 0; a < n; a++) {
int me = a;
grid.forEachNear(x[a], y[a], 3, b -> {
if (b <= me) return;
tests[0]++;
if (new Circle(x[me], y[me], 3).overlaps(new Circle(x[b], y[b], 3))) fast.add((long) me * n + b);
});
}
assertEquals(slow, fast, "the same hits");
assertTrue(tests[0] < n * (n - 1) / 2 / 100, tests[0] + " tests instead of " + n * (n - 1) / 2);
}
Is it worth it for Swarm? Measured, honestly:
| Things | Questions | Testing all pairs | With the grid |
|---|---|---|---|
| 40 | 2 | 0.005 ms | 0.006 ms |
| 2000 | 2000 | 10.0 ms | 1.1 ms |
For forty insects and two shots, the grid is no faster; filling it costs as much as it saves. Swarm uses it anyway, because it is the place to learn it, and because the next games have more: Maze's tiles, Blast's flames, the units of Towers and Sands. At 2000 things the plain loop takes more than half a frame and the grid a fifteenth of it. The cell size matters: about the size of the things, or a little more. Much smaller, and each thing is in many cells; much larger, and each cell holds many things.
A boss falls at the second hit, an insect that rams the fighter falls with it, and a flying insect is worth more:
/** A boss takes two hits; everyone else falls at the first. */
private void hit(Entity insect) {
Insect what = insect.get(Insect.class);
if (what.hit()) {
destroy(insect);
} else {
events.add(new Event.InsectHit(what.kind(), insect.get(Body.class).position()));
}
}
private void destroy(Entity insect) {
Kind kind = insect.get(Insect.class).kind();
boolean flying = insect.has(Flight.class) || insect.has(Homing.class);
int points = plan.challenge() ? Field.CHALLENGE_POINTS : kind.points(flying);
if (plan.challenge()) challengeHits++;
insect.destroy();
addPoints(points);
events.add(new Event.InsectDestroyed(kind, insect.get(Body.class).position(), points));
}
public enum Kind {
BEE(50, 100, 1),
BUTTERFLY(80, 160, 1),
/** The boss takes two hits: the first only changes its colour. */
BOSS(150, 400, 2);
private final int points;
private final int flyingPoints;
private final int hits;
Kind(int points, int flyingPoints, int hits) {
this.points = points;
this.flyingPoints = flyingPoints;
this.hits = hits;
}
/** Points for an insect shot down; more if it was flying, not sitting in the formation. */
public int points(boolean flying) {
return flying ? flyingPoints : points;
}
public int hits() {
return hits;
}
@Test
void aBossTakesTwoHits() {
Game game = new Game(4);
runUntil(game, 30, Steering.NONE, () -> sitting(game).size() == 40);
Entity boss = sitting(game).stream().filter(e -> e.get(Insect.class).kind() == Kind.BOSS).findFirst()
.orElseThrow();
// Under the boss, firing until it falls; whoever is in the way falls first.
List<Event> events = hunt(game, boss, 30);
assertFalse(boss.alive());
assertTrue(events.stream().anyMatch(e -> e instanceof Event.InsectHit(Kind kind, Vector at)
&& kind == Kind.BOSS), "the first hit only changed its colour");
assertTrue(events.stream().anyMatch(e -> e instanceof Event.InsectDestroyed(Kind kind, Vector at, int points)
&& kind == Kind.BOSS && (points == 150 || points == 400)));
}
Step 6: The fighter and the mouse¶
The fighter is simple next to the insects: it moves along one line, by the keys or towards the mouse pointer, and never past the edges.
/** Moves left or right by the steering, or towards a point it aims at (the mouse), never past the edges. */
void move(Steering steering, double seconds) {
previousX = x;
double step = Field.FIGHTER_SPEED * seconds;
if (!Double.isNaN(steering.target())) {
x += Math.clamp(steering.target() - x, -step, step);
} else {
x += steering.move() * step;
}
x = Math.clamp(x, Field.FIGHTER_MARGIN, Field.WIDTH - Field.FIGHTER_MARGIN);
}
/** The fighter flies along this line at the bottom, left and right only. */
public static final double FIGHTER_Y = 370;
public static final double FIGHTER_SPEED = 230;
/** How far the fighter's centre may go towards the edges. */
public static final double FIGHTER_MARGIN = 16;
/** Hit circles, a little smaller than the pictures. */
public static final double FIGHTER_RADIUS = 6;
public static final double INSECT_RADIUS = 7;
/** Shots in the air at once: two, as in the arcade. */
public static final int MAX_SHOTS = 2;
public static final double SHOT_SPEED = 560;
As in Rocks, the mouse steers only after it has moved, and a key takes over until it moves again; a left click fires:
/**
* A game. The keys move the fighter; once the mouse moves, the fighter follows the pointer instead,
* until a key moves it again. A left click fires.
*/
final class PlayScene implements Scene {
private final Game game = new Game(seeds.getAsLong());
private boolean mouseSteers;
PlayScene() {
effects.clear(true);
effects.react(game.events(), true); // the first stage's start: its fanfare
}
@Override
public void update(double seconds) {
if (controls.wasPressed(Action.PAUSE)) {
scenes.push(new PauseScene());
return;
}
game.update(seconds, steering());
effects.react(game.events(), true);
effects.update(seconds);
for (Event event : game.events()) {
if (event instanceof Event.GameOver(GameResult result)) {
scenes.replace(new OverScene(game, result));
// The presenter lives on the Swing thread: hand the result over there.
Consumer<GameResult> listener = onGameOver;
ui.execute(() -> listener.accept(result));
}
}
}
private Steering steering() {
int move = controls.axis(Action.LEFT, Action.RIGHT);
if (move != 0) mouseSteers = false;
else if (mouse.wasMoved()) mouseSteers = true;
double target = mouseSteers && !Double.isNaN(mouse.x()) ? mouse.x() : Double.NaN;
return new Steering(move, target, controls.wasPressed(Action.FIRE) || mouse.wasPressed(MouseEvent.BUTTON1));
}
@Test
void theFighterFollowsThePointerUntilAKeyMovesIt() {
SwarmGame.PlayScene play = play();
mouse.moved(500, 200);
seconds(1);
assertTrue(play.mouseSteers());
assertEquals(500, play.game().fighter().x(), 1e-9, "under the pointer");
mouse.pressed(MouseEvent.BUTTON1);
steps(1);
mouse.released(MouseEvent.BUTTON1);
assertEquals(1, play.game().shots().size(), "a click fires");
keyboard.pressed(KeyEvent.VK_LEFT, now[0]);
seconds(0.5);
assertFalse(play.mouseSteers(), "a key takes over again");
assertEquals(500 - Field.FIGHTER_SPEED * 0.5, play.game().fighter().x(), 1);
}
Step 7: Music¶
The original greeted each game with a short fanfare, and its challenge stages had a tune of their own. Music in a game is more than one long sound effect, for two reasons. First, it has to be written somehow; recording it into files is one way, and writing it as notes is the way of the 8-bit era, which fits our sounds made from waves. Second, it plays beside the effects: a new tune replaces the old one, but a shot does not stop the music.
Tunes as text¶
The home computers of the 1980s wrote music in the Music Macro Language, MML: a line of letters per voice. c d e are notes, a number after a note is its length (4 a quarter, 8 an eighth), o5 sets the octave, > and < go up and down one, l8 makes eighths the default, t140 sets the tempo, r is a rest, + raises a note by a semitone. Tune.voice turns such a line into a Sound:
/** One voice of a tune, played with {@code wave} at {@code volume} 0 to 1. */
public static Sound voice(Sound.Wave wave, double volume, String mml) {
String text = mml.toLowerCase(Locale.ROOT);
double tempo = 120;
int octave = 4;
double defaultLength = 4;
double beats = 0; // where in the tune we are, in quarter notes
List<double[]> notes = new ArrayList<>(); // start beat, beats, frequency
int i = 0;
while (i < text.length()) {
char c = text.charAt(i++);
switch (c) {
case 't' -> {
int[] read = number(text, i);
tempo = read[0];
i = read[1];
}
case 'o' -> {
int[] read = number(text, i);
octave = read[0];
i = read[1];
}
case 'l' -> {
int[] read = number(text, i);
defaultLength = read[0];
i = read[1];
}
case '>' -> octave++;
case '<' -> octave--;
case 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'r' -> {
int semitone = c == 'r' ? 0 : "c d ef g a b".indexOf(c);
while (i < text.length() && "+#-".indexOf(text.charAt(i)) >= 0) {
semitone += text.charAt(i++) == '-' ? -1 : 1;
}
double length = defaultLength;
if (i < text.length() && Character.isDigit(text.charAt(i))) {
int[] read = number(text, i);
length = read[0];
i = read[1];
}
double noteBeats = 4 / length;
for (double dot = noteBeats / 2; i < text.length() && text.charAt(i) == '.'; dot /= 2, i++) {
noteBeats += dot;
}
if (c != 'r') notes.add(new double[]{beats, noteBeats, frequency(octave, semitone)});
beats += noteBeats;
}
case ' ', '|', '\n', '\t' -> {
}
default -> throw new IllegalArgumentException("unknown MML command '" + c + "' at " + (i - 1));
}
}
double samplesPerBeat = Sound.RATE * 60 / tempo;
short[] samples = new short[(int) Math.round(beats * samplesPerBeat)];
for (double[] note : notes) {
// Start and end from the beat count, not by adding up rounded note lengths: voices stay together.
int start = (int) Math.round(note[0] * samplesPerBeat);
int end = (int) Math.round((note[0] + note[1]) * samplesPerBeat);
int sounding = (int) ((end - start) * 0.9);
Sound tone = Sound.tone(wave, note[2], note[2], sounding / (double) Sound.RATE, volume);
for (int s = 0; s < tone.length() && start + s < samples.length; s++) {
samples[start + s] = tone.sample(s);
}
}
return Sound.of(samples);
}
/** The frequency of a note: 440 Hz for A in octave 4, and twelve equal semitones to each octave. */
static double frequency(int octave, int semitone) {
int fromA4 = (octave - 4) * 12 + semitone - 9;
return 440 * Math.pow(2, fromA4 / 12.0);
}
Two details. Each note sounds for 90 percent of its length, so two equal notes in a row are heard as two. And each note's start is computed from the beats counted so far, not by adding up note lengths rounded to whole samples: rounded lengths drift, and after a few bars the bass would lag behind the melody. The frequency is the rule of equal temperament: A in octave 4 is 440 Hz, and each of the twelve semitones of an octave multiplies by the twelfth root of 2.
@Test
void notesHaveTheirPitchAndLength() {
assertEquals(440, Tune.frequency(4, 9), 1e-9); // A in octave 4
assertEquals(261.63, Tune.frequency(4, 0), 0.01); // middle C
assertEquals(880, Tune.frequency(5, 9), 1e-9); // an octave up: twice the frequency
// Tempo 60: a quarter note is a second. A whole A, then a half rest, then a dotted quarter C an octave up.
Sound tune = Tune.voice(Sound.Wave.SQUARE, 0.5, "t60 o4 a1 r2 > c4.");
assertEquals((4 + 2 + 1.5) * Sound.RATE, tune.length());
assertEquals(440, crossings(tune, 0, Sound.RATE), 2, "440 Hz");
assertEquals(0, tune.sample(5 * Sound.RATE), "the rest");
assertTrue(crossings(tune, 6 * Sound.RATE, (int) (6.5 * Sound.RATE)) > 120, "C5, 523 Hz");
}
Voices are mixed into one sound with Sound.mix, which adds the samples, as the mixer does:
/**
* The sounds played at once, as one sound as long as the longest: the voices of a tune, melody and bass.
* The samples are added up and clipped, as the {@link Mixer} does while playing.
*/
public static Sound mix(Sound... sounds) {
int n = 0;
for (Sound sound : sounds) n = Math.max(n, sound.samples.length);
short[] mixed = new short[n];
for (int i = 0; i < n; i++) {
int sum = 0;
for (Sound sound : sounds) {
if (i < sound.samples.length) sum += sound.samples[i];
}
mixed[i] = (short) Math.clamp(sum, Short.MIN_VALUE, Short.MAX_VALUE);
}
return new Sound(mixed);
}
/** A game begins: up the C major chord, then D minor, E minor, and home. */
final Sound start = Sound.mix(
Tune.voice(Wave.SQUARE, 0.12, "t160 o5 l16 c e g >c< g e c e d f a >d< a f d f"
+ "| e g b >e< b g e g c4 r4"),
Tune.voice(Wave.TRIANGLE, 0.25, "t160 o3 l4 c c d d | e e c r"));
/** A challenge stage: two bars over a walking bass, again and again. */
final Sound challenge = Sound.mix(
Tune.voice(Wave.SQUARE, 0.08, "t140 o5 l8 e g a g e d c d | e g a >c< a g e r"
+ "| d f g f d c < b > c | d f g b g f d r"),
Tune.voice(Wave.TRIANGLE, 0.22, "t140 o3 l8 a >c e c< a >c e c< | a >c e c< a >c e c<"
+ "| g b >d< b g b >d< b | g b >d< b g b >d< b"));
A square wave for the melody and a triangle for the bass is the sound of the consoles of that time: the triangle is soft and low, the square cuts through.
A voice for the music¶
The Mixer from chapter 20 plays effects on a number of voices, and a new effect takes the oldest voice when all are busy. Music must never be taken that way, so it gets a voice of its own. playMusic replaces what is playing and may loop; stopMusic stops it. Like play, both only leave a request for the mixer's thread, so the game loop never waits:
/**
* Plays {@code music} instead of the music playing now, from its start; with {@code loop}, again and
* again until {@link #stopMusic()}. Safe to call from any thread, never waits.
*/
public void playMusic(Sound music, boolean loop) {
musicRequest.set(new MusicRequest(music, loop));
}
/** Stops the music at once. */
public void stopMusic() {
musicRequest.set(new MusicRequest(null, false));
}
@Test
void musicPlaysBesideTheEffectsAndLoops() {
Mixer mixer = new Mixer();
mixer.playMusic(constant(100, 300), true);
for (int i = 0; i < Mixer.VOICES + 2; i++) mixer.queue(constant(1, 10_000));
short[] block = new short[Mixer.BLOCK];
mixer.mix(block);
assertEquals(100 + Mixer.VOICES, block[0], "the music and every effect voice");
assertEquals(100 + Mixer.VOICES, block[300], "the music again from its start");
mixer.setMuted(true);
mixer.mix(block);
assertEquals(Mixer.VOICES, block[0], "muted: the effects play to their end, the music is silent");
assertTrue(mixer.playingMusic(), "but goes on");
mixer.stopMusic();
mixer.mix(block);
assertTrue(!mixer.playingMusic());
}
@Test
void musicOnceEnds() {
Mixer mixer = new Mixer();
mixer.playMusic(constant(100, 300), false);
short[] block = new short[Mixer.BLOCK];
mixer.mix(block);
assertEquals(100, block[299]);
assertEquals(0, block[300]);
assertTrue(!mixer.playingMusic());
}
Muting the sound mutes the music too. The game starts its tunes from the events, like every other sound:
/** Shows and, if {@code audible}, plays what happened in the last step; the music too. */
void react(List<Event> events, boolean audible) {
for (Event event : events) {
switch (event) {
case Event.Fired fired -> {
if (audible) mixer.play(sounds.fire);
}
case Event.InsectFired fired -> {
if (audible) mixer.play(sounds.insectFired);
}
case Event.Dived dived -> {
if (audible) mixer.play(sounds.dive);
}
case Event.InsectHit(Kind kind, Vector at) -> {
burst(at, BLUE, 12, 80);
if (audible) mixer.play(sounds.bossHit);
}
case Event.InsectDestroyed(Kind kind, Vector at, int points) -> {
explode(at, false, points >= 150 ? points : 0);
burst(at, YELLOW, 14, 90);
if (audible) mixer.play(kind == Kind.BOSS ? sounds.bossDestroyed : sounds.insectDestroyed);
}
case Event.FighterLost(Vector at) -> {
explode(at, true, 0);
burst(at, WHITE, 40, 120);
if (audible) mixer.play(sounds.fighterLost);
}
case Event.FighterReady ready -> {
if (audible) mixer.play(sounds.fighterReady);
}
case Event.StageStarted(int stage, boolean challenge) -> {
challengeResult = null;
if (audible && challenge) mixer.playMusic(music.challenge, true);
else if (audible && stage == 1) mixer.playMusic(music.start, false);
}
case Event.StageCleared cleared -> {
if (audible) mixer.playMusic(music.cleared, false);
}
case Event.ChallengeOver over -> challengeResult = over;
case Event.ExtraLife life -> {
if (audible) mixer.play(sounds.extraLife);
}
case Event.GameOver over -> {
if (audible) mixer.playMusic(music.over, false);
}
}
}
}
public sealed interface Event {
record Fired(Vector at) implements Event {
}
record InsectFired(Vector at) implements Event {
}
/** An insect left the formation to dive at the fighter. */
record Dived(Kind kind, Vector at) implements Event {
}
/** A boss took its first hit and changed its colour. */
record InsectHit(Kind kind, Vector at) implements Event {
}
record InsectDestroyed(Kind kind, Vector at, int points) implements Event {
}
record FighterLost(Vector at) implements Event {
}
record FighterReady(Vector at) implements Event {
}
record StageStarted(int stage, boolean challenge) implements Event {
}
record StageCleared(int stage) implements Event {
}
/** The end of a challenge stage: insects hit of all, and the bonus. */
record ChallengeOver(int hits, int of, int bonus) implements Event {
}
record ExtraLife(int lives) implements Event {
}
record GameOver(GameResult result) implements Event {
}
}
Step 8: The computer player¶
The pilot of the title screen looks for danger first: an insect shot that will land where the fighter is, or a diver close above it. If there is one, it moves away. Otherwise it goes under the lowest insect and fires when it is there:
public Steering steer(Game game, double seconds) {
sinceShot += seconds;
Fighter fighter = game.fighter();
if (fighter == null) return Steering.NONE;
distracted -= seconds;
if (distracted <= 0 && random.nextDouble() < DISTRACTIONS * seconds) distracted = DISTRACTION;
double dodge = distracted > 0 ? Double.NaN : danger(game, fighter);
if (!Double.isNaN(dodge)) return new Steering(0, dodge, false);
// The lowest insect on the field is the nearest threat and the easiest target.
Vector[] lowest = {null};
game.insects().forEach(Body.class, (insect, body) -> {
Vector at = body.position();
if (at.y() > 10 && at.y() < Field.FIGHTER_Y - 40 && (lowest[0] == null || at.y() > lowest[0].y())) {
lowest[0] = at;
}
});
if (lowest[0] == null) return new Steering(0, Field.WIDTH / 2, false);
double aim = lowest[0].x() + shake;
boolean fire = Math.abs(fighter.x() - aim) < 6 && sinceShot >= FIRE_PAUSE;
if (fire) {
sinceShot = 0;
shake = (random.nextDouble() * 2 - 1) * SHAKE;
}
return new Steering(0, aim, fire);
}
/** Where to flee to from the nearest shot or diver about to reach the fighter; NaN if none. */
private static double danger(Game game, Fighter fighter) {
Pool<Shot> shots = game.insectShots();
for (int i = 0; i < shots.size(); i++) {
Shot shot = shots.get(i);
double above = Field.FIGHTER_Y - shot.position().y();
if (above <= 0 || above > SIGHT || shot.velocity().y() <= 0) continue;
double arrivesAt = shot.position().x() + shot.velocity().x() * above / shot.velocity().y();
if (Math.abs(arrivesAt - fighter.x()) < 16) return away(fighter.x(), arrivesAt);
}
double[] flee = {Double.NaN};
game.insects().forEach(Body.class, Flight.class, (insect, body, flight) -> {
Vector at = body.position();
double above = Field.FIGHTER_Y - at.y();
if (above > 0 && above < SIGHT && Math.abs(at.x() - fighter.x()) < 30) flee[0] = away(fighter.x(), at.x());
});
return flee[0];
}
private static double away(double x, double from) {
double side = x < from ? -1 : 1;
if (x < 60) side = 1;
if (x > Field.WIDTH - 60) side = -1;
return x + side * 60;
}
The first version did only that, and it was too good again, as in Rocks: it dodged every shot and played for ten minutes. Its aim already shakes. What made it human was distraction: now and then, for a moment, it stops looking for danger. Now it ends at the fourth or fifth stage, after three or four minutes, and the title screen shows a game over too:
@Test
void thePilotClearsAFewStagesAndLosesItsFightersInTheEnd() {
int best = 0;
int stages = 0;
int ended = 0;
for (long seed = 1; seed <= 5; seed++) {
Game game = new Game(seed);
Pilot pilot = new Pilot(game.seed());
for (int i = 0; i < 10 * 60 * 120 && game.phase() != Game.Phase.OVER; i++) {
game.update(STEP, pilot.steer(game, STEP));
}
if (game.phase() == Game.Phase.OVER) ended++;
best = Math.max(best, game.score());
stages = Math.max(stages, game.stage());
}
assertTrue(best >= 5000, "best " + best);
assertTrue(stages >= 3, "reached stage " + stages);
assertEquals(5, ended, "every game ends within ten minutes, so the title shows the end too");
}
@Test
void theSameSeedAndPilotPlayTheSameGame() {
Game one = new Game(42);
Game two = new Game(42);
Pilot first = new Pilot(42);
Pilot second = new Pilot(42);
for (int i = 0; i < 60 * 120; i++) {
one.update(STEP, first.steer(one, STEP));
two.update(STEP, second.steer(two, STEP));
}
assertEquals(one.score(), two.score());
assertEquals(one.stage(), two.stage());
assertEquals(one.insects().all().stream().map(e -> e.get(Body.class).position()).toList(),
two.insects().all().stream().map(e -> e.get(Body.class).position()).toList());
}
Step 9: Putting it together¶
The painter draws the insects with the systems' own tool: every entity with a Body and an Insect. The cell comes from the insect's kind, the wing beat and its angle between the last two steps:
/** @param alpha how far the time is between the last two update steps, 0 to 1 */
void paint(Graphics2D g, Game game, Effects effects, double alpha) {
// Space and stars are straight-edged rectangles: without antialiasing, which would cost three times as much.
Object smoothing = g.getRenderingHint(RenderingHints.KEY_ANTIALIASING);
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
g.setColor(Color.BLACK);
g.fill(space);
stars(g, effects, alpha);
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, smoothing);
this.g = g;
this.alpha = alpha;
this.wingRow = wings.frame(effects.time());
game.insects().forEach(Body.class, Insect.class, (entity, body, insect) -> drawInsect(body, insect));
Fighter fighter = game.fighter();
if (fighter != null) {
double x = fighter.previousX() + (fighter.x() - fighter.previousX()) * alpha;
sheet.frame(0, EXTRAS_ROW).draw(g, x - 8, Field.FIGHTER_Y - 8);
}
shots(g, game.shots(), SHOT, 1.5, 6);
shots(g, game.insectShots(), INSECT_SHOT, 2, 4);
explosions(g, effects);
effects.particles().render(g, alpha);
this.g = null;
}
/** The cell of an insect: its kind's two rows, the wing beat, and the column nearest its direction. */
private void drawInsect(Body body, Insect insect) {
double x = body.previous().x() + (body.position().x() - body.previous().x()) * alpha;
double y = body.previous().y() + (body.position().y() - body.previous().y()) * alpha;
double angle = body.previousAngle() + Angles.difference(body.previousAngle(), body.angle()) * alpha;
sheet.frame(column(angle), firstRow(insect) + wingRow).draw(g, x - 8, y - 8);
}
/** Bees in rows 0 and 1, butterflies in 2 and 3, bosses in 4 and 5, hit bosses in 6 and 7. */
static int firstRow(Insect insect) {
return switch (insect.kind()) {
case BEE -> 0;
case BUTTERFLY -> 2;
case BOSS -> insect.hits() > 1 ? 4 : 6;
};
}
The score, the stage and the fighters left are the overlay; points for a boss rise from its explosion:
/** The score at the top left, the stage at the top right, a small fighter for each one left below. */
void status(Graphics2D g, Game game, Effects effects) {
g.setColor(CHALK);
g.setFont(score);
g.drawString(msg.format("hud.score", game.score()), 14f, 26f);
g.setFont(small);
FontMetrics fm = g.getFontMetrics();
String stage = msg.format("hud.stage", game.stage());
g.drawString(stage, (float) (Field.WIDTH - 14 - fm.stringWidth(stage)), 24f);
int waiting = game.lives() - (game.fighter() != null ? 1 : 0);
for (int i = 0; i < waiting; i++) fighter.draw(g, 8 + i * 18, Field.HEIGHT - 18);
g.setFont(points);
g.setColor(CYAN);
for (int i = 0; i < Effects.EXPLOSIONS; i++) {
double age = effects.time() - effects.boomTime[i];
if (effects.boomPoints[i] == 0 || age < 0 || age > POINTS_SECONDS) continue;
String text = Integer.toString(effects.boomPoints[i]);
g.drawString(text, (float) (effects.boomX[i] - g.getFontMetrics().stringWidth(text) / 2.0),
(float) (effects.boomY[i] + 4 - age * 12));
}
}
A black rectangle¶
The first measurements were a surprise. In Xvfb, which draws in software, a frame of play at 1920 × 1200 took 13.5 ms, much more than Rocks with all its lines. Forty small sprites could not cost that. Measuring the pieces one by one found the culprit in the first line of paint: the black background. With antialiasing on, Java2D fills even a plain rectangle by computing the coverage of every pixel's edges, and the full-screen black cost 3.5 to 3.8 ms, three times as much as without. The veil behind the title texts cost the same again.
A rectangle on whole pixels has no edges to smooth. Turned off for the background and the stars, and on again for the rest, the world takes 1.1 ms instead of 3.4, and the whole frame:
| Scene and mode | 640 × 400 | 1280 × 800 | 1920 × 1200 |
|---|---|---|---|
| Play, direct | 1.7 ms | 3.3 ms | 6.5 ms |
| Play, pixels | 1.9 ms | 4.4 ms | 7.4 ms |
| Title, direct | 3.0 ms | 6.3 ms | 14.4 ms |
The title is still the most expensive scene, with its large antialiased letters over the veil. A frame at 60 frames per second may take 16.7 ms. As always: on a real screen with a graphics card, these cost less; press F12 and measure on yours. The rule went into the appendix.
Full screen (F11), the render modes, the frame times (F12), sound, the best scores and the five languages work as in the earlier games, from the same engine classes. In pixel mode, the whole frame becomes pixel art; in direct mode, the insects stay pixel art and the text stays smooth.
What moved into the engine¶
| From Swarm | Engine package | Why |
|---|---|---|
| Sprite sheets, hard-edged sprites | eu.amadeos.gameengine.sprite.SpriteSheet, Sprite#hardEdges |
Many pictures in one image; pixel art at any size: Maze's ghosts, Blast's bombers, Jump's hero |
| Animation | eu.amadeos.gameengine.sprite.Animation |
Wings, walking, explosions, anything that runs through frames |
| Entities and components | eu.amadeos.gameengine.entity.Entity, Entities |
Things whose behaviour changes: Maze's ghosts, the units of Towers and Sands |
| Flight paths | eu.amadeos.gameengine.motion.FlightPath |
Smooth curves at an even speed: enemies, cameras, anything on rails |
| Data files | eu.amadeos.gameengine.data.TextData |
Levels, waves, units and towers as text, with errors that name the line |
| A spatial grid | eu.amadeos.gameengine.collision.Grid |
Collisions and "who is near?" for many things |
| Music | eu.amadeos.gameengine.audio.Tune, Sound#mix, Mixer#playMusic |
Tunes written as notes, on a voice of their own |
What stays in the game: the insects and their components, the formation, the stages and their file, the fighter, the shots, the pilot, the painter, the sounds and tunes, and the scenes.
Exercises¶
- The capture beam. In the original, a boss could dive, stop above the fighter and catch it with a beam; shooting that boss later freed the fighter, and the player then flew two fighters side by side. Which new components does it take, and which systems change? Where does the double fighter's hit test go?
- Escorts. Let a boss dive with the two butterflies below it, flying the same path a little behind. Is that a new component, or the same
Flightwith an offset? - A stage of your own. Write a fourth stage in
stages.txtwith new paths, without changing any Java. Make a mistake on purpose and read the error. - An editor. Draw the paths of the stage file over the field while the game runs (the screenshot above does), and reload the file with a key. How short does the loop from idea to test become?
- More directions. Turn the insects into 32 directions instead of 16, and compare how the curves look. What does it cost in memory, and why do the explosions not need it?
- The grid at work. Fill the field with 2000 insects of a challenge stage and press F12, with the grid and with a plain loop over all pairs.
In the next game, Maze, the field is a map of tiles. Four ghosts hunt the player through the corridors, each with its own mind: state machines, and the search for the shortest way, from breadth-first search to A*.