23. Rocks¶
Paddle, Bricks and Snake move along the axes: left and right, up and down. In Rocks, everything flies at any angle. The ship turns on the spot, its engine pushes it the way its nose points, and it drifts on when the engine stops. Large rocks tumble through space; a hit breaks them into smaller and faster ones. And space has no edges: whatever flies out on one side comes back on the other.

The original came from an arcade hall in 1979, and it drew with lines on a vector screen, not with pixels. Our Rocks does the same, for a reason this chapter explains. It needs five new things in the engine, and all of them come back in the next games: Swarm's ships fly curves, Maze's ghosts turn, and every shooter has bullets.
What you will learn
- Vectors: position, velocity and direction as one small type, and the arithmetic of flying
- Rotation: turning a point, a direction and a whole figure, and the short way round between two angles
- Outlines: why a game that turns things draws lines, not pictures
- A field without edges: wrapping positions, the shortest distance, drawing things twice
- Circle collision, also across an edge
- Object pools: four shots, made once, used again and again
- The mouse as a second way to steer, and sound made from filtered noise
The complete game is in amade-rocks-game.
The rules¶
- The ship starts in the centre with its nose up. Left and right turn it, up fires the engine, Space shoots, down jumps into hyperspace.
- At most four shots fly at a time. Each flies a little more than half the field and then vanishes.
- A shot breaks a large rock (20 points) into two medium ones (50 points each), a medium one into two small ones (100 points each), and a small one into dust.
- The field has no edges. Rocks, shots and the ship leave it on one side and come back on the other.
- A rock that touches the ship destroys it. The next ship appears in the centre, but only when no rock is near. There are three ships, and an extra one every 10,000 points.
- When all rocks are gone, the next wave comes: 4, 6, 8, 10, then 11 large rocks, a little faster each time.
- Hyperspace takes the ship to a random place, perhaps right next to a rock.
Step 1: Vectors¶
A ship in Rocks has a position, a velocity and a direction. Each is two numbers, and each changes with the others: the direction changes the velocity, the velocity changes the position. Written with separate x and y fields, every line of physics comes twice, and sooner or later one of the twins has a typo. So the engine has a type for "two numbers that belong together", the vector:
public record Vector(double x, double y) {
public static final Vector ZERO = new Vector(0, 0);
/** A vector of {@code length} pointing at {@code angle}. */
public static Vector ofAngle(double angle, double length) {
return new Vector(Math.cos(angle) * length, Math.sin(angle) * length);
}
public Vector plus(Vector other) {
return new Vector(x + other.x, y + other.y);
}
public Vector minus(Vector other) {
return new Vector(x - other.x, y - other.y);
}
public Vector times(double factor) {
return new Vector(x * factor, y * factor);
}
A vector is a record and cannot change. plus does not move a vector, it makes a new one. That rules out a whole family of bugs: a rock that keeps a reference to the ship's position cannot move the ship by accident.
Behind the scenes: are all these small objects slow?
A ship's step makes half a dozen new vectors, 120 times a second. That sounds wasteful, and twenty years ago it was. Today the JVM sees that most of them never leave the method that made them (escape analysis) and keeps their two numbers in processor registers, without creating an object at all. Those that remain live a few microseconds and cost the garbage collector next to nothing. Measure before you trade readable code for a few saved objects; Step 5 is about when it is worth it.
Angles need one decision, made once for the whole engine. In mathematics, angles turn anticlockwise, with y pointing up. On the screen, y points down. The engine keeps the formulas of mathematics and accepts that a positive angle turns clockwise on the screen; angle 0 points right, and -π/2 points up:
@Test
void angleZeroPointsRightAndMinusHalfPiPointsUp() {
assertNear(new Vector(2, 0), Vector.ofAngle(0, 2));
assertNear(new Vector(0, -1), Vector.ofAngle(-Math.PI / 2, 1)); // y grows downwards
assertEquals(Math.PI / 2, new Vector(0, 5).angle(), EPSILON); // down
}
@Test
void turningAQuarterClockwiseMakesRightIntoDown() {
assertNear(new Vector(0, 3), new Vector(3, 0).rotated(Math.PI / 2));
assertNear(new Vector(-3, 0), new Vector(3, 0).rotated(Math.PI));
assertEquals(5, new Vector(3, 4).rotated(1.234).length(), EPSILON, "turning keeps the length");
}
With vectors, flying is four lines. The keys turn the nose, the engine adds a little velocity in the direction of the nose, drag takes a little away, and the velocity moves the ship:
/**
* One step of flying. The ship turns by the keys, or towards the angle {@code steering.aim()} the mouse
* points at; the engine pushes it the way its nose points; drag slows it down, and it never gets faster
* than the limit.
*/
void fly(Steering steering, double seconds) {
previous = position;
previousAngle = angle;
double turn = Field.TURN_SPEED * seconds;
if (!Double.isNaN(steering.aim())) angle = Angles.turnTowards(angle, steering.aim(), turn);
else angle += steering.turn() * turn;
thrusting = steering.thrust();
if (thrusting) velocity = velocity.plus(Vector.ofAngle(angle, Field.THRUST * seconds));
velocity = velocity.times(1 - Field.DRAG * seconds).limited(Field.MAX_SPEED);
position = Field.WRAP.wrap(position.plus(velocity.times(seconds)));
}
/** The ship's hit circle, a little smaller than its drawing, which is fairer. */
public static final double SHIP_RADIUS = 8;
/** Turning speed, radians per second: about two thirds of a turn. */
public static final double TURN_SPEED = Math.toRadians(250);
/** Acceleration while the engine fires, units per second². */
public static final double THRUST = 260;
public static final double MAX_SPEED = 260;
/**
* Drag: each step takes this times the step's seconds of the ship's speed away, a little more than a
* third per second. Space has no friction, games do.
*/
public static final double DRAG = 0.45;
Space has no friction, and the original had some anyway. Without drag, the ship would keep every bit of speed it ever had, and a player would spend the game braking with the nose turned back. Each step here takes 0.45 × 1/120 of the speed away, which adds up to a little more than a third per second; after a few seconds without thrust the ship comes to rest. Note that velocity.times(1 - DRAG * seconds) gives a slightly different result with a different step length. With fixed steps that does not matter, one more reason to have them.
@Test
void theShipTurnsThrustsAndDriftsToAStop() {
Game game = quiet();
Ship ship = game.ship();
run(game, 0.5, new Steering(1, Double.NaN, false, false, false));
assertEquals(-Math.PI / 2 + Field.TURN_SPEED * 0.5, ship.angle(), 1e-9, "turned right");
run(game, 0.5, THRUST);
double speed = ship.velocity().length();
assertTrue(speed > 100, "faster: " + speed);
assertEquals(ship.angle(), ship.velocity().angle(), 1e-9, "flies where the nose points");
run(game, 5, Steering.NONE);
assertTrue(ship.velocity().length() < speed / 5, "drag slowed it down");
}
@Test
void theShipFliesOutAtTheTopAndComesBackAtTheBottom() {
Game game = quiet();
Ship ship = game.ship(); // in the centre, nose up
boolean crossed = false;
for (int i = 0; i < 3 * 120 && !crossed; i++) {
game.update(STEP, THRUST);
crossed = ship.position().y() > Field.CENTRE.y() + 50;
}
assertTrue(crossed, "came back from below");
assertEquals(Field.CENTRE.x(), ship.position().x(), 1e-6, "on the same line");
}
Step 2: Turning¶
Turning a point by an angle around the origin is one formula, and every rotation in this chapter uses it:
/** This vector turned by {@code angle} around the origin. */
public Vector rotated(double angle) {
double cos = Math.cos(angle);
double sin = Math.sin(angle);
return new Vector(x * cos - y * sin, x * sin + y * cos);
}
Why does it work? A point at distance r and angle a is (r cos a, r sin a). Turned by b, it is at angle a + b, and the addition theorems for sine and cosine turn cos(a + b) and sin(a + b) into exactly the two lines above. You do not need to remember the proof; you need to remember that turning costs four multiplications.
The short way round¶
The mouse gives the ship a target angle, and the ship turns towards it at its turning speed. Which way? An angle has many names: 170° and -190° are the same direction. If the ship points at 170° and the target is -170°, the naive difference is -340°, and the ship would turn almost all the way round, instead of 20° the other way. Angles brings every difference into -180° … 180°:
/**
* How far to turn from {@code from} to {@code to} the short way round, -π to π: positive is clockwise on
* the screen. From 170° to -170° is +20°, not -340°.
*/
public static double difference(double from, double to) {
return normalize(to - from);
}
@Test
void theShortWayRoundCrossesTheBackOfTheCircle() {
assertEquals(degrees(20), Angles.difference(degrees(170), degrees(-170)), EPSILON);
assertEquals(degrees(-20), Angles.difference(degrees(-170), degrees(170)), EPSILON);
assertEquals(degrees(90), Angles.difference(degrees(720), degrees(90)), EPSILON);
}
@Test
void turningTowardsATargetStopsOnIt() {
assertEquals(degrees(10), Angles.turnTowards(0, degrees(90), degrees(10)), EPSILON);
assertEquals(degrees(-10), Angles.turnTowards(0, degrees(-90), degrees(10)), EPSILON);
assertEquals(degrees(5), Angles.turnTowards(0, degrees(5), degrees(10)), EPSILON, "no overshooting");
}
Pictures or lines?¶
Now the ship must be drawn at its angle, and so must every rock. In Snake, the head had four directions, and four sprites did the job. Here there are as many directions as there are numbers. Three ways come to mind:
- Turn the sprite in every frame.
Graphics2D.rotateand thendrawImage. Java2D then has to work out every pixel of the turned picture, and it cannot use the sprite cache from chapter 21: a cached picture is only valid at one angle.Sprite.drawnotices a turned transform and falls back to drawing the source, which is the slow path Bricks taught us to avoid. - Turn it in advance. Draw each sprite at, say, 64 angles once and pick the nearest. The classic solution of the 16-bit era. But count: a large rock is 60 units wide, on a 1920 × 1200 screen 180 pixels, and turned it needs a square of 255 × 255 pixels, 260 KB. Times 64 angles, times four rock shapes, plus the smaller sizes: about 90 MB of pictures. On a 4K screen, four times that.
- Draw lines. Keep the figure as a list of corners, turn the corners with the formula above, and draw the lines between them. A rock with eleven corners costs eleven turned points per frame, and nothing in memory. The lines are sharp at every size and every angle.
The original had no choice, its screen could only draw lines. We choose lines because they are the right tool for things that turn. The engine's Outline holds the corners and turns them all with one sine and one cosine:
/**
* The figure turned by {@code angle}, enlarged by {@code scale} and moved to {@code x}, {@code y}. Every
* corner is turned with the same formula as {@link eu.amadeos.gameengine.math.Vector#rotated}; sine and
* cosine are worked out once for all corners. The path is reused: it is valid until the next call.
*/
public Path2D.Double path(double x, double y, double angle, double scale) {
double cos = Math.cos(angle) * scale;
double sin = Math.sin(angle) * scale;
path.reset();
for (int i = 0; i < xs.length; i++) {
double px = x + xs[i] * cos - ys[i] * sin;
double py = y + xs[i] * sin + ys[i] * cos;
if (i == 0) path.moveTo(px, py);
else path.lineTo(px, py);
}
if (closed) path.closePath();
return path;
}
The Path2D is made once and filled anew for each drawing, so drawing creates no objects. The figure is given around its own centre, nose to the right, and scale enlarges it: Rocks has four rock shapes of radius about 1, and each rock draws one of them at its own size.
@Test
void aFigureIsTurnedMovedAndEnlarged() {
Outline arrow = Outline.closed(10, 0, -5, -5, -5, 5); // nose to the right
assertEquals(10, arrow.radius(), 1e-9);
// A quarter turn clockwise, twice the size, at 100, 50: the nose points down.
Path2D.Double path = arrow.path(100, 50, Math.PI / 2, 2);
double[] nose = first(path);
assertEquals(100, nose[0], 1e-9);
assertEquals(70, nose[1], 1e-9);
Rectangle2D bounds = path.getBounds2D();
assertEquals(new Rectangle2D.Double(90, 40, 20, 30), bounds);
}
The rock shapes come from a fixed seed, so every game has the same four rocks. Corners at even angles, each at a random distance from the centre, look like a rock to anybody:
/**
* A few jagged rocks of radius about 1, enlarged to each rock's size when drawn: corners at even angles
* around the centre, each at a random distance. A fixed seed, so the rocks look the same in every game.
*/
private static Outline[] rockShapes() {
SeededRandom random = new SeededRandom(23);
Outline[] shapes = new Outline[Game.ROCK_SHAPES];
for (int s = 0; s < shapes.length; s++) {
int corners = 9 + random.nextInt(4);
double[] xy = new double[2 * corners];
for (int i = 0; i < corners; i++) {
double angle = 2 * Math.PI * (i + random.nextDouble() * 0.4) / corners;
double distance = 0.72 + random.nextDouble() * 0.33;
xy[2 * i] = Math.cos(angle) * distance;
xy[2 * i + 1] = Math.sin(angle) * distance;
}
shapes[s] = Outline.closed(xy);
}
return shapes;
}
Fill, then lines
A rock drawn only as lines lets the stars shine through it. Each outline is filled with a very dark colour first and then drawn with a light line, from the same path. That is cheap and makes the rocks solid, and it is what the original could not do.
Step 3: A field without edges¶
When a rock flies out on the right, it comes back on the left. In the rules, that is one line: bring the position back into the field. The engine's Wrap knows the field's size:
/** {@code x} brought into 0 (inclusive) … width (exclusive). */
public double x(double x) {
return x - width * Math.floor(x / width);
}
public double y(double y) {
return y - height * Math.floor(y / height);
}
/** The position brought into the field. */
public Vector wrap(Vector position) {
double x = x(position.x());
double y = y(position.y());
return x == position.x() && y == position.y() ? position : new Vector(x, y);
}
/** Flies on in a straight line and turns; leaving the field on one side, it comes back on the other. */
void move(double seconds) {
previous = position;
previousAngle = angle;
position = Field.WRAP.wrap(position.plus(velocity.times(seconds)));
angle += spin * seconds;
}
Math.floor instead of the remainder operator %, because -10 % 640 is -10 in Java, not 630. A field without edges is what mathematicians call a torus: roll the screen into a tube, joining top and bottom, then bend the tube into a ring, joining left and right. That picture explains the three surprises that follow.
Distance goes the short way¶
Two rocks at x = 630 and x = 10 are 20 units apart, not 620: on the doughnut, they are neighbours. Everything that measures distance must know this, from collisions to the computer player choosing the nearest rock:
/**
* The shortest way from {@code from} to {@code to}, perhaps across an edge. On a field 640 wide, from
* x = 630 to x = 10 is +20, not -620.
*/
public Vector delta(Vector from, Vector to) {
double dx = to.x() - from.x();
double dy = to.y() - from.y();
dx -= width * Math.round(dx / width);
dy -= height * Math.round(dy / height);
return new Vector(dx, dy);
}
@Test
void whatLeavesOnOneSideComesBackOnTheOther() {
assertEquals(new Vector(5, 390), field.wrap(new Vector(645, -10)));
assertEquals(0, field.x(640));
assertEquals(639.5, field.x(-0.5));
}
@Test
void theShortestWayMayLeadAcrossAnEdge() {
assertEquals(new Vector(20, 0), field.delta(new Vector(630, 100), new Vector(10, 100)));
assertEquals(new Vector(-20, 30), field.delta(new Vector(10, 385), new Vector(630, 15)));
assertEquals(new Vector(100, 0), field.delta(new Vector(100, 100), new Vector(200, 100)), "else straight");
}
@Test
void aThingAcrossTheEdgeIsDrawnBetweenItsPositionsTheShortWay() {
// From 638 to 2 in one step, across the edge: a quarter of the way is 639, given as -1, just
// outside the field on the side the thing is on now; not 479, flying back across the screen.
assertEquals(-1, field.between(new Vector(638, 50), new Vector(2, 50), 0.25).x(), 1e-9);
}
A thing over the edge is in two places¶
A large rock centred 10 units from the left edge reaches 20 units beyond it. Those 20 units must appear on the right side of the screen. So a thing near an edge is drawn twice, and near a corner four times:
/**
* Calls {@code copy} for every place a circle at {@code x}, {@code y} with {@code radius} must be drawn:
* once where it is, and again on the far side of each edge it reaches over. Near a corner that makes
* four places. The position may lie a little outside the field, as {@link #between} gives it.
*/
public void forEachCopy(double x, double y, double radius, Copy copy) {
x = x(x);
y = y(y);
double otherX = x - radius < 0 ? x + width : x + radius > width ? x - width : Double.NaN;
double otherY = y - radius < 0 ? y + height : y + radius > height ? y - height : Double.NaN;
copy.at(x, y);
if (!Double.isNaN(otherX)) copy.at(otherX, y);
if (!Double.isNaN(otherY)) copy.at(x, otherY);
if (!Double.isNaN(otherX) && !Double.isNaN(otherY)) copy.at(otherX, otherY);
}
@Test
void aThingOverAnEdgeIsDrawnTwiceAndInACornerFourTimes() {
assertEquals(List.of("630.0 200.0", "-10.0 200.0"), copies(630, 200, 30));
assertEquals(List.of("5.0 395.0", "645.0 395.0", "5.0 -5.0", "645.0 -5.0"), copies(5, 395, 30));
}

Drawing between steps, across the edge¶
Pitfall: the streak across the screen
Since Paddle, every frame draws things between their last two positions, by alpha. Do that naively here, and a rock that crossed the right edge in the last step, from x = 638 to x = 2, is drawn for one frame half-way between, at x = 320, in the middle of the screen. It flashes there, and on a fast ship you see a ghostly streak. The fix is the same as for distance: go the short way. between moves back from the new position along the short step, which may lead a little outside the field, and forEachCopy then draws it on both sides.
/**
* The point {@code alpha} (0 to 1) of the way from {@code previous} to {@code current}, for drawing between
* two update steps. A thing that just crossed an edge is drawn on the short way, just outside the field,
* not flying back across the whole screen; {@link #forEachCopy} then draws it on both sides.
*/
public Vector between(Vector previous, Vector current, double alpha) {
Vector step = delta(previous, current);
return new Vector(current.x() - step.x() * (1 - alpha), current.y() - step.y() * (1 - alpha));
}
The painter puts it together. For every rock it works out the position between the steps, the angle between the steps, and then lets forEachCopy call drawCopy once per place:
/** @param alpha how far the time is between the last two update steps, 0 to 1 */
void paint(Graphics2D g, Game game, Particles particles, double alpha) {
frames++;
sky.draw(g, 0, 0);
this.g = g;
g.setStroke(stroke);
for (Rock rock : game.rocks()) {
Vector at = Field.WRAP.between(rock.previous(), rock.position(), alpha);
set(rocks[rock.shape()], between(rock.previousAngle(), rock.angle(), alpha), rock.size().radius(),
ROCK_FILL, ROCK_LINE);
Field.WRAP.forEachCopy(at.x(), at.y(), outline.radius() * scale + 1, drawCopy);
}
Ship s = game.ship();
if (s != null) {
Vector at = Field.WRAP.between(s.previous(), s.position(), alpha);
double turned = between(s.previousAngle(), s.angle(), alpha);
if (s.thrusting()) {
set(frames % 4 < 2 ? flameLong : flameShort, turned, 1, null, FLAME);
Field.WRAP.forEachCopy(at.x(), at.y(), 20, drawCopy);
}
set(ship, turned, 1, SHIP_FILL, SHIP_LINE);
Field.WRAP.forEachCopy(at.x(), at.y(), outline.radius() + 1, drawCopy);
}
g.setColor(SHOT);
Pool<Bullet> bullets = game.bullets();
for (int i = 0; i < bullets.size(); i++) {
Bullet bullet = bullets.get(i);
Vector at = Field.WRAP.between(bullet.previous(), bullet.position(), alpha);
dot.x = Field.WRAP.x(at.x()) - dot.width / 2;
dot.y = Field.WRAP.y(at.y()) - dot.height / 2;
g.fill(dot);
}
particles.render(g, alpha);
this.g = null;
}
drawCopy is a method reference made once, in a field; what to draw is in fields too, set before each call. A lambda written inline, (x, y) -> draw(rock, x, y), would capture the rock and could cost a new object per rock per frame. Not much, but the painter runs 60 times a second for the whole game, and the fields cost nothing.
Step 4: Circles¶
How do we know that a shot hit a rock? In Bricks, everything was a box, and Box.overlaps compared edges. A box does not turn, and a rock does. But a rock is roughly round, and a circle looks the same at every angle: two circles overlap when their centres are closer than both radii together. No angle, no corners, one comparison:
public record Circle(double x, double y, double radius) {
public Circle {
if (radius < 0) throw new IllegalArgumentException("negative radius");
}
/**
* True if the two circles share an area: their centres are closer than both radii together. Compared
* squared, so no square root is needed; circles that only touch do not overlap.
*/
public boolean overlaps(Circle other) {
return overlaps(other.x - x, other.y - y, radius + other.radius);
}
/** {@link #overlaps(Circle)} on a field without edges: the circles may touch across an edge. */
public boolean overlaps(Circle other, Wrap wrap) {
double dx = other.x - x;
double dy = other.y - y;
dx -= wrap.width() * Math.round(dx / wrap.width());
dy -= wrap.height() * Math.round(dy / wrap.height());
return overlaps(dx, dy, radius + other.radius);
}
/** True if the point lies inside the circle. */
public boolean contains(double px, double py) {
return overlaps(px - x, py - y, radius);
}
private static boolean overlaps(double dx, double dy, double distance) {
return dx * dx + dy * dy < distance * distance;
}
}
The comparison is squared, dx² + dy² < (r₁ + r₂)², which needs no square root. On the field without edges, dx and dy take the short way first, exactly as in Wrap.delta:
@Test
void circlesOverlapWhenTheirCentresAreCloserThanBothRadii() {
Circle rock = new Circle(100, 100, 30);
assertTrue(rock.overlaps(new Circle(120, 120, 5))); // 28.3 apart, 35 needed
assertFalse(rock.overlaps(new Circle(130, 130, 5))); // 42.4 apart
assertFalse(rock.overlaps(new Circle(140, 100, 10)), "touching is not overlapping");
assertTrue(rock.contains(100, 129));
}
@Test
void onAFieldWithoutEdgesTheyMeetAcrossTheEdge() {
Wrap field = new Wrap(640, 400);
Circle right = new Circle(630, 200, 10);
Circle left = new Circle(5, 200, 10);
assertFalse(right.overlaps(left), "625 apart on a flat field");
assertTrue(right.overlaps(left, field), "15 apart across the edge");
}
The rules check every shot against every rock, and the ship against every rock. A shot is a circle of radius 0, a point:
/** A shot that is inside a rock breaks it, and is used up. */
private void shotsHitRocks() {
for (int b = 0; b < bullets.size(); b++) {
Bullet bullet = bullets.get(b);
Circle shot = new Circle(bullet.position().x(), bullet.position().y(), 0);
for (int r = 0; r < rocks.size(); r++) {
if (rocks.get(r).hitArea().overlaps(shot, Field.WRAP)) {
bullet.spend();
breakRock(r);
break;
}
}
}
}
private void shipHitsRock() {
Circle hull = new Circle(ship.position().x(), ship.position().y(), Field.SHIP_RADIUS);
for (int r = 0; r < rocks.size(); r++) {
if (rocks.get(r).hitArea().overlaps(hull, Field.WRAP)) {
events.add(new Event.ShipLost(ship.position()));
breakRock(r); // the rock breaks too, and still counts
loseShip();
return;
}
}
}
The ship's hit circle is 8 units, smaller than its drawing: the tips of its wings may touch a rock without harm. A hit circle a little smaller than the picture feels fair, and a larger one feels like cheating, every time.
Why no sweep, as in Bricks?
Bricks needed a swept test because the ball could jump over a brick between two steps. Here, the fastest shot flies 400 units a second plus the ship's speed, at most 5.5 units per step. The smallest rock is 16 units wide. A shot cannot jump over it, and the simple test is enough. Check that sum whenever speeds or sizes change; it is the kind of number that silently stops being true.
A test shows the circle across the edge at work: the ship shoots up at a rock whose centre is near the bottom, but whose upper part shows at the top. The shot hits it at the top, long before it reaches the rock's centre on the other side:
@Test
void aShotBreaksALargeRockIntoTwoMediumOnes() {
Game game = quiet();
game.putRock(still(RockSize.LARGE, Field.CENTRE.x(), Field.CENTRE.y() - 80)); // straight ahead
game.update(STEP, FIRE);
List<Event> events = run(game, 0.5, Steering.NONE);
assertEquals(List.of(new Event.RockBroken(RockSize.LARGE, new Vector(Field.CENTRE.x(), Field.CENTRE.y() - 80))),
events.stream().filter(Event.RockBroken.class::isInstance).toList());
assertEquals(RockSize.LARGE.points(), game.score());
assertEquals(2, game.rocks().stream().filter(r -> r.size() == RockSize.MEDIUM).count());
assertEquals(0, game.bullets().size(), "the shot is used up");
}
@Test
void aShotHitsARockThatReachesOverTheEdge() {
Game game = quiet();
// The ship in the centre shoots up. The rock's centre is near the bottom edge, but its upper part
// reaches over it and shows at the top of the field.
game.putRock(still(RockSize.LARGE, Field.CENTRE.x(), Field.HEIGHT - 5));
game.update(STEP, FIRE);
List<Event> events = run(game, 0.45, Steering.NONE);
assertEquals(1, count(events, Event.RockBroken.class), "hit at the top, not only after the edge");
}
Step 5: Four shots from a pool¶
The original allowed four shots in the air at once. A fifth press of the button did nothing. That is a rule of the game, and it has a natural home: a pool of four shot objects, made once. Firing takes one from the pool; a shot that has flown its distance or hit a rock goes back:
public final class Pool<T> {
private final Object[] items;
private int size;
/** Makes all {@code capacity} objects at once with {@code factory}. */
public Pool(int capacity, Supplier<? extends T> factory) {
if (capacity < 1) throw new IllegalArgumentException("capacity " + capacity);
items = new Object[capacity];
for (int i = 0; i < capacity; i++) items[i] = factory.get();
}
/** Gives all objects back, e.g. for a new game. */
public void clear() {
size = 0;
}
/** The objects in use. */
public int size() {
return size;
}
public int capacity() {
return items.length;
}
public boolean isFull() {
return size == items.length;
}
/** A free object, now in use, as the last one; {@code null} if all are in use. */
@SuppressWarnings("unchecked")
public T obtain() {
return size == items.length ? null : (T) items[size++];
}
/** The object in use at {@code index}, 0 to {@link #size()} - 1. */
@SuppressWarnings("unchecked")
public T get(int index) {
if (index < 0 || index >= size) throw new IndexOutOfBoundsException(index);
return (T) items[index];
}
/** Gives the object at {@code index} back; the last object in use takes its place. */
public void release(int index) {
if (index < 0 || index >= size) throw new IndexOutOfBoundsException(index);
size--;
Object released = items[index];
items[index] = items[size];
items[size] = released; // kept for the next obtain: the pool never forgets an object
}
/** Gives back every object in use that {@code done} says is done; returns how many. */
public int releaseIf(Predicate<? super T> done) {
int released = 0;
for (int i = size - 1; i >= 0; i--) {
if (done.test(get(i))) {
release(i);
released++;
}
}
return released;
}
}
The pool keeps the objects in use at the front of its array. Giving one back swaps it with the last one in use, the same trick Particles uses for its rows in chapter 21: no gaps, no copying, but the order changes. That is why releaseIf walks backwards.
@Test
void theCapacityIsALimitAndObjectsAreReused() {
int[] made = {0};
Pool<Shot> shots = new Pool<>(4, () -> {
made[0]++;
return new Shot();
});
Set<Shot> seen = new HashSet<>();
for (int i = 0; i < 4; i++) seen.add(shots.obtain());
assertNull(shots.obtain(), "four in the air, no fifth");
shots.release(1);
Shot again = shots.obtain();
assertTrue(seen.contains(again), "an old object, not a new one");
assertEquals(4, made[0], "all made at the start, none later");
}
@Test
void releasingWhileWalkingKeepsTheRest() {
Pool<Shot> shots = new Pool<>(5, Shot::new);
for (int i = 0; i < 5; i++) shots.obtain().life = i % 2; // 0, 1, 0, 1, 0
assertEquals(3, shots.releaseIf(shot -> shot.life == 0));
assertEquals(2, shots.size());
for (int i = 0; i < shots.size(); i++) assertEquals(1, shots.get(i).life);
}
A pooled object must be mutable, because it is reused, and it needs a method that makes it like new. Forgetting to reset one field is the classic pool bug: the new shot inherits the old one's life and vanishes at once. launch therefore sets every field:
public final class Bullet {
private Vector position = Vector.ZERO;
private Vector previous = Vector.ZERO;
private Vector velocity = Vector.ZERO;
private double life;
/** Sends the shot off from {@code position}, as if it were new. */
void launch(Vector position, Vector velocity) {
this.position = position;
this.previous = position;
this.velocity = velocity;
this.life = Field.BULLET_LIFE;
}
void move(double seconds) {
previous = position;
position = Field.WRAP.wrap(position.plus(velocity.times(seconds)));
life -= seconds;
}
/** Flown its distance, or used up on a rock: back into the pool. */
boolean expired() {
return life <= 0;
}
void spend() {
life = 0;
}
private void fire() {
Bullet bullet = bullets.obtain();
if (bullet == null) return; // four in the air: the trigger clicks, nothing happens
bullet.launch(ship.nose(), ship.velocity().plus(Vector.ofAngle(ship.angle(), Field.BULLET_SPEED)));
events.add(new Event.Fired(ship.nose()));
}
@Test
void fourShotsAtMostAndThePoolRefills() {
Game game = quiet();
List<Event> events = new ArrayList<>();
for (int i = 0; i < 6; i++) {
game.update(STEP, FIRE);
events.addAll(game.events());
}
assertEquals(4, count(events, Event.Fired.class), "the fifth and sixth click do nothing");
assertEquals(4, game.bullets().size());
run(game, Field.BULLET_LIFE, Steering.NONE);
assertEquals(0, game.bullets().size(), "flown their distance");
game.update(STEP, FIRE);
assertEquals(1, game.bullets().size(), "and back in the pool");
}
Is pooling still worth it in Java?
Many old game books pool everything, because creating objects used to be expensive. In today's Java it is not; a new object costs about as much as a few additions. Pools are worth it for two other reasons. A limit the game relies on, like the four shots, is a pool's capacity, written down in one place. And a game that creates no garbage while it runs never makes the garbage collector stop it for a moment, which matters most for things made by the hundred every second, like the particles. For four shots, the first reason is the one that counts. For the rocks, which come and go a few times a second and have no limit, Rocks uses a plain ArrayList, and that is fine.
Step 6: Rocks, waves and new ships¶
The three sizes of rocks are an enum with their radius, points and speed:
public enum RockSize {
LARGE(30, 20, 30, 60),
MEDIUM(16, 50, 50, 95),
SMALL(8, 100, 70, 130);
private final double radius;
private final int points;
private final double minSpeed;
private final double maxSpeed;
RockSize(double radius, int points, double minSpeed, double maxSpeed) {
this.radius = radius;
this.points = points;
this.minSpeed = minSpeed;
this.maxSpeed = maxSpeed;
}
public double radius() {
return radius;
}
/** Points for breaking it: the smaller, the harder to hit, the more points. */
public int points() {
return points;
}
public double minSpeed() {
return minSpeed;
}
public double maxSpeed() {
return maxSpeed;
}
/** The size of the two pieces a hit leaves, or {@code null} for the smallest rocks. */
public RockSize smaller() {
return switch (this) {
case LARGE -> MEDIUM;
case MEDIUM -> SMALL;
case SMALL -> null;
};
}
}
A hit takes the rock out and puts two smaller ones in its place, flying apart at an angle to the old direction:
/** The rock at {@code index} breaks into two smaller ones, flying apart, or into dust. */
private void breakRock(int index) {
Rock rock = rocks.remove(index);
addPoints(rock.size().points());
events.add(new Event.RockBroken(rock.size(), rock.position()));
RockSize smaller = rock.size().smaller();
if (smaller == null) return;
double heading = rock.velocity().angle();
for (int side = -1; side <= 1; side += 2) {
double angle = heading + side * (0.3 + random.nextDouble() * 0.8);
rocks.add(newRock(smaller, rock.position(), angle));
}
}
Like Snake, the game is deterministic: all chance comes from a SeededRandom made from the game's seed. A new wave puts its rocks on the edges of the field, never close to the ship, and they reach over the edges from the first frame:
/** Large rocks at the edges of the field, none close to the ship, flying in all directions. */
private void startWave(int number) {
wave = number;
waveSeconds = 0;
waveBreak = 0;
Vector shipAt = ship != null ? ship.position() : Field.CENTRE;
for (int i = 0; i < Field.rocksInWave(number); i++) {
Vector at;
do {
at = random.nextBoolean()
? new Vector(random.nextDouble() * Field.WIDTH, 0)
: new Vector(0, random.nextDouble() * Field.HEIGHT);
} while (Field.WRAP.distance(at, shipAt) < Field.SPAWN_DISTANCE);
rocks.add(newRock(RockSize.LARGE, at, random.nextDouble() * 2 * Math.PI));
}
events.add(new Event.WaveStarted(number));
}
A new ship must not appear inside a rock. The original solved this with a simple rule that players still remember: the next ship waits until the centre is clear.
/** A new ship appears in the centre only when no rock is near it: no ship lost the moment it appears. */
private boolean centreIsClear() {
for (Rock rock : rocks) {
if (Field.WRAP.distance(rock.position(), Field.CENTRE) < Field.SAFE_RADIUS + rock.size().radius()) {
return false;
}
}
return true;
}
@Test
void aCrashCostsAShipAndTheNextWaitsForAClearCentre() {
Game game = quiet();
game.putRock(still(RockSize.MEDIUM, Field.CENTRE.x() + 10, Field.CENTRE.y()));
game.update(STEP, Steering.NONE);
assertEquals(1, count(game.events(), Event.ShipLost.class));
assertNull(game.ship());
assertEquals(Field.START_LIVES - 1, game.lives());
assertEquals(Game.Phase.WAITING, game.phase());
game.clearRocks();
game.putRock(still(RockSize.LARGE, Field.CENTRE.x() + 60, Field.CENTRE.y()));
run(game, Field.RESPAWN_SECONDS + 1, Steering.NONE);
assertNull(game.ship(), "a rock sits next to the centre");
game.clearRocks();
game.putRock(still(RockSize.LARGE, 40, 40));
List<Event> events = run(game, 0.1, Steering.NONE);
assertEquals(1, count(events, Event.ShipReady.class));
assertEquals(Field.CENTRE, game.ship().position());
assertEquals(Game.Phase.PLAYING, game.phase());
}
The whole step reads like the rules: fly, shoot, move, collide, then see whether a ship or a wave is due. The events tell the user interface what happened:
/** One step of {@code seconds}: the ship flies and shoots, everything moves, and then what touches collides. */
public void update(double seconds, Steering steering) {
events.clear();
waveSeconds += seconds;
if (ship != null) {
ship.fly(steering, seconds);
if (steering.fire()) fire();
if (steering.hyperspace()) enterHyperspace();
}
for (int i = 0; i < bullets.size(); i++) bullets.get(i).move(seconds);
for (Rock rock : rocks) rock.move(seconds);
shotsHitRocks();
if (ship != null) shipHitsRock();
bullets.releaseIf(Bullet::expired);
waitForShip(seconds);
nextWave(seconds);
}
public sealed interface Event {
record Fired(Vector at) implements Event {
}
/** A shot or the ship broke a rock of {@code size} at {@code at}. */
record RockBroken(RockSize size, Vector at) implements Event {
}
/** The ship ran into a rock. */
record ShipLost(Vector at) implements Event {
}
/** A new ship appeared in the centre. */
record ShipReady(Vector at) implements Event {
}
record HyperspaceIn(Vector from) implements Event {
}
record HyperspaceOut(Vector to) implements Event {
}
record ExtraLife(int lives) implements Event {
}
record WaveStarted(int wave) implements Event {
}
record GameOver(GameResult result) implements Event {
}
}
Step 7: Steering with the mouse¶
The rules never see a key. They get a Steering for each step: turn left or right, or turn towards an angle; thrust; fire; hyperspace. That makes a second way to steer easy. The ship turns towards the mouse pointer, the left button fires and the right button, held, fires the engine. The play scene builds the Steering from whichever the player uses:
/**
* A game. The keys turn the ship; once the mouse moves, the ship turns towards the pointer instead,
* until a turning key is pressed again. A left click fires, the right button held fires the engine, the
* middle button jumps into hyperspace.
*/
final class PlayScene implements Scene {
private final Game game = new Game(seeds.getAsLong());
private boolean mouseAims;
private double waveTitle = WAVE_TITLE_SECONDS;
PlayScene() {
effects.clear();
}
@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, game, true);
waveTitle -= seconds;
for (Event event : game.events()) {
if (event instanceof Event.WaveStarted) waveTitle = WAVE_TITLE_SECONDS;
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 turn = controls.axis(Action.LEFT, Action.RIGHT);
if (controls.isDown(Action.LEFT) || controls.isDown(Action.RIGHT)) mouseAims = false;
else if (mouse.wasMoved()) mouseAims = true;
double aim = Double.NaN;
Ship ship = game.ship();
if (mouseAims && ship != null && !Double.isNaN(mouse.x())) {
aim = new Vector(mouse.x(), mouse.y()).minus(ship.position()).angle();
}
return new Steering(turn, aim,
controls.isDown(Action.THRUST) || mouse.isDown(MouseEvent.BUTTON3),
controls.wasPressed(Action.FIRE) || mouse.wasPressed(MouseEvent.BUTTON1),
controls.wasPressed(Action.HYPERSPACE) || mouse.wasPressed(MouseEvent.BUTTON2));
}
The mouse aims only after it has moved, and a turning key takes over until it moves again. Without that rule, a player who uses the keys would find the ship pulled towards a pointer resting somewhere on the screen. For the thrust, the engine's Mouse learned one new thing, whether a button is held, like isDown on the keyboard:
public final class Mouse {
private double x = Double.NaN;
private double y = Double.NaN;
private boolean movedSincePoll;
private boolean moved;
private int pressedSincePoll; // one bit per button
private int pressed;
private int downNow; // one bit per button, as the Swing thread last saw it
private int down;
/** Listens to the canvas; positions are converted the way the canvas currently draws. */
public void attach(GameCanvas canvas) {
MouseAdapter listener = new MouseAdapter() {
@Override
public void mouseMoved(MouseEvent e) {
Point2D.Double p = canvas.toLogical(e.getX(), e.getY());
moved(p.x, p.y);
}
@Override
public void mouseDragged(MouseEvent e) {
mouseMoved(e);
}
@Override
public void mousePressed(MouseEvent e) {
canvas.requestFocusInWindow(); // a click into the game gives the keys to the game, too
pressed(e.getButton());
}
@Override
public void mouseReleased(MouseEvent e) {
released(e.getButton());
}
};
canvas.addMouseListener(listener);
canvas.addMouseMotionListener(listener);
}
public synchronized void moved(double x, double y) {
this.x = x;
this.y = y;
movedSincePoll = true;
}
public synchronized void pressed(int button) {
if (button > 0 && button < 32) {
pressedSincePoll |= 1 << button;
downNow |= 1 << button;
}
}
public synchronized void released(int button) {
if (button > 0 && button < 32) downNow &= ~(1 << button);
}
/** Call once per update step on the game loop thread, before asking. */
public synchronized void poll() {
moved = movedSincePoll;
pressed = pressedSincePoll;
down = downNow;
movedSincePoll = false;
pressedSincePoll = 0;
}
/** The last position in logical coordinates; {@code NaN} until the mouse has moved over the canvas. */
public synchronized double x() {
return x;
}
public synchronized double y() {
return y;
}
/** True if the mouse moved between the last two polls. */
public synchronized boolean wasMoved() {
return moved;
}
/** True if the button ({@link MouseEvent#BUTTON1} …) went down between the last two polls. */
public synchronized boolean wasPressed(int button) {
return button > 0 && button < 32 && (pressed & 1 << button) != 0;
}
/** True if the button was held down at the last poll: for actions that last as long as it is held. */
public synchronized boolean isDown(int button) {
return button > 0 && button < 32 && (down & 1 << button) != 0;
}
}
@Test
void theShipTurnsTowardsThePointerUntilAKeyTurnsIt() {
RocksGame.PlayScene play = play();
mouse.moved(Field.CENTRE.x() + 100, Field.CENTRE.y()); // to the right of the ship
seconds(1);
assertTrue(play.mouseAims());
assertEquals(0, play.game().ship().angle(), 1e-9, "nose to the pointer");
mouse.pressed(MouseEvent.BUTTON1);
steps(1);
assertEquals(1, play.game().bullets().size(), "a left click fires");
mouse.released(MouseEvent.BUTTON1);
mouse.pressed(MouseEvent.BUTTON3);
steps(1);
assertTrue(play.game().ship().thrusting(), "the right button held fires the engine");
mouse.released(MouseEvent.BUTTON3);
keyboard.pressed(KeyEvent.VK_LEFT, now[0]);
seconds(0.5);
assertFalse(play.mouseAims(), "a key takes over again");
assertEquals(-Math.toRadians(125), play.game().ship().angle(), 1e-6);
}
The window shows a cross-hair over the field, so the pointer reads as a sight, not as an arrow waiting for a click.
Step 8: Bangs and a heartbeat¶
Snake's and Bricks' sounds were tones. An explosion is not a tone but noise, and plain noise from Sound.tone(Wave.NOISE, …) hisses like a radio between stations. A bang is noise with the high frequencies taken away. The simplest way to take them away is to let each sample move only part of the way towards the next random value: the speaker cannot move fast any more, so only slow, deep vibrations remain. That is a low-pass filter, in one line:
/**
* Noise with its high tones taken away: a bang, not a hiss. Each sample moves only part of the way
* towards a new random value (a low-pass filter); the larger {@code darkness}, 0 to 1, the smaller that
* part, the slower the speaker moves, the deeper the sound. It starts at once and dies away.
*/
static Sound rumble(double seconds, double darkness, double volume) {
int n = (int) Math.round(seconds * Sound.RATE);
SeededRandom random = new SeededRandom(n); // the same sound every time
double[] wave = new double[n];
double value = 0;
double loudest = 1e-9;
for (int i = 0; i < n; i++) {
value += (1 - darkness) * ((random.nextDouble() * 2 - 1) - value);
wave[i] = value;
loudest = Math.max(loudest, Math.abs(value));
}
short[] samples = new short[n];
int attack = Sound.RATE / 400;
for (int i = 0; i < n; i++) {
double envelope = Math.min(1, i / (double) attack) * Math.exp(-4.0 * i / n) * (n - 1 - i) / n;
samples[i] = (short) Math.round(wave[i] / loudest * envelope * volume * Short.MAX_VALUE);
}
return Sound.of(samples);
}
/** A falling square blip: the shot. */
final Sound fire = Sound.tone(Wave.SQUARE, 1300, 300, 0.08, 0.12);
/** A short, dark rumble, played again and again while the engine fires. */
final Sound thrust = rumble(0.12, 0.92, 0.25);
/** The bigger the rock, the longer and darker the bang. */
final Sound bangLarge = rumble(0.7, 0.97, 0.6);
final Sound bangMedium = rumble(0.45, 0.93, 0.5);
final Sound bangSmall = rumble(0.25, 0.8, 0.4);
final Sound shipLost = rumble(1.2, 0.96, 0.7).then(Sound.tone(Wave.TRIANGLE, 400, 80, 0.5, 0.25));
/** The heartbeat of the original: two low notes, in turn, faster and faster. */
final Sound beatLow = Sound.tone(Wave.SQUARE, 98, 92, 0.09, 0.22);
final Sound beatHigh = Sound.tone(Wave.SQUARE, 110, 104, 0.09, 0.22);
The larger the rock, the darker and longer its bang. The engine is the same rumble, short and quiet, played again every tenth of a second while the engine fires.
The original's most famous sound is not a bang but its heartbeat: two low notes in turn, slow at the start of a wave and faster the longer it lasts. It is music made from nothing, and it makes players nervous in the best way. Its pace is a job for the Ticker from Snake, with an interval that shrinks with the wave's time:
/**
* Moves the particles; while {@code audible}, plays the engine and the heartbeat. The heartbeat starts
* slow with each wave and gets faster the longer the wave lasts, down to a beat every 0.28 seconds.
*/
void update(double seconds, Game game, boolean audible) {
particles.update(seconds, 0); // no gravity in space
Ship ship = game.ship();
untilThrust -= seconds;
if (audible && ship != null && ship.thrusting() && untilThrust <= 0) {
mixer.play(sounds.thrust);
untilThrust = THRUST_REPEAT;
}
heartbeat.setInterval(beatInterval(game.waveSeconds()));
for (int beats = heartbeat.advance(seconds); beats > 0; beats--) {
if (!audible || game.phase() == Game.Phase.OVER || game.rocks().isEmpty()) continue;
mixer.play(highBeat ? sounds.beatHigh : sounds.beatLow);
highBeat = !highBeat;
}
}
static double beatInterval(double waveSeconds) {
return Math.max(0.28, 1.0 - waveSeconds * 0.012);
}
The other events become sounds and particles as in the earlier games; in space, the particles have no gravity:
/** Shows and, if {@code audible}, plays what happened in the last step. */
void react(List<Event> events, boolean audible) {
for (Event event : events) {
switch (event) {
case Event.Fired fired -> {
if (audible) mixer.play(sounds.fire);
}
case Event.RockBroken(RockSize size, Vector at) -> {
burst(at, DUST, (int) size.radius() * 2, size.radius() * 4);
if (audible) mixer.play(bang(size));
}
case Event.ShipLost(Vector at) -> {
burst(at, FIRE, 80, 140);
burst(at, WHITE, 30, 80);
if (audible) mixer.play(sounds.shipLost);
}
case Event.ShipReady(Vector at) -> {
ring(at, BLUE, 40);
if (audible) mixer.play(sounds.shipReady);
}
case Event.HyperspaceIn(Vector from) -> {
ring(from, BLUE, 30);
if (audible) mixer.play(sounds.hyperspaceIn);
}
case Event.HyperspaceOut(Vector to) -> {
ring(to, BLUE, 30);
if (audible) mixer.play(sounds.hyperspaceOut);
}
case Event.ExtraLife life -> {
if (audible) mixer.play(sounds.extraLife);
}
case Event.WaveStarted started -> highBeat = false;
case Event.GameOver over -> {
if (audible) mixer.play(sounds.gameOver);
}
}
}
}
Step 9: Putting it together¶
The score, the ships left and the wave are the overlay; the ships left are the ship's own outline, small and pointing up:
/** The score at the top left, a small ship for each one left below it, the wave at the top right. */
void status(Graphics2D g, Game game) {
g.setColor(CHALK);
g.setFont(score);
g.drawString(msg.format("hud.score", game.score()), 14f, 26f);
g.setStroke(stroke);
for (int i = 0; i < game.lives() - (game.ship() != null || game.inHyperspace() ? 1 : 0); i++) {
life.draw(g, 22 + i * 16, 42, -Math.PI / 2, 0.7);
}
g.setFont(small);
FontMetrics fm = g.getFontMetrics();
String wave = msg.format("hud.wave", game.wave());
g.drawString(wave, (float) (Field.WIDTH - 14 - fm.stringWidth(wave)), 24f);
}
The computer player of the title screen turns towards the nearest rock and fires when its nose points there. It could aim perfectly, even where a rock will be when the shot arrives. It did, at first, and played for ten minutes without losing a ship, which made a dull title screen. So it aims at where the rock is now, and its hand shakes, from a random generator of its own:
public Steering steer(Game game, double seconds) {
sinceShot += seconds;
Ship ship = game.ship();
if (ship == null) return Steering.NONE;
Rock target = null;
double nearest = Double.MAX_VALUE;
for (Rock rock : game.rocks()) {
double gap = Field.WRAP.distance(ship.position(), rock.position()) - rock.size().radius();
if (gap < nearest) {
nearest = gap;
target = rock;
}
}
if (target == null) return Steering.NONE;
// Straight at the rock, as most people aim, and a little off.
Vector toRock = Field.WRAP.delta(ship.position(), target.position());
double aim = toRock.angle() + shake;
boolean onTarget = Math.abs(Angles.difference(ship.angle(), aim)) < 0.06;
boolean fire = onTarget && nearest < RANGE && sinceShot >= FIRE_PAUSE;
if (fire) {
sinceShot = 0;
shake = (random.nextDouble() * 2 - 1) * SHAKE; // the next aim is off by a new error
}
boolean thrust = nearest > 180 && ship.velocity().length() < 40 && onTarget;
return new Steering(0, aim, thrust, fire, false);
}
@Test
void thePilotClearsAFewWavesAndLosesItsShipsInTheEnd() {
int best = 0;
int waves = 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());
waves = Math.max(waves, game.wave());
}
assertTrue(best >= 5000, "best " + best);
assertTrue(waves >= 3, "reached wave " + waves);
assertEquals(5, ended, "every game ends within ten minutes, so the title shows the end too");
}
Now it lasts one to three minutes and reaches the third to fifth wave. A computer player that is too good is a common problem in games; making it worse in a believable way is a design task of its own.
Since the rules are deterministic, the same seed and the same pilot play the same game, and a test can prove it:
@Test
void theSameSeedAndTheSamePilotPlayTheSameGame() {
int[] scores = new int[2];
for (int round = 0; round < 2; round++) {
Game game = new Game(2026);
Pilot pilot = new Pilot(game.seed());
for (int i = 0; i < 60 * 120 && game.phase() != Game.Phase.OVER; i++) {
game.update(STEP, pilot.steer(game, STEP));
}
scores[round] = game.score();
}
assertTrue(scores[0] > 0);
assertEquals(scores[0], scores[1]);
}
Behind the scenes: what the lines cost
Measured with the title screen (the busiest one: rocks, ship, particles, a veil and large text) in Xvfb, which draws in software without a graphics card: 1.4 ms per frame at 640 × 400, 3.6 ms at 1280 × 800, 8.5 ms at 1920 × 1200. A frame at 60 frames per second may take 16.7 ms. Antialiased lines are among the more expensive things Java2D draws in software; on a real screen with a graphics card, they cost less. Press F12 and measure on yours.
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 lines become the jagged lines of an early home computer; in direct mode, they are as sharp as the vector screen of 1979.
What moved into the engine¶
| From Rocks | Engine package | Why |
|---|---|---|
| Vectors and angles | eu.amadeos.gameengine.math.Vector, Angles |
Anything that flies at an angle: ships, bullets, flight paths in Swarm, the camera in Jump |
| A field without edges | eu.amadeos.gameengine.math.Wrap |
Wrapping, the shortest distance, drawing over an edge; also Maze's tunnel |
| Figures of lines, turned and scaled | eu.amadeos.gameengine.shape.Outline |
Things that turn freely, without a picture per angle |
| Object pools | eu.amadeos.gameengine.pool.Pool |
Shots and other short-lived things with a limit: Swarm's bullets, Blast's bombs |
| Circle collision | eu.amadeos.gameengine.collision.Circle |
Round things and things that turn, also across an edge |
| Held mouse buttons | eu.amadeos.gameengine.input.Mouse#isDown |
Actions that last as long as a button is held |
What stays in the game: the ship, the rocks and their sizes, the shots, waves and lives, hyperspace, the pilot, the painter, the sounds and the scenes.
Exercises¶
- The saucer. The original sent a flying saucer now and then, which shot at the ship. Add one that crosses the field, changes its course from time to time and fires in random directions; a small saucer that aims comes later. Where does its pool of shots live, and how many may it have?
- Debris. When the ship is lost, let its outline fall apart: each of its four lines flies off on its own, turning. What does a line need, and which engine class helps?
- Pre-turned pictures. Draw the rocks as sprites turned in advance at 32 angles, as described in Step 2, and compare memory and frame times (F12) with the outlines at 1920 × 1200.
- Smaller field, same game. Let the field have the size of the window, not 640 × 400, so a larger window shows more space. Which classes have to change, and which do not notice?
- Gravity. Put a small star in the centre that pulls the ship and the shots towards it, as in Spacewar! (1962), the first video game of this kind. Which one line of
Ship.flychanges?
In the next game, Swarm, enemies come in formation: sprite sheets and animation, entities made from components, flight paths, waves read from files, and a grid that keeps the collisions of many things cheap.