Skirmish shares the module layout, the accounts and sign-in, push notifications and the operations console with Savor and Wayline. What it adds is a game: a deterministic simulation that the app, the server, the replay viewer and the tests all run, a 3D battle drawn over it, and the progression, store and moderation a live game needs. This chapter shows where each of those lives and what to change to make the game yours.

Running it

The project is in scripts/skirmish, and the Initializr generates it under your own package and name. Start the server on the development profile, which keeps its database in memory, seeds the demo accounts and simulates purchases and push notifications:

CN1_PROFILE=dev backend/server.sh run

Then run the app in the simulator, from the project’s directory:

mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase

The app looks for the server at http://localhost:8080/; Settings changes the address, which is how a phone reaches a server on your computer. To build the native iOS app on a Mac, generate the Xcode project, which lands under ios/target, then open it and run it on a simulator or a device from Xcode:

mvn package -DskipTests -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source -Dopen=false

The development profile needs nothing installed, and four demo accounts exist, each with the password skirmish-demo:

AccountWhat it’s for

[email protected]

A player with some history: level 3, two chests waiting and a daily quest ready to claim.

[email protected]

A second player, to battle the first from another simulator or device.

[email protected]

A player the server has already flagged, so the console has something to review.

[email protected]

The operator. Signs in to the same app and gets the admin console as well.

What the sample does

This tour follows a player through the app, with a pointer to the section that explains how each part is built.

Signing in

The first screen offers to sign in, to create an account, or to practice against the bot without one. A new account picks a battle name, which is what opponents and the leaderboard see.

Creating an account: three heroes above a field for the battle name, its preview, and fields for e-mail and password
The sign-in screen: three heroes above the name Skirmish, fields for e-mail and password, and buttons to sign in, create an account or practice

A new player goes straight into the training battle, described in The training battle.

The home screen

The home screen shows the player’s progress at a glance: level and how close the next one is, coins, gems and trophies, the hero of the deck, the BATTLE button, chests, today’s quests and the deck.

The home screen: avatar, level and experience bar, the hero on stage, the BATTLE button, the chest slots and the daily quests
  • Chests are earned by winning and by levelling up. A tap opens one.

  • Daily quests change every day. Three are picked for each player, and a finished one is claimed with Claim.

  • Achievements are long-term goals with gem rewards, listed in the Trophies room.

Every reward is shown in a popup with the player’s hero cheering. The server decides every part of it, as Progression shows.

A reward popup: Training complete, a level-up badge, coins, gems, experience and a chest, and the achievement unlocked

The deck

A deck is eight cards, and the battle hand is four of them at a time, with the next card waiting. Deck shows every card with its elixir cost, and the eight in use are framed in gold. A tap swaps a card in or out.

The deck screen: ten card portraits, eight of them framed in gold, the deck’s average elixir cost and a button to save it

Battle

A battle lasts three minutes. Each side has a king’s castle and two archer towers. Toppling a tower wins a crown, and toppling the castle wins outright. When the clock runs out, the side with more crowns wins. A tie goes to a minute of overtime where the next crown wins, and is a draw if nobody scores. Elixir comes twice as fast in the last minute of regular time.

A battle: the field with the river and two bridges, both sides' towers, troops on the left lane, and the hand of cards under the elixir bar

Elixir fills the bar at the bottom, one drop at a time up to ten. A card is played by tapping it and then the field, or by dragging it there. Troops and buildings go on the player’s own side of the river, and spells go anywhere. Troops walk to the nearest bridge and attack the first thing in their way.

The result screen follows, with the crowns, trophies and coins won. From there a player can watch the replay, report the opponent, or watch a sponsored video for extra coins.

The result screen: Victory, the crown count, and buttons to watch an ad for coins and to continue

Finding an opponent

BATTLE puts the player in a queue that pairs players with similar trophy counts and falls back to the bot if nobody is found. During an online battle, the buttons under the hand send a quick emote to the opponent. The server is the referee explains how the two phones stay in step.

An online battle against the bot, with the emote buttons GG, Wow!, Oops and Thanks under the hand

Practice plays the bot straight away and earns nothing. Challenge makes a code and a link to send a friend, and opening the link starts the friend’s app straight into the battle.

Replays

Every online battle is kept, and Replays lists them. Any signed-in player can watch any battle, with both players' names. A replay isn’t a recording. It’s the starting state and every card played, run again through the same rules. The viewer pauses, plays at two or four times speed, and a slider jumps to any moment.

The replay viewer showing a battle in progress with play, pause and speed controls

The shop and settings

The shop sells gems for real money, and cosmetic items for gems: arenas, emote sets and a season pass. Purchases go through the platform’s store, and the server checks every receipt before granting anything, as Purchases shows. On the development profile the store is simulated, so a purchase completes immediately.

The shop: gem packs with prices, and cosmetic items with their gem cost

Settings renames the player, and switches the sound, push notifications and sharing anonymous usage statistics on or off. The server address can be changed here too, which is how the app reaches a server on another machine.

The settings screen with the battle name, sound, notification and privacy switches, and the server address

The modules

ModuleWhat it holds

rules

The game itself: the cards, the arena, the units, the bot and replays. It uses nothing but a handful of java.* classes, no Codename One API and no floating point, so the app, the server and the tests compile the same classes.

shared

The contract: the data transfer objects and the @RestClient interfaces both sides compile against.

common

The app: the 3D battle, the menus, the store, ads, analytics, push and the admin console.

backend

The server: accounts, decks, matchmaking, the authoritative match rooms, replays, progression, receipts, push and moderation.

ios

The iOS build, for generating the Xcode project.

The rules run everywhere

Everything that decides a battle is in Match.step, and nothing else changes a match. It advances the game by one tick, a twentieth of a second, applying the cards played on that tick:

/// Advances one tick. `commands` may hold anything; only those stamped with
/// the current tick are applied, in the order given (the server's order).
public void step(List<Command> commands) {
    if (isOver()) {
        return;
    }
    events.clear();
    if (commands != null) {
        for (int i = 0; i < commands.size(); i++) {
            Command c = commands.get(i);
            if (c.tick == tick) {
                apply(c);
            }
        }
    }
    runWaves();
    regenElixir();
    int count = units.size();
    for (int i = 0; i < count; i++) {
        Unit u = units.get(i);
        if (u.hp > 0) {
            update(u);
        }
    }
    separate();
    removeDead();
    tick++;
    checkEnd();
}

Three rules keep every copy of a match identical:

  • No floating point. Positions are thousandths of a tile, held in int, and FixedMath supplies the integer square root and distance checks. Floating point rounds differently on different processors and compilers, so a battle that used it would drift apart between a phone and the server.

  • No clock and no java.util.Random. Time is the tick count, and chance comes from Rng, seeded by the server when the match starts.

  • The order is fixed. Units act in the order they were created, and cards played on the same tick apply in the order the server received them, which the server’s log fixes for every copy of the match.

Because of that, a replay is only the seed, the two decks and the list of cards played. Replay re-runs them through Match and arrives at the same battle, and the server and both players compare a hash of the state to detect a client that has gone its own way.

Adding a card

A card is a row in Cards:

add(new CardDef("squire", "Squire", CardDef.TROOP, 3)
        .hp(1450).damage(160).hitTicks(24).range(700).speed(45).radius(500));

Hit points and damage are whole numbers, ranges and radii are in thousandths of a tile, speed is thousandths of a tile per tick, and hitTicks is the time between attacks. CardDef also describes what a card targets, whether it splashes, how many units it spawns and how long a building lasts.

The look of a card on the field is a row in Models, the glTF character it uses, its height in tiles and its attack animation:

public final class Models {
    /// How one card looks on the field.
    public static final class Look {
        public final String model;
        /// Height in tiles for a character; footprint width in tiles for a
        /// building.
        public final float size;
        public final String attack;
        public final float hover;
        public final int tint;
        public final String idle;
        public final String run;
        public final String death;

        Look(String model, float size, String attack, float hover, int tint) {
            this(model, size, attack, hover, tint, "Idle", "Running_A", "Death_A");
        }

        Look(String model, float size, String attack, float hover, int tint, String idle, String run,
                String death) {
            this.model = model;
            this.size = size;
            this.attack = attack;
            this.hover = hover;
            this.tint = tint;
            this.idle = idle;
            this.run = run;
            this.death = death;
        }
    }

    private static final Map<String, Look> LOOKS = new HashMap<String, Look>();
    private static final Map<String, GltfScene> SCENES = new HashMap<String, GltfScene>();

    static {
        look("squire", "knight", 1.96f, "1H_Melee_Attack_Chop", 0f, 0xffffffff);
        look("twin-archers", "rogue_hooded", 1.67f, "1H_Ranged_Shoot", 0f, 0xffffffff);
        look("ogre", "barbarian", 3.04f, "2H_Melee_Attack_Chop", 0f, 0xffffffff);
        look("bat-swarm", "skeleton_rogue", 1.16f, "1H_Melee_Attack_Chop", 1.6f, 0xffb8c8ff);
        look("sharpshooter", "rogue", 1.89f, "2H_Ranged_Shoot", 0f, 0xffffffff);
        look("spearmen", "skeleton_warrior", 1.74f, "1H_Melee_Attack_Chop", 0f, 0xffffffff);
        look("drake", "mage", 2.03f, "Spellcast_Shoot", 1.8f, 0xffffffff);
        look("imp", "skeleton_minion", 1.38f, "Unarmed_Melee_Attack_Punch_A", 0f, 0xffffffff);
        look("imp-den", "crypt", 3.77f, null, 0f, 0xffffffff);
    }

