Skip to content

47. Chance, Data and Saving

The state of a game, where it comes from and where it goes: random numbers that repeat, a game kept as its input or as snapshots for undo, the data files a designer writes, the money and production of a strategy game, and what is written to disk, from high scores to a whole running battle.

Chance that repeats: SeededRandom

Package random · introduced in 22. Snake; saving its state in 31. Sands · source: SeededRandom

What it is for. Random numbers that repeat exactly: the same seed gives the same numbers, on every computer, with every Java version. Replays, tests of whole games, a game of the day and loaded saves all depend on it.

When to choose it. For all chance in the rules of every game. java.util.Random repeats too, but its sequence belongs to the JDK; SeededRandom writes its algorithm out, so no update can ever change a sequence and break a recorded replay. SeededRandom.freshSeed() gives a game that should differ from the last one a new seed from the clock.

How it works. The algorithm is SplitMix64: a counter that grows by a constant, and a few multiplications and shifts that mix it. Its whole state is one long. The methods a game uses are written out too: nextInt(bound) (without the bias that % alone gives), nextInt(origin, bound), nextDouble(), nextBoolean(). The other methods of RandomGenerator work, but their results depend on Java's implementation.

amade-game-engine · random/SeededRandom.java
public final class SeededRandom implements RandomGenerator {
    private final long seed;
    private long state;

    public SeededRandom(long seed) {
        this.seed = seed;
        this.state = seed;
    }

    /** A new seed from the clock, for a game that should differ from the last one. */
    public static long freshSeed() {
        return new SeededRandom(System.nanoTime() ^ System.currentTimeMillis()).nextLong();
    }

    /** The seed this generator started with; with it, the same numbers can be drawn again. */
    public long seed() {
        return seed;
    }

    @Override
    public long nextLong() {
        long z = state += 0x9E3779B97F4A7C15L;
        z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L;
        z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL;
        return z ^ (z >>> 31);
    }

    /** 0 inclusive to {@code bound} exclusive, every value equally likely. */
    @Override
    public int nextInt(int bound) {
        if (bound <= 0) throw new IllegalArgumentException("bound must be positive: " + bound);
        // 31 random bits; the few largest values that would favour small results are drawn again.
        int bits;
        int value;
        do {
            bits = (int) (nextLong() >>> 33);
            value = bits % bound;
        } while (bits - value + (bound - 1) < 0);
        return value;
    }
Method What it does
fork() A second generator, seeded from this one: its numbers never change what this one draws next
copy() A generator at exactly this one's position
seed() The seed it started with
state(), SeededRandom.resume(seed, state) Where it stands, and a generator that goes on from there: saved with a game, the loaded game draws the very numbers the saved one would have

Pitfall: effects that change the game

Splinters and sparks need chance too. Drawn from the rules' generator, a particle more or less changes every number the rules draw after it, and a replay goes another way when the player turns effects off. Give the effects a generator of their own, rules.fork() (chapter 22).

A game as its input: Replay

Package replay · introduced in 22. Snake · source: Replay

What it is for. A whole game as a seed and the player's actions, each with the tick at which the rules received it. When the rules are deterministic (all chance from a SeededRandom with this seed, all time from ticks), feeding the same actions at the same ticks plays the same game again, to the last point. A title screen that replays a good game, a bug report that can be played back, a test that plays a whole game: all of them are a replay.

How it works. record(tick, action) while playing; playback() gives a Playback whose actionsAt(tick) hands the actions for each tick in order, and finished() says when all are played. As text, one line per action after the seed, so a replay can be saved, read and written by hand in a test (chapter 22):

amade-game-engine · replay/Replay.java
    public String toText() {
        StringBuilder text = new StringBuilder("seed ").append(seed).append('\n');
        for (Entry<A> entry : entries) text.append(entry.tick()).append(' ').append(entry.action().name()).append('\n');
        return text.toString();
    }

