28. Hover Cab¶
In Hover Cab the player flies a cab through a city in the air. Nothing holds it up but its jets: one under the cab that lifts it against gravity, two at the sides that steer it, one on the roof that presses it down. A passenger waves on a numbered pad and calls "Hey, cab!"; the player lands next to them, gently, with the gear out, and they get in and say where to: "Pad 3, please!". Land there and they pay the fare and a tip that melts during the ride. The last passenger of a shift wants "Up, please!", the gate in the ceiling opens, and out through it the shift is done.

Games of this kind go back to the arcade lunar landers of 1979: a craft, gravity, a little fuel and a flat place to put it down. The home computers of the early 1980s gave it a city and passengers who talked, out loud, through a chip that made speech without a single recording. This chapter makes all of that: the flight, the landing, collisions as exact as the pictures, levels as text, a pilot that proves every level can be flown, and a speech synthesizer, so the passengers talk too.
What you will learn
- Thrust against gravity: jets that burn fuel, air that slows the cab, and why a craft needs drag to be steerable
- Landing: soft, hard or a crash, from the speed at touchdown; standing on a lift
- Pixel-exact collisions with bit masks: 64 pixels compared in one instruction
- Levels as text again, now with polygons, beams, signs, lifts and moving vans
- Speech synthesis: how a voice is made of a source and a filter, phonemes, formants, and a tune for the phrase
- An autopilot made of a distance map and jets switched on and off: it flies the title screen and proves that every shift can be flown
- Preparing expensive things in the background before they are needed
The complete game is in amade-hover-cab-game.
The rules¶
- The screen is one shift: rooftops, pads, a ceiling with a gate. The cab starts on its start pad with a full tank and the gear out.
- Up fires the main jet, left and right the side jets, down the roof jet. Every jet burns fuel; with an empty tank the cab falls.
- Space moves the gear in or out. With the gear in, the cab is a little faster through the air, but it cannot land.
- A landing needs the gear out, both legs on the deck of a pad, and a slow touchdown. A firm one is allowed, but the passenger complains and the tip loses a dollar. Anything else solid is a crash: a wall, a sign, a lift's underside, a moving van, the closed gate, the sea.
- Passengers appear one after the other on their pad and call. Land on their pad, they walk over and get in; land on the pad they name, they pay the fare (two dollars and one for every hundred units of distance) and the tip (five dollars, melting by twenty cents a second).
- The green fuel pad fills the tank while the cab stands on it, for money.
- The last passenger wants up: the gate opens, and flying through it ends the shift.
- Three spare cabs at the start, and one more for every hundred dollars earned. Twelve shifts; the money earned enters the high scores.
Step 1: Jets against gravity¶
The cab is a Body from chapter 27: a box with a speed. Jump gave its bodies a speed when a button was pressed; a cab's jets are different, they push. A push is an acceleration, like gravity: as long as the jet burns, the speed changes, a little in every step. The engine's Thrusters know the jets of a craft and how hard each pushes:
public final class Thrusters {
private final double up;
private final double side;
private final double down;
private final double burn;
/**
* Jets pushing up (the main one, under the craft), to the sides and down, and the fuel the main jet burns
* in a second; the side and down jets burn in proportion to their push.
*/
public Thrusters(double up, double side, double down, double burn) {
if (up < 0 || side < 0 || down < 0 || burn < 0) throw new IllegalArgumentException("negative thrust or burn");
this.up = up;
this.side = side;
this.down = down;
this.burn = burn;
}
/**
* Fires the jets for a step: {@code x} -1 pushes left, 1 right; {@code y} -1 pushes up (the main jet),
* 1 down; 0 neither. Returns the fuel burned, at most {@code fuel}; with less fuel left than the step
* needs, the jets push for that part of the step only.
*/
public double fire(Body body, double x, double y, double fuel, double seconds) {
double wanted = rate(x, y) * seconds;
if (wanted <= 0 || fuel <= 0) return 0;
double part = Math.min(1, fuel / wanted);
double time = seconds * part;
body.vx += Math.signum(x) * Math.min(1, Math.abs(x)) * side * time;
if (y < 0) body.vy -= Math.min(1, -y) * up * time;
else if (y > 0) body.vy += Math.min(1, y) * down * time;
return wanted * part;
}
How strong should the jets be? The numbers are again in one place:
/** Gravity of a normal shift; a shift may weaken it, out in space. */
public static final double GRAVITY = 110;
public static final double MAX_FALL = 260;
/** The jets: the one under the cab lifts it at 0.9 g, the side jets steer at about 1 g, the top jet presses down. */
public static final double MAIN_JET = 1.9 * GRAVITY;
public static final double SIDE_JET = 0.95 * GRAVITY;
public static final double DOWN_JET = 0.6 * GRAVITY;
/** Fuel the main jet burns in a second: a full tank lasts half a minute of full lift, a minute of hovering. */
public static final double BURN = 3.2;
public static final double TANK = 100;
/** The air slows the cab by this part of its speed a second; the gear out adds to it. */
public static final double AIR = 0.2;
public static final double GEAR_AIR = 0.45;
public static final double GEAR_SECONDS = 0.35;
/** A touchdown up to this speed is soft, up to the next hard (the passenger complains), beyond it a crash. */
public static final double SOFT_LANDING = 40;
public static final double HARD_LANDING = 80;
The main jet pushes with 1.9 times gravity. Together with gravity, that lifts the cab at 0.9 g, about as fast as it falls without the jet: going up and coming down feel the same. A main jet of exactly 1 g could only hover; one of 3 g would be impossible to hold still. The side jets push with about 1 g, enough to stop the cab from its cruising speed over a third of the screen.
The fuel burned is in proportion to the push, and with less fuel left than a step needs, the jets push only for that part of the step. No jet ever pushes with fuel the tank does not have:
@Test
void theMainJetBeatsGravityUntilTheTankIsEmpty() {
Thrusters jets = new Thrusters(1.5 * GRAVITY, 0.5 * GRAVITY, 0.5 * GRAVITY, 2);
Body cab = new Body(0, 0, 10, 10);
double fuel = 1;
for (int i = 0; i < 60; i++) { // half a second at 120 steps a second
fuel -= jets.fire(cab, 0, -1, fuel, 1 / 120.0);
cab.fall(GRAVITY, 1000, 1 / 120.0);
}
assertEquals(-0.5 * GRAVITY * 0.5, cab.vy, 1e-9, "rising at half a g after half a second");
assertEquals(0, fuel, 1e-9, "two units a second: the tank is empty");
assertEquals(0, jets.fire(cab, 0, -1, fuel, 1 / 120.0), "nothing left to burn");
}
Why a cab needs air¶
The first version had jets and gravity and nothing else, and it could not be flown. A tap on the side jet set the cab drifting, and it drifted until the opposite jet stopped it, which needed a tap of exactly the same length. In space that is right; in a city it feels like steering a bar of soap. Real air slows everything that moves through it, and the faster it moves, the more. That is drag, and it is one line:
/**
* Air: the velocity loses the part {@code perSecond} of itself each second, smoothly, whatever the
* length of the step. Without it a craft that has been pushed drifts on for ever, as in space; with
* {@code 0.5} it has lost about forty percent after a second and is slow after a few.
*/
public void drag(double perSecond, double seconds) {
double keep = Math.pow(1 - perSecond, seconds);
vx *= keep;
vy *= keep;
}
drag(0.2, seconds) takes away a fifth of the speed each second, whatever the step: (1 − 0.2)^t is left after t seconds, the same formula as the camera's smoothing in chapter 27. With the gear out, the legs catch more air and the cab slows faster: the gear is a brake too. The orbit station has almost no air, and there the cab drifts nearly as it did in the first version, on purpose.
Air also limits the speed by itself. A side jet that keeps pushing gains less and less, because the faster the cab, the more speed the air takes each second; the speed levels off where the two are equal. Falling, Field.MAX_FALL comes first.
A step of the cab adds the pushes in a fixed order: jets, wind, gravity, air:
/** The jets, the wind, gravity and the air: what changes the cab's speed in a step. */
private void push(double seconds, Input input) {
Body body = cab.body;
int vertical = input.up() ? -1 : input.down() ? 1 : 0;
double burned = jets.fire(body, input.side(), vertical, cab.fuel, seconds);
cab.fuel = Math.max(0, cab.fuel - burned);
if (burned > 0) {
cab.jetSide = input.side();
cab.jetUp = input.up();
cab.jetDown = input.down() && !input.up();
}
if (input.side() != 0) cab.facingLeft = input.side() < 0;
if (cab.fuel < Field.LOW_FUEL && !fuelWarned) {
fuelWarned = true;
events.add(new Event.FuelLow());
}
body.vx += shift.wind(time) * seconds;
body.fall(shift.gravity(), Field.MAX_FALL, seconds);
body.drag(shift.air() + Field.GEAR_AIR * cab.gear, seconds);
}
The wind is a push like the jets, steady, or in gusts that swell and fade with a sine. In the storm the wind blows steadily to the left, and a gust adds up to 55 more: 65 units per second per second against the side jets' 104. Against a gust the player has not much to spare.
Step 2: Touching down¶
Moving the cab is simple: by its speed, both axes at once. Jump moved its bodies one axis at a time, because a body in a grid of tiles has to know whether it hit a wall or a floor. A cab does not care: anything it hits is a crash, except one thing, a deck coming from above:
/**
* Moves the cab by its speed and finds out what it met: the deck of a pad from above with the gear
* out is a landing, the open gate is the way out, and everything else solid is a crash.
*/
private void move(double seconds) {
Body body = cab.body;
double bottomBefore = body.bottom();
body.x += body.vx * seconds;
body.y += body.vy * seconds;
if (body.x < 0 || body.right() > Field.WIDTH) { // the edges of the screen stop the cab, gently
body.x = Math.clamp(body.x, 0, Field.WIDTH - Field.CAB_WIDTH);
body.vx = 0;
}
if (gateOpen && body.bottom() < Field.TOP) {
clear();
return;
}
if (cab.gearOut()) {
for (Pad pad : shift.pads()) {
double top = pad.top(time);
double falling = body.vy - pad.speed(time);
if (falling >= 0 && bottomBefore <= pad.top(time - seconds) + 0.5 && body.bottom() >= top - 1
&& body.x + Field.LEG_LEFT >= pad.x() && body.x + Field.LEG_RIGHT <= pad.right()) {
touchdown(pad, top, falling);
return;
}
}
}
Event.Cause cause = collision();
if (cause != null) crash(cause);
else if (shift.hasSea() && body.bottom() > shift.sea() + 3) crash(Event.Cause.WATER);
else if (body.y > Field.HEIGHT) crash(Event.Cause.HIT);
}
A landing is found before any collision. In this step the cab's bottom crossed the deck's top, it came from above, and both legs are on the deck. Then the speed decides. A cab that comes down at 30 units a second lands; at 120 it breaks its legs. In between is a range where it lands hard. That is not Hover Cab's own idea: every lander, every jump onto a platform with a fall damage, every helicopter game has it. So it is the engine's:
public enum Touchdown {
SOFT, HARD, CRASH;
/** SOFT up to {@code soft}, HARD up to {@code hard}, CRASH beyond: of the downward speed and twice the sideways one. */
public static Touchdown of(double vx, double vy, double soft, double hard) {
if (soft < 0 || hard < soft) throw new IllegalArgumentException("need 0 <= soft <= hard");
double speed = Math.max(Math.max(0, vy), 2 * Math.abs(vx));
if (speed <= soft) return SOFT;
if (speed <= hard) return HARD;
return CRASH;
}
}
The sideways speed counts double: a cab that slides onto a deck at 25 units a second tips over sooner than one that sits down at 25. What the game does with each kind of touchdown is its own business:
private void touchdown(Pad pad, double top, double falling) {
Body body = cab.body;
Touchdown touchdown = Touchdown.of(body.vx, falling, Field.SOFT_LANDING, Field.HARD_LANDING);
body.y = top - Field.CAB_HEIGHT;
if (touchdown == Touchdown.CRASH) {
crash(Event.Cause.TOO_FAST);
return;
}
body.vx = 0;
body.vy = 0;
cab.pad = pad;
events.add(new Event.Landed(body.centerX(), top, touchdown, pad));
Rider rider = rider();
if (rider == null || rider.state != Rider.State.RIDING) return;
if (touchdown == Touchdown.HARD) {
rider.tip = Math.max(0, rider.tip - Field.HARD_PENALTY);
say(rider, Call.OUCH, 0);
}
if (pad.number() == rider.to() && rider.to() != Shift.UP) arrive(rider, pad);
else if (rider.sinceCall > 2) say(rider, rider.to() == Shift.UP ? Call.UP : Call.PAD, rider.to());
}
Lifts¶
In The Lift, a pad goes up and down. It moves along a cosine, slow at the top and bottom, fast in the middle, and its speed is the derivative, so the two always agree:
/** The top of the deck at {@code time} seconds into the shift. */
public double top(double time) {
if (!isLift()) return y;
double along = 0.5 - 0.5 * Math.cos(2 * Math.PI * time / seconds);
return y + (y2 - y) * along;
}
/** How fast the deck moves at {@code time}, units per second, down positive. */
public double speed(double time) {
if (!isLift()) return 0;
return (y2 - y) * Math.PI / seconds * Math.sin(2 * Math.PI * time / seconds);
}

Landing on a moving deck needs the speed against the deck: a cab sinking at 50 onto a deck sinking at 30 lands at 20, softly. A cab standing on a lift is put on its deck in every step; it does not fall and land again sixty times a second.
Half a pixel
The lift was the source of the most puzzling bug of this game. Now and then, a cab standing quietly on a lift exploded. The cab's position was rounded to whole pixels for the collision with Math.round, the lift's box with (int), which cuts off. At a deck height of 251.6, the cab was rounded to 236 + 16 = 252 at its bottom, the deck to 251: one pixel of overlap, a crash. The fix is to round both the same way, and to accept a landing up to one unit early. Whenever two things are compared in pixels, round them the same way.
Step 3: Collisions as exact as the pictures¶
The cab is not a box: it has a cabin on top, a long low body and two thin legs. The scenery is not boxes either: a slanted strut under a balcony, a round neon sign, the jagged walls of a mine. With boxes, a cab would crash into the empty air beside a sign and feel cheated. With circles, as in Rocks, it would be no better. What is needed is to ask, pixel by pixel, whether the cab and the scenery have a solid pixel in the same place.
A picture of the cab is 32 × 16 pixels. Comparing pixel by pixel would be 512 tests per step, and the scenery is 640 × 400. Too slow? Not if each pixel is one bit. A long holds 64 of them, a row of the cab fits in one, and one AND of two longs compares 64 pixels at once:

The picture above is Harbour Heights as the rules see it: blue is solid, the rest is air. The engine's PixelMask keeps a row as longs, the leftmost pixel in the lowest bit:
public final class PixelMask {
private final int width;
private final int height;
private final int words; // longs per row
private final long[] bits;
/** An empty mask: no pixel is solid. */
public PixelMask(int width, int height) {
if (width <= 0 || height <= 0) throw new IllegalArgumentException("empty mask: " + width + "x" + height);
this.width = width;
this.height = height;
this.words = (width + 63) / 64;
this.bits = new long[words * height];
}
/** The pixels of an image that are mostly opaque, alpha 128 or more, are solid. */
public static PixelMask of(BufferedImage image) {
PixelMask mask = new PixelMask(image.getWidth(), image.getHeight());
for (int y = 0; y < image.getHeight(); y++) {
for (int x = 0; x < image.getWidth(); x++) {
if ((image.getRGB(x, y) >>> 24) >= 128) mask.set(x, y);
}
}
return mask;
}
/**
* A mask of what {@code painter} fills: shapes drawn in any colour on a transparent image, without
* antialiasing, so each pixel is either in or out. The same shapes that are drawn can be the mask.
*/
public static PixelMask painted(int width, int height, Consumer<Graphics2D> painter) {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = image.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
painter.accept(g);
g.dispose();
return of(image);
}
painted is the trick that makes the masks easy to get right: the same shapes that are drawn are filled once more into an image, without antialiasing, and that image becomes the mask. The cab's outline is a path:
/** The hull: a long low body, the cabin a step up in the middle, the nose to the right. */
public static Path2D hull() {
Path2D p = new Path2D.Double();
p.moveTo(1, 11);
p.lineTo(1, 5);
p.lineTo(6, 4);
p.lineTo(11, 0);
p.lineTo(22, 0);
p.lineTo(27, 5);
p.lineTo(31, 6);
p.lineTo(31, 11);
p.quadTo(31, 13, 29, 13);
p.lineTo(3, 13);
p.quadTo(1, 13, 1, 11);
p.closePath();
return p;
}
The painter draws this path in yellow with a checkered band, and CabShape.mask fills it, with or without the legs, facing right or left. What is seen is what collides. The shift's mask is made in the same way from the shapes of its blocks, polygons, beams and signs:
/**
* Where the scenery is solid, pixel by pixel: the ceiling but for the gate, the blocks, polygons, beams
* and signs, and the decks of the pads that do not move. Made once, the first time it is asked for.
*/
public PixelMask mask() {
if (mask == null) {
mask = PixelMask.painted((int) Field.WIDTH, (int) Field.HEIGHT, g -> {
g.fill(new Rectangle2D.Double(0, 0, gateFrom, Field.CEILING));
g.fill(new Rectangle2D.Double(gateTo, 0, Field.WIDTH - gateTo, Field.CEILING));
for (Solid solid : solids) g.fill(solid.shape());
for (Pad pad : pads) {
if (!pad.isLift()) g.fill(new Rectangle2D.Double(pad.x(), pad.y(), pad.width(), Field.DECK));
}
});
}
return mask;
}
Comparing the two masks needs one more idea. The cab is at some x, rarely a multiple of 64, so each of its rows lies across two longs of the scenery's row. Shifting the cab's long left by the rest of x gives the part in the first, shifting it right by 64 minus that gives the part in the second:
/**
* Whether {@code other}, its top left corner at {@code dx}, {@code dy} of this mask, has a solid pixel
* where this one has one. Row by row, each 64 pixels of the other mask are shifted to where they lie in
* this one: they fall into one long of this row, or into two neighbouring ones.
*/
public boolean overlaps(PixelMask other, int dx, int dy) {
return firstOverlap(other, dx, dy) != null;
}
/** The first pixel, in this mask's coordinates, where both masks are solid, top row first; null if none. */
public Point firstOverlap(PixelMask other, int dx, int dy) {
int top = Math.max(0, dy);
int bottom = Math.min(height, dy + other.height);
if (top >= bottom || dx >= width || dx + other.width <= 0) return null;
int q = Math.floorDiv(dx, 64);
int r = Math.floorMod(dx, 64);
for (int y = top; y < bottom; y++) {
int mine = y * words;
int theirs = (y - dy) * other.words;
for (int j = 0; j < other.words; j++) {
long word = other.bits[theirs + j];
if (word == 0) continue;
int k = q + j; // the long of this row the word starts in
if (k >= 0 && k < words) {
long hit = bits[mine + k] & (word << r);
if (hit != 0) return new Point(k * 64 + Long.numberOfTrailingZeros(hit), y);
}
if (r != 0 && k + 1 >= 0 && k + 1 < words) {
long hit = bits[mine + k + 1] & (word >>> (64 - r));
if (hit != 0) return new Point((k + 1) * 64 + Long.numberOfTrailingZeros(hit), y);
}
}
}
return null;
}
For the cab, that is 16 rows of one or two ANDs: about 30 instructions per step for a collision as exact as the picture. firstOverlap also says where the first hit is, which the game uses to tell a crash into a deck with the gear in from a crash into a wall:
/** What the cab touches where it is now, or null: the scenery, a lift, a moving block, the closed gate. */
private Event.Cause collision() {
Body body = cab.body;
int x = (int) Math.round(body.x);
int y = (int) Math.round(body.y);
PixelMask mask = CabShape.mask(cab.facingLeft, cab.gearOut());
Point hit = shift.mask().firstOverlap(mask, x, y);
if (hit != null) return onTopOfDeck(hit) && !cab.gearOut() ? Event.Cause.NO_GEAR : Event.Cause.HIT;
for (Pad pad : shift.pads()) {
if (pad.isLift() && overlaps(mask, x, y, pad.x(), pad.top(time), pad.width(), Field.DECK)) {
return cab.gearOut() ? Event.Cause.HIT : Event.Cause.NO_GEAR;
}
}
if (touchesMovers()) return Event.Cause.HIT;
if (!gateOpen && overlaps(mask, x, y, shift.gateFrom(), Field.TOP, shift.gateTo() - shift.gateFrom(),
Field.CEILING - Field.TOP)) {
return Event.Cause.GATE;
}
return null;
}
Lifts and moving vans are not in the scenery's mask, since they move. For them the cab's mask is asked whether any of its pixels lies in their box: exact on the cab's side, a box on theirs, which is right for their shapes.
@Test
void aRoundSignIsHitOnlyWhereItIsRound() {
// a disc 40 pixels across in a 200 × 100 scene, and a 4 × 4 block flown around it
PixelMask scene = PixelMask.painted(200, 100, g -> g.fill(new Ellipse2D.Double(80, 30, 40, 40)));
PixelMask block = new PixelMask(4, 4);
for (int y = 0; y < 4; y++) for (int x = 0; x < 4; x++) block.set(x, y);
assertTrue(scene.overlaps(block, 98, 48), "the middle of the disc");
assertFalse(scene.overlaps(block, 80, 30), "the corner of its box is empty");
assertFalse(scene.overlaps(block, 120, 48), "beside it");
assertTrue(scene.overlaps(block, 117, 48), "its right edge");
}
Java2D without a screen
PixelMask.painted draws into a BufferedImage, which works without a display, on a build server too. That is why the rules may use it: domain stays free of the user interface even though its masks are drawn with Java2D.
Step 4: Shifts as text¶
Maze, Swarm and Blast kept their boards in text files; Jump moved to Tiled because its levels were grids of tiles. A shift of Hover Cab is no grid. It is a handful of shapes at free positions, and a text file describes it best, one thing per line:
name Harbour Heights
theme dusk
sea 362
gate 276 364
start 2
block brick 0 150 116 212 # the old brick tower
block concrete 0 146 120 6 # its cornice
block concrete 116 286 52 6 # a ledge on its side
poly concrete 116 292 140 292 116 312
block wood 0 356 150 6 # the dock
block concrete 262 276 18 96 # the pier's legs and deck
block concrete 360 276 18 96
block concrete 238 270 172 10
block glass 508 112 132 250 # the glass tower
block concrete 504 108 136 5
block steel 446 238 62 6 # its balcony
beam 452 244 508 276
sign pink 158 84 76 26 NOODLES
pad 1 14 146 92
pad 5 120 286 44
pad 2 244 270 70
fuel 334 270 70
pad 3 450 238 54
pad 4 516 108 104
riders 1>4 5>3 4>2 3>1 2>5 1>up
The reader is TextData from chapter 24, with a switch over the first word of each line. Every mistake names the file and the line: a pad number used twice, a passenger to a pad that does not exist, a gate too narrow for the cab.
private static Shift read(int number, List<TextData.Line> lines) {
Shift shift = new Shift(number);
TextData.Line last = null;
for (TextData.Line line : lines) {
last = line;
switch (line.keyword()) {
case "name" -> shift.name = String.join(" ", line.words().subList(1, line.size()));
case "theme" -> shift.theme = known(line, 1, THEMES);
case "gravity" -> shift.gravity = line.number(1);
case "air" -> shift.air = line.number(1);
case "wind" -> {
shift.wind = line.number(1);
shift.gust = line.number(2);
shift.gustSeconds = line.number(3);
}
case "sea" -> shift.sea = line.number(1);
case "gate" -> {
shift.gateFrom = line.number(1);
shift.gateTo = line.number(2);
}
case "start" -> shift.start = line.integer(1);
case "block" -> shift.solids.add(new Solid(Solid.Kind.BLOCK, known(line, 1, LOOKS), numbers(line, 2, 4), null));
case "poly" -> {
if (line.size() < 8 || line.size() % 2 != 0) throw line.error("a polygon needs a look and at least three corners");
shift.solids.add(new Solid(Solid.Kind.POLY, known(line, 1, LOOKS), numbers(line, 2, line.size() - 2), null));
}
case "beam" -> shift.solids.add(new Solid(Solid.Kind.BEAM, "steel", numbers(line, 1, 4), null));
case "sign" -> shift.solids.add(new Solid(Solid.Kind.SIGN, line.word(1), numbers(line, 2, 4),
String.join(" ", line.words().subList(6, Math.max(6, line.size())))));
case "deco" -> shift.decos.add(new Deco(known(line, 1, DECOS), line.number(2), line.number(3)));
case "pad" -> shift.pads.add(Pad.fixed(padNumber(line), line.number(2), line.number(3), line.number(4)));
case "fuel" -> shift.pads.add(Pad.fuelPad(line.number(1), line.number(2), line.number(3)));
case "lift" -> shift.pads.add(new Pad(padNumber(line), line.number(2), line.number(3), line.number(4),
false, line.number(5), positive(line, 6)));
case "mover" -> shift.movers.add(new Mover(known(line, 1, MOVER_LOOKS), line.number(2), line.number(3),
line.number(4), line.number(5), line.number(6), line.number(7), positive(line, 8),
line.size() > 9 ? line.number(9) : 0));
case "riders" -> {
for (String word : line.words().subList(1, line.size())) shift.fares.add(fare(line, word));
}
default -> throw line.error("unknown keyword \"" + line.keyword() + '"');
}
}
shift.check(last);
return shift;
}
The still picture¶
All of a shift that does not move, the sky, the buildings with their windows, the signs, the pads, is drawn once into a picture the size of the screen and kept as an opaque sprite. Every frame copies it in one go; only the cab, the passengers, the lifts, the vans and the weather are drawn anew. Chapter 27 showed why opaque matters: a copy without blending.
/** The picture of a shift's scenery, opaque: the backdrop covers every pixel. */
static Sprite of(Shift shift) {
return Sprite.drawn(Field.WIDTH, Field.HEIGHT, (g, w, h) -> paint(g, shift)).opaque();
}
Each look of a block, brick, glass, concrete, steel, rock, ice, has its own painter: bricks in rows, lit and dark windows chosen by a random generator seeded with the block's position, so a building has the same lights every time.
/** Fills a solid shape in its look: a base colour, a texture clipped to the shape, light above and shadow below. */
static void material(Graphics2D g, Shape shape, String look, Random r) {
Shape clip = g.getClip();
g.clip(shape);
Rectangle2D b = shape.getBounds2D();
switch (look) {
case "brick" -> bricks(g, b, 0x8A4A3C, 0x5A2E28, r);
case "glass" -> glass(g, b, r);
case "concrete" -> concrete(g, b, r);
case "steel" -> steel(g, b);
case "rock" -> rock(g, b, 0x6A5040, r);
case "sand" -> sandstone(g, b, r);
case "ice" -> ice(g, b, r);
case "hull" -> hull(g, b);
case "wood" -> wood(g, b, r);
default -> {
g.setColor(new Color(0x2E3244));
g.fill(b);
}
}
g.setClip(clip);
// a lit edge along the top of the shape, and a dark one below
Stroke stroke = g.getStroke();
g.setStroke(new BasicStroke(1.2f));
g.setColor(new Color(255, 255, 255, 50));
g.draw(shape);
g.setStroke(stroke);
}
A pad has a deck with yellow and black edges, lights, and a sign with its number on a post, so the player can find "pad 3" at a glance:
/** A pad: a deck with a striped edge, landing lights, and a sign with its number (FUEL for the fuel pad). */
static void pad(Graphics2D g, Pad pad, double top) {
double x = pad.x(), w = pad.width();
boolean fuel = pad.fuel();
g.setColor(new Color(0x30343E));
g.fill(new Rectangle2D.Double(x, top, w, Field.DECK));
g.setColor(new Color(0x5A606E));
g.fill(new Rectangle2D.Double(x, top + Field.DECK - 1, w, 1));
for (double i = 0; i < w; i += 6) {
g.setColor(fuel ? FUEL_GREEN : STRIPE);
g.fill(new Rectangle2D.Double(x + i, top, Math.min(3, w - i), 2.5));
}
for (double i = 3; i < w; i += 10) {
g.setColor(fuel ? new Color(106, 255, 176, 70) : new Color(255, 240, 160, 70));
g.fill(new Ellipse2D.Double(x + i - 1.5, top - 2.5, 4, 4));
g.setColor(fuel ? new Color(0x6AFFB0) : new Color(0xFFF0A0));
g.fill(new Rectangle2D.Double(x + i, top - 1, 1, 1));
}
double sx = x + 1, sy = top - 21, sw = fuel ? 22 : 11;
g.setColor(new Color(0x5A6070));
g.fill(new Rectangle2D.Double(sx + 4, sy + 12, 2, 9));
g.setColor(fuel ? new Color(0x1A6A44) : new Color(0x1C2A5A));
g.fill(new RoundRectangle2D.Double(sx, sy, sw, 12, 3, 3));
g.setColor(new Color(255, 255, 255, 200));
g.draw(new RoundRectangle2D.Double(sx + 0.5, sy + 0.5, sw - 1, 11, 3, 3));
g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, 1).deriveFont(fuel ? 6.5f : 9f));
String label = fuel ? "FUEL" : Integer.toString(pad.number());
g.setColor(Color.WHITE);
g.drawString(label, (float) (sx + (sw - g.getFontMetrics().stringWidth(label)) / 2), (float) (sy + (fuel ? 8.5 : 9.5)));
if (fuel) pump(g, pad.right() - 13, top);
}