    private Models() {
    }

    private static void look(String card, String model, float size, String attack, float hover, int tint) {
        LOOKS.put(card, new Look(model, size, attack, hover, tint));
    }

    /// The look of `card`, or null for a spell or a tower (drawn by the view).
    public static Look of(String card) {
        return LOOKS.get(card);
    }

    /// The look of a card from a Forge pack: its own model, bundled or
    /// uploaded, its size and tint, and the clips its author mapped. A card
    /// with no model of its own falls back to a bundled one by kind.
    public static Look of(CardDef card) {
        Look builtin = LOOKS.get(card.id);
        if (builtin != null || card.kind == CardDef.SPELL || card.kind == CardDef.TOWER) {
            return builtin;
        }
        String model = card.model();
        if (model == null || model.length() == 0) {
            model = card.kind == CardDef.BUILDING ? "crypt" : card.isFlying() ? "skeleton_rogue" : "knight";
        }
        float size = (card.kind == CardDef.BUILDING ? 3.5f : 1.9f) * Math.max(25, card.scalePercent()) / 100f;
        int tint = card.tint() < 0 ? 0xffffffff : 0xff000000 | card.tint();
        String clips = card.clips();
        String attack = GlbInspector.clip(clips, "attack");
        if (attack == null && card.kind != CardDef.BUILDING) {
            attack = card.range() > 1500 ? "1H_Ranged_Shoot" : "1H_Melee_Attack_Chop";
        }
        return new Look(model, size, attack, card.isFlying() ? 1.6f : 0f, tint,
                orElse(GlbInspector.clip(clips, "idle"), "Idle"), orElse(GlbInspector.clip(clips, "run"), "Running_A"),
                orElse(GlbInspector.clip(clips, "death"), "Death_A"));
    }

    private static String orElse(String v, String fallback) {
        return v == null ? fallback : v;
    }

    /// Whether `name` is one of the models that ship with the game.
    public static boolean isBundled(String name) {
        return CN.getResourceAsStream("/model_" + name + ".glb") != null;
    }

    /// The parsed model `name`, loaded once: `/model_<name>.glb` for a bundled
    /// one, or an uploaded file, `asset:<sha256>`, from [ForgeFiles].
    public static synchronized GltfScene scene(String name) {
        GltfScene s = SCENES.get(name);
        if (s == null) {
            InputStream in = name.startsWith("asset:") ? ForgeFiles.open(name.substring(6))
                    : CN.getResourceAsStream("/model_" + name + ".glb");
            if (in == null) {
                throw new IllegalStateException("missing model " + name);
            }
            try {
                s = GltfLoader.loadScene(in);
            } catch (java.io.IOException err) {
                Log.e(err);
                throw new IllegalStateException("unreadable model " + name);
            }
            SCENES.put(name, s);
        }
        return s;
    }
}

The card’s portrait in the hand and the deck is a PNG in common/src/main/resources, card_<id>.png. PortraitStudio renders a portrait from the card’s model, so a new card gets art that matches the others:

mvn -pl common test-compile surefire:test -Dtest=PortraitStudio \
    -Dskirmish.portraits="$PWD/common/src/main/resources"

Changing what any card does changes the outcome of recorded battles. Bump RulesVersion.ID with it: each replay records the version it was played under, and GoldenReplayTest fails until the golden replays are recorded again.

The server is the referee

An online battle is played in lockstep. Neither phone decides anything: each sends the card it wants to play, and the server’s MatchRoom runs the match, stamps the play with the tick it takes effect on and sends it to both players:

final class MatchRoom {
    /// Plays a seat may make in [#FLOOD_WINDOW] ticks before the rest are dropped.
    static final int FLOOD_LIMIT = 6;
    static final int FLOOD_WINDOW = 20;

    final String id;
    final Match match;
    final Seat[] seats;
    final Bot bot;
    final List<Command> log = new ArrayList<Command>();
    final List<WebSocketSession> spectators = new ArrayList<WebSocketSession>();
    final long startedAt;
    /// Index into [#log] of the first command not yet sent.
    int sent;
    int lastSentTick = -1;
    int forfeit = -1;
    final java.util.Map<Integer, Long> hashes = new java.util.HashMap<Integer, Long>();

    MatchRoom(String id, long seed, Seat a, Seat b, long now) {
        this.id = id;
        this.seats = new Seat[] {a, b};
        this.match = new Match(seed, a.deck, b.deck);
        this.bot = b.isBot() ? new Bot(1, seed ^ 0x1234L) : null;
        this.startedAt = now;
    }

    int seatOf(String username) {
        for (int i = 0; i < 2; i++) {
            if (seats[i].username.equals(username)) {
                return i;
            }
        }
        return -1;
    }

    /// Accepts a play intent from `player`. Answers false when it was dropped.
    boolean intent(int player, String card, int x, int y) {
        if (match.isOver()) {
            return false;
        }
        Seat seat = seats[player];
        int now = match.tick();
        int inWindow = 0;
        for (int i = 0; i < seat.recentCount; i++) {
            if (now - seat.recent[i] < FLOOD_WINDOW) {
                inWindow++;
            }
        }
        if (inWindow >= FLOOD_LIMIT) {
            seat.floods++;
            return false;
        }
        seat.recent[seat.recentCount % seat.recent.length] = now;
        seat.recentCount = Math.min(seat.recentCount + 1, seat.recent.length);
        // A cheap early answer; the rules check again when the command executes.
        if (!match.canPlay(player, card, x, y)) {
            seat.rejected++;
            return false;
        }
        log.add(new Command(now + GameProtocol.INPUT_DELAY, player, card, x, y));
        return true;
    }

    /// Advances one tick, letting the bot decide first.
    void step() {
        if (bot != null) {
            Command c = bot.decide(match, GameProtocol.INPUT_DELAY);
            if (c != null) {
                log.add(c);
            }
        }
        match.step(log);
        if (match.tick() % GameProtocol.HASH_EVERY == 0) {
            hashes.put(Integer.valueOf(match.tick()), Long.valueOf(match.stateHash()));
            hashes.remove(Integer.valueOf(match.tick() - 10 * GameProtocol.HASH_EVERY));
        }
    }

    /// The result: [Match#result], or the side that did not leave.
    int result() {
        return forfeit >= 0 ? 1 - forfeit : match.result();
    }

    boolean isOver() {
        return forfeit >= 0 || match.isOver();
    }

    Replay replay() {
        return new Replay(match.seed(), seats[0].deck, seats[1].deck, log);
    }
}

The play is scheduled a few ticks ahead, so it reaches both phones before it’s due, and each phone advances its own copy of the match only as far as the server has said. OnlineDriver is the client’s half:

public final class OnlineDriver extends Driver {
    /// What the battle screen wants to hear besides the ticks.
    public interface Listener {
        void emote(int seat, String id);

        void ended(int result, int crowns0, int crowns1, int trophies, int coins, String matchId);

        void lost();
    }

    private final GameLink link;
    private final boolean spectator;
    private Listener listener;
    private String matchId;
    private String[] names = {"", ""};
    private int allowed;

    public OnlineDriver(GameLink link, Map<String, Object> start, boolean spectator) {
        this.link = link;
        this.spectator = spectator;
        restart(start);
    }

    public void setListener(Listener listener) {
        this.listener = listener;
    }

    public String matchId() {
        return matchId;
    }

    /// A `start` message: the first one, or the server's answer to a hash
    /// that did not match. Either way the match is rebuilt from its log.
    private void restart(Map<String, Object> m) {
        matchId = GameLink.text(m, "match");
        me = spectator ? 0 : GameLink.number(m, "you");
        names = new String[] {GameLink.text(m, "name0"), GameLink.text(m, "name1")};
        match = new Match(Long.parseLong(GameLink.text(m, "seed")),
                split(GameLink.text(m, "deck0"), ','), split(GameLink.text(m, "deck1"), ','));
        log.clear();
        append(GameLink.text(m, "log"));
        allowed = GameLink.number(m, "tick");
        // Catch up at once to where the server already is.
        while (match.tick() < allowed && !match.isOver()) {
            match.step(log);
        }
    }

    /// Everything the server sends during a match comes through here.
    public void message(Map<String, Object> m) {
        String type = GameLink.text(m, "type");
        if (GameProtocol.TICKS.equals(type)) {
            append(GameLink.text(m, "c"));
            allowed = Math.max(allowed, GameLink.number(m, "t"));
        } else if (GameProtocol.START.equals(type)) {
            restart(m);
        } else if (GameProtocol.EMOTE.equals(type) && listener != null) {
            listener.emote(GameLink.number(m, "player"), GameLink.text(m, "id"));
        } else if (GameProtocol.END.equals(type) && listener != null) {
            listener.ended(GameLink.number(m, "result"), GameLink.number(m, "crowns0"),
                    GameLink.number(m, "crowns1"), GameLink.number(m, "trophies"),
                    GameLink.number(m, "coins"), GameLink.text(m, "match"));
        } else if (GameProtocol.KICKED.equals(type) && listener != null) {
            listener.lost();
        }
    }

    @Override
    public boolean canStep() {
        return match.tick() < allowed && !match.isOver();
    }

    @Override
    public void step() {
        super.step();
        if (!spectator && match.tick() % GameProtocol.HASH_EVERY == 0) {
            link.send("{\"type\":\"sync\",\"t\":" + match.tick() + ",\"h\":\""
                    + Match.hex(match.stateHash()) + "\"}");
        }
    }