    /** Reads {@link #toText()}; empty lines and lines starting with {@code #} are ignored. */
    public static <A extends Enum<A>> Replay<A> parse(Class<A> actions, String text) {
        Replay<A> replay = null;
        int number = 0;
        for (String line : text.split("\\R")) {
            number++;
            line = line.strip();
            if (line.isEmpty() || line.startsWith("#")) continue;
            String[] parts = line.split("\\s+");
            try {
                if (replay == null) {
                    if (parts.length != 2 || !parts[0].equals("seed")) throw new IllegalArgumentException("no seed");
                    replay = new Replay<>(actions, Long.parseLong(parts[1]));
                } else {
                    if (parts.length != 2) throw new IllegalArgumentException("expected: tick action");
                    replay.record(Long.parseLong(parts[0]), Enum.valueOf(actions, parts[1]));
                }
            } catch (IllegalArgumentException e) {
                throw new IllegalArgumentException("line " + number + ": " + e.getMessage(), e);
            }
        }
        if (replay == null) throw new IllegalArgumentException("no seed");
        return replay;
    }

Pitfall: record ticks, not time

"UP after 3.2 seconds" depends on the frame rate, the computer and the pauses the player took. The tick count does not, and a replay recorded on a fast computer plays the same on a slow one (chapter 22).

When a replay is not enough. A replay plays the game again from the start. That suits a game of a few minutes with one player's actions. A battle of an hour, or a game the player wants to continue tomorrow, is saved as a Snapshot of its state instead.

Undo and redo: History

Package undo · introduced in 11. Solitaire · source: History

What it is for. Undo and redo in a game of moves. Before each move the game records a copy of its state; undo hands the last copy back. This is simpler than undoing each kind of move by hand, and it cannot go wrong for moves with side effects (a card turned over, a score changed), as long as the copies are complete.

How it works. Two stacks. record(before) pushes the state before a move and forgets everything undone, because a new move makes it unreachable; undo(current) keeps the current state for redo and returns the one before; redo(current) goes forward again. At most 1,000 steps are kept by default. The copies must share nothing mutable with the live state: a copy() method of the domain that copies deeply, tested once, is the whole price. GameMenus.undo and redo give the menu entries with Ctrl+Z and Ctrl+Y.

amade-game-engine · undo/History.java
    /** Remembers the state before a move; a new move makes the undone moves unreachable. */
    public void record(S before) {
        past.push(Objects.requireNonNull(before, "before"));
        if (past.size() > limit) past.removeLast();
        future.clear();
    }

    /**
     * Goes one move back.
     *
     * @param current the state now, kept for {@link #redo}
     * @return the state before the last move, empty if there is none
     */
    public Optional<S> undo(S current) {
        if (past.isEmpty()) return Optional.empty();
        future.push(Objects.requireNonNull(current, "current"));
        return Optional.of(past.pop());
    }

    /** Goes forward again after {@link #undo}; empty if nothing was undone. */
    public Optional<S> redo(S current) {
        if (future.isEmpty()) return Optional.empty();
        past.push(Objects.requireNonNull(current, "current"));
        return Optional.of(future.pop());
    }

Data files: TextData

Package data · introduced in 24. Swarm · source: TextData

What it is for. A game's data as plain text, one thing per line: words separated by spaces, the first word saying what the line is. Stages and their flight paths in Swarm, the maze of Maze, the arena of Blast, the waves of Towers. A designer, or a player, changes the game without a compiler.

How it works. TextData.load(owner, "/data/waves.txt") reads a resource in UTF-8; everything after # is a comment and empty lines are skipped. Each Line keeps its file name and number, so every question can fail with an error that says where: word(i), number(i) and integer(i) throw a DataException like waves.txt:12: "fast" is not a number, and line.error(message) makes one for the game's own checks.

amade-game-engine · data/TextData.java
public final class TextData {
    /** A line with the words in it, and where it came from. */
    public record Line(String source, int lineNumber, List<String> words) {
        public Line {
            words = List.copyOf(words);
        }

        /** The first word: what the line is. */
        public String keyword() {
            return words.getFirst();
        }

        public int size() {
            return words.size();
        }

        /** Word {@code index}; an error naming the line if there is none. */
        public String word(int index) {
            if (index >= words.size()) throw error("expected " + (index + 1) + " words, found " + words.size());
            return words.get(index);
        }

        /** Word {@code index} as a number, such as {@code 12}, {@code -0.5} or {@code 1e3}. */
        public double number(int index) {
            String word = word(index);
            try {
                return Double.parseDouble(word);
            } catch (NumberFormatException e) {
                throw error('"' + word + "\" is not a number");
            }
        }

        /** Word {@code index} as a whole number. */
        public int integer(int index) {
            String word = word(index);
            try {
                return Integer.parseInt(word);
            } catch (NumberFormatException e) {
                throw error('"' + word + "\" is not a whole number");
            }
        }

