26. Blast¶
Maze had one player against four ghosts that the computer moved. In Blast, all four figures can be people: up to four players share one arena, two at the keyboard and the others with gamepads, and the computer takes the seats nobody wants. Each lays bombs among crates; the bombs explode after a moment, their flames run along the corridors, burn the crates and set off other bombs. The last one standing wins the round.

The original came out in 1983 and became a party game when it let five people play on one screen. Its rules fit on a card, and the interesting part is underneath: many things wait at the same time (every fuse, every flame, every burning crate), and one thing causes the next, which causes the next. This chapter builds the pieces for that, timers and events, and the one Blast cannot do without: gamepads.
What you will learn
- Gamepads: four pads, read from the joystick devices on Linux and through XInput on Windows, with a dead zone for the stick
- The foreign function interface: calling a function of a Windows library from Java, without a line of C
- Seats: one player's controls, whatever they are: keys, a pad or the computer; a lobby where people join with a button
- Timers: many countdowns that run out in the order of their time, even within one step
- Events: consequences as messages on a queue, so that a chain reaction runs wave by wave
- Danger maps: when flames will reach each tile, along chains, for a computer player that does not blow itself up
The complete game is in amade-blast-game.
The rules¶
- The arena is 15 tiles wide and 13 high: walls round it, pillars in every second row and column, and crates on most of the rest. The four players start in the corners.
- A player lays a bomb on the tile they stand on. It explodes after two and a half seconds. Its flames run from it in the four directions, two tiles at first, and stay for half a second. Walls stop them; a crate stops them and burns.
- A flame that reaches another bomb sets it off at once: a chain reaction.
- Crates hide power-ups: one more bomb at a time, one tile longer flames, faster feet. A flame burns a power-up that lies in the open.
- Bombs block the way. A player can leave the bomb they just laid, but cannot walk onto one.
- Whoever is in the flames is out of the round. The last one standing wins it; if the last ones fall together, nobody does. Three won rounds win the match.
- After two minutes, walls fall: one every 0.3 seconds, in a spiral from the edge inwards, on whatever is there.
Step 1: The arena as a text file¶
The arena is a TileMap, as the maze was, read from a text file. Blast adds one idea: some tiles are crates only by chance, so every round has a different arena.
row XXXXXXXXXXXXXXX
row X__.........__X
row X_X.X.X.X.X.X_X
row X.............X
row X.X.X.X.X.X.X.X
...
row X__.........__X
row XXXXXXXXXXXXXXX
start 1 1
start 13 11
start 13 1
start 1 11
crates 0.8
hide bomb 10
hide fire 10
hide speed 6
X is a wall, + always a crate, _ always free floor, and . a crate with the chance given by crates. hide says how many of each power-up are hidden in the crates. The corners are free: a player who cannot get round a corner from their start is dead with their first bomb, because flames go straight. Board checks exactly that, with a breadth-first search from each start:
/** Reads an arena; every mistake is an error that names the file and the line. */
public static Board read(List<TextData.Line> lines) {
List<TextData.Line> rows = new ArrayList<>();
List<TextData.Line> startLines = new ArrayList<>();
double crates = 0;
Map<PowerUp, Integer> hidden = new EnumMap<>(PowerUp.class);
for (TextData.Line line : lines) {
switch (line.keyword()) {
case "row" -> rows.add(line);
case "start" -> startLines.add(line);
case "crates" -> {
crates = line.number(1);
if (crates < 0 || crates > 1) throw line.error("the chance of a crate is between 0 and 1");
}
case "hide" -> hidden.put(powerUp(line), line.integer(2));
default -> throw line.error("\"" + line.keyword() + "\" is not a line of an arena");
}
}
TileMap<Letter> letters = TileMap.read(rows, LEGEND);
if (startLines.size() != Field.PLAYERS) {
throw lines.getLast().error("an arena needs " + Field.PLAYERS + " starts, not " + startLines.size());
}
List<Cell> starts = new ArrayList<>();
for (TextData.Line line : startLines) {
Cell at = new Cell(line.integer(1), line.integer(2));
if (!letters.contains(at.col(), at.row()) || letters.get(at) != Letter.FREE) {
throw line.error("(" + at.col() + ", " + at.row() + ") is not a free tile, '_'");
}
if (!escapes(letters, at)) {
throw line.error("a player at (" + at.col() + ", " + at.row()
+ ") cannot escape a first bomb: it needs free tiles round a corner");
}
starts.add(at);
}
return new Board(letters, starts, crates, hidden);
}
/**
* The flames of a bomb go straight: a tile round a corner is safe. Among the free tiles a player can
* reach from the start, there must be two that are not in one row or column: lay the bomb on one, wait
* on the other. Otherwise the player's first bomb is the end of the player.
*/
private static boolean escapes(TileMap<Letter> letters, Cell start) {
DistanceMap reach = DistanceMap.search(letters, (col, row) -> letters.get(col, row) == Letter.FREE, start);
List<Cell> free = letters.cells(letter -> letter == Letter.FREE).stream()
.filter(cell -> reach.distance(cell) != DistanceMap.UNREACHABLE).toList();
for (Cell bomb : free) {
for (Cell wait : free) {
if (bomb.col() != wait.col() && bomb.row() != wait.row()) return true;
}
}
return false;
}
@Test
void mistakesInTheFileNameTheLine() {
assertEquals("board.txt:7: (2, 2) is not a free tile, '_'",
assertThrows(TextData.DataException.class, () -> read(SMALL.replace("start 7 3", "start 2 2")))
.getMessage());
assertEquals("board.txt:6: a player at (1, 1) cannot escape a first bomb: it needs free tiles round a corner",
assertThrows(TextData.DataException.class, () -> read(SMALL
.replace("row X_______X\nrow X_X", "row X_++++++X\nrow X+X"))).getMessage(),
"walled in by crates: the first bomb would be the last");
assertEquals("board.txt:3: \"shield\" is not a power-up: bomb, fire or speed",
assertThrows(TextData.DataException.class, () -> read("crates 0.5\nhide bomb 2\nhide shield 1\n"))
.getMessage());
}
Each round lays the crates anew, from the match's seeded random generator, and shuffles the power-ups into them:
/**
* The arena of a round: walls as in the file, crates where the file says so and, by chance, where it
* allows them; the power-ups go into crates chosen by chance, into {@code powerUps}.
*/
public TileMap<Tile> lay(SeededRandom random, Map<Cell, PowerUp> powerUps) {
TileMap<Tile> tiles = new TileMap<>(width(), height(), Tile.FLOOR);
List<Cell> crateCells = new ArrayList<>();
for (int row = 0; row < height(); row++) {
for (int col = 0; col < width(); col++) {
Tile tile = switch (letters.get(col, row)) {
case WALL -> Tile.WALL;
case FREE -> Tile.FLOOR;
case CRATE -> Tile.CRATE;
case MAYBE -> random.nextDouble() < crates ? Tile.CRATE : Tile.FLOOR;
};
tiles.set(col, row, tile);
if (tile == Tile.CRATE) crateCells.add(new Cell(col, row));
}
}
// Shuffle the crates with the game's generator, so the same seed hides the same power-ups.
for (int i = crateCells.size() - 1; i > 0; i--) Collections.swap(crateCells, i, random.nextInt(i + 1));
int next = 0;
for (PowerUp powerUp : PowerUp.values()) {
for (int n = 0; n < hidden.getOrDefault(powerUp, 0) && next < crateCells.size(); n++) {
powerUps.put(crateCells.get(next++), powerUp);
}
}
return tiles;
}
The players walk as the muncher did: a Walker from centre to centre, turning only at centres. One rule is new. A bomb blocks the way, but the player who just laid it stands on it; walkable only asks about the tile ahead, so they can always step off:
/**
* Walks for {@code seconds} in the way the player wants. It turns and stops only at the centre of a
* tile, as in Maze; without a direction it stops at the next centre. Back the way it came works at once.
*/
void move(double seconds, Direction wanted, Game game) {
this.game = game;
this.wanted = wanted;
if (wanted != null && walker.heading() == wanted.opposite()) walker.turnAround();
walked += walker.walk(speed() * seconds, game.tiles(), steering);
if (walker.heading() != null) facing = walker.heading();
else if (wanted != null) facing = wanted; // against a wall or a bomb: it still looks there
}
private Direction choose(int col, int row, Direction heading) {
if (wanted == null) return null;
Cell next = game.tiles().step(col, row, wanted);
return game.walkable(next) ? wanted : null;
}
Step 2: Gamepads¶
Four people at one keyboard do not work: hands get in each other's way, and most keyboards cannot even report more than a few keys held at once. So Blast needs gamepads, and the engine gets them.
Java has no gamepad support of its own. Every operating system has one, though, and the engine needs two of them: Linux, which amadeOS is, and Windows, where many of the games' players are. Behind both stands the same small class for one pad:
public final class Gamepad {
/** The buttons, named as on an Xbox pad. UP to RIGHT are the d-pad, or the left stick pushed far enough. */
public enum Button {
A, B, X, Y, LB, RB, BACK, START, UP, DOWN, LEFT, RIGHT;
final int bit = 1 << ordinal();
}
/** Stick movements smaller than this, as a part of the full way, count as none. */
public static final double DEAD_ZONE = 0.25;
/** From here on, the stick also presses the d-pad in its direction. */
public static final double AS_DPAD = 0.5;
private final int index;
// Written by the backend, under the lock.
private boolean reportedConnected;
private String reportedName = "";
private int reportedButtons;
private int pressedSincePoll;
private double reportedX;
private double reportedY;
// As of the last poll, for the game loop.
private boolean connected;
private String name = "";
private int down;
private int pressed;
private double x;
private double y;
public Gamepad(int index) {
this.index = index;
}
/** The backend's side: the pad is there, with this name. */
public synchronized void connect(String name) {
reportedConnected = true;
reportedName = name;
}
/** The backend's side: the pad is gone; its buttons count as released. */
public synchronized void disconnect() {
reportedConnected = false;
reportedButtons = 0;
reportedX = 0;
reportedY = 0;
}
/**
* The backend's side: the buttons held now, as bits of {@link Button}, and the left stick, -1 to 1 on
* both axes, positive to the right and down, as on the screen.
*/
public synchronized void report(int buttons, double stickX, double stickY) {
pressedSincePoll |= buttons & ~reportedButtons;
reportedButtons = buttons;
reportedX = stickX;
reportedY = stickY;
}
/** Takes over the state the backend reported; once per update step, on the game loop thread. */
public synchronized void poll() {
connected = reportedConnected;
name = reportedName;
double[] stick = deadZone(reportedX, reportedY);
x = stick[0];
y = stick[1];
int dpad = 0;
if (x <= -AS_DPAD) dpad |= Button.LEFT.bit;
if (x >= AS_DPAD) dpad |= Button.RIGHT.bit;
if (y <= -AS_DPAD) dpad |= Button.UP.bit;
if (y >= AS_DPAD) dpad |= Button.DOWN.bit;
int held = reportedButtons | dpad;
// Pressed: reported as going down since the last poll, also if released again already; or, for the
// stick's d-pad, held now and not before.
pressed = pressedSincePoll | (held & ~down);
down = held;
pressedSincePoll = 0;
}
/**
* A round dead zone: inside it the stick is at rest; outside, the way from its edge to the full way is
* stretched to 0 to 1 again, so the player still gets every speed, and the direction is kept.
*/
static double[] deadZone(double x, double y) {
double length = Math.hypot(x, y);
if (length < DEAD_ZONE) return new double[]{0, 0};
double scaled = Math.min(1, (length - DEAD_ZONE) / (1 - DEAD_ZONE));
return new double[]{x / length * scaled, y / length * scaled};
}
Two sides, two threads. A backend reports what the pad does, whenever it learns about it, perhaps on a thread of its own: connect, disconnect, report. The game calls poll() once per update step on the loop thread, and from then on until the next poll it sees one unchanging state, as the keyboard does since chapter 20. The lock is held only for the moment of copying.
Two details make the pad pleasant to play:
- A press is never lost. A button pressed and released again between two polls (a quick tap, at 120 polls a second, is rare, but it happens) still counts as pressed once:
reportcollects every button that went down intopressedSincePoll. - A stick at rest is at rest. A stick never comes back exactly to the middle; a worn one stops a few percent off. Inside a round dead zone it counts as zero; outside, the rest of the way is stretched to the full range again, so the player still gets every speed. Pushed more than halfway, the stick also counts as the d-pad: Blast's players walk on a grid and only need four directions.
@Test
void aStickAtRestIsInTheMiddleAndAFullPushStaysFull() {
assertEquals(0, Gamepad.deadZone(0.2, -0.1)[0], "a worn stick that does not quite come back");
assertEquals(1, Gamepad.deadZone(1, 0)[0], 1e-9);
assertEquals(0.5, Gamepad.deadZone(0, 0.625)[1], 1e-9, "halfway between the dead zone and the edge");
double[] diagonal = Gamepad.deadZone(0.6, 0.6);
assertEquals(diagonal[0], diagonal[1], 1e-9, "the direction is kept");
}
Gamepads holds four pads, as many as XInput knows, and picks the backend by the operating system. If none can start, the game runs without pads and says so in the log:
public final class Gamepads implements AutoCloseable {
/** As many pads as XInput knows; enough for four players. */
public static final int SLOTS = 4;
private static final Logger log = LoggerFactory.getLogger(Gamepads.class);
/** Where the pads' state comes from. */
interface Backend extends AutoCloseable {
/** What it is, for the log and the About dialog. */
String name();
/** Fills the pads' state; called from {@link Gamepads#poll}, before the pads take it over. */
void poll(List<Gamepad> pads);
@Override
void close();
}
private final List<Gamepad> pads = IntStream.range(0, SLOTS).mapToObj(Gamepad::new).toList();
private Backend backend;
private Gamepads() {
}
/** The pads of this computer, by the backend of its operating system; no pads if there is none. */
public static Gamepads open() {
String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
if (os.startsWith("windows")) return start(pads -> XInput.open());
if (os.startsWith("linux")) return start(LinuxJoysticks::open);
return none();
}
/** With a backend made for the four pads; without one if it cannot start, e.g. no XInput. */
static Gamepads start(Function<List<Gamepad>, Backend> backend) {
Gamepads gamepads = new Gamepads();
try {
gamepads.backend = backend.apply(gamepads.pads);
log.info("Gamepads: {}", gamepads.backend.name());
} catch (RuntimeException | LinkageError e) {
log.warn("No gamepads: {}", e.toString());
}
return gamepads;
}
Linux: reading a device file¶
On Linux, every gamepad is a file: /dev/input/js0, js1 and so on. Reading it gives events of eight bytes each: a time, a value, a type (button or axis) and a number. When the file is opened, the driver first sends the current state of every button and axis, marked with INIT. The engine reads each pad on a thread of its own, which blocks until the next event comes; a scanner looks for new files once a second, so a pad plugged in during the game is found:
/** Reads the events of one pad until it is unplugged. */
private void read(Path device, Gamepad pad) {
State state = new State();
byte[] event = new byte[State.EVENT_SIZE];
try (InputStream in = Files.newInputStream(device)) {
pad.connect(name(device));
log.info("Gamepad {}: {} ({})", pad.index() + 1, name(device), device);
while (running && in.readNBytes(event, 0, event.length) == event.length) {
state.apply(event);
pad.report(state.buttons(), state.x(), state.y());
}
} catch (java.nio.file.AccessDeniedException e) {
log.warn("Gamepad {} cannot be read: no permission", device);
synchronized (this) {
refused.add(device);
}
} catch (IOException e) {
// Unplugged: the read fails.
} finally {
pad.disconnect();
synchronized (this) {
open.remove(device);
}
}
}
The numbers of the buttons and axes come from the driver. For Xbox pads and the many that copy them it is xpad, and its numbering is the table:
static final class State {
/** {@code struct js_event}: u32 time in milliseconds, s16 value, u8 type, u8 number. */
static final int EVENT_SIZE = 8;
static final int BUTTON = 0x01;
static final int AXIS = 0x02;
/** Added to the type of the events that report the state at the moment the device is opened. */
static final int INIT = 0x80;
/** The xpad driver's buttons; with the driver's option {@code dpad_to_buttons}, 11 to 14 are the d-pad. */
private static final Gamepad.Button[] BUTTONS = {
Gamepad.Button.A, Gamepad.Button.B, Gamepad.Button.X, Gamepad.Button.Y,
Gamepad.Button.LB, Gamepad.Button.RB, Gamepad.Button.BACK, Gamepad.Button.START,
null, null, null, // Guide and the two stick buttons
Gamepad.Button.LEFT, Gamepad.Button.RIGHT, Gamepad.Button.UP, Gamepad.Button.DOWN};
private static final int STICK_X = 0;
private static final int STICK_Y = 1;
private static final int DPAD_X = 6;
private static final int DPAD_Y = 7;
private final int[] axes = new int[8];
private int buttons;
void apply(byte[] event) {
ByteBuffer data = ByteBuffer.wrap(event).order(ByteOrder.LITTLE_ENDIAN);
data.getInt(); // the time: the game has its own
int value = data.getShort();
int type = (data.get() & 0xFF) & ~INIT; // the opening state counts like any other event
int number = data.get() & 0xFF;
if (type == BUTTON && number < BUTTONS.length && BUTTONS[number] != null) {
int bit = Gamepad.bits(BUTTONS[number]);
buttons = value != 0 ? buttons | bit : buttons & ~bit;
} else if (type == AXIS && number < axes.length) {
axes[number] = value;
}
}
/** The buttons held, with the d-pad's axes as buttons. */
int buttons() {
int held = buttons;
if (axes[DPAD_X] < 0) held |= Gamepad.bits(Gamepad.Button.LEFT);
if (axes[DPAD_X] > 0) held |= Gamepad.bits(Gamepad.Button.RIGHT);
if (axes[DPAD_Y] < 0) held |= Gamepad.bits(Gamepad.Button.UP);
if (axes[DPAD_Y] > 0) held |= Gamepad.bits(Gamepad.Button.DOWN);
return held;
}
/** The driver gives axes from -32767 to 32767, down positive as on the screen. */
double x() {
return Math.clamp(axes[STICK_X] / 32767.0, -1, 1);
}
double y() {
return Math.clamp(axes[STICK_Y] / 32767.0, -1, 1);
}
}
@Test
void eventsBecomeButtonsAndAxes() {
LinuxJoysticks.State state = new LinuxJoysticks.State();
state.apply(LinuxJoysticks.event(BUTTON | INIT, 7, 0)); // Start, released, when opened
state.apply(LinuxJoysticks.event(BUTTON, 0, 1)); // A down
state.apply(LinuxJoysticks.event(AXIS, 1, -32767)); // left stick fully up
state.apply(LinuxJoysticks.event(AXIS, 6, 32767)); // d-pad right
assertEquals(Gamepad.bits(Gamepad.Button.A, Gamepad.Button.RIGHT), state.buttons());
assertEquals(-1, state.y(), 1e-9);
state.apply(LinuxJoysticks.event(BUTTON, 0, 0));
state.apply(LinuxJoysticks.event(AXIS, 6, 0));
assertEquals(0, state.buttons());
}
Reading a device needs permission. On a desktop the user at the screen usually has it; where they do not, the pad is logged once as "no permission" and not tried again.
Devices that are no gamepads¶
The kernel makes a joystick device for everything that has axes and a few buttons, not only for pads. A laptop's accelerometer gets one, and so does the tablet that QEMU and VirtualBox use as the mouse of a virtual machine: its axes are the mouse pointer, its first button is the left mouse button. Taken for a pad, it fills slot 1, and every click into the window "presses A", so the phantom pad joins the game before the keyboard can.
What a device can do is in /sys/class/input/js0/device: capabilities/key is a bitmap of its keys and buttons, properties one of its properties, both written by the kernel as hex words, the highest first. A pad has buttons in the range of joysticks and gamepads, BTN_JOYSTICK (0x120) to BTN_THUMBR (0x13e); a mouse has BTN_LEFT (0x110), a touchpad BTN_TOUCH, an accelerometer the property INPUT_PROP_ACCELEROMETER. The scanner asks before it gives a device a slot, and skips the others with one line in the log:
/** A gamepad has pad buttons, neither mouse buttons nor touch, and is no accelerometer. */
static boolean isGamepad(String keys, String properties, int wordBits) {
if (bit(properties, ACCELEROMETER, wordBits)) return false;
if (bit(keys, MOUSE_BUTTON, wordBits) || bit(keys, TOUCH, wordBits)) return false;
for (int button = FIRST_PAD_BUTTON; button <= LAST_PAD_BUTTON; button++) {
if (bit(keys, button, wordBits)) return true;
}
return false;
}
/**
* One bit of a bitmap as the kernel writes it to sysfs: words in hex, separated by spaces, the highest
* first, and without the zero words in front.
*/
static boolean bit(String bitmap, int bit, int wordBits) {
String[] words = bitmap.strip().split("\\s+");
int word = words.length - 1 - bit / wordBits;
if (word < 0) return false;
return (Long.parseUnsignedLong(words[word], 16) >>> (bit % wordBits) & 1) == 1;
}
/** Bitmaps as a 64-bit kernel writes them to /sys/class/input/js0/device/capabilities/key. */
@Test
void onlyGamepadsAreGamepads() {
assertTrue(LinuxJoysticks.isGamepad("7cdb000000000000 0 0 0 0", "0", 64)); // Xbox pad
assertTrue(LinuxJoysticks.isGamepad("ffff00000000 0 0 0 0", "0", 64)); // joystick
assertFalse(LinuxJoysticks.isGamepad("70000 0 0 0 0", "0", 64)); // QEMU tablet
assertFalse(LinuxJoysticks.isGamepad("0", "40", 64)); // accelerometer
assertFalse(LinuxJoysticks.isGamepad("400 7cdb000000000000 0 0 0 0", "0", 64)); // touch
assertFalse(LinuxJoysticks.isGamepad("0", "0", 64));
}
Should a pad still vanish in the middle of a match, the pause screen waits for it, and Esc leaves the match for the lobby, where the keyboard players can start again without it.
Windows: calling XInput¶
Windows has no device files for pads. It has a library, XInput, with a C function that fills a small structure with the state of one pad:
Until recently, calling it from Java meant writing C with JNI and building it for every platform. Since Java 22, the foreign function interface (java.lang.foreign) does it in Java: find the function in the library, describe its parameters, and call it like a method. The structure is a piece of memory outside the Java heap, and the fields are read at their byte offsets:
final class XInput implements Gamepads.Backend {
/** {@code XINPUT_STATE}: DWORD packet number, then {@code XINPUT_GAMEPAD}. */
static final int STATE_SIZE = 16;
static final int BUTTONS = 4; // WORD wButtons
static final int THUMB_LX = 8; // SHORT sThumbLX, then sThumbLY
static final int THUMB_LY = 10;
private static final int ERROR_SUCCESS = 0;
/** An empty slot is asked again after this long. */
static final long RECHECK_NANOS = 1_000_000_000L;
private final String library;
private final MethodHandle getState;
private final MemorySegment state = Arena.global().allocate(STATE_SIZE);
private final boolean[] present = new boolean[Gamepads.SLOTS];
private final long[] nextCheck = new long[Gamepads.SLOTS];
private final LongSupplier clock = System::nanoTime;
private XInput(String library, MethodHandle getState) {
this.library = library;
this.getState = getState;
}
/** XInput 1.4 comes with Windows 8 and later, 9.1.0 with older versions. */
static XInput open() {
for (String library : new String[]{"xinput1_4.dll", "xinput9_1_0.dll", "xinput1_3.dll"}) {
try {
SymbolLookup lookup = SymbolLookup.libraryLookup(library, Arena.global());
MemorySegment function = lookup.find("XInputGetState").orElseThrow();
MethodHandle getState = Linker.nativeLinker().downcallHandle(function,
FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.ADDRESS));
return new XInput(library, getState);
} catch (IllegalArgumentException e) {
// This version is not installed; try the next.
}
}
throw new IllegalStateException("no XInput library");
}
@Override
public void poll(List<Gamepad> pads) {
long now = clock.getAsLong();
for (int i = 0; i < Gamepads.SLOTS; i++) {
if (!present[i] && now < nextCheck[i]) continue;
Gamepad pad = pads.get(i);
if (call(i) == ERROR_SUCCESS) {
if (!present[i]) pad.connect("XInput " + (i + 1));
present[i] = true;
pad.report(buttons(state.get(ValueLayout.JAVA_SHORT, BUTTONS)),
axis(state.get(ValueLayout.JAVA_SHORT, THUMB_LX)),
-axis(state.get(ValueLayout.JAVA_SHORT, THUMB_LY))); // XInput counts up as positive
} else {
if (present[i]) pad.disconnect();
present[i] = false;
nextCheck[i] = now + RECHECK_NANOS;
}
}
}
private int call(int slot) {
try {
return (int) getState.invokeExact(slot, state);
} catch (Throwable e) {
throw new IllegalStateException("XInputGetState failed", e);
}
}
XInput is not asked by events but polled, so this backend reads all four pads in Gamepads.poll(), on the loop thread. Asking for an empty slot is slow on some Windows versions, so an empty slot is asked only once a second. XInput counts the stick's up as positive, the screen counts down; the minus sign in front of THUMB_LY turns it round. The button bits have their own table:
/** XInput's button bits, in the order of {@link Gamepad.Button}. */
private static final int[] BITS = {
0x1000, 0x2000, 0x4000, 0x8000, // A, B, X, Y
0x0100, 0x0200, 0x0020, 0x0010, // LB, RB, Back, Start
0x0001, 0x0002, 0x0004, 0x0008}; // d-pad up, down, left, right
/** XInput's {@code wButtons} as bits of {@link Gamepad.Button}. */
static int buttons(int xinput) {
int bits = 0;
for (Gamepad.Button button : Gamepad.Button.values()) {
if ((xinput & BITS[button.ordinal()]) != 0) bits |= Gamepad.bits(button);
}
return bits;
}
/** A stick axis, -32768 to 32767, as -1 to 1. */
static double axis(short value) {
return Math.max(-1, value / 32767.0);
}
Native access
Calling native code can crash the JVM, so Java warns when a program does it without saying so. A game that uses the gamepads starts with --enable-native-access=ALL-UNNAMED: in build.gradle for run and the tests, in the jpackage options for Windows, and as Enable-Native-Access: ALL-UNNAMED in the jar's manifest for java -jar.
Pads in the controls¶
The game should not care whether an action came from a key or a button. Controls, the mapping of actions to keys from chapter 20, learns to bind pad buttons too:
@Test
void aGamepadTriggersTheSameActions() {
Keyboard keyboard = new Keyboard();
Gamepad pad = new Gamepad(0);
Controls<Action> controls = new Controls<>(Action.class, keyboard)
.bind(Action.UP, KeyEvent.VK_UP)
.bind(Action.UP, pad, Gamepad.Button.UP)
.bind(Action.PAUSE, pad, Gamepad.Button.START);
pad.connect("test pad");
pad.report(0, 0, -0.9); // the stick pushed up
pad.poll();
keyboard.poll();
assertTrue(controls.isDown(Action.UP), "the stick counts as the d-pad");
pad.report(Gamepad.bits(Gamepad.Button.START), 0, 0);
pad.report(0, 0, 0); // pressed and released between two polls
pad.poll();
assertTrue(controls.wasPressed(Action.PAUSE));
assertFalse(controls.isDown(Action.UP));
}
Step 3: Seats and the lobby¶
Who controls a player? Keys, a pad, or the computer. A Seat hides the difference: each step, it turns its input into a Move, a direction and whether to lay a bomb.
final class Seat {
enum Kind { KEYS, PAD, COMPUTER }
/** The actions of a player; which keys or buttons trigger them is in the controls. */
enum Action { UP, LEFT, DOWN, RIGHT, BOMB, PAUSE }
final Kind kind;
/** Keys 1 or 2, gamepad 1 to 4; 0 for the computer. */
final int number;
private final Controls<Action> controls;
private final Gamepad pad;
private Bot bot;
private Direction wanted;
private Seat(Kind kind, int number, Controls<Action> controls, Gamepad pad) {
this.kind = kind;
this.number = number;
this.controls = controls;
this.pad = pad;
}
static Seat keys(int number, Controls<Action> controls) {
return new Seat(Kind.KEYS, number, controls, null);
}
static Seat pad(Gamepad pad, Controls<Action> controls) {
return new Seat(Kind.PAD, pad.index() + 1, controls, pad);
}
static Seat computer() {
return new Seat(Kind.COMPUTER, 0, null, null);
}
/** A new match: a fresh computer player, no key held over from before. */
void start(long seed) {
bot = kind == Kind.COMPUTER ? new Bot(seed) : null;
wanted = null;
}
/** What the player in this seat does in this step. */
Move move(Game game, Player player, double seconds) {
if (kind == Kind.COMPUTER) return bot.steer(game, player, seconds);
return new Move(wanted(), controls.wasPressed(Action.BOMB));
}
/**
* The way the player wants to go: the direction pressed last, while it is held. Let go, the one still
* held before it counts again, so a player can hold right and tap up to turn at the next crossing.
*/
private Direction wanted() {
for (Direction d : Direction.values()) {
if (controls.wasPressed(action(d))) wanted = d;
}
if (wanted != null && !controls.isDown(action(wanted))) {
wanted = null;
for (Direction d : Direction.values()) {
if (controls.isDown(action(d))) {
wanted = d;
break;
}
}
}
return wanted;
}
/** The first four actions are the directions, in the same order. */
private static Action action(Direction d) {
return Action.values()[d.ordinal()];
}
wanted() is a small kindness. The muncher kept the last key pressed until the next one; here, a player who lets go wants to stop, as a bomb might be ahead. But a player who holds right and taps up to turn at the next crossing should go on to the right after letting go of up. So the wanted way is the one pressed last while it is held, and after that the one still held.
There are two keyboard seats and one for each pad, all made at the start; a pad that is not plugged in simply never presses anything:
inputs.add(Seat.keys(1, new Controls<>(Action.class, keyboard)
.bind(Action.UP, settings.keys("1.up", KeyEvent.VK_W))
.bind(Action.LEFT, settings.keys("1.left", KeyEvent.VK_A))
.bind(Action.DOWN, settings.keys("1.down", KeyEvent.VK_S))
.bind(Action.RIGHT, settings.keys("1.right", KeyEvent.VK_D))
.bind(Action.BOMB, settings.keys("1.bomb", KeyEvent.VK_SPACE))));
inputs.add(Seat.keys(2, new Controls<>(Action.class, keyboard)
.bind(Action.UP, settings.keys("2.up", KeyEvent.VK_UP))
.bind(Action.LEFT, settings.keys("2.left", KeyEvent.VK_LEFT))
.bind(Action.DOWN, settings.keys("2.down", KeyEvent.VK_DOWN))
.bind(Action.RIGHT, settings.keys("2.right", KeyEvent.VK_RIGHT))
.bind(Action.BOMB, settings.keys("2.bomb", KeyEvent.VK_ENTER))));
for (Gamepad pad : gamepads.pads()) {
inputs.add(Seat.pad(pad, new Controls<>(Action.class, keyboard)
.bind(Action.UP, pad, Gamepad.Button.UP)
.bind(Action.LEFT, pad, Gamepad.Button.LEFT)
.bind(Action.DOWN, pad, Gamepad.Button.DOWN)
.bind(Action.RIGHT, pad, Gamepad.Button.RIGHT)
.bind(Action.BOMB, pad, Gamepad.Button.A, Gamepad.Button.B)
.bind(Action.PAUSE, pad, Gamepad.Button.START)));
}
Who plays?¶
The lobby lets people sit down without a menu: whoever presses their bomb key or button A takes the next free seat, and pressing it again starts the match. C switches the computer players for the empty seats on or off:

/**
* Who plays? Each person presses the bomb key of their keys, or button A of their gamepad, and takes the
* next free seat; pressing it again starts the match. Computer players take the seats left empty,
* unless C has switched them off.
*/
final class LobbyScene implements Scene {
private final Game backdrop = new Game(1);
LobbyScene() {
effects.clear();
}
@Override
public void update(double seconds) {
if (keys.wasPressed(Key.BACK)) {
scenes.replace(new TitleScene());
return;
}
if (keys.wasPressed(Key.COMPUTERS)) {
computers = !computers;
settings.setComputers(computers);
}
Seat pressed = joining();
boolean start = false;
for (Seat seat : chosen) start |= seat != null && seat.pressed(Action.PAUSE); // Start on a pad
if (pressed != null) {
int at = Arrays.asList(chosen).indexOf(pressed);
if (at >= 0) start = true;
else {
int free = Arrays.asList(chosen).indexOf(null);
if (free >= 0) chosen[free] = pressed;
}
}
if (start && ready()) scenes.replace(new PlayScene(seats()));
}
/** Somebody at the keys or a pad; and somebody to play against. */
boolean ready() {
int people = 0;
for (Seat seat : chosen) if (seat != null) people++;
return people >= 1 && (computers || people >= 2);
}
/** The seats of the match: the people where they sat, computers or nobody in the others. */
private List<Seat> seats() {
List<Seat> seats = new ArrayList<>();
for (Seat seat : chosen) seats.add(seat != null ? seat : computers ? Seat.computer() : null);
return seats;
}
@Test
void playersJoinWithTheirBombKeyOrButtonAndTheComputerFillsTheRest() {
Gamepad pad = gamepads.pad(0);
pad.connect("Test pad");
tap(KeyEvent.VK_SPACE); // keys 1, from the title
assertInstanceOf(BlastGame.LobbyScene.class, game.top());
tap(pad, Gamepad.Button.A); // the gamepad takes the next seat
tap(pad, Gamepad.Button.START); // and starts the match
BlastGame.PlayScene play = assertInstanceOf(BlastGame.PlayScene.class, game.top());
assertSame(Seat.Kind.KEYS, play.seats().get(0).kind);
assertSame(Seat.Kind.PAD, play.seats().get(1).kind);
assertSame(Seat.Kind.COMPUTER, play.seats().get(2).kind);
assertSame(Seat.Kind.COMPUTER, play.seats().get(3).kind);
}
A cable can be pulled in the middle of a match. The match pauses, names the missing pad, and goes on only when the pad is back and its owner presses Start:
@Test
void anUnpluggedGamepadPausesTheMatchUntilItIsBack() {
Gamepad pad = gamepads.pad(2);
pad.connect("Test pad");
steps(1);
tap(pad, Gamepad.Button.A);
tap(pad, Gamepad.Button.A);
BlastGame.PlayScene play = assertInstanceOf(BlastGame.PlayScene.class, game.top());
assertEquals(3, play.seats().getFirst().number, "gamepad 3");
pad.disconnect();
steps(2);
assertEquals(2, game.sceneCount(), "paused");
tap(KeyEvent.VK_ENTER);
assertEquals(2, game.sceneCount(), "not without the pad");
pad.connect("Test pad");
steps(1);
tap(pad, Gamepad.Button.START);
assertEquals(1, game.sceneCount(), "back in the match");
}
Step 4: Timers¶
Count what waits in a round of Blast: every bomb's fuse, every tile in flames, every burning crate, the falling walls. Earlier games had a field per waiting thing (the energizer's time in Maze, for one), counted down in update. With dozens of them, that becomes a loop over lists in every place, and one question gets lost: in which order did they run out?
That question matters. A step is 1/120 second, but with eight bombs on the arena, two fuses will run out in the same step sooner or later. If bomb A was laid a moment before bomb B, A explodes first, and when its flames reach B, B is a chained explosion that A's owner caused. Handled in the wrong order, the history of the round changes.
Timers is the engine's answer: many countdowns, each belonging to a key, handed over in the order they ran out.
public final class Timers<K> {
/** A nanosecond of tolerance, as in the Ticker: 300 steps of 1/120 s leave 2.5 s at 1e-15, not at 0. */
private static final double EPSILON = 1e-9;
private static final class Timer {
double left;
long order;
}
private final Map<K, Timer> timers = new LinkedHashMap<>();
private final List<Map.Entry<K, Timer>> due = new ArrayList<>();
private long started;
/** Starts the timer of {@code key} with {@code seconds}; a running one starts again. */
public void start(K key, double seconds) {
Timer timer = timers.computeIfAbsent(key, k -> new Timer());
timer.left = seconds;
timer.order = started++;
}
/**
* Lets {@code seconds} pass. The timers that ran out are removed and handed to {@code expired}, the one
* that ran out first first. Timers the handler starts count from the next step on.
*/
public void update(double seconds, Consumer<? super K> expired) {
due.clear();
for (Map.Entry<K, Timer> entry : timers.entrySet()) {
Timer timer = entry.getValue();
timer.left -= seconds;
if (timer.left <= EPSILON) due.add(Map.entry(entry.getKey(), timer));
}
if (due.isEmpty()) return;
// The most negative ran out earliest within the step; equal times by the order they were started.
due.sort((a, b) -> {
int byTime = Double.compare(a.getValue().left, b.getValue().left);
return byTime != 0 ? byTime : Long.compare(a.getValue().order, b.getValue().order);
});
for (Map.Entry<K, Timer> entry : due) timers.remove(entry.getKey(), entry.getValue());
for (Map.Entry<K, Timer> entry : due) expired.accept(entry.getKey());
}
How much a timer is below zero says how early in the step it ran out: −0.004 ran out before −0.001. Of two at exactly the same moment, the one started first comes first, so the order never depends on a hash code. The EPSILON is the same lesson as the Ticker's: 300 steps of 1/120 second add up to 2.5 seconds only on paper; in floating point they leave a remainder of about 10⁻¹⁵, and a fuse of 2.5 seconds would go off one step late.
@Test
void timersRunOutInTheOrderOfTheirTimeEvenWithinOneStep() {
Timers<String> fuses = new Timers<>();
fuses.start("late", 0.5);
fuses.start("early", 0.3);
fuses.start("same as early, started later", 0.3);
List<String> expired = new ArrayList<>();
fuses.update(0.2, expired::add);
assertEquals(List.of(), expired);
assertEquals(0.1, fuses.left("early"), 1e-9);
fuses.update(0.4, expired::add); // a long step: all three run out in it
assertEquals(List.of("early", "same as early, started later", "late"), expired);
assertTrue(fuses.isEmpty());
}
@Test
void aHandlerMayStartTimersThatCountFromTheNextStep() {
Timers<String> timers = new Timers<>();
timers.start("fuse", STEP);
List<String> expired = new ArrayList<>();
timers.update(STEP, key -> {
expired.add(key);
if (key.equals("fuse")) timers.start("flames", 0.5);
});
assertEquals(List.of("fuse"), expired);
assertEquals(0.5, timers.left("flames"), 1e-9, "no time taken from the new timer in this step");
}
The game has three of them, plus the bombs and power-ups on the arena:
private final EventQueue<Event> events = new EventQueue<>();
private final Timers<Bomb> fuses = new Timers<>();
private final Timers<Cell> flames = new Timers<>();
private final Timers<Cell> burning = new Timers<>();
private final Map<Cell, Bomb> bombs = new LinkedHashMap<>();
private final Map<Cell, PowerUp> hidden = new HashMap<>();
private final Map<Cell, PowerUp> powerUps = new LinkedHashMap<>();
/** A bomb explodes this long after it was laid, unless a flame reaches it first. */
public static final double FUSE_SECONDS = 2.5;
/** Flames stay on their tiles this long; whoever walks into them is hit. */
public static final double FLAME_SECONDS = 0.5;
/** A crate burns this long before it is gone and shows what was hidden in it. */
public static final double BURN_SECONDS = 0.5;
/** After this, walls fall into the arena, one by one, from the outside in. */
public static final double ROUND_SECONDS = 120;
public static final double HURRY_INTERVAL = 0.3;
Step 5: Events and chain reactions¶
What happens when a fuse runs out? The bomb explodes; its flames reach tiles; on one there is a crate, which starts to burn; on another a bomb, which explodes, and its flames reach more tiles, and a player. Written as method calls, that is recursion: explode calls explode, and the second bomb's flames are all worked out before the first bomb's flames in the other three directions are. A long chain goes deep into the stack, and the order of what happens depends on where in a loop the recursion started.
The alternative is to make each consequence a message: an event, posted on a queue, handled when its turn comes. Handling an event may post new ones; they go to the back of the queue. So a chain runs in waves: first everything the first bomb causes, then everything those cause, and so on.
public final class EventQueue<E> {
private final ArrayDeque<E> pending = new ArrayDeque<>();
private final List<E> happened = new ArrayList<>();
private final int limit;
/** @param limit most events one {@link #process} may handle */
public EventQueue(int limit) {
if (limit < 1) throw new IllegalArgumentException("limit must be positive: " + limit);
this.limit = limit;
}
public EventQueue() {
this(10_000);
}
/** Adds an event to the end of the queue; also from within a handler. */
public void post(E event) {
pending.addLast(event);
}
/** Hands every waiting event to {@code handler}, also those it posts, oldest first, until none is left. */
public void process(Consumer<? super E> handler) {
int handled = 0;
while (!pending.isEmpty()) {
E event = pending.removeFirst();
if (++handled > limit) {
pending.clear();
throw new IllegalStateException("more than " + limit + " events in one go, the last " + event);
}
happened.add(event);
handler.accept(event);
}
}
@Test
void aChainReactionRunsInWaves() {
// Bomb a reaches b and c; b reaches d and a player.
EventQueue<Event> queue = new EventQueue<>();
List<String> order = new ArrayList<>();
queue.post(new Event.Explode("a"));
queue.process(event -> {
switch (event) {
case Event.Explode(String bomb) -> {
order.add(bomb);
if (bomb.equals("a")) {
queue.post(new Event.Explode("b"));
queue.post(new Event.Explode("c"));
}
if (bomb.equals("b")) {
queue.post(new Event.Explode("d"));
queue.post(new Event.Hit("white"));
}
}
case Event.Hit(String player) -> order.add(player + " hit");
}
});
assertEquals(List.of("a", "b", "c", "d", "white hit"), order, "c before d: first the whole first wave");
assertEquals(5, queue.happened().size());
assertEquals(new Event.Explode("a"), queue.happened().getFirst());
}
The limit is a fuse of its own: an event that posts itself would loop forever, and an exception that names the event is easier to find than a hanging game.
The queue keeps what it handled, happened(). That is what the game reports after each step, and what the sounds, the particles and the shaking of the screen react to; the events Maze reported from a list are now the same events that drive the rules.
public sealed interface Event {
/** A round begins: "Round 2 · Ready?". */
record Ready(int round) implements Event {
}
/** "Go!": the players may move. */
record Go() implements Event {
}
record BombLaid(int player, Cell at) implements Event {
}
/** A bomb goes off: by its fuse, or because a flame reached it ({@code chained}). */
record Exploded(Bomb bomb, boolean chained) implements Event {
}
/** A flame reached a crate; it burns for a moment, then it is gone. */
record CrateHit(Cell at) implements Event {
}
/** A crate has burnt; {@code hidden} is what it held, {@code null} for nothing. */
record CrateBurnt(Cell at, PowerUp hidden) implements Event {
}
/** A flame destroyed a power-up lying on the floor. */
record PowerUpBurnt(Cell at, PowerUp powerUp) implements Event {
}
record PowerUpTaken(int player, PowerUp powerUp, Cell at) implements Event {
}
/** A flame or a falling wall hit a player. */
record PlayerHit(int player, double x, double y) implements Event {
}
/** Time is up: walls begin to fall. */
record Hurry() implements Event {
}
record WallFell(Cell at) implements Event {
}
/** {@code winner} -1 when the last players fell together. */
record RoundOver(int round, int winner) implements Event {
}
record MatchOver(MatchResult result) implements Event {
}
}
One step of the game is now short. The timers run out first, in their order, and post their explosions; then the players move, take power-ups and lay bombs; then the queue is processed, explosions and all they cause; then whoever is in the flames is hit:
/** One step of {@code seconds}; {@code moves} holds what each of the four players wants. */
public void update(double seconds, List<Move> moves) {
events.clear();
phaseTime += seconds;
switch (phase) {
case READY -> {
if (phaseTime >= Field.READY_SECONDS) {
phase(Phase.PLAYING);
events.post(new Event.Go());
}
}
case PLAYING -> play(seconds, moves);
case ROUND_OVER -> {
if (phaseTime >= Field.ROUND_OVER_SECONDS) {
if (result != null) phase(Phase.MATCH_OVER);
else startRound();
}
}
case MATCH_OVER -> {
}
}
events.process(this::handle);
}
private void play(double seconds, List<Move> moves) {
roundTime += seconds;
// Whatever ran out in this step, in the order it ran out.
fuses.update(seconds, bomb -> detonate(bomb, false));
burning.update(seconds, this::burnt);
flames.update(seconds, cell -> { });
for (Player player : players) {
if (!player.playing()) continue;
Move move = moves.get(player.index());
player.move(seconds, move.direction(), this);
take(player);
if (move.bomb()) lay(player);
}
hurry(seconds);
events.process(this::handle); // the explosions, and all they cause
for (Player player : players) {
if (player.playing() && flames.running(player.cell())) hit(player);
}
events.process(this::handle);
for (Player player : players) {
if (player.state() == Player.State.DYING) player.fall(seconds);
}
decide();
}
And all consequences are in one place:
/** The one place where events have consequences; posting more events is how one causes the next. */
private void handle(Event event) {
switch (event) {
case Event.Exploded(Bomb bomb, boolean chained) -> explode(bomb);
case Event.CrateHit(Cell at) -> burning.start(at, Field.BURN_SECONDS);
case Event.PlayerHit hit -> {
Player player = players.get(hit.player());
if (player.playing()) player.hit();
}
default -> {
}
}
}
/** Takes a bomb off the arena and posts its explosion; a bomb already gone does nothing. */
private void detonate(Bomb bomb, boolean chained) {
if (!bombs.remove(bomb.cell(), bomb)) return;
fuses.cancel(bomb);
players.get(bomb.owner()).laid(-1);
events.post(new Event.Exploded(bomb, chained));
}
/** Flames on the bomb's tile and along the four directions, as far as {@link #reach} lets them go. */
private void explode(Bomb bomb) {
reach(bomb.cell(), bomb.range(), cell -> {
switch (tiles.get(cell)) {
case CRATE -> {
if (!burning.running(cell)) events.post(new Event.CrateHit(cell));
}
case FLOOR -> {
flames.start(cell, Field.FLAME_SECONDS);
Bomb other = bombs.get(cell);
if (other != null) detonate(other, true); // the chain reaction
PowerUp powerUp = powerUps.remove(cell);
if (powerUp != null) events.post(new Event.PowerUpBurnt(cell, powerUp));
}
case WALL -> {
}
}
});
}
detonate takes the bomb off the arena before posting its explosion. A bomb reached by the flames of two others in the same wave is then set off only once: the second time, it is no longer there.
How far do the flames go? reach answers it, and it is public, because the computer players need the same answer to know where it is dangerous. If the explosion and the fear of it were two pieces of code, they would sooner or later disagree, and a computer player would walk into flames it believed were not there:
/**
* The tiles the flames of a bomb at {@code from} reach: its own, and up to {@code range} in each
* direction. Walls stop the flames before them; a crate, a bomb or a power-up is reached and stops them
* there. The explosion and the computer players' fear of it both use this, so they always agree.
*/
public void reach(Cell from, int range, Consumer<Cell> reached) {
reached.accept(from);
for (Direction d : Direction.values()) {
Cell cell = from;
for (int i = 1; i <= range; i++) {
cell = tiles.step(cell.col(), cell.row(), d);
Tile tile = tiles.get(cell);
if (tile == Tile.WALL) break;
reached.accept(cell);
if (tile == Tile.CRATE || bombs.containsKey(cell) || powerUps.containsKey(cell)) break;
}
}
}
@Test
void aFlameSetsOffTheBombsItReachesInTheSameStep() {
Game game = small();
game.put(5, 1, Tile.FLOOR);
game.putPlayer(0, 1, 1);
bomb(game, 0); // at (1, 1)
game.putPlayer(1, 3, 1);
seconds(game, 1.0);
bomb(game, 1); // at (3, 1), a second later
game.putPlayer(2, 5, 1);
bomb(game, 2); // at (5, 1), out of reach of the first
game.putPlayer(0, 7, 2);
game.putPlayer(1, 7, 2);
game.putPlayer(2, 7, 2);
game.putPlayer(3, 7, 2);
events.clear();
seconds(game, game.fuse(game.bomb(new Cell(1, 1))) - STEP);
assertTrue(events.stream().noneMatch(e -> e instanceof Event.Exploded));
step(game);
List<Event> exploded = events.stream().filter(e -> e instanceof Event.Exploded).toList();
assertEquals(3, exploded.size(), "all three in one step");
Event.Exploded first = (Event.Exploded) exploded.get(0);
Event.Exploded third = (Event.Exploded) exploded.get(2);
assertEquals(new Cell(1, 1), first.bomb().cell());
assertFalse(first.chained());
assertEquals(new Cell(5, 1), third.bomb().cell(), "the second set off the third");
assertTrue(third.chained());
assertTrue(game.bombs().isEmpty());
assertEquals(0, game.player(1).laid(), "the bombs are given back");
}
The test shows the point of the waves: the bomb at (5, 1) is out of reach of the first one, and still explodes in the same step, set off by the second.
Before and after¶
@Test
void aBombExplodesAfterItsFuseAndItsFlamesStopAtWallsAndCrates() {
Game game = small();
game.put(4, 1, Tile.CRATE);
game.putPowerUp(4, 1, PowerUp.FIRE);
game.putPlayer(0, 2, 1);
game.putPlayer(2, 7, 2); // out of the way
bomb(game, 0);
Bomb bomb = game.bomb(new Cell(2, 1));
assertNotNull(bomb);
assertEquals(2, bomb.range());
moves[0] = new Move(Direction.LEFT, false);
seconds(game, 0.5); // to (1, 1), then down: round the corner
moves[0] = new Move(Direction.DOWN, false);
seconds(game, 0.5);
assertEquals(new Cell(1, 3), game.player(0).cell());
seconds(game, Field.FUSE_SECONDS - 1.0 - STEP);
assertEquals(0, game.flames(new Cell(2, 1)), "not yet");
step(game);
assertTrue(game.flames(new Cell(2, 1)) > 0, "the bomb's own tile");
assertTrue(game.flames(new Cell(3, 1)) > 0);
assertEquals(0, game.flames(new Cell(2, 2)), "a pillar stops the flame");
assertEquals(0, game.flames(new Cell(4, 1)), "a crate stops it, and burns");
assertTrue(game.burning(new Cell(4, 1)) > 0);
assertTrue(game.player(0).playing(), "safe round the corner");
seconds(game, Field.BURN_SECONDS);
assertEquals(Tile.FLOOR, game.tiles().get(4, 1));
assertEquals(PowerUp.FIRE, game.powerUps().get(new Cell(4, 1)), "what the crate hid");
assertTrue(events.contains(new Event.CrateBurnt(new Cell(4, 1), PowerUp.FIRE)));
}
Step 6: Rounds and falling walls¶
A round ends when at most one player is left, but not before everybody hit has finished falling: two players caught by the same flames should both fall, and nobody wins.
/** The round is over when at most one player is left and nobody is still falling. */
private void decide() {
List<Player> left = new ArrayList<>();
boolean falling = false;
for (Player player : players) {
if (player.playing()) left.add(player);
if (player.state() == Player.State.DYING) falling = true;
}
if (left.size() > 1 || falling) return;
int winner = left.isEmpty() ? -1 : left.getFirst().index();
if (winner >= 0) players.get(winner).won();
phase(Phase.ROUND_OVER);
events.post(new Event.RoundOver(round, winner));
if (winner >= 0 && players.get(winner).wins() >= Field.WINS) {
int[] wins = players.stream().mapToInt(Player::wins).toArray();
result = new MatchResult(winner, wins);
events.post(new Event.MatchOver(result));
}
}
@Test
void aPlayerInTheFlamesFallsAndTheLastOneWinsTheRound() {
Game game = new Game(1, BoardTest.read(BoardTest.SMALL), new boolean[]{true, true, false, false});
game.skipReady();
game.putPlayer(0, 3, 1);
game.putPlayer(1, 7, 3);
bomb(game, 0);
seconds(game, Field.FUSE_SECONDS);
assertEquals(Player.State.DYING, game.player(0).state(), "it stayed on its bomb");
assertTrue(events.stream().anyMatch(e -> e instanceof Event.PlayerHit(int p, double x, double y) && p == 0));
seconds(game, Field.DYING_SECONDS + STEP);
assertEquals(Game.Phase.ROUND_OVER, game.phase());
assertTrue(events.contains(new Event.RoundOver(1, 1)));
assertEquals(1, game.player(1).wins());
seconds(game, Field.ROUND_OVER_SECONDS + STEP);
assertEquals(2, game.round());
assertEquals(Game.Phase.READY, game.phase());
assertEquals(new Cell(1, 1), game.player(0).cell(), "back at the start");
assertTrue(game.player(0).playing());
assertEquals(Player.State.OUT, game.player(2).state(), "nobody took this seat");
}
Careful players could hide forever. After two minutes, walls fall into the arena, ring by ring from the outside in. The order is worked out once, when the board is read:
/**
* The order in which walls fall when time is up: ring after ring from the outside in, each clockwise
* from its top left corner, all tiles that are not walls already.
*/
private static List<Cell> spiral(TileMap<Letter> letters) {
List<Cell> order = new ArrayList<>();
int left = 0;
int top = 0;
int right = letters.width() - 1;
int bottom = letters.height() - 1;
while (left <= right && top <= bottom) {
for (int col = left; col <= right; col++) order.add(new Cell(col, top));
for (int row = top + 1; row <= bottom; row++) order.add(new Cell(right, row));
if (top < bottom) for (int col = right - 1; col >= left; col--) order.add(new Cell(col, bottom));
if (left < right) for (int row = bottom - 1; row > top; row--) order.add(new Cell(left, row));
left++;
top++;
right--;
bottom--;
}
order.removeIf(cell -> letters.get(cell) == Letter.WALL);
return List.copyOf(order);
}
The falling uses a Ticker from chapter 22: it says how many times 0.3 seconds have passed in this step, usually none, sometimes one.
/** When time is up, a wall falls on the next tile of the spiral, on whatever lies there. */
private void hurry(double seconds) {
if (roundTime < Field.ROUND_SECONDS) return;
if (roundTime - seconds < Field.ROUND_SECONDS) events.post(new Event.Hurry());
for (int due = hurry.advance(seconds); due > 0 && wallsFallen < board.spiral().size(); due--) {
Cell at = board.spiral().get(wallsFallen);
wallsFallen++;
if (tiles.get(at) == Tile.WALL) continue;
tiles.set(at.col(), at.row(), Tile.WALL);
Bomb bomb = bombs.remove(at);
if (bomb != null) {
fuses.cancel(bomb);
players.get(bomb.owner()).laid(-1);
}
powerUps.remove(at);
hidden.remove(at);
burning.cancel(at);
flames.cancel(at);
for (Player player : players) {
if (player.playing() && player.cell().equals(at)) hit(player);
}
events.post(new Event.WallFell(at));
}
}
@Test
void whenTimeIsUpWallsFallOnWhateverIsThere() {
Game game = small();
game.putPlayer(1, 2, 1); // the second tile of the spiral
game.passTime(Field.ROUND_SECONDS - STEP);
step(game);
assertInstanceOf(Event.Hurry.class, events.getFirst());
seconds(game, 2 * Field.HURRY_INTERVAL);
assertEquals(Tile.WALL, game.tiles().get(1, 1));
assertEquals(Tile.WALL, game.tiles().get(2, 1));
assertEquals(Player.State.DYING, game.player(1).state());
assertTrue(events.contains(new Event.WallFell(new Cell(2, 1))));
}
Step 7: The computer player¶
A computer player in Blast has one thing to learn above all: do not stand where the flames will be. Everything else (collecting power-ups, burning crates, hunting the others) comes after.
A map of danger¶
Danger says for every tile when flames will reach it: the time left on the fuse of the bomb whose flames reach it first; zero where flames are now; the next falling walls; infinity where nothing comes. Chains make it interesting. A bomb with two seconds left, in reach of a bomb with half a second left, explodes in half a second, and so does every tile it reaches. So the times are lowered along the chain until nothing changes any more:
public final class Danger {
/** The time of a tile no flame will reach. */
public static final double SAFE = Double.POSITIVE_INFINITY;
private final TileMap<Tile> tiles;
private final double[] doom;
private Danger(TileMap<Tile> tiles) {
this.tiles = tiles;
this.doom = new double[tiles.width() * tiles.height()];
Arrays.fill(doom, SAFE);
}
/** The danger on the arena as it is. */
public static Danger of(Game game) {
return of(game, null, 0);
}
/** The danger if a bomb with {@code range} were laid at {@code extra} now: could one escape it? */
public static Danger of(Game game, Cell extra, int range) {
Danger danger = new Danger(game.tiles());
List<Cell> at = new ArrayList<>();
List<Integer> ranges = new ArrayList<>();
List<Double> times = new ArrayList<>();
for (Bomb bomb : game.bombs()) {
at.add(bomb.cell());
ranges.add(bomb.range());
times.add(game.fuse(bomb));
}
if (extra != null) {
at.add(extra);
ranges.add(range);
times.add(Field.FUSE_SECONDS);
}
// Which tiles each bomb's flames reach.
List<List<Cell>> reached = new ArrayList<>();
for (int i = 0; i < at.size(); i++) {
List<Cell> cells = new ArrayList<>();
game.reach(at.get(i), ranges.get(i), cells::add);
reached.add(cells);
}
// A bomb in the flames of an earlier one goes off with it; and a third one with that one.
boolean changed = true;
while (changed) {
changed = false;
for (int i = 0; i < at.size(); i++) {
for (int j = 0; j < at.size(); j++) {
if (times.get(j) > times.get(i) && reached.get(i).contains(at.get(j))) {
times.set(j, times.get(i));
changed = true;
}
}
}
}
for (int i = 0; i < at.size(); i++) {
for (Cell cell : reached.get(i)) danger.lower(cell, times.get(i));
}
for (Cell cell : game.flameCells()) danger.lower(cell, 0);
List<Cell> walls = game.nextWalls(8);
for (int i = 0; i < walls.size(); i++) danger.lower(walls.get(i), i * Field.HURRY_INTERVAL);
return danger;
}
@Test
void dangerFollowsTheChain() {
Game game = new Game(1, BoardTest.read(BoardTest.SMALL), new boolean[]{true, true, true, true});
game.skipReady();
game.putPlayer(0, 1, 1);
game.update(STEP, List.of(new Move(null, true), Move.NONE, Move.NONE, Move.NONE));
game.update(1.0, List.of(Move.NONE, Move.NONE, Move.NONE, Move.NONE));
game.putPlayer(1, 3, 1);
game.update(STEP, List.of(Move.NONE, new Move(null, true), Move.NONE, Move.NONE));
Danger danger = Danger.of(game);
double first = Field.FUSE_SECONDS - 1.0 - STEP; // laid, a second, the second bomb
assertEquals(first, danger.at(1, 1), 1e-9);
assertEquals(first, danger.at(5, 1), 1e-9, "the second bomb goes off with the first");
assertEquals(first, danger.at(3, 2), 1e-9, "and so do the tiles only it reaches");
assertTrue(danger.safe(new Cell(7, 1)));
assertTrue(danger.safe(new Cell(7, 3)));
}

The red tiles are the danger, with the seconds; the darker, the sooner. The white player has just laid a bomb (2.5) and is on its way round the corner to the tile marked green. The bomb in the left column goes off in 0.4 seconds, and its long flames cover the whole column; where the bomb at 1.9 reaches the same tiles, they show 0.4, because a tile shows the earliest flames that will come. The bomb at 1.1 is out of reach of both and keeps its own time.
Steering¶
Each step, the computer player looks at the next centre ahead. If it is not safe, it flees: a breadth-first search over the tiles that will not burn too soon, to the nearest tile that will not burn at all. Otherwise it hunts:
/** What the player {@code me} does in this step. */
public Move steer(Game game, Player me, double seconds) {
if (!me.playing() || game.phase() != Game.Phase.PLAYING) return Move.NONE;
if (bombAt != null && me.walker().atCentre() && me.cell().equals(bombAt)) {
bombAt = null;
return new Move(null, true);
}
Cell next = nextCentre(me.walker());
danger = Danger.of(game);
if (!danger.safe(next)) {
bombAt = null;
return new Move(flee(game, next), false);
}
if (dawdle > 0) {
dawdle -= seconds;
return Move.NONE;
}
if (random.nextDouble() < 0.004) dawdle = 0.2 + random.nextDouble() * 0.6;
return new Move(hunt(game, me, next), false);
}
/** To the nearest tile no flame will reach, over tiles not yet burning. */
private Direction flee(Game game, Cell from) {
DistanceMap map = DistanceMap.search(game.tiles(),
(col, row) -> game.walkable(new Cell(col, row)) && danger.at(col, row) > IMMINENT, from);
target = nearest(game.tiles(), map, cell -> danger.safe(cell));
return toward(game.tiles(), map, from, target);
}
The hunt is a list of wishes, the first that can be fulfilled wins:
- Lay a bomb here, if it would burn a crate nobody else's bomb will burn, or reach another player, and a safe tile could be reached before it explodes. The second half is the important one, and
Danger.of(game, at, range)answers it: the danger as if the bomb were already laid. - Fetch a power-up nearby.
- Go to a tile where a bomb would be worth laying and could be escaped.
- Go for another player.
- Wander.
private Direction hunt(Game game, Player me, Cell from) {
DistanceMap map = DistanceMap.search(game.tiles(),
(col, row) -> game.walkable(new Cell(col, row)) && danger.at(col, row) == Danger.SAFE, from);
if (bombAt == null && me.laid() < me.bombs() && worth(game, me, from) && escapes(game, me, from)) {
bombAt = from;
}
if (bombAt != null) {
target = bombAt;
return toward(game.tiles(), map, from, bombAt);
}
Cell powerUp = nearest(game.tiles(), map, game.powerUps()::containsKey);
if (powerUp != null && map.distance(powerUp) <= GREED) target = powerUp;
else if (me.laid() < me.bombs()) {
target = nearest(game.tiles(), map, cell -> worth(game, me, cell) && escapes(game, me, cell));
} else target = null;
if (target == null) target = nearest(game.tiles(), map, cell -> enemyAt(game, me, cell));
if (target == null) return wander(game, from);
return toward(game.tiles(), map, from, target);
}
/** A bomb here would reach a crate nobody else's bomb will reach, or another player. */
private boolean worth(Game game, Player me, Cell at) {
boolean[] worth = {false};
game.reach(at, me.fire(), cell -> {
if (game.tiles().get(cell) == Tile.CRATE && danger.safe(cell)) worth[0] = true;
if (enemyAt(game, me, cell)) worth[0] = true;
});
return worth[0];
}
/** With a bomb here, a safe tile is near enough to reach before the flames come. */
private boolean escapes(Game game, Player me, Cell at) {
Danger after = Danger.of(game, at, me.fire());
DistanceMap map = DistanceMap.search(game.tiles(),
(col, row) -> game.walkable(new Cell(col, row)) && after.at(col, row) > IMMINENT, at);
Cell refuge = nearest(game.tiles(), map, after::safe);
return refuge != null && map.distance(refuge) / me.speed() < Field.FUSE_SECONDS * 0.7;
}
All the searches start at the player and spread out. The way to a goal is then read backwards: from the goal downhill to the start, and the last step of that way, turned round, is the first one to take:
/**
* The first step from {@code from} to {@code to}. The search began at {@code from}, so downhill from
* {@code to} leads back to it; the last step of that way, turned round, is the first one there.
*/
static Direction toward(TileMap<Tile> tiles, DistanceMap map, Cell from, Cell to) {
if (to == null || to.equals(from) || map.distance(to) == DistanceMap.UNREACHABLE) return null;
Cell cell = to;
while (true) {
Direction back = map.downhill(cell.col(), cell.row());
if (back == null) return null;
Cell previous = tiles.step(cell.col(), cell.row(), back);
if (previous.equals(from)) return back.opposite();
cell = previous;
}
}
Now and then the computer player dawdles for a moment. Without it, four computer players are so alike that rounds often end in a draw; with it, they make the small mistakes that people make.
Do they play well enough? The test lets four of them play whole matches and counts how often one is hit in the first ten seconds of a round, the time when a player who cannot escape their own bomb dies:
@Test
void computerPlayersPlayWholeMatchesAndSurviveTheirFirstBombs() {
int early = 0;
int rounds = 0;
for (long seed = 1; seed <= 4; seed++) {
String log = match(seed);
assertTrue(log.endsWith("MATCH_OVER"), log);
rounds += (int) log.lines().filter(line -> line.startsWith("round")).count();
early += (int) log.lines().filter(line -> line.startsWith("hit") && seconds(line) < 10).count();
}
assertTrue(early <= rounds / 2, early + " hit in the first ten seconds of " + rounds + " rounds");
}
As in every game since Snake, the same seed and the same moves play the same match, computer players included. The test compares the log of two matches:
@Test
void theSameSeedAndTheSameMovesPlayTheSameMatch() {
assertEquals(BotTest.match(42), BotTest.match(42));
}
Step 8: Drawing¶
The arena is drawn from tiles, as the maze was; the players, bombs and power-ups are figures drawn in code, cached as sprites. The flames reuse the trick of Maze's walls: a tile in flames is drawn with arms towards its neighbours in flames, so the neighbour mask chooses one of sixteen pieces, and a cross of flames is one shape without seams:

/**
* Each tile in flames is drawn with arms towards its neighbours in flames, as the walls of Maze were:
* the neighbour mask chooses one of sixteen pictures. The flames grow thinner as they die down.
*/
private void flames(Graphics2D g, Game game) {
TileMap<Tile> tiles = game.tiles();
for (Cell cell : game.flameCells()) {
int mask = 0;
for (Direction d : Direction.values()) {
if (game.flames(tiles.step(cell.col(), cell.row(), d)) > 0) mask |= 1 << d.ordinal();
}
double age = 1 - game.flames(cell) / Field.FLAME_SECONDS;
int stage = Math.min(FLAME_WIDTHS.length - 1, (int) (age * FLAME_WIDTHS.length));
flames[stage][mask].draw(g, left + cell.col() * TILE, top + cell.row() * TILE);
}
}
The flames grow thinner as they die down: three widths, sixteen pieces each, all cached. Players are drawn from top to bottom, so one lower on the screen stands in front of one above:
/** Front to back: the player lower on the screen covers the one above. */
private void players(Graphics2D g, Game game, double alpha) {
byRow.clear();
for (Player player : game.players()) {
if (player.state() != Player.State.OUT) byRow.add(player);
}
byRow.sort(Comparator.comparingDouble(p -> p.walker().y()));
for (Player player : byRow) {
Walker walker = player.walker();
double cx = x(between(walker.previousX(), walker.x(), alpha));
double cy = y(between(walker.previousY(), walker.y(), alpha));
if (player.state() == Player.State.DYING) {
// Hit: it blinks, faster and faster, and is gone.
double t = player.dying() / Field.DYING_SECONDS;
if ((int) (t * t * 24) % 2 == 1) continue;
}
shadow(g, cx, cy + TILE / 2 - 3, 18, 6);
int step = walker.heading() == null ? 0 : (int) (player.walked() * 4) % STEPS;
players[player.index()][player.facing().ordinal()][step].draw(g, cx - TILE / 2, cy - TILE / 2 - 8);
}
}
The cards beside the arena show each player's face, who plays it, the rounds won, and the bombs, flame length and speed:
/** The four cards, the clock, and what the round has to say. */
void status(Graphics2D g, Game game, List<Seat> seats, Effects effects) {
for (Player player : game.players()) card(g, player, seats.get(player.index()));
g.setFont(clock);
double left = game.timeLeft();
boolean hurry = left == 0;
g.setColor(hurry ? RED : CHALK);
int seconds = (int) Math.ceil(left);
centred(g, String.format("%d:%02d", seconds / 60, seconds % 60), Field.WIDTH / 2, 23);
double middle = painter.y(game.board().height() / 2.0 - 0.5);
switch (game.phase()) {
case READY -> {
band(g, middle);
text(g, msg.format("play.round", game.round()), middle - 4, 22, true, GOLD);
text(g, msg.get("play.ready"), middle + 18, 14, false, CHALK);
}
case PLAYING -> {
if (game.phaseTime() < 0.7) {
band(g, middle);
text(g, msg.get("play.go"), middle + 8, 26, true, GOLD);
} else if (hurry && game.roundTime() - Field.ROUND_SECONDS < 3 && (int) (effects.time() * 4) % 2 == 0) {
text(g, msg.get("play.hurry"), middle + 8, 26, true, RED);
}
}
case ROUND_OVER, MATCH_OVER -> {
band(g, middle);
int winner = roundWinner(game);
String line = winner < 0 ? msg.get("play.draw") : msg.format("play.won", name(winner));
text(g, line, middle + 8, 22, true, winner < 0 ? CHALK : BlastPainter.PLAYERS[winner]);
}
}
}
/** The one still playing at the end of a round; -1 for a draw. */
private static int roundWinner(Game game) {
for (Player player : game.players()) {
if (player.playing()) return player.index();
}
return -1;
}
/** A player's card: the figure, the seat, the rounds won, the bombs, flames and speed. */
private void card(Graphics2D g, Player player, Seat seat) {
double[] place = CARDS[player.index()];
double x = place[0] == 0 ? (painter.left - CARD_WIDTH) / 2 : Field.WIDTH - (painter.left + CARD_WIDTH) / 2;
double y = place[1] == 0 ? painter.top : Field.HEIGHT - 4 - CARD_HEIGHT;
g.setColor(CARD);
g.fill(new RoundRectangle2D.Double(x, y, CARD_WIDTH, CARD_HEIGHT, 10, 10));
Color colour = BlastPainter.PLAYERS[player.index()];
g.setColor(colour);
g.fill(new Rectangle2D.Double(x + 8, y + 4, CARD_WIDTH - 16, 2));
double cx = x + CARD_WIDTH / 2;
painter.portrait(player.index()).draw(g, cx - BlastPainter.TILE / 2, y + 10);
g.setFont(label);
g.setColor(colour);
centred(g, name(player.index()), cx, y + 58);
g.setFont(small);
g.setColor(CHALK);
centred(g, seatName(seat, player), cx, y + 72);
for (int i = 0; i < Field.WINS; i++) {
double wx = cx + (i - (Field.WINS - 1) / 2.0) * 16;
g.setColor(i < player.wins() ? GOLD : GREY);
g.fill(new Ellipse2D.Double(wx - 5, y + 80, 10, 10));
}
int[] values = {player.bombs(), player.fire(), player.speedUps()};
for (PowerUp powerUp : PowerUp.values()) {
double row = y + 98 + powerUp.ordinal() * 17;
painter.icon(powerUp).draw(g, cx - 30, row);
g.setFont(label);
g.setColor(CHALK);
g.drawString("× " + values[powerUp.ordinal()], (float) (cx - 10), (float) (row + 12));
}
if (!player.playing() && player.state() != Player.State.DYING) {
g.setColor(VEIL);
g.fill(new RoundRectangle2D.Double(x, y, CARD_WIDTH, CARD_HEIGHT, 10, 10));
}
}
The events of each step become sounds, splinters and a shaking screen. A chain of eight bombs is one bang, not eight on top of each other: one explosion sound per step is enough.
/** Shows and, if {@code audible}, plays what happened in the last step; the music too. */
void react(List<Event> events, boolean audible, BlastPainter painter) {
bangThisStep = false;
for (Event event : events) {
switch (event) {
case Event.Ready ready -> {
if (audible) mixer.playMusic(music.ready, false);
}
case Event.Go go -> {
if (audible) mixer.play(sounds.go);
}
case Event.BombLaid laid -> {
if (audible) mixer.play(sounds.laid);
}
case Event.Exploded(var bomb, boolean chained) -> {
shake = Math.min(SHAKE, shake + (chained ? 0.8 : 1.6));
if (audible && !bangThisStep) mixer.play(chained ? sounds.chained : sounds.explosion);
bangThisStep = true;
}
case Event.CrateHit hit -> {
}
case Event.CrateBurnt(Cell at, var hidden) -> {
splinters(painter, at, 0, 1, 10);
if (audible) mixer.play(sounds.crate);
}
case Event.PowerUpBurnt(Cell at, var powerUp) -> {
splinters(painter, at, 2, 3, 8);
if (audible) mixer.play(sounds.burnt);
}
case Event.PowerUpTaken taken -> {
if (audible) mixer.play(sounds.powerUp);
}
case Event.PlayerHit hit -> {
if (audible) mixer.play(sounds.hit);
}
case Event.Hurry hurry -> {
if (audible) mixer.play(sounds.hurry);
}
case Event.WallFell fell -> {
shake = Math.min(SHAKE, shake + 0.4);
if (audible) mixer.play(sounds.wall);
}
case Event.RoundOver(int round, int winner) -> {
if (audible) mixer.playMusic(winner >= 0 ? music.roundWon : music.draw, false);
}
case Event.MatchOver over -> {
if (audible) mixer.playMusic(music.matchWon, false);
}
}
}
}
The gamepads are opened when the program starts and closed when it ends, which stops the reading threads on Linux:
public static void main(String[] args) {
AmadeApplication.launch(APP, injector -> {
Mixer mixer = injector.getInstance(Mixer.class);
mixer.start();
Gamepads gamepads = injector.getInstance(Gamepads.class);
BlastWindow window = injector.getInstance(BlastWindow.class);
BlastPresenter presenter = injector.getInstance(BlastPresenter.class);
BlastGame game = injector.getInstance(BlastGame.class);
window.bind(presenter);
presenter.start();
window.whenClosed(() -> {
game.stop();
gamepads.close();
mixer.close();
System.exit(0);
});
window.setLocationByPlatform(true);
window.setVisible(true);
game.start(window.canvas());
}, new BlastModule());
}
Full screen (F11), the render modes, the frame times (F12), sound and the five languages work as in the earlier games. Blast keeps no high scores: a match between friends is its own reward.
What moved into the engine¶
| From Blast | Engine package | Why |
|---|---|---|
| Gamepads: four slots, the stick's dead zone, presses never lost | eu.amadeos.gameengine.input.Gamepad, Gamepads |
Every game that more than one person plays, and every game played from the sofa |
Reading /dev/input/js*; XInput through java.lang.foreign |
eu.amadeos.gameengine.input.LinuxJoysticks, XInput |
The pads of Linux and Windows behind one interface |
| Pad buttons in the controls | eu.amadeos.gameengine.input.Controls |
Keys and buttons trigger the same actions |
| Many countdowns, in the order they run out | eu.amadeos.gameengine.time.Timers |
Fuses, cooldowns, spawn waves, the build times of Towers |
| Events that cause events, handled in waves | eu.amadeos.gameengine.event.EventQueue |
Consequences that spread: chain reactions, the water and sand of Sands |
What stays in the game: the arena file, the bombs, flames and power-ups, the rounds and the falling walls, the danger map and the computer players, the seats and the lobby, the painter, the sounds and tunes, and the scenes.
Exercises¶
- An arena of your own. Change
board.txt: fewer pillars, a ring of solid crates round the middle, other power-up numbers. Wall in a start with+and read the error. - The kick. Add a power-up that lets a player kick a bomb: it slides until it hits something. Where does the bomb's position live now, and what must
reachandDangerlearn about bombs that move? - Remote control. A power-up that keeps a player's bombs from exploding by themselves; a second button sets them off. Which timer is no longer started, and which event takes its place?
- Recursion against waves. Replace the event queue with direct calls:
explodecallsdetonate, which callsexplode. Lay a long chain where the order is visible (two chained bombs that both reach the same player). Does the history of the round change? - A careful computer. The computer players never lay a bomb in the reach of another bomb. That is why chains are rare on the title screen. Let them do it when the chain is safe for them, and watch the title screen.
- Rumble. XInput has
XInputSetState, which makes a pad vibrate. Call it throughjava.lang.foreignwhen a player is hit.
In the next game, Jump, the camera follows one player through a level much larger than the screen: scrolling, gravity, and standing on things.