    /// Only asks. The play appears when the server stamps it and sends it
    /// back -- to both players at once.
    @Override
    public void play(String card, int x, int y) {
        if (!spectator && match.canPlay(me, card, x, y)) {
            link.send("{\"type\":\"play\",\"card\":\"" + card + "\",\"x\":" + x + ",\"y\":" + y + "}");
        }
    }

    @Override
    public boolean interactive() {
        return !spectator;
    }

    public void emote(String id) {
        link.send("{\"type\":\"emote\",\"id\":\"" + id + "\"}");
    }

    public void leave() {
        link.send("{\"type\":\"leave\"}");
    }

    @Override
    public String name(int seat) {
        if (!spectator && seat == me) {
            return "You";
        }
        return names[seat];
    }

    @Override
    public void dispose() {
        link.close();
    }

    private void append(String encoded) {
        for (String c : split(encoded, ';')) {
            if (c.length() > 0) {
                log.add(Command.decode(c));
            }
        }
    }

    /// `String.split` takes a regular expression and is missing on some
    /// targets; this takes one character.
    static String[] split(String s, char sep) {
        if (s == null || s.length() == 0) {
            return new String[0];
        }
        int n = 1;
        for (int i = 0; i < s.length(); i++) {
            if (s.charAt(i) == sep) {
                n++;
            }
        }
        String[] out = new String[n];
        int from = 0;
        int k = 0;
        for (int i = 0; i <= s.length(); i++) {
            if (i == s.length() || s.charAt(i) == sep) {
                out[k++] = s.substring(from, i);
                from = i + 1;
            }
        }
        return out;
    }
}

A play that isn’t legal is dropped by the rules, and a player who floods the room is flagged for the console. Practice against the bot and the replay viewer use the same screen with another Driver, which is why all three look and behave the same.

The finishing camera

The battle is a com.codename1.gaming 3D scene, and only the renderer knows about it. When the match is over, BattleView swings the camera around the field and down beside the fallen castle before the result screen appears:

Halfway through the finishing camera sweep, the field seen from the side
The camera’s final view, low beside the enemy castle
/// The finishing move: once the match is decided the camera leaves its
/// fixed battle view, swings around the field and comes down low beside
/// the fallen king tower, so the last blow is seen from the ground. The
/// rules are already over, so this is purely the renderer's -- a replay
/// or a spectator sees the same sweep.
private void startFinale(Match m) {
    previewFinale(m.result());
}

/// Runs the sweep toward `winner`'s victory (or a draw) without the match
/// being over: the screenshot tests use it to photograph the finale.
public void previewFinale(int winner) {
    int loser = winner < 0 ? -1 : 1 - winner;
    if (loser < 0) {
        // A draw: look at the bridge between the two sides.
        finaleX = 0f;
        finaleZ = 0f;
    } else {
        Arena.Tower king = arena().king(loser);
        finaleX = wx(king.x, king.y);
        finaleZ = wz(king.x, king.y);
    }
    finale = 0f;
}

private void finale(float dt) {
    finale = Math.min(FINALE_SECONDS, finale + dt);
    float t = finale / FINALE_SECONDS;
    // Ease in and out, so the sweep starts and settles without a jolt.
    float e = t * t * (3f - 2f * t);
    // Orbit from straight behind the player's side to a three-quarter view
    // beside the tower, dropping from the battle height to eye level.
    double angle = Math.toRadians(e * 125f);
    float radius = 42f - e * 22f;
    float height = 33f - e * 24f;
    float cx = finaleX * e;
    float cz = 1.6f + (finaleZ - 1.6f) * e;
    float ex = cx + (float) Math.sin(angle) * radius;
    float ez = cz + (float) Math.cos(angle) * radius * (30f / 42f + e * (1f - 30f / 42f))
            - 1.6f * (1f - e);
    GameCamera cam = getCamera();
    cam.setPosition(ex, height, ez);
    cam.setTarget(cx, 1.6f * e, cz);
}

/// True while the finishing sweep still has frames to show.
public boolean finaleRunning() {
    return finale >= 0f && finale < FINALE_SECONDS;
}

Particle effects and the camera are cosmetic: they read the match and never change it, so they can be changed without touching a replay.

Progression

Levels, experience, chests, daily quests and achievements are all decided on the server, after it has recorded a result. The app only shows what the server says was earned:

@Component
public class Progression {
    /// Chests wait in slots; a win with every slot full earns none.
    public static final int CHEST_SLOTS = 4;
    public static final int WIN_XP = 40;
    public static final int DRAW_XP = 20;
    public static final int LOSS_XP = 15;
    public static final int CROWN_XP = 5;
    public static final int TUTORIAL_XP = 100;
    public static final int TUTORIAL_COINS = 100;
    public static final int LEVEL_UP_COINS = 60;
    public static final int LEVEL_UP_GEMS = 5;

    private final ProgressRepository progress;
    private final ProfileRepository profiles;

    public Progression(ProgressRepository progress, ProfileRepository profiles) {
        this.progress = progress;
        this.profiles = profiles;
    }

    /// Counts a finished battle for one side. Called from inside the
    /// transaction that records the match, with the profile it is about to
    /// save, so the coins and gems this grants are saved with it.
    public RewardDto afterMatch(Profile p, boolean won, boolean draw, int crowns, int cards, int spells)
            throws IOException {
        Progress g = load(p.getUsername());
        int[] before = stats(g);
        g.setMatches(g.getMatches() + 1);
        g.setCards(g.getCards() + cards);
        g.setSpells(g.getSpells() + spells);
        g.setTowers(g.getTowers() + crowns);
        int chests = 0;
        if (won) {
            g.setWins(g.getWins() + 1);
            g.setStreak(g.getStreak() + 1);
            g.setBestStreak(Math.max(g.getBestStreak(), g.getStreak()));
            if (g.getChests() < CHEST_SLOTS) {
                g.setChests(g.getChests() + 1);
                chests = 1;
            }
        } else if (!draw) {
            g.setStreak(0);
        }
        int[] after = stats(g);
        Goals.Goal[] quests = Goals.questsFor(p.getUsername(), g.getQuestDay());
        for (int i = 0; i < 3; i++) {
            int gained = after[quests[i].stat] - before[quests[i].stat];
            setQuest(g, i, Math.min(quests[i].goal, quest(g, i) + gained));
        }
        int xp = (won ? WIN_XP : draw ? DRAW_XP : LOSS_XP) + CROWN_XP * crowns;
        Reward r = new Reward(won ? "Victory!" : draw ? "Draw" : "Defeat");
        r.chests = chests;
        r.xp(g, p, xp);
        unlock(g, p, r);
        progress.save(g);
        return r.dto(g);
    }

    @Transactional(rollbackFor = IOException.class)
    public ProgressDto view(String username) throws IOException {
        Profile p = profile(username);
        // Reading never creates the row: the first battle's result, recorded
        // on the match ticker's thread, may be inserting it at this moment,
        // and a second insert of the same key fails (MySQL waits on the
        // uncommitted row, then reports a duplicate).
        boolean stored = progress.findById(username) != null;
        Progress g = stored ? load(username) : fresh(username);
        if (stored) {
            progress.save(g);
        }
        return dto(g, p);
    }

    @Transactional(rollbackFor = IOException.class)
    public RewardDto claim(String username, String questId) throws IOException {
        Profile p = profile(username);
        Progress g = load(username);
        Goals.Goal[] quests = Goals.questsFor(username, g.getQuestDay());
        int index = -1;
        for (int i = 0; i < 3; i++) {
            if (quests[i].id.equals(questId)) {
                index = i;
            }
        }
        if (index < 0) {
            throw new ResponseStatusException(404, "That is not one of today's quests");
        }
        if ((g.getClaimed() & (1 << index)) != 0) {
            throw new ResponseStatusException(409, "Already claimed");
        }
        Goals.Goal q = quests[index];
        if (quest(g, index) < q.goal) {
            throw new ResponseStatusException(409, "Not finished yet");
        }
        g.setClaimed(g.getClaimed() | (1 << index));
        Reward r = new Reward(q.title);
        r.coins(p, q.coins);
        r.xp(g, p, q.xp);
        unlock(g, p, r);
        progress.save(g);
        profiles.save(p);
        return r.dto(g);
    }

    /// Opens the oldest chest. What is inside grows with the level; every
    /// third chest also holds gems.
    @Transactional(rollbackFor = IOException.class)
    public RewardDto openChest(String username) throws IOException {
        Profile p = profile(username);
        Progress g = load(username);
        if (g.getChests() <= 0) {
            throw new ResponseStatusException(409, "No chest to open");
        }
        int level = Goals.levelOf(g.getXp());
        g.setChests(g.getChests() - 1);
        g.setChestsOpened(g.getChestsOpened() + 1);
        boolean rare = g.getChestsOpened() % 3 == 0;
        Reward r = new Reward(rare ? "Gold Chest" : "Wooden Chest");
        r.coins(p, 40 + 15 * level + (rare ? 40 : 0));
        if (rare) {
            r.gems(p, 4 + level);
        }
        r.xp(g, p, 20);
        unlock(g, p, r);
        progress.save(g);
        profiles.save(p);
        return r.dto(g);
    }

    /// The training battle pays once.
    @Transactional(rollbackFor = IOException.class)
    public RewardDto tutorialDone(String username) throws IOException {
        Profile p = profile(username);
        Progress g = load(username);
        Reward r = new Reward("Training complete");
        if (!g.isTutorialDone()) {
            g.setTutorialDone(true);
            r.coins(p, TUTORIAL_COINS);
            if (g.getChests() < CHEST_SLOTS) {
                g.setChests(g.getChests() + 1);
                r.chests = 1;
            }
            r.xp(g, p, TUTORIAL_XP);
            unlock(g, p, r);
            progress.save(g);
            profiles.save(p);
        }
        return r.dto(g);
    }