The twelve shifts: two on the rooftops by day, a harbour at dusk, an old town, a mine, a canyon in the wind, a factory with a lift, snowy peaks, a night market, an orbit station with weak gravity and hardly any air, a storm at sea, and the rush hour with a van and a drone crossing the sky.
Step 5: Passengers, fares and tips¶
A passenger is a small state machine: coming (not yet there), waiting on their pad and calling, boarding (walking to the cab's door), riding, leaving at their pad, gone. Each state knows what it waits for:
private void riders(double seconds) {
Rider rider = rider();
if (rider == null) return;
rider.stateTime += seconds;
if (rider.state == Rider.State.RIDING) rider.tip = Math.max(0, rider.tip - Field.TIP_DROP * seconds);
rider.sinceCall += seconds;
switch (rider.state) {
case COMING -> {
nextRider -= seconds;
if (nextRider <= 0 && phase == Phase.PLAYING) appear(rider);
}
case WAITING -> {
if (cab.pad == rider.pad && phase == Phase.PLAYING) rider.become(Rider.State.BOARDING);
else if (rider.sinceCall >= Field.CALL_AGAIN) say(rider, rider.tip() > 0 ? Call.TAXI : Call.HEY, 0);
}
case BOARDING -> {
if (cab.pad != rider.pad) {
// the cab left without them: back to their spot
if (walk(rider, rider.spot, seconds)) rider.become(Rider.State.WAITING);
} else if (walk(rider, cab.doorX(), seconds)) {
rider.become(Rider.State.RIDING);
rider.pad = null;
events.add(new Event.Boarded(rider));
}
}
case RIDING -> {
if (rider.stateTime - seconds < 0.5 && rider.stateTime >= 0.5) {
say(rider, rider.to() == Shift.UP ? Call.UP : Call.PAD, rider.to());
if (rider.to() == Shift.UP) {
gateOpen = true;
events.add(new Event.GateOpened());
}
}
}
case LEAVING -> {
double away = rider.x < cab.body.centerX() ? rider.pad.x() + 4 : rider.pad.right() - 4;
walk(rider, away, seconds);
if (rider.stateTime >= 1.0) {
rider.become(Rider.State.GONE);
current++;
nextRider = Field.NEXT_RIDER;
}
}
case GONE -> {
}
}
}
Two details make it fair. A cab that takes off while the passenger walks over leaves them standing; they walk back to their spot and wait again, without a penalty. And a passenger aboard during a crash is not lost: they wait on their pad for the next cab, with a fresh tip.
The fare is by the straight distance between the pads, the tip by the time the ride took, and a hard landing on the way costs a dollar of it:
/** At the pad they wanted: they get out and pay the fare, by the distance, and what is left of the tip. */
private void arrive(Rider rider, Pad pad) {
rider.pad = pad;
rider.x = cab.doorX();
rider.become(Rider.State.LEAVING);
Pad from = shift.pad(rider.from()).orElseThrow();
pay(rider, fare(from.centerX(), from.y(), pad.centerX(), pad.y()));
}
static int fare(double x1, double y1, double x2, double y2) {
return Field.FARE_BASE + (int) Math.round(Math.hypot(x2 - x1, y2 - y1) / 100 * Field.FARE_PER_100);
}
/** The tip is five dollars when a passenger gets in and melts by 20 cents a second of the ride. */
public static final int TIP_START = 500;
public static final int TIP_DROP = 20;
/** A hard landing with a passenger aboard costs a dollar of the tip. */
public static final int HARD_PENALTY = 100;
/** The fare: two dollars, and one more for every 100 units between the pads. */
public static final int FARE_BASE = 200;
public static final int FARE_PER_100 = 100;
/** A spare cab for every hundred dollars earned. */
public static final int BONUS_CAB_EVERY = 10_000;
public static final int SPARE_CABS = 3;
/** Refuelling: units a second, and cents a unit. */
public static final double REFUEL_RATE = 25;
public static final double FUEL_PRICE = 3;
The test flies a ride on a small shift written for it, a few lines of text in the test itself:
@Test
void aPassengerBoardsOnTheirPadAndPaysOnTheirs() {
Game game = game();
until(game, Rider.State.RIDING);
game.put(514, 340, 0, 20); // just over pad 2, sinking slowly
run(game, 1, Input.NONE);
Event.Landed landed = all(Event.Landed.class).getFirst();
assertEquals(Touchdown.SOFT, landed.touchdown());
Event.Paid paid = all(Event.Paid.class).getFirst();
assertEquals(Game.fare(70, 360, 530, 360), paid.fare());
assertEquals(660, paid.fare(), "two dollars and a dollar for every hundred units");
assertTrue(paid.tip() > 400 && paid.tip() < Field.TIP_START, "the tip melted a little: " + paid.tip());
assertEquals(paid.fare() + paid.tip(), career.cents());
}
@Test
void theLastPassengerWantsUpAndTheGateOpens() {
Game game = game();
until(game, Rider.State.RIDING);
game.put(514, 340, 0, 20);
run(game, 1, Input.NONE); // the first one is home, the next one waits on pad 2
until(game, Rider.State.RIDING);
run(game, 0.6, Input.NONE);
assertTrue(game.gateOpen());
assertTrue(all(Event.Spoke.class).stream().anyMatch(s -> s.call() == Call.UP));
game.put(304, 60, 0, -60);
run(game, 1, new Input(0, true, false, false)); // up and out
assertEquals(Game.Phase.CLEARED, game.phase());
ShiftResult result = game.result();
assertNotNull(result);
assertEquals(2, result.passengers());
assertEquals(0, result.crashes());
assertEquals(2, all(Event.Paid.class).size());
}
What happens reaches the user interface as events, as in Blast and Jump. A passenger speaking is one of them, with the call and the pad, but without the words: they are the user interface's business.
public sealed interface Event {
record GearMoved(boolean down) implements Event {
}
/** The cab came down on a pad; a HARD touchdown shakes it. */
record Landed(double x, double y, Touchdown touchdown, Pad pad) implements Event {
}
record TookOff(double x, double y) implements Event {
}
/** A passenger speaks; {@code pad} is the pad named, for {@link Call#PAD}. */
record Spoke(Rider rider, Call call, int pad) implements Event {
}
record Boarded(Rider rider) implements Event {
}
/** A passenger got out and paid, in cents. */
record Paid(Rider rider, int fare, int tip, double x, double y) implements Event {
}
/** All fares of the shift are paid but the last one's, who wants up: the gate in the ceiling opens. */
record GateOpened() implements Event {
}
/** The tank ran low: the gauge starts to blink. */
record FuelLow() implements Event {
}
record BonusCab(int spare) implements Event {
}
record Crashed(double x, double y, Cause cause) implements Event {
}
/** A new cab on the start pad, after a crash. */
record Respawned(double x, double y) implements Event {
}
record Cleared(ShiftResult result) implements Event {
}
record GameOver(GameResult result) implements Event {
}
/**
* What the cab crashed into: a wall, the water, a pad too fast, a pad with the gear in, the closed gate;
* or it stood with an empty tank away from fuel and was towed away.
*/
enum Cause { HIT, WATER, TOO_FAST, NO_GEAR, GATE, EMPTY }
}
Step 6: Passengers who talk¶
The passengers' calls are the soul of this kind of game. Recordings would be the easy way: six speakers, six phrases, nine numbers, five languages. But this handbook makes its sounds from numbers, so the voices are made from numbers too, by the engine's Speech, a formant synthesizer of the kind that made the talking arcade machines and home computers of the early 1980s talk.
How a voice is made¶
Say "aaa" and then "eee" without changing the pitch. The voice is the same, the vowel changes. That is the source-filter idea of speech, worked out in the 1940s and 50s:
- The source is the vocal folds in the throat. They let the air from the lungs out in puffs, about 110 times a second for a man, 200 for a woman, 280 for a child: that is the pitch. A narrow place in the mouth adds a hiss instead, for an s or an f.
- The filter is the throat and the mouth, a tube that rings at a few frequencies, the formants. Moving the tongue and the lips moves them. An ee has its second formant high, near 2300 Hz, because the tongue is in front; an aa has its first formant high, because the mouth is open.
A vowel is nothing but where its formants are. So a synthesizer needs the puffs, the hiss, a few resonators tuned to the formants, and a table of where the formants of each sound are. The sounds are written in the ARPAbet, the alphabet American speech research has used since the 1970s: k ae1 b is "cab", and the 1 stresses the vowel.
public enum Phoneme {
// vowels: formants, length in milliseconds
IY(Kind.VOWEL, 110, 270, 2290, 3010), // beet
IH(Kind.VOWEL, 80, 390, 1990, 2550), // bit
EH(Kind.VOWEL, 90, 530, 1840, 2480), // bet
AE(Kind.VOWEL, 130, 660, 1720, 2410), // bat
AA(Kind.VOWEL, 130, 730, 1090, 2440), // father
AH(Kind.VOWEL, 90, 640, 1190, 2390), // but
AO(Kind.VOWEL, 130, 570, 840, 2410), // law
UH(Kind.VOWEL, 80, 440, 1020, 2240), // book
UW(Kind.VOWEL, 110, 300, 870, 2240), // boot
ER(Kind.VOWEL, 120, 490, 1350, 1690), // bird
AX(Kind.VOWEL, 60, 500, 1500, 2500), // the a in "about"
// diphthongs: they glide from the first vowel to the second
EY(Kind.VOWEL, 150, 480, 1720, 2520, 330, 2200, 2600), // bait
AY(Kind.VOWEL, 170, 660, 1200, 2550, 400, 1900, 2550), // bite
OW(Kind.VOWEL, 150, 540, 1100, 2300, 450, 900, 2300), // boat
AW(Kind.VOWEL, 170, 640, 1230, 2550, 420, 940, 2350), // bout
OY(Kind.VOWEL, 170, 550, 960, 2400, 360, 1820, 2450), // boy
// glides and liquids: voiced, like short vowels
W(Kind.GLIDE, 60, 290, 610, 2150),
Y(Kind.GLIDE, 60, 260, 2070, 3020),
L(Kind.GLIDE, 70, 310, 1050, 2880),
R(Kind.GLIDE, 70, 310, 1060, 1380),
// nasals: voiced, quieter, the sound goes out through the nose
M(Kind.NASAL, 70, 480, 1270, 2130),
N(Kind.NASAL, 65, 480, 1340, 2470),
NG(Kind.NASAL, 70, 480, 2000, 2900),
// fricatives: noise from a narrow place, around a centre frequency; the voiced ones hum as well
F(Kind.FRICATIVE, 100, 340, 1100, 2080, 6000, 6000, 0.25, false),
V(Kind.FRICATIVE, 70, 220, 1100, 2080, 6000, 6000, 0.18, true),
TH(Kind.FRICATIVE, 100, 320, 1290, 2540, 5000, 5000, 0.22, false),
DH(Kind.FRICATIVE, 50, 270, 1290, 2540, 5000, 5000, 0.15, true),
S(Kind.FRICATIVE, 110, 320, 1390, 2530, 6200, 2400, 1.0, false),
Z(Kind.FRICATIVE, 80, 240, 1390, 2530, 6200, 2400, 0.6, true),
SH(Kind.FRICATIVE, 110, 300, 1840, 2750, 3000, 1600, 0.9, false),
ZH(Kind.FRICATIVE, 80, 300, 1840, 2750, 3000, 1600, 0.55, true),
/** Breath through an open mouth: it takes the formants of the vowel after it. */
HH(Kind.ASPIRATE, 90, 0, 0, 0),
// stops: the mouth closes, opens with a burst, and the unvoiced ones breathe out before the vowel
P(Kind.STOP, 90, 200, 800, 2200, 1200, 2400, 0.35, false),
B(Kind.STOP, 70, 200, 800, 2200, 1200, 2400, 0.25, true),
T(Kind.STOP, 90, 200, 1700, 2600, 4200, 3000, 0.7, false),
D(Kind.STOP, 70, 200, 1700, 2600, 4200, 3000, 0.45, true),
K(Kind.STOP, 95, 200, 1900, 2100, 2300, 1400, 0.7, false),
G(Kind.STOP, 75, 200, 1900, 2100, 2300, 1400, 0.45, true),
// affricates: a stop that opens into a fricative
CH(Kind.AFFRICATE, 130, 300, 1840, 2750, 3000, 1600, 0.9, false),
JH(Kind.AFFRICATE, 110, 300, 1840, 2750, 3000, 1600, 0.55, true);
/** How the voice makes a phoneme. */
public enum Kind { VOWEL, GLIDE, NASAL, FRICATIVE, ASPIRATE, STOP, AFFRICATE }
public final Kind kind;
/** Length in milliseconds at the normal pace, unstressed. */
public final int millis;
/** The formants, or the locus of a consonant; for a diphthong where it starts. */
public final int f1, f2, f3;
/** Where a diphthong ends; the same as the start for all others. */
public final int g1, g2, g3;
/** The centre and the width of the noise band of a fricative, a stop's burst and an affricate, in Hz. */
public final int noise, noiseWidth;
/** How loud the noise is against the voice, 0 to 1. */
public final double noiseLevel;
/** Whether the vocal folds hum while it is made. */
public final boolean voiced;
Phoneme(Kind kind, int millis, int f1, int f2, int f3) {
this(kind, millis, f1, f2, f3, f1, f2, f3, 0, 0, 0, kind != Kind.ASPIRATE);
}
Phoneme(Kind kind, int millis, int f1, int f2, int f3, int g1, int g2, int g3) {
this(kind, millis, f1, f2, f3, g1, g2, g3, 0, 0, 0, true);
}
Phoneme(Kind kind, int millis, int f1, int f2, int f3, int noise, int noiseWidth, double noiseLevel,
boolean voiced) {
this(kind, millis, f1, f2, f3, f1, f2, f3, noise, noiseWidth, noiseLevel, voiced);
}
Phoneme(Kind kind, int millis, int f1, int f2, int f3, int g1, int g2, int g3, int noise, int noiseWidth,
double noiseLevel, boolean voiced) {
this.kind = kind;
this.millis = millis;
this.f1 = f1;
this.f2 = f2;
this.f3 = f3;
this.g1 = g1;
this.g2 = g2;
this.g3 = g3;
this.noise = noise;
this.noiseWidth = noiseWidth;
this.noiseLevel = noiseLevel;
this.voiced = voiced;
}
The numbers are textbook values, measured on many speakers. A consonant's formants are its locus: not something heard in the consonant itself, but where the formants of the vowel next to it seem to come from. A b is heard when they rise from low, a d when the second one comes from about 1700 Hz.
The resonator¶
One formant is one resonator, the two-pole filter of Dennis Klatt's synthesizer of 1980. Each output sample is the input plus the last two outputs, weighted so that a tone at its frequency builds up and other tones die away. Three numbers, worked out from the frequency and the bandwidth:
/**
* One resonance of the mouth: a filter that rings at a frequency with a bandwidth, the two-pole
* resonator of Dennis Klatt's synthesizer of 1980. Each output is the input plus the last two outputs,
* weighted so that a tone at the frequency builds up and others die away.
*/
static final class Resonator {
private double a = 1, b, c;
private double y1, y2;
void tune(double hz, double bandwidth) {
double t = 1.0 / Sound.RATE;
c = -Math.exp(-2 * Math.PI * bandwidth * t);
b = 2 * Math.exp(-Math.PI * bandwidth * t) * Math.cos(2 * Math.PI * hz * t);
a = 1 - b - c;
}
double next(double x) {
double y = a * x + b * y1 + c * y2;
y2 = y1;
y1 = y;
return y;
}
}
Five of them in a row are the mouth: F1 to F3 move with the phonemes, F4 and F5 stay put and give the voice its colour.
A phrase¶
Speech.say turns a phrase into a Sound, sample by sample:
/** The phrase spoken by {@code voice}, its loudest moment at {@code volume} 0 to 1. */
public static Sound say(Voice voice, String phrase, double volume) {
Tune tune = tune(phrase);
List<Segment> segments = segments(phrase, voice.pace());
int[] starts = new int[segments.size() + 1];
for (int i = 0; i < segments.size(); i++) starts[i + 1] = starts[i] + segments.get(i).samples();
int tail = Sound.RATE / 30; // the resonators ring out
int length = starts[segments.size()] + tail;
List<Anchor> anchors = anchors(segments, starts);
Resonator[] tract = {new Resonator(), new Resonator(), new Resonator(), new Resonator(), new Resonator()};
BandPass hiss = new BandPass();
double[] out = new double[length];
long noise = 0x9E3779B97F4A7C15L; // a fixed seed: the same phrase sounds the same each time
double phase = 0, tilt = 0, voiceLevel = 0, hissLevel = 0, breathLevel = 0, nasal = 0;
double jitter = 0;
int segment = 0, anchor = 0;
for (int i = 0; i < length; i++) {
while (segment < segments.size() && i >= starts[segment + 1]) segment++;
Segment s = segment < segments.size() ? segments.get(segment) : null;
double within = s == null ? 1 : (i - starts[segment]) / (double) s.samples();
Source source = source(s, within, voice);
// the sources: the amplitudes follow their targets within a few milliseconds, against clicks
voiceLevel += (source.voice - voiceLevel) * 0.006;
hissLevel += (source.hiss - hissLevel) * (source.hiss > hissLevel ? 0.05 : 0.01);
breathLevel += (source.breath - breathLevel) * 0.01;
nasal += ((s != null && s.phoneme() != null && s.phoneme().kind == Phoneme.Kind.NASAL ? 1 : 0) - nasal) * 0.004;
if (i % CONTROL == 0) {
while (anchor + 1 < anchors.size() && anchors.get(anchor + 1).at() <= i) anchor++;
double[] f = formants(anchors, anchor, i);
double scale = voice.formants();
double widen = 1 + 2.5 * nasal;
tract[0].tune(f[0] * scale, B1 * (1 + nasal));
tract[1].tune(f[1] * scale, B2 * widen);
tract[2].tune(f[2] * scale, B3 * widen);
tract[3].tune(F4 * scale, B4);
tract[4].tune(F5 * scale, B5);
if (s != null && s.phoneme() != null && s.phoneme().noise > 0) {
hiss.tune(s.phoneme().noise, s.phoneme().noiseWidth);
}
jitter = jitter * 0.9 + (random(noise ^ i) - 0.5) * 0.004;
}
// the vocal folds: one puff of air per period, its slope is what the ear hears
double hz = pitch(voice.pitch(), tune, segments, starts, segment, i, length) * (1 + jitter);
phase += hz / Sound.RATE;
if (phase >= 1) phase -= 1;
double open = 0.6;
double puff = phase < open ? 2 * (phase / open) - 3 * (phase / open) * (phase / open) : 0;
tilt += (puff - tilt) * 0.35; // a soft voice, not a buzzer
noise ^= noise << 13;
noise ^= noise >>> 7;
noise ^= noise << 17;
double white = (noise >>> 11) / (double) (1L << 53) * 2 - 1;
double excitation = tilt * voiceLevel * (1 - nasal * 0.35)
+ white * (breathLevel + voice.breath() * voiceLevel * 0.08) * 0.35;
double y = excitation;
for (Resonator r : tract) y = r.next(y);
out[i] = y + hiss.next(white) * hissLevel * 0.9;
}
return normalized(out, volume);
}
Three things decide whether it sounds like speech or like a machine trying.
The formants glide. A tongue cannot jump from k to ae; it moves, and the ear listens for that movement more than for the sounds themselves. So the formants are not set per phoneme but pass through anchors: a vowel holds its formants through its middle, a diphthong like the ay of "five" glides from one vowel to another, a stop sits at its locus while the mouth is closed, so the vowel after it starts from there.
/**
* The points the formants pass through. A vowel holds its formants through its middle, a diphthong
* glides from its first to its second vowel, a stop sits at its locus from closing to opening, so the
* vowel after it starts from there: the ear hears the stop in that movement more than in the burst.
*/
private static List<Anchor> anchors(List<Segment> segments, int[] starts) {
List<Anchor> anchors = new ArrayList<>();
for (int i = 0; i < segments.size(); i++) {
Segment s = segments.get(i);
if (s.isPause()) continue;
Phoneme p = s.phoneme();
double start = starts[i], length = s.samples();
switch (p.kind) {
case VOWEL -> {
if (p.isDiphthong()) {
anchors.add(new Anchor(start + 0.2 * length, p.f1, p.f2, p.f3));
anchors.add(new Anchor(start + 0.9 * length, p.g1, p.g2, p.g3));
} else {
anchors.add(new Anchor(start + 0.25 * length, p.f1, p.f2, p.f3));
anchors.add(new Anchor(start + 0.75 * length, p.f1, p.f2, p.f3));
}
}
case GLIDE, NASAL -> {
anchors.add(new Anchor(start + 0.3 * length, p.f1, p.f2, p.f3));
anchors.add(new Anchor(start + 0.7 * length, p.f1, p.f2, p.f3));
}
case FRICATIVE -> anchors.add(new Anchor(start + 0.5 * length, p.f1, p.f2, p.f3));
case STOP, AFFRICATE -> {
anchors.add(new Anchor(start, p.f1, p.f2, p.f3));
anchors.add(new Anchor(start + length, p.f1, p.f2, p.f3));
}
case ASPIRATE -> {
// breath takes the shape of the mouth that comes next
Phoneme next = i + 1 < segments.size() && !segments.get(i + 1).isPause()
? segments.get(i + 1).phoneme() : Phoneme.AX;
if (next.kind == Phoneme.Kind.ASPIRATE) next = Phoneme.AX;
anchors.add(new Anchor(start, next.f1, next.f2, next.f3));
anchors.add(new Anchor(start + length, next.f1, next.f2, next.f3));
}
}
}
if (anchors.isEmpty()) anchors.add(new Anchor(0, Phoneme.AX.f1, Phoneme.AX.f2, Phoneme.AX.f3));
return anchors;
}
The sources fade. Each kind of sound wants its own mix of voice, hiss and breath. A p is silence, a burst, then breath before the vowel; a b is the same with the voice humming through. The levels follow their targets within a few milliseconds instead of switching, or every phoneme would click:
private static Source source(Segment s, double within, Voice voice) {
if (s == null || s.isPause()) return Source.SILENT;
Phoneme p = s.phoneme();
double hum = p.voiced ? 1 : 0;
return switch (p.kind) {
case VOWEL -> new Source(s.stressed() ? 1 : 0.8, 0, 0);
case GLIDE -> new Source(0.75, 0, 0);
case NASAL -> new Source(0.6, 0, 0);
case FRICATIVE -> new Source(0.45 * hum, p.noiseLevel, 0);
case ASPIRATE -> new Source(0, 0, 1.6);
case STOP -> {
// closed, then a burst of noise, then breath (unvoiced) or the voice starting (voiced)
double closed = p.voiced ? 0.7 : 0.55;
double burst = closed + 0.12;
if (within < closed) yield new Source(0.08 * hum, 0, 0);
if (within < burst) yield new Source(0.3 * hum, p.noiseLevel, 0);
yield p.voiced ? new Source(0.7, 0, 0) : new Source(0, 0, 0.6);
}
case AFFRICATE -> within < 0.4 ? new Source(0.08 * hum, 0, 0) : new Source(0.45 * hum, p.noiseLevel, 0);
};
}
The phrase has a tune. Nobody speaks on one note. The voice starts a little high and sinks as the breath runs out, each stressed vowel rises in a small hump, a call starts higher with bigger humps, and a question rises at the end. A little vibrato of 4.5 Hz keeps the voice alive:
/**
* The melody of the phrase: the voice starts a little high and sinks as the breath runs out; each stressed
* vowel rises in a small hump; a call starts higher with bigger humps and falls at the end, a question
* rises at the end.
*/
private static double pitch(double base, Tune tune, List<Segment> segments, int[] starts, int segment,
int sample, int length) {
double along = sample / (double) length;
double high = tune == Tune.CALL ? 1.18 : 1.08;
double hz = base * (high - 0.22 * along);
if (segment < segments.size()) {
Segment s = segments.get(segment);
if (s.stressed()) {
double within = (sample - starts[segment]) / (double) s.samples();
hz += base * (tune == Tune.CALL ? 0.3 : 0.16) * Math.sin(Math.PI * within);
}
}
if (tune == Tune.QUESTION && along > 0.7) hz += base * 0.6 * (along - 0.7) / 0.3;
hz *= 1 + 0.006 * Math.sin(2 * Math.PI * 4.5 * sample / Sound.RATE); // a little vibrato, as alive
return hz;
}
A picture of the sound shows all of it. This is a spectrogram of "Pad 3, please!" in a man's voice: time from left to right, frequency from bottom to top, dark where the sound is loud. The dark bands are the formants. In p ae d they rise from the p; th r iy starts with hiss, and in r iy the second formant climbs from 1100 Hz to 2300 as the tongue moves forward; p l iy z has the same climb and ends in the hiss of the z.

Voices¶
A voice is four numbers: its pitch, the size of its mouth as a factor on the formants (women's and children's mouths are smaller, so their formants are higher), its pace, and how breathy it is:
public record Voice(double pitch, double formants, double pace, double breath) {
public static final Voice MAN = new Voice(112, 1.0, 1.0, 0.04);
public static final Voice WOMAN = new Voice(205, 1.15, 1.0, 0.08);
public static final Voice CHILD = new Voice(280, 1.28, 0.95, 0.06);
public static final Voice OLD_MAN = new Voice(92, 0.97, 0.85, 0.16);
public static final Voice ROBOT = new Voice(100, 1.05, 1.0, 0);
public Voice {
if (pitch < 40 || pitch > 600) throw new IllegalArgumentException("pitch out of range: " + pitch);
if (formants < 0.7 || formants > 1.5) throw new IllegalArgumentException("formants out of range: " + formants);
if (pace <= 0) throw new IllegalArgumentException("pace must be positive: " + pace);
}
/** The same voice at another pitch: to tell two passengers apart who are both women. */
public Voice withPitch(double hz) {
return new Voice(hz, formants, pace, breath);
}
}
Hover Cab has six kinds of passengers, each with a voice of its own: a woman with a suitcase, a man in a hat, a boy, an old man with a stick, a younger woman, a builder with a deep voice:
/** The voice of each kind. */
static final Speech.Voice[] VOICES = {
Speech.Voice.WOMAN,
Speech.Voice.MAN,
Speech.Voice.CHILD,
Speech.Voice.OLD_MAN,
Speech.Voice.WOMAN.withPitch(235),
Speech.Voice.MAN.withPitch(92)};
The tests check what can be checked of a voice: that the vowels have their formants where the table says, measured with a Goertzel filter (one frequency of a Fourier transform, written in the test), and that a woman's voice is higher than a man's:
@Test
void vowelsAreToldApartByTheirFormants() {
Sound ee = Speech.say(Speech.Voice.MAN, "iy");
Sound ah = Speech.say(Speech.Voice.MAN, "aa");
int a = ee.length() / 4, b = ee.length() * 3 / 4;
// "ee" has its second formant high, near 2300 Hz; "ah" has it low, near 1100 Hz
assertTrue(energy(ee, a, b, 2290) > 10 * energy(ee, a, b, 1100), "ee rings high");
int c = ah.length() / 4, d = ah.length() * 3 / 4;
assertTrue(energy(ah, c, d, 1090) > 10 * energy(ah, c, d, 2290), "ah rings low");
}
@Test
void aWomansVoiceIsHigherThanAMans() {
Sound man = Speech.say(Speech.Voice.MAN, "aa");
Sound woman = Speech.say(Speech.Voice.WOMAN, "aa");
double low = pitch(man, man.length() / 3, man.length() / 3 + 2000);
double high = pitch(woman, woman.length() / 3, woman.length() / 3 + 2000);
assertEquals(1.08 * 112, low, 15, "about the voice's pitch, a little raised at the start of a phrase");
assertTrue(high > 1.6 * low, high + " Hz against " + low + " Hz");
}
Five languages¶
The words of the speech bubble and the phonemes of the voice both come from the message files. A German passenger calls "He, Taxi!" and sounds like it:
say.pad=Platz {0}, bitte!
voice.pad=p l ae1 t s / {0} , b ih1 t ax !
voice.number.3=d r ay1
The ARPAbet was made for English, but its sounds are enough for an accent in German, French, Spanish and Italian. A translator who adds a language writes the phrases twice: once in letters, once in phonemes. A test speaks every phrase in every language, so a mistyped phoneme fails the build.
Speaking ahead¶
Speaking "Pad 3, please!" takes the synthesizer 12 to 19 milliseconds, and almost 60 the first time, while Java still compiles the code. A step of the game has 8. Spoken on the loop thread at the moment the passenger calls, the phrase would make the game stutter, every time. So the phrases are spoken ahead: when a shift starts, Voices hands everything its passengers may say to a thread of its own, at low priority, in the order the passengers come:
final class Voices {
private final Messages msg;
private final Map<String, Sound> spoken = new ConcurrentHashMap<>();
private final ExecutorService ahead = Executors.newSingleThreadExecutor(Thread.ofPlatform().daemon()
.priority(Thread.MIN_PRIORITY).name("voices").factory());
Voices(Messages msg) {
this.msg = msg;
}
/** The words of a call, for the bubble: "Pad 3, please!" */
String text(Call call, int pad) {
String key = "say." + call.name().toLowerCase(Locale.ROOT);
return call == Call.PAD ? msg.format(key, pad) : msg.get(key);
}
/** The phonemes of a call: "p ae1 d / th r iy1 , p l iy1 z !" */
String phonemes(Call call, int pad) {
String key = "voice." + call.name().toLowerCase(Locale.ROOT);
return call == Call.PAD ? msg.format(key, msg.get("voice.number." + pad)) : msg.get(key);
}
/** Speaks, in the background, everything the passengers of a shift may say, in the order they come. */
void prepare(Shift shift) {
for (int i = 0; i < shift.fares().size(); i++) {
int kind = People.kind(i, shift.number());
int to = shift.fares().get(i).to();
for (Call call : Call.values()) {
if (call == Call.PAD && to == Shift.UP || call == Call.UP && to != Shift.UP) continue;
ahead.execute(() -> sound(kind, call, to));
}
}
}
/** The call spoken by a passenger of {@code kind}; made now if it is not ready yet. */
Sound sound(int kind, Call call, int pad) {
return spoken.computeIfAbsent(key(kind, call, pad), k -> Speech.say(People.VOICES[kind], phonemes(call, pad), 0.55));
}
/** Whether the call is spoken already. */
boolean ready(int kind, Call call, int pad) {
return spoken.containsKey(key(kind, call, pad));
}
private static String key(int kind, Call call, int pad) {
return kind + ":" + call + ":" + (call == Call.PAD ? pad : 0);
}
}
The cache is a ConcurrentHashMap, since two threads use it. If the loop asks for a phrase that is not ready yet, computeIfAbsent makes it there and then; that costs a stutter but never a missing voice. In practice the first passenger calls a second after the start, and by then the whole shift is spoken.
Step 7: An autopilot¶
A shift drawn in a text file can be broken in ways no test of the rules finds: a pad under a beam with no room to land, a passage one unit too narrow for the cab, a corridor blocked by a lift at the wrong moment. Jump proved its levels with a search over every jump. A cab has no jumps, it has a continuous flight. So Hover Cab proves its shifts the most convincing way: something flies them, with the game's own rules, through every passenger and out of the gate. The same pilot flies on the title screen.
/** What the autopilot does in this step of the game. */
public Input steer(Game game) {
if (game.phase() != Game.Phase.PLAYING) return Input.NONE;
Cab cab = game.cab();
if (!cab.gearWanted && !cab.landed()) return new Input(0, false, false, true); // gear out, always
Target target = target(game);
if (cab.landed()) {
if (target == null || target.pad() == cab.pad()) return Input.NONE; // wait for the passenger
return new Input(0, true, false, false); // lift off
}
if (target == null) target = new Target(null, hoverCell(game.shift().startPad()));
return fly(game, target);
}
Where to fly¶
The pilot plans on a grid of cab positions four units apart. A position is open if the cab, three units fatter all round, touches nothing there: the scenery by its mask, a lift or a van by the whole box it sweeps through. The margin is room for mistakes: a pilot that plans to pass a wall at one unit will hit it. From the hover point over the pad it wants, a DistanceMap (chapter 25) gives each open position its distance in steps, and from anywhere the next step downhill is a step on a shortest way:

The picture shows the mine with the open positions as dots and the planned way from pad 3 to pad 5 in yellow. The way is a staircase, because steps go only up, down, left and right. The cab does not fly the staircase: it looks four steps ahead and flies straight towards that point, which smooths the stairs into slants.
How fast¶
The pilot wants a speed: towards the point ahead, as fast as it can still stop before the goal. A cab that brakes with b from the speed v needs v² / 2b to stop, so with s left, v = √(2bs): fast far away, slow near the goal. The braking it plans with is less than the jets can do, so some is left for surprises, and less again against a strong wind, which eats into the side jets.
A corner needs the same care. The way through the mine turns twice; a cab that arrives at a turn at full speed flies on into the rock. So the pilot also looks for the next bend in the way, where the heading over four steps turns by more than sixty degrees, and slows down to take it.
Which jets¶
A jet is on or off; the pilot wants a speed. The answer is bang-bang control: in each step, fire a jet if that brings the cab's speed after the step nearer the wanted speed than not firing. Main jet against roof jet against neither, left against right against neither. On and off 120 times a second, the jets average out to just the push needed. Simple, and good enough for spacecraft too:
private Input fly(Game game, Target target) {
Body body = game.cab().body();
Pad pad = target.pad();
double wantX, wantVx, wantVy;
double hoverX = target.cell().col() * CELL, hoverY = target.cell().row() * CELL;
double deck = pad == null ? 0 : pad.top(game.time()) - Field.CAB_HEIGHT;
boolean over = pad != null && Math.abs(body.x - hoverX) < 3 && body.y > hoverY - 3 && body.y < deck + 2;
if (over && body.x + Field.LEG_LEFT >= pad.x() && body.x + Field.LEG_RIGHT <= pad.right()) {
// over the pad: hold still sideways and sink onto the deck
wantVx = (hoverX - body.x) * 3;
wantVy = pad.speed(game.time()) + Math.min(SINK, Math.max(8, (deck - body.y) * 2));
return jets(game, wantVx, wantVy);
}
if (pad == null && body.y < Field.CEILING + 2 * CELL && Math.abs(body.x - hoverX) < 6 && game.gateOpen()) {
return jets(game, (hoverX - body.x) * 3, -CRUISE); // out through the gate
}
Cell from = nearestOpen(body.x, body.y, game.gateOpen());
DistanceMap map = map(target.cell(), game.gateOpen());
Cell to = from;
for (int i = 0; i < LOOK_AHEAD && from != null; i++) {
int distance = map.distance(to.col() + 1, to.row() + 1);
if (distance == 0 || distance == DistanceMap.UNREACHABLE) break;
Direction d = map.downhill(to.col() + 1, to.row() + 1);
if (d == null) break;
to = new Cell(to.col() + d.dx, to.row() + d.dy);
}
wantX = to == null ? body.x : to.col() * CELL;
double wantY = to == null ? body.y - CELL : to.row() * CELL;
double dx = wantX - body.x, dy = wantY - body.y;
double length = Math.max(1e-6, Math.hypot(dx, dy));
// as fast as the cab can still stop before the hover point, along the way there
int steps = from == null ? 0 : map.distance(from.col() + 1, from.row() + 1);
double left = steps == DistanceMap.UNREACHABLE ? length : steps * CELL;
// slower where the wind pulls, since the side jets have less to spare against it
double cruise = CRUISE - Math.abs(shift.wind(game.time())) * 0.8;
double speed = Math.min(cruise, Math.min(Math.sqrt(2 * brake * left), length * 6));
// and slow enough to take the next bend in the way
int bend = bend(map, from);
if (bend >= 0) speed = Math.min(speed, TURN + Math.sqrt(2 * brake * bend * CELL));
wantVx = dx / length * speed;
wantVy = Math.min(FALL, dy / length * speed);
return jets(game, wantVx, wantVy);
}
/**
* How many steps along the way from {@code from} the first bend is, where the heading over the next few
* steps turns by more than sixty degrees; -1 if there is none ahead. A heading over a few steps, because
* the steps themselves only go up, down, left or right, and a slant is a staircase of them.
*/
private static int bend(DistanceMap map, Cell from) {
if (from == null) return -1;
List<Cell> way = new ArrayList<>();
Cell at = from;
way.add(at);
while (way.size() < BEND_LOOK) {
int distance = map.distance(at.col() + 1, at.row() + 1);
if (distance == 0 || distance == DistanceMap.UNREACHABLE) break;
Direction d = map.downhill(at.col() + 1, at.row() + 1);
if (d == null) break;
at = new Cell(at.col() + d.dx, at.row() + d.dy);
way.add(at);
}
int w = 4;
if (way.size() <= w) return -1;
double hx = way.get(w).col() - from.col(), hy = way.get(w).row() - from.row();
for (int i = 1; i + w < way.size(); i++) {
double x = way.get(i + w).col() - way.get(i).col(), y = way.get(i + w).row() - way.get(i).row();
if (hx * x + hy * y < 0.5 * Math.hypot(hx, hy) * Math.hypot(x, y)) return i;
}
return -1;
}
/**
* Fires each jet if the speed it gives after this step is nearer the wanted one than without it: the
* main jet against the roof jet against neither, the left against the right against neither.
*/
private Input jets(Game game, double wantVx, double wantVy) {
Body body = game.cab().body();
double step = 1.0 / 120;
double gravity = shift.gravity();
double needY = (wantVy - body.vy) / step;
double needX = (wantVx - body.vx) / step - shift.wind(game.time());
double none = Math.abs(needY - gravity);
double up = Math.abs(needY - (gravity - Field.MAIN_JET));
double down = Math.abs(needY - (gravity + Field.DOWN_JET));
boolean fireUp = up < none && up <= down;
boolean fireDown = !fireUp && down < none;
int side = 0;
if (Math.abs(needX - Field.SIDE_JET) < Math.abs(needX)) side = 1;
else if (Math.abs(needX + Field.SIDE_JET) < Math.abs(needX)) side = -1;
return new Input(side, fireUp, fireDown, false);
}
Over the pad it sinks slowly onto the deck, faster than the deck if the deck is a lift. Below half a tank it fills up first, if the shift has a fuel pad.
The test flies all twelve shifts:
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12})
void theAutopilotFliesTheShift(int number) {
Shift shift = Shift.load(number);
Game game = new Game(shift, new Career());
Autopilot pilot = new Autopilot(shift);
for (int i = 0; i < 120 * 400 && game.phase() == Game.Phase.PLAYING; i++) game.update(1 / 120.0, pilot.steer(game));
assertEquals(Game.Phase.CLEARED, game.phase(), "shift " + number);
assertEquals(0, game.result().crashes());
assertEquals(shift.fares().size(), game.result().passengers());
assertTrue(game.result().tips() > 0, "some tips");
}
While the shifts were made, it failed again and again, and every failure was worth it. Shift 4 had a fuel pad straight above a pad, and the pilot, "over the pad" by its x, sank onto the wrong one. Shift 7 had a piston that swept through the only corridor to pad 3. Shift 9 had pad 7 between two beams with no way in. In the storm the cab overshot every pad in a gust until the pilot learned to brake less against the wind. In the mine it flew into the rock at the corners until it learned bends. And the lift crashes of Step 2 were found by the pilot, not by a player.
Step 8: Everything else¶
The keys and the gamepad are bound as in the earlier games; the gear is on Space, and on a pad on X or Y:
controls = new Controls<>(Action.class, keyboard)
.bind(Action.LEFT, settings.keys("left", KeyEvent.VK_LEFT, KeyEvent.VK_A))
.bind(Action.RIGHT, settings.keys("right", KeyEvent.VK_RIGHT, KeyEvent.VK_D))
.bind(Action.UP, settings.keys("up", KeyEvent.VK_UP, KeyEvent.VK_W))
.bind(Action.DOWN, settings.keys("down", KeyEvent.VK_DOWN, KeyEvent.VK_S))
.bind(Action.GEAR, settings.keys("gear", KeyEvent.VK_SPACE, KeyEvent.VK_G))
.bind(Action.PAUSE, settings.keys("pause", KeyEvent.VK_P, KeyEvent.VK_ESCAPE))
.bind(Action.BACK, KeyEvent.VK_ESCAPE)
.bind(Action.OK, KeyEvent.VK_ENTER, KeyEvent.VK_SPACE);
// One pilot, any gamepad: the cross steers, A is the main jet, X the gear.
for (Gamepad pad : gamepads.pads()) {
controls.bind(Action.LEFT, pad, Gamepad.Button.LEFT)
.bind(Action.RIGHT, pad, Gamepad.Button.RIGHT)
.bind(Action.UP, pad, Gamepad.Button.UP, Gamepad.Button.A)
.bind(Action.DOWN, pad, Gamepad.Button.DOWN, Gamepad.Button.B)
.bind(Action.GEAR, pad, Gamepad.Button.X, Gamepad.Button.Y)
.bind(Action.PAUSE, pad, Gamepad.Button.START)
.bind(Action.BACK, pad, Gamepad.Button.BACK)
.bind(Action.OK, pad, Gamepad.Button.A, Gamepad.Button.START);
}
The play scene makes the game, prepares the voices, starts the tune, and in each step passes the input to the rules and the events to the effects:
/** A shift being flown. */
final class PlayScene implements Scene {
private final Career career;
private final Game game;
private Event.Cause crash;
private double sinceCrash;
PlayScene(Career career, int number) {
this.career = career;
this.game = new Game(shifts.get(number - 1), career);
effects.clear();
effects.prepare(game.shift());
effects.startTune();
}
@Override
public void update(double seconds) {
if (game.phase() == Game.Phase.CLEARED && game.phaseTime() >= CLEARED_SECONDS) {
scenes.replace(new ClearedScene(this));
return;
}
if (controls.wasPressed(Action.PAUSE)) {
scenes.push(new PauseScene());
return;
}
Input input = new Input(controls.axis(Action.LEFT, Action.RIGHT), controls.isDown(Action.UP),
controls.isDown(Action.DOWN), controls.wasPressed(Action.GEAR));
step(seconds, input);
}
void step(double seconds, Input input) {
game.update(seconds, input);
effects.react(game, true);
effects.update(seconds, game, true);
sinceCrash += seconds;
for (Event event : game.events()) {
if (event instanceof Event.Crashed(double x, double y, Event.Cause cause)) {
crash = cause;
sinceCrash = 0;
}
if (event instanceof Event.GameOver(GameResult result)) {
scenes.replace(new OverScene(this, result));
Consumer<GameResult> listener = onGameOver;
ui.execute(() -> listener.accept(result));
}
}
}
The effects turn the events into dust under a landing, a shake after a hard one, fire, smoke and sparks after a crash, a splash in the sea, a speech bubble and the voice when a passenger speaks:
/** Shows and, if {@code audible}, plays what happened in the last step. */
void react(Game game, boolean audible) {
int shift = game.shift().number();
for (Event event : game.events()) {
switch (event) {
case Event.GearMoved(boolean down) -> play(audible, down ? sounds.gearOut : sounds.gearIn);
case Event.Landed(double x, double y, Touchdown touchdown, var pad) -> {
dust(x, y, touchdown == Touchdown.HARD ? 14 : 6);
if (touchdown == Touchdown.HARD) shake = 2.5;
play(audible, touchdown == Touchdown.HARD ? sounds.hardLanding : sounds.land);
}
case Event.TookOff(double x, double y) -> dust(x, y, 4);
case Event.Spoke(Rider rider, Call call, int pad) -> {
bubbles.removeIf(b -> b.rider() == rider);
bubbles.add(new Bubble(rider, voices.text(call, pad), time));
if (audible) mixer.play(voices.sound(People.kind(rider, shift), call, pad));
}
case Event.Boarded boarded -> {
}
case Event.Paid(Rider rider, int fare, int tip, double x, double y) -> {
money.add(new Money(Hud.dollars(fare + tip), x, y, time));
play(audible, sounds.pay);
}
case Event.GateOpened opened -> play(audible, sounds.gate);
case Event.FuelLow low -> play(audible, sounds.lowFuel);
case Event.BonusCab bonus -> {
if (audible) mixer.playMusic(music.bonus, false);
}
case Event.Crashed(double x, double y, Event.Cause cause) -> {
if (cause == Event.Cause.WATER) {
burst(x, y + 6, 30, WATER, 110, -80);
play(audible, sounds.splash);
} else {
burst(x, y, 40, FIRE, 120, -30);
burst(x, y, 24, SMOKE, 60, -40);
burst(x, y, 16, SPARK, 160, -60);
play(audible, sounds.crash);
}
shake = 5;
bubbles.clear();
}
case Event.Respawned(double x, double y) -> {
burst(x, y - 8, 14, WHITE, 50, -20);
play(audible, sounds.respawn);
}
case Event.Cleared cleared -> {
if (audible) mixer.playMusic(music.cleared, false);
}
case Event.GameOver over -> {
if (audible) mixer.playMusic(music.over, false);
}
}
}
}
The jets leave a trail of exhaust, and each one hums while it fires:
/** Smoke from the jets that fire, their hum, and the gurgle of fuel. Every step. */
void update(double seconds, Game game, boolean audible) {
time += seconds;
shake = Math.max(0, shake - seconds * 8);
particles.update(seconds, 20);
bubbles.removeIf(b -> time - b.born() > BUBBLE_SECONDS);
money.removeIf(m -> time - m.born() > MONEY_SECONDS);
Cab cab = game.cab();
if (game.phase() == Game.Phase.CRASHED) {
if (random.nextInt(3) == 0) {
Body body = cab.body();
particles.spawn(body.centerX() + random.nextDouble() * 10 - 5, body.centerY(), random.nextDouble() * 10 - 5,
-30 - random.nextDouble() * 20, 0.8, 2 + random.nextDouble() * 2, SMOKE);
}
return;
}
if (game.phase() != Game.Phase.PLAYING) return;
Body body = cab.body();
if (cab.jetUp()) {
particles.spawn(body.centerX() + random.nextDouble() * 4 - 2, body.y + Field.HULL_HEIGHT + 8,
body.vx * 0.3 + random.nextDouble() * 16 - 8, body.vy + 60 + random.nextDouble() * 40,
0.25 + random.nextDouble() * 0.15, 1 + random.nextDouble() * 1.5, random.nextInt(4) == 0 ? SMOKE : EXHAUST);
}
if (cab.jetSide() != 0) {
double back = cab.jetSide() > 0 ? body.x : body.right();
particles.spawn(back, body.y + 8, -cab.jetSide() * (50 + random.nextDouble() * 30) + body.vx * 0.3,
random.nextDouble() * 10 - 5, 0.2, 1 + random.nextDouble(), EXHAUST);
}
nextPuff -= seconds;
if (nextPuff <= 0 && (cab.jetUp() || cab.jetSide() != 0 || cab.jetDown())) {
play(audible, cab.jetUp() ? sounds.jet : sounds.puff);
nextPuff = cab.jetUp() ? 0.1 : 0.09;
}
nextFuel -= seconds;
if (cab.refuelling() && nextFuel <= 0) {
play(audible, sounds.fuel);
nextFuel = 0.3;
}
}
The cab is drawn from CabShape's outline, so the picture and the mask cannot disagree:
/** The cab with its top left corner at {@code x}, {@code y}; {@code flicker} 0 to 1 makes the flames dance. */
static void paint(Graphics2D g, Cab cab, double x, double y, boolean passenger, double flicker) {
AffineTransform saved = g.getTransform();
g.translate(x, y);
if (cab.facingLeft()) g.transform(new AffineTransform(-1, 0, 0, 1, Field.CAB_WIDTH, 0));
flames(g, cab, flicker);
body(g, cab.gear(), passenger);
g.setTransform(saved);
}
The painter copies the still picture and draws what moves on it, and the weather on top: rain slanted by the wind in the storm, snow on the peaks:
void paint(Graphics2D g, Game game, Effects effects, double alpha) {
Shift shift = game.shift();
double time = game.time();
double shake = effects.shake();
AffineTransform saved = g.getTransform();
if (shake > 0) {
SeededRandom r = new SeededRandom((long) (effects.time() * 60));
g.translate((r.nextDouble() - 0.5) * shake, (r.nextDouble() - 0.5) * shake);
}
scenery.computeIfAbsent(shift.number(), n -> Scenery.of(shift)).draw(g, 0, 0);
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
for (Mover mover : shift.movers()) {
movers.computeIfAbsent(mover, m -> Sprite.drawn(m.width(), m.height(), (mg, w, h) -> {
mg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
Scenery.mover(mg, new Mover(m.look(), 0, 0, w, h, 0, 0, 1, 0), 0);
})).draw(g, mover.x(time), mover.y(time));
}
for (Pad pad : shift.pads()) if (pad.isLift()) Scenery.lift(g, pad, pad.top(time));
Scenery.gate(g, shift, game.gateOpen(), time);
target(g, game, time);
Rider rider = game.rider();
if (rider != null && rider.visible()) {
boolean waving = rider.state() == Rider.State.WAITING;
People.paint(g, People.kind(rider, shift.number()), rider.x(), rider.pad().top(time), rider.facingLeft(),
waving, !waving, rider.stateTime());
}
if (game.phase() != Game.Phase.CRASHED && game.phase() != Game.Phase.OVER) {
Cab cab = game.cab();
Body body = cab.body();
boolean aboard = rider != null && rider.state() == Rider.State.RIDING;
CabArt.paint(g, cab, body.x(alpha), body.y(alpha), aboard, (effects.time() * 37) % 1);
}
effects.render(g, alpha);
weather(g, shift, time);
g.setTransform(saved);
}
/** Rain in a storm, snow in the mountains, streaks of wind where it blows. */
private void weather(Graphics2D g, Shift shift, double time) {
double wind = shift.wind(time);
if (shift.theme().equals("storm")) {
g.setColor(new Color(170, 190, 220, 110));
g.setStroke(new BasicStroke(0.8f));
double slant = wind * 0.08;
for (int i = 0; i < drops.length; i++) {
double x = (drops[i] * Field.WIDTH * 1.3 + time * slant * 20) % (Field.WIDTH * 1.3) - Field.WIDTH * 0.15;
double y = (drops[(i * 7) % drops.length] * Field.HEIGHT + time * 380) % Field.HEIGHT;
g.draw(new Line2D.Double(x, y, x + slant, y + 7));
}
g.setStroke(new BasicStroke(1));
double flash = (time % 7.3);
if (flash < 0.12) {
g.setColor(new Color(255, 255, 255, (int) (90 * (1 - flash / 0.12))));
g.fill(new Rectangle2D.Double(0, 0, Field.WIDTH, Field.HEIGHT));
}
} else if (shift.theme().equals("snow")) {
g.setColor(new Color(255, 255, 255, 200));
for (int i = 0; i < 120; i++) {
double x = (drops[i] * Field.WIDTH + time * (wind * 0.4) + Math.sin(time + i) * 6) % Field.WIDTH;
if (x < 0) x += Field.WIDTH;
double y = (drops[(i * 7) % drops.length] * Field.HEIGHT + time * 30) % Field.HEIGHT;
g.fill(new Rectangle2D.Double(x, y, 1.5, 1.5));
}
} else if (Math.abs(wind) > 1) {
g.setColor(new Color(255, 255, 255, (int) Math.min(80, Math.abs(wind) * 1.2)));
for (int i = 0; i < 24; i++) {
double x = (drops[i] * Field.WIDTH * 1.5 + time * wind * 6) % (Field.WIDTH * 1.5);
if (x < 0) x += Field.WIDTH * 1.5;
x -= Field.WIDTH * 0.25;
double y = Field.CEILING + drops[i + 40] * (Field.HEIGHT - Field.CEILING);
g.fill(new Rectangle2D.Double(x, y, Math.min(40, Math.abs(wind) * 0.6), 0.8));
}
}
}
The top bar shows the shift, the money, the melting tip, the fuel gauge, the passengers and the spare cabs; a speech bubble points at whoever speaks:
/** The bar along the top. */
void status(Graphics2D g, Game game) {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g.setPaint(new GradientPaint(0, 0, new Color(0x2A2E40), 0, (float) Field.TOP, new Color(0x161924)));
g.fill(new Rectangle2D.Double(0, 0, Field.WIDTH, Field.TOP));
g.setColor(GOLD);
g.fill(new Rectangle2D.Double(0, Field.TOP - 1, Field.WIDTH, 1));
int shift = game.shift().number();
g.setFont(font(8.5f, true));
g.setColor(GOLD);
String label = msg.format("hud.shift", shift);
g.drawString(label, 8, 14);
double x = 8 + g.getFontMetrics().stringWidth(label) + 6;
g.setFont(font(8.5f, false));
g.setColor(CHALK);
g.drawString(name(shift), (float) x, 14);
label(g, msg.get("hud.fares"), 150);
g.setFont(font(9, true));
g.setColor(CHALK);
g.drawString(dollars(game.career().cents()), 180, 14.5f);
Rider rider = game.rider();
boolean riding = rider != null && rider.state() == Rider.State.RIDING;
label(g, msg.get("hud.tip"), 236);
g.setFont(font(9, true));
g.setColor(riding ? GREEN : GREY);
int tip = riding ? rider.tip() : Field.TIP_START;
g.drawString(dollars(tip), 254, 14.5f);
g.setColor(new Color(0x3A3E50));
g.fill(new Rectangle2D.Double(290, 9, 30, 4));
g.setColor(riding ? GREEN : GREY);
g.fill(new Rectangle2D.Double(290, 9, 30.0 * tip / Field.TIP_START, 4));
Cab cab = game.cab();
label(g, msg.get("hud.fuel"), 334);
g.setColor(new Color(0x3A3E50));
g.fill(new Rectangle2D.Double(358, 8, 70, 6));
boolean low = cab.fuel() < Field.LOW_FUEL;
if (!low || (game.time() * 3) % 1 < 0.6) {
g.setPaint(new GradientPaint(358, 0, RED, 428, 0, GREEN));
g.fill(new Rectangle2D.Double(358, 8, 70 * cab.fuel() / Field.TANK, 6));
}
g.setColor(new Color(0x161924));
for (double t = 372; t < 428; t += 14) g.fill(new Rectangle2D.Double(t, 8, 1, 6));
label(g, msg.get("hud.passengers"), 442);
g.setFont(font(9, true));
g.setColor(CHALK);
String count = game.carried() + " / " + game.shift().fares().size();
g.drawString(count, 442 + g.getFontMetrics().stringWidth(msg.get("hud.passengers")) * 0.8f + 6, 14.5f);
int spare = game.career().spare();
for (int i = 0; i < Math.min(spare, 4); i++) miniCab(g, 574 + i * 16, 15);
if (spare > 4) {
g.setFont(font(7, true));
g.setColor(CHALK);
g.drawString("+" + (spare - 4), 574 + 4 * 16 - 4, 14);
}
g.setFont(font(7, false));
g.setColor(cab.gearOut() ? GREEN : GREY);
String gear = cab.gearOut() ? "↓" : "↑";
g.drawString(gear, 632, 14);
}
/** The speech bubbles: over a passenger on a pad, or over the cab when they speak from inside it. */
void bubbles(Graphics2D g, Game game, Effects effects) {
g.setFont(font(8, true));
for (Effects.Bubble bubble : effects.bubbles()) {
Rider rider = bubble.rider();
double tipX, tipY;
boolean fromCab = !rider.visible();
if (fromCab) {
if (game.phase() == Game.Phase.CRASHED) continue;
Body body = game.cab().body();
tipX = body.centerX() + 2;
tipY = body.y - 5;
} else {
tipX = rider.x() + 2;
tipY = rider.pad().top(game.time()) - 24;
}
double age = effects.time() - bubble.born();
double grow = Math.min(1, age / 0.12);
bubble(g, bubble.text(), tipX, tipY, grow);
}
for (Effects.Money money : effects.money()) {
double age = effects.time() - money.born();
int alpha = (int) (255 * Math.clamp((Effects.MONEY_SECONDS - age) / 0.5, 0, 1));
g.setFont(font(10, true));
g.setColor(new Color(0, 0, 0, alpha / 2));
g.drawString(money.text(), (float) money.x() - 14, (float) (money.y() - 8 - age * 20) + 1);
g.setColor(new Color(GREEN.getRed(), GREEN.getGreen(), GREEN.getBlue(), alpha));
g.drawString(money.text(), (float) money.x() - 15, (float) (money.y() - 9 - age * 20));
}
}
private void bubble(Graphics2D g, String text, double tipX, double tipY, double grow) {
FontMetrics fm = g.getFontMetrics();
double w = fm.stringWidth(text) + 12, h = 15;
double bx = Math.clamp(tipX - 12, 2, Field.WIDTH - w - 2);
double by = tipY - h - 5;
if (by < Field.CEILING + 2) by = Field.CEILING + 2;
AffineTransform saved = g.getTransform();
g.translate(tipX, tipY);
g.scale(grow, grow);
g.translate(-tipX, -tipY);
g.setColor(new Color(0, 0, 0, 70));
g.fill(new RoundRectangle2D.Double(bx + 1, by + 1, w, h, 8, 8));
Path2D tail = new Path2D.Double();
tail.moveTo(Math.clamp(tipX - 2, bx + 4, bx + w - 10), by + h - 1);
tail.lineTo(tipX, tipY);
tail.lineTo(Math.clamp(tipX + 5, bx + 8, bx + w - 4), by + h - 1);
tail.closePath();
g.setColor(BUBBLE);
g.fill(new RoundRectangle2D.Double(bx, by, w, h, 8, 8));
g.fill(tail);
g.setColor(INK);
g.drawString(text, (float) (bx + 6), (float) (by + 10.5));
g.setTransform(saved);
}


./gradlew benchmark measures every shift: 1 to 2 ms per frame at 1280 × 800 and 2 to 3 ms at 1920 × 1200 on the computer this chapter was written on, drawing in software. The still picture of each shift is what makes it cheap. Full screen (F11, chapter 20), the render modes, the frame times (F12), sound and the five languages work as in the earlier games.
What moved into the engine¶
| From Hover Cab | Engine package | Why |
|---|---|---|
| Speech from phonemes: a formant synthesizer with voices, a tune and five resonators | eu.amadeos.gameengine.audio.Speech, Phoneme |
Characters who talk without recordings, in any language |
| Bit masks of pixels, made from shapes or pictures, compared 64 pixels at a time | eu.amadeos.gameengine.collision.PixelMask |
Collisions as exact as the pictures |
| Jets that push and burn fuel | eu.amadeos.gameengine.physics.Thrusters |
Every craft that flies against gravity: landers, jetpacks, ships |
| Soft, hard or a crash, from the speed at touchdown | eu.amadeos.gameengine.physics.Touchdown |
Every landing and every fall with damage |
| Air that slows a body, whatever the step | eu.amadeos.gameengine.physics.Body.drag |
Everything that flies or floats |
What stays in the game: the cab and its shape, the shifts and their reader, pads, lifts and movers, the passengers and their calls, fares and tips, the autopilot, the scenery, the painter, the voices of the six kinds of passengers, the sounds and tunes, and the scenes.
Exercises¶
- A shift of your own. Copy
shift3.txttoshift13.txt, change it, and add it to the list of shifts and the texts. Put a pad where the cab cannot land and read whatAutopilotTestsays. - Say more. Passengers could comment on a long wait ("Finally!") or a fast ride ("Wow!"). Add a call, its words and its phonemes in five languages, and decide which event says it.
- A voice of your own. Make a robot voice for a passenger in the orbit station: no pitch movement at all, and a buzz instead of puffs. Which parts of
saychange? - Tilt. The cab is always level. Let the side jets tilt it a few degrees, drawn and in its mask (a rotated
PixelMaskper few degrees, made once). Does landing get harder? - Fuel pads that run dry. Give the fuel pad a stock that lasts for a few fills per shift. How does the autopilot have to change?
- A second autopilot. The pilot brakes for corners by a rule. Write a pilot that searches over jet inputs instead, a few steps ahead with the real
Game.update, like Jump's level check. Which one flies better, and which one is faster?
Hover Cab ends Part II. In Part III, the camera looks down on a map instead of from the side, and the player commands many units with the mouse instead of steering one: first Towers, a tower defence, then Sands, a real-time strategy game.