        /** An error at this line, to throw: {@code waves.txt:12: message}. */
        public DataException error(String message) {
            return new DataException(source + ":" + lineNumber + ": " + message);
        }
    }

    /** Something in a data file the game cannot use. */
    public static final class DataException extends RuntimeException {
        public DataException(String message) {
            super(message);
        }
    }

    private TextData() {
    }

    /** Reads all lines that say something; {@code source} names the file in error messages. */
    public static List<Line> parse(String source, Reader reader) {
        List<Line> lines = new ArrayList<>();
        try (BufferedReader in = new BufferedReader(reader)) {
            int number = 0;
            for (String text; (text = in.readLine()) != null; ) {
                number++;
                int comment = text.indexOf('#');
                if (comment >= 0) text = text.substring(0, comment);
                text = text.strip();
                if (!text.isEmpty()) lines.add(new Line(source, number, Arrays.asList(text.split("\\s+"))));
            }
        } catch (IOException e) {
            throw new UncheckedIOException("cannot read " + source, e);
        }
        return Collections.unmodifiableList(lines);
    }

What a data file is good for. Lines of a few words each, in an order that matters: a stage's groups, a wave's creeps, the rows of a map (TileMap.read). Things with many named properties read better as a Catalog; a map larger than a screen is drawn in Tiled.

Tip: read every data file in a test

Errors in data arrive at run time. A test that reads every data file of the game, and a test that plays each level, find them at build time (chapter 30).

Things with properties: Catalog

Package data · introduced in 30. Towers · source: Catalog

What it is for. The units, towers, weapons and buildings of a game, one block each in a data file: a line naming the thing, then its properties, one per line. The numbers a designer changes again and again while the game is played. With like, a block starts with all properties of an earlier one of its kind and changes some:

unit orc
  health 60
  speed 1.1        # tiles per second

unit orc-chief like orc
  health 180       # everything else as the orc

How it works. Catalog.read(lines, "unit", "tower") reads the blocks of the kinds the game names, in the order of the file, as Entrys. The game asks each entry for the properties it knows (number, integer, text, each with or without a fallback, lines for a property given several times, such as a tower's levels) and then calls finish(). Whatever the game did not ask for is an error with the file and the line: a property typed rnage is not silently ignored while the tower keeps its old range, the classic mistake of every data file read without such a check.

amade-game-engine · data/Catalog.java
    /**
     * Reads the blocks of the {@code kinds} given, in the order of the file. Names are unique within a
     * kind; a line before the first block is an error.
     */
    public static List<Entry> read(List<TextData.Line> lines, String... kinds) {
        Set<String> known = Set.of(kinds);
        List<Entry> entries = new ArrayList<>();
        Map<String, Entry> byName = new LinkedHashMap<>();
        Entry current = null;
        for (TextData.Line line : lines) {
            String keyword = line.keyword();
            if (known.contains(keyword)) {
                current = header(line, byName);
                entries.add(current);
                continue;
            }
            if (current == null) {
                throw line.error("expected " + String.join(" or ", kinds) + " before \"" + keyword + '"');
            }
            if (current.properties.containsKey(keyword) && current.inherited.contains(keyword)) {
                // The block's own line replaces what it took over with like.
                current.properties.remove(keyword);
                current.inherited.remove(keyword);
            }
            current.properties.computeIfAbsent(keyword, key -> new ArrayList<>()).add(line);
        }
        return Collections.unmodifiableList(entries);
    }

    private static Entry header(TextData.Line line, Map<String, Entry> byName) {
        String kind = line.keyword();
        String name = line.word(1);
        if (line.size() != 2 && !(line.size() == 4 && line.word(2).equals("like"))) {
            throw line.error("expected \"" + kind + " <name>\" or \"" + kind + " <name> like <other>\"");
        }
        if (byName.containsKey(kind + " " + name)) throw line.error("there is already a " + kind + " " + name);
        Entry entry = new Entry(kind, name, line);
        if (line.size() == 4) {
            Entry model = byName.get(kind + " " + line.word(3));
            if (model == null) throw line.error("no " + kind + " " + line.word(3) + " before this line");
            model.properties.forEach((key, given) -> {
                entry.properties.put(key, new ArrayList<>(given));
                entry.inherited.add(key);
            });
        }
        byName.put(kind + " " + name, entry);
        return entry;
    }
Test: errors that name the line
amade-game-engine · test/data/CatalogTest.java
    @Test
    void mistakesNameTheFileAndTheLine() {
        assertEquals("units.txt:1: expected unit or tower before \"health\"",
                assertThrows(TextData.DataException.class, () -> read("health 60\nunit orc\n")).getMessage());
        assertEquals("units.txt:3: there is already a unit orc",
                assertThrows(TextData.DataException.class, () -> read("unit orc\nhealth 1\nunit orc\n")).getMessage());
        assertEquals("units.txt:2: no unit troll before this line",
                assertThrows(TextData.DataException.class, () -> read("unit orc\nunit wolf like troll\n")).getMessage());

        Catalog.Entry orc = read("""
                unit orc
                  health 60
                  sped 1.1
                  speed 1.0 1.2
                """).getFirst();
        assertEquals(60, orc.number("health"));
        assertEquals("units.txt:4: speed takes one number",
                assertThrows(TextData.DataException.class, () -> orc.number("speed")).getMessage());
        assertEquals("units.txt:1: unit orc: bounty is missing",
                assertThrows(TextData.DataException.class, () -> orc.integer("bounty")).getMessage());
        // The game asked for health, speed and bounty; "sped" is a typo nobody asked for.
        assertEquals("units.txt:3: \"sped\" is not a property of a unit",
                assertThrows(TextData.DataException.class, orc::finish).getMessage());
    }

Money and production: Credits and Production

Package economy · introduced in 31. Sands · source: Credits, Production

What it is for. The money of one side and what it builds. Credits is money with a store: what the harvesters bring in fills the refineries and silos, and what they cannot hold is lost, which makes the player spend instead of saving up for ever. Production is a queue of orders, the first one in the works, paid while it is made: a tank of 300 credits that takes 20 seconds costs 15 credits a second. Without money the work stops and waits; with too little power it goes slower; a cancelled order gives back what was paid so far.

When to choose them. For any game in which things cost and take time: a strategy game's buildings and units, the workshops of an economy game. Neither knows what is made or where the money comes from.

How it works. Credits are whole: a game shows its money as a number, and a number that grows in fractions is a puzzle. The payment per step is not: 15 credits a second at 120 steps a second is an eighth of a credit. So update(seconds, credits, speed) pays a whole credit as soon as the work reaches it, and the work only grows as far as it has been paid: no credit, no progress. It returns the item finished in this step, or null.

amade-game-engine · economy/Production.java
public final class Production<T> {
    /** One order: what, its price and how long it takes at full speed. */
    public record Order<T>(T item, int cost, double seconds) {
        public Order {
            if (cost < 0 || seconds <= 0) throw new IllegalArgumentException("cost " + cost + ", seconds " + seconds);
        }
    }

    private final List<Order<T>> queue = new ArrayList<>();
    /** Credits paid for the first order so far, and how far it is, 0 to 1. */
    private int paid;
    private double done;
    private boolean held;
    private T ready;

    /** Adds an order at the end of the queue. */
    public void order(T item, int cost, double seconds) {
        queue.add(new Order<>(item, cost, seconds));
    }

    /**
     * One step: the first order is worked on for {@code seconds} at {@code speed} (1 full, 0.5 half), as
     * far as the credits pay for it. Returns the item finished in this step, or {@code null}.
     */
    public T update(double seconds, Credits credits, double speed) {
        if (queue.isEmpty() || held || ready != null || speed <= 0) return null;
        Order<T> first = queue.getFirst();
        double wanted = done + seconds * speed / first.seconds();
        if (wanted > 1 - 1e-9) wanted = 1;             // what the sum of many small steps misses
        // The credits this much work costs, rounded up: a credit is paid before its work is done.
        int due = (int) Math.ceil(wanted * first.cost() - 1e-9) - paid;
        if (due > 0) {
            int paying = Math.min(due, credits.amount());
            credits.pay(paying);
            paid += paying;
        }
        // The work goes only as far as it is paid for: without money it stands still.
        done = first.cost() == 0 ? wanted : Math.min(wanted, (double) paid / first.cost());
        if (done < 1 || paid < first.cost()) return null;
        queue.removeFirst();
        paid = 0;
        done = 0;
        return first.item();
    }
Method What it does
credits.pay(price), earn(income), refund(credits) Pays if there is enough; brings in what fits and returns the rest; gives back, past the limit
credits.setCapacity(capacity), full() A silo built or destroyed; credits above a lowered limit stay
production.order(item, cost, seconds) Adds an order
hold(on), cancel(item, credits) Stops and goes on, keeping what was paid; removes the last order of an item, with a refund if it was in the works
keep(item), ready(), take() A finished building that waits to be placed; the queue waits with it
progress(), paid(), current(), queue(), count(item) For the panel
restore(paid, done, held) The first order's state from a save file

Goods and recipes: Stock and Recipe

Package economy · introduced in 32. Colony · source: Stock, Recipe

What it is for. Goods counted by kind, and what a workshop makes of them. A Stock is a count for each value of an enum of goods, with a limit per good if wanted: a storehouse, a building site, the door of a bakery. A Recipe is a list of inputs, the good made and the seconds it takes; each input is a set of goods, any one of which will do. Colony's residence needs bread or fish, and cloth.

When to choose them. For an economy of many goods, where Credits (above) is an economy of one. Neither knows what the goods are or who carries them.

How it works. Stock keeps an EnumMap, so it is fast, and its order is the enum's, so a save file or a panel lists the goods always in the same order. take takes all of a count or nothing. Recipe.take chooses one good for each input, trying the goods of a set in the enum's order, and takes them only if every input is served; it never takes half of what it needs. The same good can serve two inputs only if there are two of it.

amade-game-engine · economy/Recipe.java
public record Recipe<G extends Enum<G>>(List<Set<G>> inputs, G makes, double seconds) {
    public Recipe {
        inputs = List.copyOf(inputs);
        for (Set<G> input : inputs) {
            if (input.isEmpty()) throw new IllegalArgumentException("an input needs one good at least");
        }
        if (seconds <= 0) throw new IllegalArgumentException("seconds " + seconds);
    }

    /** True if {@code stock} has a good for every input. */
    public boolean ready(Stock<G> stock) {
        return choose(stock) != null;
    }

    /** Takes one good for every input from {@code stock}; the goods taken, or {@code null} and nothing taken. */
    public List<G> take(Stock<G> stock) {
        List<G> chosen = choose(stock);
        if (chosen == null) return null;
        for (G good : chosen) stock.take(good, 1);
        return chosen;
    }

    private List<G> choose(Stock<G> stock) {
        List<G> chosen = new ArrayList<>(inputs.size());
        for (Set<G> input : inputs) {
            G found = null;
            for (G good : input.stream().sorted().toList()) {
                long already = chosen.stream().filter(good::equals).count();
                if (stock.count(good) > already) {
                    found = good;
                    break;
                }
            }
            if (found == null) return null;
            chosen.add(found);
        }
        return chosen;
    }
Method What it does
stock.count(good), add(good, n), take(good, n) How many; adds as far as they fit and returns the rest; takes all or nothing
limit(good), limit(good, n), room(good) The most that may be kept, and how many more fit
has(price), pay(price) Whether a price of several goods is there; takes it all or nothing
counts(), total(), set(good, n), kinds() For the panel and a save file
new Recipe<>(List.of(Set.of(BREAD, FISH), Set.of(CLOTH)), null, 16) The residence: bread or fish, and cloth
recipe.ready(stock), take(stock), needs() Whether it can start; takes the inputs and returns them; every good it may use

Progress on disk: SaveFile

Package save · introduced in 27. Jump · source: SaveFile

What it is for. A player's progress on disk: which levels are open, the best times, what was collected; in Sands also the whole running battle. Settings may get lost without harm, progress may not. So a save file is written with more care than settings:

  • Never half written. The new state goes into a temporary file next to the save, is forced onto the disk, and only then takes the save's name in one atomic rename. A crash leaves the old file or the new one, never a mixture.
  • A backup. The file before the last save is kept as <name>.bak. If the save is damaged, load() reads the backup.
  • A checksum. The last line is a CRC-32 of everything above it. A file cut short is recognised.
  • A version. The first line names the format's version, so a later game can read and convert what an earlier one wrote.

The content is keys and values, sorted, as in a properties file, so a person can read it and a diff can compare two saves.

Method What it does
SaveFile.of(xdg, "jump", "progress") $XDG_DATA_HOME/amadeos/games/jump/progress.save
store(version, values) Writes a new state; the one before becomes the backup
load() The last state saved, Optional<Saved> with the version and the values; empty on the first start
delete() A fresh start

A whole game: Snapshot

Package save · introduced in 31. Sands · source: Snapshot

Progress is a handful of numbers. A running battle is a few hundred things, each with a dozen values, in lists. A Snapshot.Writer gives them names a save file can hold and a person can read, unit.count=2, unit.0.kind=tank, unit.0.x=212.5, nested as deep as needed with child(key) and add(key); a Snapshot.Reader reads them back with text, integer, whole, number, bool, choice (an enum), child and list, and says which key is missing or malformed when a file does not fit.

The goal is a loaded game that goes on exactly as the saved one would have, step for step. Three rules make that possible (chapter 31):

  • Numbers to the bit. The writer uses Double.toString, the shortest text that reads back as exactly the same double; never a format with a fixed number of digits.
  • The random numbers. Save random.state() and the seed, and SeededRandom.resume them.
  • Nothing derived. What follows from other values (walls of buildings on the map, power, storage, flow fields) is worked out again after loading. What is not saved cannot be saved wrong.
amade-game-engine · test/save/SnapshotTest.java
    @Test
    void listsOfThingsGoIntoASaveFileAndComeBackExactly() {
        Snapshot.Writer w = Snapshot.writer();
        w.put("credits", 1250).put("time", 0.1 + 0.2);
        Snapshot.Writer tank = w.add("unit");
        tank.put("kind", "tank").put("x", Math.PI * 100).put("order", Order.MOVE);
        Snapshot.Writer harvester = w.add("unit");
        harvester.put("kind", "harvester").put("x", 1e-300).put("order", Order.HARVEST).put("full", true);
        w.child("camera").put("x", 96.5);
        w.list("shot");
        assertEquals("2", w.values().get("unit.count"));
        assertEquals("tank", w.values().get("unit.0.kind"));

        SaveFile file = new SaveFile(dir.resolve("game.save"));
        file.store(1, w.values());
        Snapshot.Reader r = Snapshot.reader(file.load().orElseThrow().values());
        assertEquals(1250, r.integer("credits"));
        assertEquals(0.1 + 0.2, r.number("time"), "to the last bit, not 0.3");
        List<Snapshot.Reader> units = r.list("unit");
        assertEquals(2, units.size());
        assertEquals(Math.PI * 100, units.get(0).number("x"));
        assertEquals(Order.HARVEST, units.get(1).choice("order", Order.class));
        assertTrue(units.get(1).bool("full"));
        assertEquals(1e-300, units.get(1).number("x"));
        assertEquals(96.5, r.child("camera").number("x"));
        assertEquals(List.of(), r.list("shot"), "an empty list is a list");
    }

The test a game writes on top of that: run a game, save it, load it, run both on, and compare their snapshots key by key. When it fails, it names the first key that differs, nearly always the field someone forgot to save (Sands' test).

Which kind of keeping?

What Use Where
Choices of the player: render mode, sound, keys A properties file of the game's repository package (amade-commons' PropertiesFile) ~/.config/amadeos/<game>/
The best results HighScores ~/.local/share/amadeos/games/<game>/*.scores
Progress: levels open, best times SaveFile ~/.local/share/amadeos/games/<game>/*.save
A running game, to continue later Snapshot in a SaveFile the same
A game to play again from the start Replay wherever the game keeps it, as text
The moves of this game, for undo History memory only

High scores: HighScores

Package score · introduced in 10. Mines · source: HighScores, Score, Ranking, PlayerName

What it is for. The best ten results of a game, one table per difficulty or variant. A Ranking says whether lower wins (times, moves) or higher (points); on a tie, the older entry stays in front. PlayerName remembers the name typed last time and offers it again, the login name the first time.

How it works. Each table is a text file <table>.scores in $XDG_DATA_HOME/amadeos/games/<game>/, one entry per line: value, date and name, separated by tabs. Lines that cannot be read are skipped, so a damaged file loses entries but never stops the game; a table is written to a temporary file and renamed, like a save. qualifies(table, value) says whether a result enters the table, add(table, score) stores it and returns its place (0 for the best, -1 if it did not make it), list(table) gives the entries best first, clear(table) empties it. A Score's name is cleaned of tabs and line breaks and cut to 32 characters.

The dialogs that show the tables and ask for the name are HighScoreDialog.