    /// Pays for stars won in a Forge mission. The caller has already verified
    /// the result and decided the amounts; this only books them, so experience
    /// still levels the player up and reaches achievements as it does anywhere.
    @Transactional(rollbackFor = IOException.class)
    public RewardDto missionReward(String username, String title, int coins, int xp) throws IOException {
        Profile p = profile(username);
        Progress g = load(username);
        Reward r = new Reward(title);
        if (coins > 0 || xp > 0) {
            r.coins(p, coins);
            r.xp(g, p, xp);
            unlock(g, p, r);
            progress.save(g);
            profiles.save(p);
        }
        return r.dto(g);
    }

    /// Gives a demo account some history -- a few levels, two chests, a
    /// finished quest waiting to be claimed -- so the development server's
    /// home screen shows what a regular player sees, and the tutorial does not
    /// start on every sign-in.
    @Transactional(rollbackFor = IOException.class)
    public void seedDemo(String username) throws IOException {
        Progress g = load(username);
        if (g.getMatches() > 0) {
            return;
        }
        g.setTutorialDone(true);
        g.setXp(420);
        g.setChests(2);
        g.setChestsOpened(1);
        g.setMatches(7);
        g.setWins(4);
        g.setCards(62);
        g.setSpells(9);
        g.setTowers(6);
        g.setStreak(2);
        g.setBestStreak(2);
        Goals.Goal[] today = Goals.questsFor(username, g.getQuestDay());
        g.setQuest0(today[0].goal);
        g.setQuest1(today[1].goal / 2);
        // Unlocked for what the counters say, without paying for it: the
        // profile here is a throwaway, never saved.
        Profile scratch = new Profile();
        unlock(g, scratch, new Reward(""));
        g.setXp(420);
        progress.save(g);
    }

    private Profile profile(String username) throws IOException {
        Profile p = profiles.findById(username);
        if (p == null) {
            throw new ResponseStatusException(404, "No such player");
        }
        return p;
    }

    /// The player's row, created on first use, with today's quests reset when
    /// the day has turned.
    private Progress load(String username) throws IOException {
        Progress g = progress.findById(username);
        long today = Clock.dayOf(Clock.now());
        if (g == null) {
            g = fresh(username);
            progress.insert(g);
        }
        if (g.getQuestDay() != today) {
            g.setQuestDay(today);
            g.setQuest0(0);
            g.setQuest1(0);
            g.setQuest2(0);
            g.setClaimed(0);
        }
        return g;
    }

    private static Progress fresh(String username) {
        Progress g = new Progress();
        g.setUsername(username);
        g.setQuestDay(Clock.dayOf(Clock.now()));
        return g;
    }

    private static int[] stats(Progress g) {
        return new int[] {g.getWins(), g.getMatches(), g.getCards(), g.getSpells(), g.getTowers(),
            g.getBestStreak(), g.isTutorialDone() ? 1 : 0, Goals.levelOf(g.getXp())};
    }

    private static int quest(Progress g, int i) {
        return i == 0 ? g.getQuest0() : i == 1 ? g.getQuest1() : g.getQuest2();
    }

    private static void setQuest(Progress g, int i, int value) {
        if (i == 0) {
            g.setQuest0(value);
        } else if (i == 1) {
            g.setQuest1(value);
        } else {
            g.setQuest2(value);
        }
    }

    private static boolean unlocked(Progress g, String id) {
        return ("," + g.getAchievements() + ",").indexOf("," + id + ",") >= 0;
    }

    /// Unlocks every achievement now reached. Unlocking pays experience, which
    /// can level up, which can reach the level achievement: hence the loop.
    private static void unlock(Progress g, Profile p, Reward r) {
        boolean more = true;
        while (more) {
            more = false;
            int[] s = stats(g);
            for (Goals.Goal a : Goals.ACHIEVEMENTS) {
                if (!unlocked(g, a.id) && s[a.stat] >= a.goal) {
                    g.setAchievements(g.getAchievements().isEmpty() ? a.id : g.getAchievements() + "," + a.id);
                    r.unlocked.add(a.title);
                    r.gems(p, a.gems);
                    r.xp(g, p, a.xp);
                    more = true;
                }
            }
        }
    }

    private static ProgressDto dto(Progress g, Profile p) {
        int level = Goals.levelOf(g.getXp());
        Goals.Goal[] today = Goals.questsFor(g.getUsername(), g.getQuestDay());
        List<QuestDto> quests = new ArrayList<>();
        for (int i = 0; i < 3; i++) {
            Goals.Goal q = today[i];
            quests.add(new QuestDto(q.id, q.title, quest(g, i), q.goal, q.coins, q.xp,
                    (g.getClaimed() & (1 << i)) != 0));
        }
        int[] s = stats(g);
        List<AchievementDto> achievements = new ArrayList<>();
        for (Goals.Goal a : Goals.ACHIEVEMENTS) {
            achievements.add(new AchievementDto(a.id, a.title, a.detail, Math.min(a.goal, s[a.stat]), a.goal,
                    unlocked(g, a.id)));
        }
        return new ProgressDto(level, g.getXp(), Goals.levelStart(level), Goals.levelStart(level + 1),
                g.isTutorialDone(), g.getChests(), p.getCoins(), p.getGems(), g.getStreak(), g.getWins(),
                quests, achievements);
    }

    /// A reward being put together; granting through it keeps the popup and
    /// the balances in step.
    private static final class Reward {
        final String title;
        final List<String> unlocked = new ArrayList<>();
        int coins;
        int gems;
        int xp;
        int chests;
        boolean levelUp;

        Reward(String title) {
            this.title = title;
        }

        void coins(Profile p, int n) {
            p.setCoins(p.getCoins() + n);
            coins += n;
        }

        void gems(Profile p, int n) {
            p.setGems(p.getGems() + n);
            gems += n;
        }

        /// Adds experience; each level gained pays coins, gems and a chest.
        void xp(Progress g, Profile p, int n) {
            int from = Goals.levelOf(g.getXp());
            g.setXp(g.getXp() + n);
            xp += n;
            int to = Goals.levelOf(g.getXp());
            for (int l = from; l < to; l++) {
                levelUp = true;
                coins(p, LEVEL_UP_COINS);
                gems(p, LEVEL_UP_GEMS);
                if (g.getChests() < CHEST_SLOTS) {
                    g.setChests(g.getChests() + 1);
                    chests++;
                }
            }
        }

        RewardDto dto(Progress g) {
            return new RewardDto(title, coins, gems, xp, chests, Goals.levelOf(g.getXp()), levelUp, unlocked);
        }
    }
}

The amounts and the quests are tables in that class, so balancing the economy is a change to one file.

Re-skinning

The menus are ordinary Codename One components, and the game’s look is common/src/main/css/theme.css. Its palette is a set of variables at the top:

--night: #141a2e;
--night-alt: #1f2843;
--stone: #59627a;
--stone-light: #7a849e;
--stone-dark: #3a4157;
--wood: #c8862f;
--wood-light: #f0b04c;
--wood-dark: #7a4a12;
--gold: #ffd34d;
--parchment: #f6e7c1;
--ink: #2b1a07;
--blue: #2f7bff;
--red: #e0453a;
--elixir: #d13cf2;
--elixir-glow: #f7a8ff;
--text: #ffffff;
--text-muted: #b9c0d6;
--danger: #c5292a;

The wooden buttons are a style like any other, a gradient with a border and a shadow, so they need no images and stay sharp at any density:

/* The big carved button: a wooden plank with a darker rim and a lighter top
   edge, pressed in when touched. */
WoodButton {
    background: linear-gradient(to bottom, var(--wood-light), var(--wood));
    border: 0.6mm solid var(--wood-dark);
    border-radius: 2mm;
    color: var(--ink);
    font-family: "native:MainBold";
    font-size: 4mm;
    text-align: center;
    padding: 2.5mm;
    /* The same 2mm inset as the section titles and panels above it. */
    margin: 1.2mm 2mm;
}

WoodButton.pressed {
    background: linear-gradient(to bottom, var(--wood), var(--wood-dark));
    color: var(--parchment);
}

WoodButton.disabled {
    background-color: var(--stone-dark);
    color: var(--stone-light);
}

The characters and buildings are glTF files in common/src/main/resources. They’re KayKit models, packed from the original downloads by tools/pack-models.py; any rigged glTF character with the same animation names can replace one.

Purchases

The shop sells gems through the platform’s store. The app’s Store starts the purchase, and the server grants nothing until it has checked the receipt with Apple or Google:

public interface ReceiptValidator {
    /// The store's transaction id when `receipt` proves a purchase of `sku`, or
    /// null when it does not.
    String verify(String store, String sku, String transactionId, String receipt) throws IOException;
}

The server picks the validator from its configuration:

