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:
| Account | What it’s for |
|---|---|
A player with some history: level 3, two chests waiting and a daily quest ready to claim. | |
A second player, to battle the first from another simulator or device. | |
A player the server has already flagged, so the console has something to review. | |
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.


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.

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.

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.

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.

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.

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.

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 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.

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 modules
| Module | What it holds |
|---|---|
| The game itself: the cards, the arena, the units, the bot and replays. It uses
nothing but a handful of |
| The contract: the data transfer objects and the |
| The app: the 3D battle, the menus, the store, ads, analytics, push and the admin console. |
| The server: accounts, decks, matchmaking, the authoritative match rooms, replays, progression, receipts, push and moderation. |
| 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, andFixedMathsupplies 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 fromRng, 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:


/// 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:

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.

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.

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();

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 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.

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 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.

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
Turn on analytics for the app in the Codename One console.
Telemetrysends throughCodenameOneAnalyticsProvideronce the player agrees on the consent screen; without that agreement nothing leaves the device.Set
AppConfig.PUSH_APP_KEYto the app’s push key from the console’s Push section. Without it the game works, but sends no notifications.Run a few battles in the simulator. The events,
battle_start,battle_end,shareand the rest, appear in the console’s event list, and every report can be sliced by thelevel_bandandtrophy_banduser 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 aMissionDef, 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.
Sign in as the administrator, open the console, and choose Promote.

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.
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.
Give it a campaign name, such as
comeback-march. The app reports apush_openevent with that name when the notification is tapped, and the campaign is how the console tells broadcasts apart.Choose Count players, then Send, and confirm.



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:
The amounts are constants at the top of the server’s
Referrals:FRIEND_COINSfor the friend,INVITER_COINSandINVITER_XPfor the player who invited them, andMAX_REWARDEDfor how many friends pay.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.
In the console, compare the
shareevents withcontentset toinviteagainst the installs attributed to thefriendscampaign and theirfirst_battleandfirst_winconversions. 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.

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.
Step 5: Campaign links for the world outside
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.



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 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.


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.

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.


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.

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
.glbfile, 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.


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