/// `skirmish.iap.provider` is one of:
///
/// - `store`: receipts are checked with Apple (`skirmish.iap.apple.secret`)
///   and Google Play (`skirmish.iap.google.package` and
///   `skirmish.iap.google.token`). A store with no settings refuses its
///   receipts; the other keeps working.
/// - `simulated`: every receipt is accepted, on a development profile only.
///
/// Unset, it is `simulated` on a development profile and `store` otherwise.
@Bean
public ReceiptValidator receipts(Config config,
        @Value("${skirmish.iap.provider:}") String chosen,
        @Value("${skirmish.iap.apple.secret:}") String appleSecret,
        @Value("${skirmish.iap.google.package:}") String googlePackage,
        @Value("${skirmish.iap.google.token:}") String googleToken) {
    String kind = chosen.trim().length() > 0 ? chosen.trim()
            : config.isDevelopmentProfile() ? "simulated" : "store";
    if ("simulated".equalsIgnoreCase(kind)) {
        if (!config.isDevelopmentProfile()) {
            return new MisconfiguredReceipts("skirmish.iap.provider=simulated grants every "
                    + "purchase for free and is refused on the '" + config.getProfile()
                    + "' profile; set it to store");
        }
        return new SimulatedReceipts();
    }
    if (!"store".equalsIgnoreCase(kind)) {
        return new MisconfiguredReceipts("skirmish.iap.provider is '" + kind
                + "', which is neither store nor simulated");
    }
    if (appleSecret.trim().length() == 0 && (googlePackage.trim().length() == 0
            || googleToken.trim().length() == 0)) {
        return new MisconfiguredReceipts("in-app purchases are not configured; set "
                + "skirmish.iap.apple.secret, or skirmish.iap.google.package and "
                + "skirmish.iap.google.token");
    }
    return new StoreReceipts(appleSecret.trim(), googlePackage.trim(), googleToken.trim());
}

The product identifiers and the ad units are in AppConfig, and the server’s Catalog names the same products. Replace both with your own before release:

/// Google's published test ad units. They always fill, never pay, and are
/// safe to click; replace them with your own AdMob units before release.
public static final String REWARDED_AD_UNIT = "ca-app-pub-3940256099942544/5224354917";
public static final String INTERSTITIAL_AD_UNIT = "ca-app-pub-3940256099942544/1033173712";
public static final String BANNER_AD_UNIT = "ca-app-pub-3940256099942544/6300978111";
/// The store products, as configured in App Store Connect and the Play
/// Console. The server's `Catalog` names the same ones.
public static final String SKU_GEMS_80 = "skirmish.gems_80";
public static final String SKU_GEMS_500 = "skirmish.gems_500";
public static final String SKU_ROYAL_PASS = "skirmish.royal_pass";

Ads

Ads shows an interstitial between battles and a rewarded video for extra coins after one. The units in AppConfig are Google’s test units, which always fill and never pay:

public final class Ads {
    private static final int INTERSTITIAL_EVERY = 3;
    private static int battlesSinceAd;

    private Ads() {
    }

    public static void start() {
        if (AdManager.getProvider() != null) {
            AdManager.initialize(new AdConfig().testMode(true), ready -> { });
        }
    }

    /// Plays a rewarded ad and runs `granted` once the network says it was
    /// watched to the end.
    public static void rewarded(final Runnable granted, final Runnable unavailable) {
        Telemetry.adWatched("rewarded_offer");
        if (!AdManager.isInitialized() || !AdManager.isSupported(AdFormat.REWARDED)) {
            standIn(granted);
            return;
        }
        final RewardedAd ad = new RewardedAd(AppConfig.REWARDED_AD_UNIT);
        // Server-side verification: the network calls the game's server with
        // this user id, so a reward could be granted there and nowhere else.
        String who = Session.user() == null ? "" : Session.user().username();
        ad.setServerSideVerificationOptions(new ServerSideVerificationOptions(who, "coins"));
        ad.setAdListener(new AdListener() {
            @Override
            public void onLoaded() {
                ad.show(reward -> CN.callSerially(granted));
            }

            @Override
            public void onFailedToLoad(AdError error) {
                CN.callSerially(unavailable);
            }
        });
        ad.load();
    }

    /// Called after every battle; shows an interstitial every few.
    public static void betweenBattles(boolean adFree) {
        battlesSinceAd++;
        if (adFree || battlesSinceAd < INTERSTITIAL_EVERY || !AdManager.isInitialized()
                || !AdManager.isSupported(AdFormat.INTERSTITIAL)) {
            return;
        }
        battlesSinceAd = 0;
        final InterstitialAd ad = new InterstitialAd(AppConfig.INTERSTITIAL_AD_UNIT);
        ad.setAdListener(new AdListener() {
            @Override
            public void onLoaded() {
                ad.show();
            }
        });
        ad.load();
    }

    /// A banner for the home screen, or null where there is no network.
    public static Component banner(boolean adFree) {
        if (adFree || !AdManager.isInitialized() || !AdManager.isSupported(AdFormat.BANNER)) {
            return null;
        }
        BannerAd banner = new BannerAd(AppConfig.BANNER_AD_UNIT);
        banner.load();
        return banner;
    }

    private static void standIn(final Runnable granted) {
        Dialog d = new Dialog("Sponsored", new com.codename1.ui.layouts.BorderLayout());
        d.setUIID("AdDialog");
        d.add(com.codename1.ui.layouts.BorderLayout.CENTER,
                new com.codename1.components.SpanLabel("An ad would play here. Install an ad network cn1lib "
                        + "to show real ones."));
        d.setTimeout(1500);
        d.show();
        granted.run();
    }
}

A rewarded video is only a request: the server pays the coins, up to three times a day.

Push notifications and analytics

The server tells a player when a chest is ready or a friend has challenged them. It sends through the Codename One push service once the app’s push key is set:

/// The app's push key, from the Push section of the Codename One console.
/// Empty, the app registers for no notifications: everything still works,
/// and a phone in a pocket simply hears nothing until it is opened.
public static final String PUSH_APP_KEY = "";
/// `skirmish.push.provider` is one of:
///
/// - `cloud`: the Codename One push service. It needs `skirmish.push.app`,
///   the app's push id, and `skirmish.push.key`, a server key with the push
///   scope; both are in the Push section of the Codename One console.
/// - `console`: prints each notification, on a development profile only.
/// - `recording`: keeps each notification for the tests to read, on a
///   development profile only.
///
/// Unset, it is `recording` on the `test` profile, `console` on the other
/// development profiles and `cloud` otherwise. A server whose push settings
/// are missing still starts and still serves deliveries: it logs a configuration
/// error at start-up and again for every notification it could not send.
@Bean
public PushSender pushSender(Config config,
        @Value("${skirmish.push.provider:}") String chosen,
        @Value("${skirmish.push.app:}") String app,
        @Value("${skirmish.push.key:}") String key,
        @Value("${skirmish.push.url:}") String url) {
    String kind = chosen.trim().length() > 0 ? chosen.trim()
            : "test".equals(config.getProfile()) ? "recording"
            : config.isDevelopmentProfile() ? "console" : "cloud";
    if ("console".equalsIgnoreCase(kind) || "recording".equalsIgnoreCase(kind)) {
        if (!config.isDevelopmentProfile()) {
            return new MisconfiguredPushSender("skirmish.push.provider=" + kind
                    + " sends nothing and is refused on the '" + config.getProfile()
                    + "' profile; set it to cloud");
        }
        return "console".equalsIgnoreCase(kind) ? new ConsolePushSender()
                : new RecordingPushSender();
    }
    if (!"cloud".equalsIgnoreCase(kind)) {
        return new MisconfiguredPushSender("skirmish.push.provider is '" + kind
                + "', which is none of cloud, console or recording");
    }
    String missing = missing("skirmish.push.", new String[] {"app", app, "key", key});
    if (missing.length() > 0) {
        return new MisconfiguredPushSender(unset("push", "cloud", missing));
    }
    try {
        CloudPush push = new CloudPush(app.trim(), key.trim());
        return new CloudPushSender(url.trim().length() > 0 ? push.withEndpoint(url.trim())
                : push);
    } catch (IllegalArgumentException err) {
        return new MisconfiguredPushSender("push provider 'cloud' is misconfigured: "
                + err.getMessage());
    }
}

Telemetry records what a game is measured by: sign-ups and sign-ins, every battle and its result, the cards played, deck changes, spending, purchases, ad rewards and shares. It sends nothing unless the player allows it in Settings.

Moderation

The console is part of the same app, shown only to an account with the admin role. The server flags players on its own, as well as on reports:

@Component
public class Flags {
    public static final String REPORT = "REPORT";
    public static final String AUTO = "AUTO";

    private final FlagRepository flags;
    private final ProfileRepository profiles;
    private final MatchRecordRepository matches;
    private final GamePushes pushes;
    private final AdminChannel channel;
    private final List<String> adminNames;

    public Flags(FlagRepository flags, ProfileRepository profiles, MatchRecordRepository matches,
            GamePushes pushes, AdminChannel channel, @Value("${skirmish.admin.notify:}") String adminNames) {
        this.flags = flags;
        this.profiles = profiles;
        this.matches = matches;
        this.pushes = pushes;
        this.channel = channel;
        this.adminNames = com.codenameone.examples.skirmish.account.Lists.split(adminNames);
    }

    /// A player reporting the opponent of a match they played.
    @Transactional(rollbackFor = IOException.class)
    public FlagDto report(String reporter, String matchId, String reason) throws IOException {
        MatchRecord m = matchId == null ? null : matches.findById(matchId);
        if (m == null || !(reporter.equals(m.getPlayer0()) || reporter.equals(m.getPlayer1()))) {
            throw new ResponseStatusException(404, "No such match");
        }
        String accused = reporter.equals(m.getPlayer0()) ? m.getPlayer1() : m.getPlayer0();
        if (accused.length() == 0) {
            throw new ResponseStatusException(400, "The training bot cannot be reported");
        }
        if (flags.countByReporterAndMatchId(reporter, matchId) > 0) {
            throw new ResponseStatusException(409, "You already reported this match");
        }
        String why = Text.required(reason, 255, "what happened");
        return raise(accused, REPORT, why, matchId, reporter);
    }

    /// Something the server noticed itself. Transactional on its own because
    /// the demo seeding calls it with no transaction open; inside one (a match
    /// being recorded) it joins that one.
    @Transactional(rollbackFor = IOException.class)
    public void auto(String username, String reason, String matchId) {
        try {
            raise(username, AUTO, reason, matchId == null ? "" : matchId, "");
        } catch (IOException | RuntimeException failed) {
            System.err.println("[flags] could not flag " + username + ": " + failed);
        }
    }

    FlagDto raise(String username, String kind, String reason, String matchId, String reporter)
            throws IOException {
        Flag f = store(username, kind, reason, matchId, reporter);
        channel.flagged(username, reason);
        pushes.flagRaised(adminNames, username, reason);
        return toDto(f, profiles.findById(username));
    }

    @Transactional(rollbackFor = IOException.class)
    Flag store(String username, String kind, String reason, String matchId, String reporter)
            throws IOException {
        Flag f = new Flag();
        f.setId(Ids.next());
        f.setUsername(username);
        f.setKind(kind);
        f.setReason(reason);
        f.setMatchId(matchId);
        f.setReporter(reporter);
        f.setCreatedAt(Clock.now());
        f.setPending(true);
        flags.insert(f);
        Profile p = profiles.findById(username);
        if (p != null && !p.isFlagged()) {
            p.setFlagged(true);
            profiles.save(p);
        }
        return f;
    }

    @Transactional(rollbackFor = IOException.class)
    public FlagDto resolve(String admin, String id) {
        Flag f = flags.findById(id);
        if (f == null) {
            throw new ResponseStatusException(404, "No such flag");
        }
        f.setPending(false);
        f.setResolvedBy(admin);
        flags.save(f);
        Profile p = profiles.findById(f.getUsername());
        if (p != null && flags.countByUsernameAndPendingTrue(f.getUsername()) == 0) {
            p.setFlagged(false);
            profiles.save(p);
        }
        return toDto(f, p);
    }

    @Transactional(readOnly = true)
    public List<FlagDto> list(boolean pending) {
        List<FlagDto> out = new ArrayList<FlagDto>();
        for (Flag f : flags.inState(pending, new PageRequest(0, 200))) {
            out.add(toDto(f, profiles.findById(f.getUsername())));
        }
        return out;
    }

    @Transactional(readOnly = true)
    public List<FlagDto> of(String username) {
        List<FlagDto> out = new ArrayList<FlagDto>();
        Profile p = profiles.findById(username);
        for (Flag f : flags.of(username, new PageRequest(0, 50))) {
            out.add(toDto(f, p));
        }
        return out;
    }

    public long pendingCount() {
        return flags.countByPendingTrue();
    }

    static FlagDto toDto(Flag f, Profile p) {
        return new FlagDto(f.getId(), f.getUsername(), p == null ? f.getUsername() : p.getDisplayName(),
                f.getKind(), f.getReason(), f.getMatchId(), f.getReporter(), f.getCreatedAt(), f.isPending());
    }
}

Every action an operator takes goes through Moderation, which records it with its reason and removes a suspended or banned player from any battle they’re in.

The admin account sees Admin console on the home screen. Its tabs are:

The admin console’s overview: players online, matches today, purchases and gems sold
  • Overview counts players online, matches and purchases.

  • The Live matches tab lists the battles being played, and any of them can be watched as it happens.

  • The Flags tab lists reports from players and signals the server raises on its own: a player who keeps leaving matches, whose game state keeps diverging from the server’s, or who floods it with invalid plays.

  • Players finds an account by name or e-mail and shows its matches, purchases and moderation history.

  • Promote is where the game is marketed, as described in the next section.

  • Forge lists reported and hidden packs, and the models and images players uploaded.

The flags tab: a report and an automatic flag, each with the reason and the match it came from

An operator can warn, mute, suspend or ban a player, reset their trophies or revoke purchased items, and the history of actions sits beside the player’s numbers.

A player in the console, with their numbers, the moderation buttons and the history of actions

Inviting friends and promoting the game

Growth uses what Codename One already has, and the game counts nothing of its own. An invitation link comes from com.codename1.analytics.invite. The app makes it, with a campaign, a channel and the player’s code as its payload:

/// Opens the share sheet with the player's invite link.
public static void share(InviteDto mine) {
    Invite invite = Invites.create(InviteRequest.create()
            .campaign(CAMPAIGN)
            .channel("share_sheet")
            .payload(mine.code())
            .title("Join me in Skirmish")
            .description("A real-time card battle. Build a deck and fight me.")
            .build());
    Invites.share(invite, "Battle me in Skirmish! You get " + mine.friendCoins()
            + " coins to start with: " + invite.getUrl());
    Telemetry.inviteSent(CAMPAIGN);
}

The friend’s app reads it back on its first start, whether the link opened the installed app or went through the store first:

/// From the app's start(): pick up an invite this launch carries.
public static void start() {
    Invites.setInviteListener(new InviteListener() {
        @Override
        public void inviteReceived(InviteAttribution a) {
            if (a.getPayload() == null || a.getPayload().length() == 0) {
                // A campaign link from the console: attributed in
                // analytics, nobody to reward.
                return;
            }
            Preferences.set(PENDING + "code", a.getPayload());
            Preferences.set(PENDING + "campaign", a.getCampaign() == null ? "" : a.getCampaign());
            Preferences.set(PENDING + "channel", a.getChannel() == null ? "" : a.getChannel());
            Preferences.set(PENDING + "match", a.getMatchType() == null ? "" : a.getMatchType());
            if (Session.user() != null) {
                Ui.later(Friends::claimPending);
            }
        }

        @Override
        public void attributionUnavailable(String reason) {
            // The ordinary case: most installs were not invited.
        }
    });
    Invites.checkForInvite();
}

The Friends screen shows the player’s invite code and what an invitation pays, and its Invite friends button opens the share sheet. A friend who opens the link starts with extra coins, and the screen lists everyone who joined through the player’s link and whether they have played yet.

From then on, everything the friend’s app reports through Codename One analytics carries the campaign and the referrer, so the invite funnel, retention and revenue per campaign are read in the Codename One console. Telemetry adds the player’s level and trophy band as user properties, which the console slices every report by.

The server keeps only what’s part of the game: the codes, who joined through whom, and the rewards. A claim is refused for an account older than a week, for a player’s own code and for a second code, and the inviter is paid once the friend has played a battle, for at most ten friends:

/// The friend's side: the app found an invite and the player is signed in.
@Transactional(rollbackFor = IOException.class)
public RewardDto claim(String username, InviteClaimDto claim) throws IOException {
    String code = claim == null || claim.code() == null ? "" : claim.code().trim();
    ReferralCode owner = code.length() == 0 ? null : codes.findById(code);
    if (owner == null) {
        throw new ResponseStatusException(404, "That invite is not known");
    }
    String inviter = owner.getUsername();
    if (inviter.equals(username)) {
        throw new ResponseStatusException(400, "That is your own invite");
    }
    if (referrals.findById(username) != null) {
        throw new ResponseStatusException(409, "An invite was already counted for you");
    }
    Referral back = referrals.findById(inviter);
    if (back != null && back.getInviter().equals(username)) {
        throw new ResponseStatusException(400, "You invited that player");
    }
    Profile p = profiles.findById(username);
    if (p == null || p.getCreatedAt() < Clock.now() - CLAIM_DAYS * Clock.DAY) {
        throw new ResponseStatusException(400, "Invites count for new players only");
    }
    Referral r = new Referral();
    r.setInvitee(username);
    r.setInviter(inviter);
    r.setCode(code);
    r.setCampaign(clip(claim.campaign(), 40));
    r.setChannel(clip(claim.channel(), 40));
    r.setMatchType(clip(claim.match(), 20));
    r.setState(JOINED);
    r.setJoinedAt(Clock.now());
    referrals.insert(r);
    Profile from = profiles.findById(inviter);
    pushes.friendJoined(inviter, p.getDisplayName());
    return progression.missionReward(username,
            "A gift from " + (from == null ? "your friend" : from.getDisplayName()), FRIEND_COINS, 0);
}

The Promote tab in the console is where the operator works. It opens the analytics console, and it sends Codename One push notifications to a segment of players: everyone, players away for some days, new players, or those who came through an invitation. A broadcast is counted before it’s confirmed, the server sends at most five a day, and each one carries a campaign name in its data, which the app reports when the notification is tapped:

@Transactional(rollbackFor = IOException.class)
public BroadcastDto broadcast(String admin, BroadcastDto b) throws IOException {
    check(b, true);
    long now = Clock.now();
    if (broadcasts.countBySentAtGreaterThan(now - Clock.DAY) >= MAX_BROADCASTS_PER_DAY) {
        throw new ResponseStatusException(429, "At most " + MAX_BROADCASTS_PER_DAY
                + " broadcasts a day: players who are pushed too often switch notifications off");
    }
    String id = Ids.next();
    String campaign = b.campaign() == null || b.campaign().trim().length() == 0
            ? "broadcast-" + id.substring(0, 8) : b.campaign().trim();
    int reached = pushes.broadcast(audienceOf(b), id, b.title().trim(), b.body().trim(), screen(b.screen()),
            campaign);
    Broadcast row = new Broadcast();
    row.setId(id);
    row.setSegment(b.segment());
    row.setDays(b.days());
    row.setTitle(b.title().trim());
    row.setBody(b.body().trim());
    row.setScreen(screen(b.screen()));
    row.setCampaign(campaign.length() > 40 ? campaign.substring(0, 40) : campaign);
    row.setTargets(reached);
    row.setSentBy(admin);
    row.setSentAt(now);
    broadcasts.insert(row);
    record(admin, "BROADCAST", b.segment() + ": " + b.title().trim());
    return dto(row);
}

The same tab sets the spotlight, a banner at the top of every player’s home screen, and makes campaign links for adverts and posts: invite links without a player’s code, which reward nobody and attribute everyone they bring in.

The store’s rating prompt is Codename One’s AppReview, asked after a win and only of a player who keeps coming back:

// The store's own rating prompt, asked only of a player who has come
// back: five launches over three days, and never twice in a month.
// ResultForm asks after a win, the moment a player is happiest.
AppReview.getInstance().setMinimumLaunches(5).setMinimumDaysInstalled(3)
        .setDaysBetweenPrompts(30).registerSession();
The admin console in a desktop window, with its tabs in a sidebar on the left and the overview’s numbers beside them

On a desktop or a tablet, the console puts its tabs in a sidebar:

// On a desktop or a tablet the console is a side bar and a page, the
// way an operator's tools usually are; on a phone, tabs along the top.
if (wide()) {
    tabs.setTabPlacement(Component.LEFT);
    tabs.setTabTextPosition(Component.RIGHT);
} else {
    // Nine tabs do not fit across a phone: they scroll sideways.
    tabs.getTabsContainer().setScrollableX(true);
    tabs.setTabTextPosition(Component.BOTTOM);
}

run-e2e.sh runs its tests a second time on Linux with the simulator as a desktop window, and that pass captures the console’s tabs.

Reports, queries and engagement pipelines

The console is an operator’s tool, so it doesn’t wear the game’s theme. Its styles are a separate set of Desk UIIDs in the same theme.css: a light page, cards with thin borders, and one accent colour. Every form puts its labels in a right-aligned column of one width, so the fields start at the same place:

/// A label and its field on one line, the label in a column of its own,
/// right aligned, so every field of a form starts at the same place.
static Container field(String label, Component input) {
    Label l = new Label(label, "DeskLabel");
    l.setPreferredW(CN.convertToPixels(AdminConsole.wide() ? WIDE_LABEL_MM : LABEL_MM));
    Container c = new Container(new BorderLayout());
    c.setUIID("DeskFieldRow");
    c.add(BorderLayout.WEST, l).add(BorderLayout.CENTER, input);
    return c;
}

The Overview tab draws its trends with Codename One’s charts package: daily players and new accounts, battles, gems sold, the funnel from joining to paying, retention, and the trophy ladder. The server’s Insights builds them from the game’s own records, accounts, battles and purchases, so they need no extra tracking. What players do inside the app, the screens they open and the pushes they read, is in Codename One analytics, one button away.

The Overview tab’s trends: line charts of daily players and battles, a bar chart of the funnel, and gems sold over the last 30 days

The Query tab finds players by conditions on what the server knows about them: days since they last played or joined, trophies, battles, wins, gems, coins, purchases and friends invited. Make a pipeline turns the same conditions into an automated pipeline.

The Query tab: a condition that purchases equal 0, the Run button, and the four matching players with their trophies, battles and gems

A pipeline is a rule of the form "players who match these conditions get this": a push that opens a chosen screen, or gems. Each player is reached at most once per cool-down, and the server runs every enabled pipeline once an hour, on one server at a time:

/// Every enabled pipeline, once an hour, on one server at a time.
/// Transactional itself: a call to [#run] from here is a call on `this`,
/// which does not go through the transaction wrapper.
@Scheduled(fixedDelay = EVERY, initialDelay = 5L * 60 * 1000, lock = "skirmish-pipelines")
@Transactional(rollbackFor = IOException.class)
public void runAll() throws IOException {
    for (Pipeline p : pipelines.byName()) {
        if (p.isEnabled()) {
            run(p.getId());
        }
    }
}
The Pipelines tab listing Win back, which is on, and Keen, never paid, which is off, each with its conditions, action, cool-down and players reached

The demo seeds two. Win back pushes "Your troops miss you" to a player away for a week who has played before, and Keen, never paid gives 20 gems to a player with ten battles and no purchases. The Count players button shows how many players match today before a pipeline is saved.

Editing Win back: name, an on switch, two conditions, the push action with its title, message and screen, a 14 day cool-down, and Cancel, Count players and Save

Tutorial: Growing your game

A game that’s finished still needs players, and players who come back. This tutorial walks through the levers Skirmish already has, in the order a new game usually needs them, and what to read in the analytics console to know whether each one is working. Everything here is measured by Codename One analytics, so nothing needs a dashboard of its own.

Step 1: Switch the measurements on

  1. Turn on analytics for the app in the Codename One console. Telemetry sends through CodenameOneAnalyticsProvider once the player agrees on the consent screen; without that agreement nothing leaves the device.

  2. Set AppConfig.PUSH_APP_KEY to the app’s push key from the console’s Push section. Without it the game works, but sends no notifications.

  3. Run a few battles in the simulator. The events, battle_start, battle_end, share and the rest, appear in the console’s event list, and every report can be sliced by the level_band and trophy_band user properties.

Step 2: Read the funnel

The first report to open is the funnel a new player walks: sign_up, the training battle’s reward, the first battle_end with a win, deck_saved, purchase. The step where most players stop is the one to work on first:

  • Players who sign up and never battle need a shorter way in. The training battle is in Tutorial; its lines are in a MissionDef, and a shorter one is a smaller list.

  • Players who battle once and leave need a reason to return. That’s what the next steps are for.

  • Players who play but never spend may need a cheaper first offer. The prices are in the server’s Catalog.

Step 3: Bring players back with push

Retention, the share of players who return a day and a week after they installed, is the number most games live or die by. Push brings players back, but only when it’s rare and worth reading.

  1. Sign in as the administrator, open the console, and choose Promote.

    The Promote tab on a desktop: the button that opens Codename One analytics, the player numbers, and the top of the push form
  2. Under Broadcast a push, choose who it’s for. Away for a while with 3 days reaches players who stopped recently and still remember the game.

  3. Write a title and a message about something new, and choose the screen a tap opens: the shop for a sale, the Forge for new content.

  4. Give it a campaign name, such as comeback-march. The app reports a push_open event with that name when the notification is tapped, and the campaign is how the console tells broadcasts apart.

  5. Choose Count players, then Send, and confirm.

    The push form filled in: everyone, a title and message about season two, the Home screen to open, and the campaign season-two
    The confirmation before a push goes out, naming its title and the number of players it reaches
The Promote tab on a phone: the analytics button, the player numbers and the push form with its audience, days and title

A day later, compare that campaign’s push_open count with the number of players it was sent to, and the cohort’s return rate with the week before. A broadcast that nobody opens is worse than none: it teaches players to ignore the next one. The server allows five a day for that reason, and MAX_BROADCASTS_PER_DAY in Promotion is the place to lower it.

Step 4: Grow through invitations

An invitation is the cheapest player a game gets, because a friend is already playing. The rewards decide how often players send one:

  1. The amounts are constants at the top of the server’s Referrals: FRIEND_COINS for the friend, INVITER_COINS and INVITER_XP for the player who invited them, and MAX_REWARDED for how many friends pay.

  2. The inviter is paid only once the friend has played a battle, so a reward can’t be farmed with empty accounts, and the friend must claim within a week of joining.

  3. In the console, compare the share events with content set to invite against the installs attributed to the friends campaign and their first_battle and first_win conversions. When many links are shared and few bring anyone in, raise the friend’s reward; when few are shared at all, make the Friends screen easier to reach.

The Friends screen a player invites from, with the rewards spelled out above the code

Set the invite.domain build hint to a domain of your own before release, so the links carry the game’s name rather than the default service.

An advert, a post or a newsletter gets a link from Campaign link on the same tab. It’s an invitation without a player’s code: it opens the store for anyone without the game, rewards nobody, and every player it brings in is reported under its campaign. Use one name per channel, such as newsletter-may and video-launch, and the console compares what each one brought in, down to revenue.

Step 6: The home screen spotlight

The banner at the top of every player’s home screen is the quickest message there is: no notification permission, no store review. Set it under Home spotlight with a line of text and either a Forge pack, which opens that pack, or a screen. Use it for what’s new this week; take it down by saving an empty banner.

The spotlight editor under the push form: the banner text, an optional Forge pack, the screen it opens, and Save spotlight
The home screen in a desktop window, with the new banner announcing the Frost Arena under the hero
The spotlight banner on a player’s home screen, under the hero

Step 7: Ratings

A store’s rating is what a stranger sees first. AppReview asks only players who’ve launched the game five times over three days, never more than once a month, and only after a win. A low score goes to feedback rather than the store. Tune those numbers in Skirmish.runApp.

The training battle

Tutorial is a script of steps run over an ordinary practice battle. Each step freezes the clock, shows Elda’s line and, if it asks for something, the finger and the one card that can be played. Changing the lesson means changing the list of steps.

The mentor, Elda, explains one idea at a time while the clock is frozen: the red towers are the targets, cards cost elixir, and elixir refills by itself. Then she asks for something and a finger shows how. It points at the card to pick, then slides from the card to the spot to drop it, and only that card can be picked while she’s teaching it.

The training battle with Elda’s speech bubble welcoming the player and a Next button
A finger pointing at the Squire card in the hand while Elda asks the player to pick it up
The finger dragging from the hand to a glowing circle by the left bridge

The lesson covers attacking, defending when the enemy sends troops, and throwing a spell at a tower. Finishing the battle against a slow opponent pays once: coins, experience, a chest and the first achievement. A player can replay the lesson from How to play on the home screen.

Elda warning that enemies are coming and asking the player to pick the Twin Archers
The finger dragging the Fire Orb spell toward the red tower

The app can also drive itself through its own screens: Tour signs in, collects rewards, visits the deck and plays a battle, which is how the promotional video is recorded. Any Codename One app can be driven the same way without code of its own, through its built-in MCP server; see the MCP headless API.

Content packs and the Forge

Everything the Forge edits is data in rules: a ContentPack holds cards (CardDef), arenas (Arena), missions (MissionDef) and campaigns (CampaignDef), and PackCodec reads and writes it as JSON. The classic game is the same model: Arena.CLASSIC and the built-in cards describe the field and the cards the battle always had, so the golden replays still pass. A mission is played by MissionRun, which builds a Match from the pack’s arena and cards, plays the scripted waves and the bot, and decides the stars.

The Forge is the game’s own level editor, in the same app on every platform. Players build packs of new cards, arenas, missions and campaigns, share them with a link, and play what others made. The game’s designers use the same editor: a pack an operator marks official appears first, for everyone. Forge on the home screen lists the packs. Official comes with the game, Popular and New are what players published, and Yours holds a player’s own drafts and packs.

The Forge: tabs for official, popular, new and the player’s own packs, a Create a pack button and the official campaign

Playing a pack

A pack shows its campaigns, each a list of missions that open one after another, and the stars won in each. A mission can be anything the author wrote: hold the castle for two minutes, win three crowns against waves of enemies, or beat a bot with a fixed deck. Its story is told during the battle, and a line that stops the clock waits for Next, the way the training battle does.

A mission pays up to three stars. The first is for winning, and the second and third are for what the author asked, such as keeping towers standing or winning before a set second. Rewards come once per star, and the server plays every mission again before it believes the stars, as Results are played again shows.

An official pack: its campaign with the first mission open and the others locked, and buttons to share, copy the link, remix, rate or report
A mission under way, with Elda telling the story in a bubble and a Next button

Share sends a link that opens the pack in the app, and Remix copies a pack into a new one of the player’s own to change.

Making a pack

Create a pack starts one with a single mission against the bot, ready to play. The pack editor lists its cards, arenas, missions and campaigns, shows any problem that would stop it from being published, and saves a draft that only its author sees until Publish.

The pack editor: the name and description, then sections for cards, arenas, missions and campaigns, each with a button to add one
  • Cards start from any card in the game and change its cost, hit points, damage, speed, range, targets and what it spawns. A card can look like any of the game’s characters, or like a rigged glTF model imported from a .glb file, with its animations matched to walking, attacking and dying on a turntable preview. An imported model is visible to the author and to anyone they share the pack with straight away, and to everyone once a moderator has approved it.

  • Arenas are drawn on a grid: castles, towers, rocks and trees placed with a tap, mirrored to the enemy’s half as they’re placed, on a field of any size with one to three lanes and a frost or desert theme.

  • Missions pick an arena, the goal, the length of the battle, each side’s elixir and deck, the waves of enemies that arrive at set seconds, the lines of the story, the stars and the reward. Test play plays the mission at once.

  • Campaigns put missions in order, with a story before the first and after the last.

The card editor: a turntable preview of the character with Walk, Attack and Idle buttons, and fields for the name, kind, elixir and hit points
The arena editor: tools for the king, towers, rocks and trees above the field, with both castles and the river drawn on the grid

Limits

A pack comes from a stranger, so Validator checks every number in it before the server publishes it and before the app loads it. The limits keep a pack from stalling a phone, a server or the economy, not from being odd:

public static final int MAX_CARDS = 40;
public static final int MAX_ARENAS = 10;
public static final int MAX_MISSIONS = 60;
public static final int MAX_CAMPAIGNS = 10;
public static final int MAX_CAMPAIGN_MISSIONS = 30;
public static final int MAX_ASSETS = 40;
public static final int MAX_DIALOG = 60;
public static final int MAX_TEXT = 280;
public static final int MAX_LONG_TEXT = 1200;
/// A model travels base64 encoded in one request, under the server's 8 MB body limit.
public static final int MAX_MODEL_BYTES = 5 * 1024 * 1024;
public static final int MAX_IMAGE_BYTES = 1024 * 1024;
/// What any one mission may pay, on top of the server's caps for packs that
/// are not official.
public static final int MAX_REWARD_COINS = 500;
public static final int MAX_REWARD_XP = 200;

An uploaded model is checked by GlbInspector on the server: the size, the .glb header, and caps on its nodes, meshes, joints, and animations. It’s stored by its SHA-256 and waits in the console’s Forge tab until a moderator approves it.

Results are played again

The app plays a mission locally and sends its replay. The replay names the pack, its content hash and the mission, so the server finds the exact version that was played, plays the player’s commands again with MissionRun.verify and pays only for stars it saw won, and only once:

Match played;
try {
    played = MissionRun.verify(pack, ref.mission, replay);
} catch (IllegalArgumentException e) {
    throw new ResponseStatusException(400, e.getMessage());
}
int won = mission.stars(played);

String key = Ids.sha256(username + "|" + p.getId() + "|" + ref.mission);
MissionStars row = stars.findById(key);
if (row == null) {
    row = new MissionStars();
    row.setId(key);
    row.setUsername(username);
    row.setPackId(p.getId());
    row.setMissionId(ref.mission);
    stars.insert(row);
}
row.setPlays(row.getPlays() + 1);
row.setStars(Math.max(row.getStars(), won));
int newStars = Math.max(0, won - row.getPaidStars());
row.setPaidStars(row.getPaidStars() + newStars);
row.setUpdatedAt(Clock.now());
stars.save(row);
p.setPlays(p.getPlays() + 1);
packs.save(p);

int coins = mission.rewardCoins * newStars / 3;
int xp = mission.rewardXp * newStars / 3;
if (username.equals(p.getOwner())) {
    // Testing your own mission is free, and pays nothing.
    coins = 0;
    xp = 0;
} else if (!p.isOfficial()) {
    coins = Math.min(coins, COMMUNITY_MISSION_COINS * newStars / 3);
    xp = Math.min(xp, COMMUNITY_MISSION_XP * newStars / 3);
    coins = spendDailyAllowance(username, coins);
}

The app’s side starts the battle, fetches the pack’s files first, and posts the result when it ends; a test play from the editor posts nothing:

public final class MissionPlay {
    private MissionPlay() {
    }

    public static void start(final ContentPack pack, final String missionId, final boolean test,
            final Runnable back) {
        List<String> files = new ArrayList<String>();
        for (CardDef c : pack.cards) {
            if (c.model() != null && c.model().startsWith("asset:")) {
                files.add(c.model().substring(6));
            }
        }
        ForgeFiles.fetch(files, () -> Ui.later(() -> battle(pack, missionId, test, back)), Ui::failed);
    }

    private static void battle(final ContentPack pack, String missionId, final boolean test, final Runnable back) {
        MissionDef m = pack.mission(missionId);
        CardSet cards = pack.cardSet();
        CardArt.use(cards);
        MissionDriver driver = new MissionDriver(pack, missionId, System.currentTimeMillis(),
                m.playerDeck == null ? ownDeck(cards) : null);
        BattleForm.mission(driver, new BattleForm.MissionEnd() {
            @Override
            public void finished(MissionDriver d) {
                CardArt.use(null);
                int stars = d.run().stars();
                if (test) {
                    Dialog.show(stars > 0 ? "Won" : "Lost", stars > 0 ? PackScreen.starText(stars)
                            + "  -- a test battle counts for nothing." : "The enemy won this one.", "OK", null);
                    back.run();
                    return;
                }
                Api.forge().result(new MissionResultDto(d.run().replay().encode()), Net.to(
                        outcome -> Ui.later(() -> Rewards.show(outcome.reward(), back)), (status, message) -> {
                            Ui.failed(status, message);
                            back.run();
                        }));
            }

            @Override
            public void left(MissionDriver d) {
                CardArt.use(null);
                back.run();
            }
        });
    }

    /// The player's saved deck when the pack's cards allow it, else the
    /// starter deck: a mission that leaves the deck to the player may still
    /// run on a pack that changed which cards there are.
    private static String[] ownDeck(CardSet cards) {
        if (Session.user() != null && Session.user().deck() != null) {
            List<String> d = Session.user().deck();
            String[] deck = d.toArray(new String[d.size()]);
            if (cards.isValidDeck(deck)) {
                return deck;
            }
        }
        return Deck.starter();
    }
}

Shipping official content

The official pack is seeded from Samples.official() when the server starts. A designer can instead build a campaign in the Forge, publish it, and mark it official in the console; an official pack is listed first and pays the rewards its missions set, while a community pack’s rewards are capped per star and per day. The amounts are constants at the top of the server’s Forge class.

Tests

rules has the determinism tests and the golden replays; backend plays whole online matches over its sockets and checks receipts, progression, moderation and the Forge’s publishing and mission results, the invite rewards and their refusals, and the broadcast limits; run-e2e.sh drives the app through its screens in the simulator against a running server and takes the screenshots in this guide:

mvn -pl rules test
mvn -pl rules,shared,backend -Dcodename1.platform=backend test
./run-e2e.sh