Splat is a first-person team paintball shooter, four against four, that takes itself with the same gravity as a dad in a borrowed tactical vest. Every match opens with a grim mission briefing ("Operation: Borrowed Bedsheet"). Every hit lands a big red PAINTED stamp, and every player who is out may try to wipe the paint off before the referee notices. Under the jokes it’s a real-time shooter that most game frameworks would split across an engine, a networking product and a separate server:
The server referees every match. Phones send their inputs, the server runs the match and sends snapshots back, and each phone predicts its own player so the marker fires with no wait (The server referees, the phone predicts).
A shot from a phone with a slow connection still lands where it was aimed: the rules make up for the shooter’s lag (Making up for lag).
Bots think inside the rules, so a practice match, a match the server fills with bots and a replay all play the same way from the same seed.
Everything is ordinary Codename One code in one project: the 3D field drawn with
GameView, the HUD as a component layered over it, and the server as a Codename One
Backend. Splat shares the module layout, the accounts, push notifications, the shop and
the operations console with Dash and
Skirmish. This chapter shows where each part lives and
what to change to make the game yours.
Running it
The project is in scripts/splat, 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 the app in the simulator:
mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase
For a device build, generate the Xcode project:
mvn package -DskipTests -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source -Dopen=false
The rules have their own tests, the server has its own, and run-e2e.sh drives the app
through its real screens against a running server:
mvn -pl rules test
mvn -pl rules,shared,backend -Dcodename1.platform=backend test
./run-e2e.sh
What the sample does
A player signs in, or plays a practice match as a guest, and lands on the main menu: a vertical list of modes on the left over a 3D operator holding the equipped marker, and a rank card with level, XP and currencies on the right. OPERATIONS opens the playlist: two fields, the Lot 7 Self Storage yard and the Cul-de-Sac Siege, each with three modes and a tactical map of the field drawn from its cover. Team Paint is first to 25 paints, Capture the Flag is three captures with a flag that’s clearly a bedsheet, and Hold the Snack Bar is king of the hill. Each operation can be played as a quick match, with a party of friends or as practice against bots.


A match opens on a letterboxed briefing that types itself out, redactions included, then hands over to the field in first person: the marker in hand bobs, sways and kicks, and aiming down the sights zooms in and tightens the spread. The HUD has a minimap, the score bar and clock, a killfeed, the hopper count, hit markers and damage arcs. The controls are a virtual stick on the left, look by dragging on the right, and buttons to fire, aim, jump, throw a paint grenade and wipe. On a desktop the keyboard and mouse do the same. A hit player is out until the respawn, arms up the way the rules of every field require, and may try to wipe the paint off. One try in three goes unnoticed; the others cost three more seconds.



The view button lifts the camera to a bird’s-eye view of the whole field, in a match, while spectating and in a replay. When a ball finds you, the match shows an instant replay of the last seconds from the shooter’s eyes, at half speed, while the live match runs on under it.


The Mission Builder lets a player lay out half a field on a grid: drag props, place spawns and the flag, pick the mode and write the briefing. The other half is the mirror image, so every mission is fair. A play test puts the draft in front of bots at once, and publishing it lets friends and parties play it like any built-in field.


A party is a private lobby: a code, two rosters and a chat. The friends who join it play the next match together, talk over WebRTC voice and trade emotes during it ("It bounced!" and "REF!").


On a desktop the same app is a window, not a phone. The menus keep to a readable column, small text is sized for a monitor instead of being scaled down from a phone, and the match drops the touch controls for the keyboard and mouse, with a wider view and a fuller HUD.



The modules
rulesis the game: a deterministic simulation in fixed-point integers with no dependencies. The app predicts with it, the server runs it as the authority and the replay viewer re-runs it.sharedholds the REST interfaces and the objects they carry.backendis the server: accounts, matchmaking, the match rooms, results, the shop, social features and the admin API.commonis the app.play/is the match itself,ui/the surrounding screens,engage/ads, analytics, push and the store, andadmin/the operations console.
What every full-stack sample needs and none of them is about comes from the
cn1-app-kit library: Net, the app’s error handling around a REST call, and on the
server Caller, Text and the push senders.
The rules are data
The fields and markers are tables. A marker is a handful of numbers (time between shots, muzzle speed, spread, hopper size, reload and how fast you move carrying it), and rebalancing the game is editing one of them:
public static final MarkerDef[] MARKERS = {
new MarkerDef("pump", "The Purist",
"One shot, one paint. Owned exclusively by men who say 'back in my day'.",
18, 2000, 0, 14, 70, 105),
new MarkerDef("semi", "Tactical Office Supply",
"Semi-automatic. Comes with a rail for a flashlight you will never attach.",
6, 1700, 16, 40, 80, 100),
new MarkerDef("ramp", "The Ramping Dad",
"Fully automatic, fully unnecessary. Banned at three fields and one barbecue.",
3, 1500, 44, 90, 105, 90),
};
A field is a size, a base, a hill for the snack bar mode, the spawns, and boxes of
cover given by center and size in decimeters. mirror places each box twice, once per
side, so a field is fair by construction:
// Every box is the footprint of the prop the app draws there (see
// tools/pack-art.py): centre and size in decimetres, height last.
new Arena.Builder("lot7", "Lot 7 Self Storage", 40, 60)
.base(200, 110).hill(45)
.spawn(140, 25).spawn(200, 20).spawn(260, 25).spawn(170, 45).spawn(230, 45)
.spawn(200, 55)
.mirror("sandbags", 130, 150, 34, 9, 13)
.mirror("sandbags", 270, 150, 34, 9, 13)
.mirror("sandbags-small", 95, 120, 25, 9, 10)
.mirror("sandbags-small", 305, 120, 25, 9, 10)
.mirror("container", 90, 175, 44, 21, 21)
.mirror("container", 310, 195, 44, 21, 21)
.mirror("barrier", 145, 235, 38, 7, 32)
.mirror("car", 260, 245, 26, 55, 18)
.mirror("tank", 335, 280, 24, 25, 21)
.mirror("boxes", 55, 260, 13, 12, 22)
.mirror("tires", 120, 295, 18, 11, 11)
.mirror("barrier-single", 250, 175, 19, 7, 6)
.mirror("snackbar", 200, 300, 22, 21, 21)
.build(),
A box’s kind (bunker, minivan, snackbar) picks its color and shape in the
client; the rules only see its extent. Change any of these and run
mvn -pl rules test: the golden replays report which recorded matches no longer end
the same way. Bump RulesVersion.ID when you change the rules themselves, so old
replays aren’t judged by new rules.
Drawing the field
ArenaView is a GameView. It builds the field from the arena’s boxes when it starts,
keeps a pool of paintballs and paint splats, and places the camera at the player’s eyes
on every frame. It tells the HUD what happened through one small interface, once per
drawn frame and once per event:
public class ArenaView extends GameView {
/// What the view tells the HUD once per drawn frame and once per event.
public interface Hud {
void frame(Match match, int me, float lookYaw, float lookPitch);
void event(Event e);
}
The simulation runs at a fixed 30 ticks a second, whatever the frame rate, and the view draws between the last two ticks:
@Override
protected void update(double dt) {
if (device == null || getWidth() <= 0 || getHeight() <= 0) {
return;
}
controlsFor(driver.match());
govern(dt);
accumulator += dt * driver.speedPercent() / 100.0;
int steps = 0;
while (accumulator >= TICK && steps < MAX_STEPS_PER_FRAME) {
if (!driver.canStep()) {
// The briefing, or online and the server has gone quiet.
accumulator = Math.min(accumulator, TICK);
break;
}
remember();
Input in = !driver.interactive() ? Input.IDLE : autopilot != null
? com.codenameone.examples.splat.rules.Bot.think(driver.match(), driver.match().player(driver.me()), autopilot)
: input();
if (heldButtons != 0 && driver.interactive()) {
in = new Input(in.strafe, in.forward, in.yaw, in.pitch, in.buttons | heldButtons, in.viewTick);
}
driver.step(in);
// every tick, not every frame: a slow frame steps several
record();
accumulator -= TICK;
steps++;
}
alpha = (float) Math.min(1, accumulator / TICK);
// and once a frame for a driver whose ticks arrive from the server
record();
events();
bodySounds((float) dt);
draw((float) dt);
tick((float) dt);
}
The look is the engine’s, not a texture pack’s: haze that thins with height, an open sky with clouds and the sun, shadows from the props and trees, and a color grade with bloom on the paint. It’s a dozen lines at the start of the view, and the menu backdrop uses the same calls with dusk colors:
getEnvironment().setFog(HAZE, 0.0075f, 0.05f).setHemisphere(0xffa9bdd2, 0xff4f5a3c)
.setToneMapping(true).setExposure(1.3f)
.setSky(0xff4f7fb8, HAZE, 0xfffff0d0, 0.45f);
setPostProcess(new PostProcess().setBloom(0.78f, 0.55f)
.setGrade(1.12f, 0.86f, 0xff0b1720, 0xfffff2dc).setVignette(0.42f).setGrain(0.035f));
What ticks the match is a Driver. Practice, an online match and a replay are three
drivers behind the same view and the same screen:
public abstract class Driver {
protected Match match;
/// The player the camera looks out of.
protected int me;
protected String[] names = new String[0];
private final List<Event> events = new ArrayList<Event>();
public Match match() {
return match;
}
public int me() {
return me;
}
public String name(int player) {
if (interactive() && player == me) {
return "You";
}
return player < names.length ? names[player] : "Player " + (player + 1);
}
/// Whether the next tick may be simulated now.
public abstract boolean canStep();
/// Simulates one tick with `in` as the player's input (ignored when not
/// [#interactive()]).
public abstract void step(Input in);
/// What happened since the view last asked: splats, PAINTED, captures.
public List<Event> drainEvents() {
List<Event> out = new ArrayList<Event>(events);
events.clear();
return out;
}
protected void emit(List<Event> happened) {
events.addAll(happened);
}
protected void emit(Event e) {
events.add(e);
}
public boolean interactive() {
return true;
}
public int speedPercent() {
return 100;
}
/// Ticks still to wait before the first step: the briefing.
public int countdownTicks() {
return 0;
}
public void dispose() {
}
}
The HUD is a plain component layered over the view, so it’s styled from theme.css
and respects the safe area like any other screen. On a phone it’s compact — a
small minimap, a two line killfeed, the hopper in one tab — and on a desktop or
tablet it shows everything. It steps aside entirely for the briefing and the
result card, so nothing in a match ever draws over a menu:
public final class MatchForm implements ArenaView.Hud, OnlineDriver.Listener {
private final Driver driver;
private final String mode;
private final Runnable back;
private final ArenaView view;
private final Hud hud;
private final Form form;
private final Container layers;
private Container chrome;
private boolean endShown;
private MatchForm(Driver driver, String mode, Runnable back) {
this.driver = driver;
this.mode = mode;
this.back = back;
view = new ArenaView(driver);
view.setName("arena");
hud = new Hud(driver);
hud.attach(view);
view.setHud(this);
form = new Form(driver.match().mode().operation, new BorderLayout());
form.setName("match");
form.setUIID("MatchScreen");
form.getToolbar().hideToolbar();
chrome = new Container(new BorderLayout());
chrome.setSafeArea(true);
Button quit = Ui.button("", "HudButton", "quit", e -> quit());
quit.setMaterialIcon(FontImage.MATERIAL_PAUSE, 4.4f);
Container top = new Container(new BorderLayout());
Container corner = new Container(BoxLayout.y());
if (driver instanceof OnlineDriver && driver.interactive()) {
// Comms stay folded behind one button: open, they cover the
// killfeed and nothing else, and they fold again once you speak.
final Container emotes = new Container(BoxLayout.y());
emotes.setUIID("EmoteBar");
emotes.setHidden(true);
Button comms = Ui.button("", "HudButton", "comms", null);
comms.setMaterialIcon(FontImage.MATERIAL_CHAT_BUBBLE, 4.4f);
comms.addActionListener(e -> showComms(emotes, emotes.isHidden()));
String[] ids = {"bounce", "cover", "ref", "gg"};
for (final String id : ids) {
emotes.add(Ui.button(emoteText(id), "EmoteButton", "emote-" + id, e -> {
((OnlineDriver) driver).emote(id);
showComms(emotes, false);
}));
}
corner.add(FlowLayout.encloseRight(comms, quit));
corner.add(emotes);
} else {
corner.add(FlowLayout.encloseRight(quit));
}
top.add(BorderLayout.EAST, corner);
if (driver instanceof ReplayDriver) {
top.add(BorderLayout.WEST, replayControls((ReplayDriver) driver));
}
chrome.add(BorderLayout.NORTH, top);
layers = new Container(new LayeredLayout());
layers.add(view);
layers.add(hud);
layers.add(chrome);
form.add(BorderLayout.CENTER, layers);
}
The server referees, the phone predicts
An online match is a room on the server. It collects each player’s newest input, steps the match, and every second tick sends a snapshot of the whole state with the newest input it has applied for that player:
final class MatchRoom {
/// Inputs one seat may send in a second. A client sends one per tick.
static final int FLOOD_LIMIT = Match.TICK_HZ + 12;
/// A turn larger than this in one tick is a mouse flick at best; many of them
/// in a match is an aimbot.
static final int SNAP_TURN = Trig.FULL / 3;
final String id;
final String level;
final Mode mode;
final Seat[] seats;
final Match match;
final Replay replay;
final long startedAt;
final List<WebSocketSession> spectators = new ArrayList<WebSocketSession>();
/// The inputs each seat has sent and the server has not used yet, oldest
/// first, one per tick. A client simulates one tick per input it sends,
/// so two that arrive within one server tick must both be played, or the
/// client's prediction runs a tick ahead and a trigger pull in the first
/// is never fired.
static final int QUEUE = 8;
private final Input[][] queued;
private final int[][] queuedSeq;
private final int[] queueHead;
private final int[] queueCount;
/// Wipes each seat got away with, for the achievement nobody should be proud of.
private final int[] wipes;
/// Events since the last snapshot, already encoded.
private final StringBuilder events = new StringBuilder();
MatchRoom(String id, String level, String arena, Mode mode, long seed, Seat[] seats, long startedAt) {
this.id = id;
this.level = level;
this.mode = mode;
this.seats = seats;
List<Slot> slots = new ArrayList<Slot>();
for (int i = 0; i < seats.length; i++) {
Seat s = seats[i];
// Bots of mixed skill, so a bot-filled match has someone to laugh at.
int skill = s.isBot() ? 35 + 15 * (i % 4) : 0;
slots.add(new Slot(s.name, s.team, s.marker, skill));
}
this.replay = new Replay(seed, arena, mode, slots);
this.match = replay.newMatch();
this.startedAt = startedAt;
this.queued = new Input[seats.length][QUEUE];
this.queuedSeq = new int[seats.length][QUEUE];
this.queueHead = new int[seats.length];
this.queueCount = new int[seats.length];
this.wipes = new int[seats.length];
}
int seatOf(String username) {
for (int i = 0; i < seats.length; i++) {
if (seats[i].username.equals(username)) {
return i;
}
}
return -1;
}
/// A player's input with its sequence number. Answers false when it is
/// dropped: a flood, an old or repeated sequence, or a seat that is not theirs.
boolean input(int seat, int seq, Input in) {
if (seat < 0 || match.over() || in == null) {
return false;
}
Seat s = seats[seat];
if (seq <= s.lastSeq) {
return false;
}
int now = match.tick();
int inWindow = 0;
int n = Math.min(s.recentCount, s.recent.length);
for (int i = 0; i < n; i++) {
if (now - s.recent[i] < Match.TICK_HZ) {
inWindow++;
}
}
if (inWindow >= FLOOD_LIMIT) {
s.floods++;
return false;
}
s.recent[s.recentCount % s.recent.length] = now;
s.recentCount++;
Input previous = queueCount[seat] > 0
? queued[seat][(queueHead[seat] + queueCount[seat] - 1) % QUEUE] : match.player(seat).input();
if (Math.abs(Trig.delta(previous.yaw, in.yaw)) > SNAP_TURN) {
s.snaps++;
}
s.lastSeq = seq;
if (queueCount[seat] == QUEUE) {
// A client this far ahead of the server is snapped back by the
// next snapshot anyway; its oldest guess goes.
queueHead[seat] = (queueHead[seat] + 1) % QUEUE;
queueCount[seat]--;
}
int at = (queueHead[seat] + queueCount[seat]) % QUEUE;
queued[seat][at] = in;
queuedSeq[seat][at] = seq;
queueCount[seat]++;
return true;
}
void step() {
int now = match.tick();
Input[] frame = new Input[seats.length];
for (int i = 0; i < seats.length; i++) {
if (queueCount[i] > 0) {
int h = queueHead[i];
frame[i] = queued[i][h];
// Only the input actually played is acknowledged.
seats[i].appliedSeq = queuedSeq[i][h];
queued[i][h] = null;
queueHead[i] = (h + 1) % QUEUE;
queueCount[i]--;
} else if (seats[i].left) {
frame[i] = Input.IDLE;
}
}
replay.record(now, frame);
match.step(frame);
List<Event> happened = match.events();
for (int i = 0; i < happened.size(); i++) {
Event e = happened.get(i);
if (e.kind == Event.WIPE_OK) {
wipes[e.a]++;
}
if (e.kind == Event.SHOT) {
// the snapshot carries the ball; sending every shot doubles the traffic
continue;
}
if (events.length() > 0) {
events.append(';');
}
events.append(e.kind).append(',').append(e.tick).append(',').append(e.a).append(',').append(e.b)
.append(',').append(e.x).append(',').append(e.y).append(',').append(e.z);
}
}
/// Takes the events gathered since the last snapshot.
String drainEvents() {
String out = events.toString();
events.setLength(0);
return out;
}
/// Over when the rules say so, or when no human is left on the field.
boolean isOver() {
if (match.over()) {
return true;
}
for (Seat s : seats) {
if (!s.isBot() && !s.left) {
return false;
}
}
return true;
}
/// 0 for the winning team, 1 for the losers, and everyone 0 on a draw.
int[] places() {
int[] out = new int[seats.length];
int w = match.winner();
for (int i = 0; i < seats.length; i++) {
out[i] = w < 0 || seats[i].team == w ? 0 : 1;
}
return out;
}
int[] wipes() {
return wipes.clone();
}
int humans() {
int h = 0;
for (Seat s : seats) {
if (!s.isBot()) {
h++;
}
}
return h;
}
/// The seat with the most paints, for the admin's live list.
int leader() {
int best = 0;
for (int i = 1; i < seats.length; i++) {
if (match.player(i).paints() > match.player(best).paints()) {
best = i;
}
}
return best;
}
}
The phone doesn’t wait for the snapshots. It applies its own input at once, and when a snapshot arrives it puts the match back where the server says it’s and plays the inputs the server hasn’t seen yet on top of it again:
public final class OnlineDriver extends Driver {
public interface Listener {
void emote(String from, String id);
void chat(String from, String text);
void ended(int[] places, int[] scores, int trophies, int coins, String matchId);
/// The match is gone for this player: removed by the ref, or the
/// connection to the server dropped. `why` is said to the player.
void lost(String why);
}
/// The most inputs the phone runs ahead of the last snapshot. Past this the
/// server has gone quiet, and guessing further only makes the snap back worse.
static final int MAX_AHEAD = 20;
private GameLink link;
private int reconnects;
private final boolean spectator;
private Listener listener;
private String matchId;
private String level;
private long startsAt;
private int seq;
private int serverTick;
private final List<int[]> pendingSeq = new ArrayList<int[]>();
private final List<Input> pending = new ArrayList<Input>();
private Input[] frame = new Input[0];
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;
}
public String level() {
return level;
}
private void restart(Map<String, Object> m) {
matchId = GameLink.text(m, "match");
level = GameLink.text(m, "level");
me = spectator ? 0 : GameLink.number(m, "you");
List<Slot> slots = new ArrayList<Slot>();
Object list = m.get("slots");
if (list instanceof List) {
for (Object o : (List<?>) list) {
slots.add(Slot.decode(String.valueOf(o)));
}
}
names = new String[slots.size()];
for (int i = 0; i < names.length; i++) {
names[i] = slots.get(i).name;
}
int colon = level.indexOf(':');
match = new Match(Long.parseLong(GameLink.text(m, "seed")), Catalog.arena(level.substring(0, colon)),
Mode.byName(level.substring(colon + 1)), slots);
frame = new Input[slots.size()];
pending.clear();
pendingSeq.clear();
String snap = GameLink.text(m, "snap");
if (snap.length() > 0) {
match.restore(ints(snap));
}
serverTick = match.tick();
startsAt = System.currentTimeMillis() + GameLink.number(m, "startIn");
}
private long lastSnapAt;
public void message(Map<String, Object> m) {
String type = GameLink.text(m, "type");
if (GameProtocol.SNAP.equals(type)) {
lastSnapAt = System.currentTimeMillis();
snap(GameLink.number(m, "t"), GameLink.number(m, "ack"), GameLink.text(m, "s"), GameLink.text(m, "e"));
} else if (GameProtocol.START.equals(type)) {
restart(m);
} else if (listener == null) {
return;
} else if (GameProtocol.EMOTE.equals(type)) {
listener.emote(GameLink.text(m, "from"), GameLink.text(m, "id"));
} else if (GameProtocol.CHAT.equals(type)) {
listener.chat(GameLink.text(m, "from"), GameLink.text(m, "text"));
} else if (GameProtocol.END.equals(type)) {
if (matchId != null && !matchId.equals(GameLink.text(m, "match"))) {
// the end of a match this socket has already left
return;
}
listener.ended(ints(GameLink.text(m, "places")), ints(GameLink.text(m, "scores")),
GameLink.number(m, "trophies"), GameLink.number(m, "coins"), GameLink.text(m, "match"));
} else if (GameProtocol.KICKED.equals(type)) {
listener.lost("The ref removed you from the field.");
}
}
/// Reconciliation: back to the server's state, then the unacknowledged
/// inputs again on top of it.
private void snap(int tick, int ack, String state, String events) {
if (state.length() == 0 || tick < serverTick) {
return;
}
serverTick = tick;
match.restore(ints(state));
while (!pendingSeq.isEmpty() && pendingSeq.get(0)[0] <= ack) {
pendingSeq.remove(0);
pending.remove(0);
}
for (int i = 0; i < pending.size() && !match.over(); i++) {
stepWith(pending.get(i));
}
// The server's events are the ones that count: a PAINTED the phone
// guessed and the server did not agree with is never shown.
List<String> items = Text.split(events, ';');
for (int i = 0; i < items.size(); i++) {
int[] f = ints(items.get(i));
if (f.length == 7) {
emit(new Event(f[0], f[1], f[2], f[3], f[4], f[5], f[6]));
}
}
}
/// Where the link stands, for a test that has to say why a match stalled.
public String describe() {
return "server tick " + serverTick + " (" + (lastSnapAt == 0 ? "no snapshot"
: (System.currentTimeMillis() - lastSnapAt) + " ms ago") + "), " + pending.size()
+ " inputs unacknowledged, countdown " + countdownTicks() + ", " + reconnects + " reconnects, "
+ link.describe();
}
@Override
public boolean canStep() {
return countdownTicks() == 0 && !match.over() && (spectator || pending.size() < MAX_AHEAD);
}
@Override
public void step(Input in) {
if (spectator) {
stepWith(null);
return;
}
// The tick the player is looking at is the newest snapshot, which is what
// the server rewinds a shot to.
Input stamped = new Input(in.strafe, in.forward, in.yaw, in.pitch, in.buttons, serverTick);
seq++;
link.send("{\"type\":\"" + GameProtocol.INPUT + "\",\"s\":" + seq + ",\"i\":\"" + stamped.encode() + "\"}");
pendingSeq.add(new int[] {seq});
pending.add(stamped);
stepWith(stamped);
// Your own shot is heard at once; everything else waits for the server.
List<Event> happened = match.events();
for (int i = 0; i < happened.size(); i++) {
Event e = happened.get(i);
if (e.kind == Event.SHOT && e.a == me) {
emit(e);
}
}
}
private void stepWith(Input in) {
for (int i = 0; i < frame.length; i++) {
frame[i] = null;
}
if (in != null && me < frame.length) {
frame[me] = in;
}
match.step(frame);
}
@Override
public boolean interactive() {
return !spectator;
}
@Override
public int countdownTicks() {
long left = startsAt - System.currentTimeMillis();
return left <= 0 ? 0 : (int) (left * Match.TICK_HZ / 1000) + 1;
}
public void emote(String id) {
link.send("{\"type\":\"" + GameProtocol.EMOTE + "\",\"id\":\"" + id + "\"}");
}
public void leave() {
link.send("{\"type\":\"" + GameProtocol.LEAVE + "\"}");
}
/// Carries on over a new socket after the old one dropped. The server
/// seats it in the same place and answers with the whole match so far,
/// which arrives as a start and rebuilds the field from it.
public void relink(GameLink fresh) {
link = fresh;
reconnects++;
}
/// How many times the match had to carry on over a new socket.
public int reconnects() {
return reconnects;
}
public GameLink link() {
return link;
}
/// Tells the match screen the match is gone, when the lobby that owns the
/// socket learns it first.
public void lost(String why) {
if (listener != null) {
listener.lost(why);
}
}
@Override
public void dispose() {
link.close();
}
static int[] ints(String csv) {
if (csv == null || csv.length() == 0) {
return new int[0];
}
List<String> f = Text.split(csv, ',');
int[] out = new int[f.size()];
for (int i = 0; i < out.length; i++) {
out[i] = f.get(i).length() == 0 ? 0 : Integer.parseInt(f.get(i));
}
return out;
}
}
Making up for lag
Every input carries the tick the player was looking at. When it reaches the server a few ticks later, the ball is flown forward the ticks it lost on the way:
// The shooter aimed at the world as it was `viewTick`; the ball is flown
// forward the ticks it lost on the way here, at most MAX_LAG_TICKS, so a
// shot on a phone with a slow connection still lands where it was aimed.
int lag = in.viewTick < 0 ? 0 : FixedMath.clamp(t - in.viewTick, 0, MAX_LAG_TICKS);
boolean alive = true;
for (int i = 0; i < lag && alive; i++) {
alive = fly(b, t);
}
if (alive) {
balls.add(b);
}
The cap keeps a player with a slow connection from painting people around corners. The match test plays a full party match and checks that the server’s result replays to the same paints:
@Test
void aPartyMatchIsRecordedAndReplaysToTheSamePaints() throws Exception {
String host = Api.player(accounts, "host");
String guest = Api.player(accounts, "guest");
rooms.host(profile(host), null, "cul-de-sac:SNACK_BAR", null);
String code = rooms.partyCode(host);
assertFalse(code.isEmpty());
rooms.join(profile(guest), null, code);
assertEquals("", rooms.raceOf(host));
// Only the host starts the match.
rooms.go(guest, null);
assertEquals("", rooms.raceOf(host));
rooms.go(host, null);
String id = rooms.raceOf(host);
assertFalse(id.isEmpty());
assertEquals(id, rooms.raceOf(guest));
// Past the countdown, the host walks forward with the trigger held.
rooms.tick(System.currentTimeMillis() + GameProtocol.COUNTDOWN_MILLIS + 100);
rooms.input(host, 1, new Input(0, 100, 0, 0, Input.FIRE, -1));
rooms.fastForward(id);
assertEquals("", rooms.raceOf(host));
List mine = Api.list(mvc, Api.as(get("/api/races"), host));
assertEquals(1, mine.size());
assertEquals(id, ((Map) mine.get(0)).get("id"));
// The record is the field, the seed and every input. Played again
// here, with the same rules, every player paints exactly as many.
Map stored = Api.json(mvc, Api.as(get("/api/races/" + id), guest));
Replay replay = Replay.decode((String) stored.get("replay"));
Match again = replay.play();
assertTrue(again.over());
List scores = (List) stored.get("scores");
for (int i = 0; i < scores.size(); i++) {
assertEquals(((Number) scores.get(i)).intValue(), again.player(i).paints());
}
}
Parties, voice and replays
The lobby is a socket to the server, shared by the party code, the chat and the start of the match:
public final class MatchLobby implements GameLink.Listener, Voice.Listener {
private final String hello;
/// Times a match carries on over a new socket before it gives up.
private static final int MAX_RECONNECTS = 3;
private final boolean spectator;
private GameLink link;
private OnlineDriver driver;
private boolean cancelled;
private Form form;
private ShareButton share;
private final Container members = new Container(BoxLayout.y());
private final Container chat = new Container(BoxLayout.y());
private Container page;
private final Label code = new Label("", "ChallengeCode");
private final Label url = new Label("", "ChallengeUrl");
private boolean host;
private String level = Arenas.DEFAULT;
private String[] names = new String[0];
private String[] players = new String[0];
private String[] talking = new String[0];
private final java.util.Set<String> mutedPeers = new java.util.HashSet<String>();
private MatchLobby(String hello, boolean spectator) {
this.hello = hello;
this.spectator = spectator;
}
/// A quick race on `level` (an official id or `pack/level`), or on the
/// game's featured race level when null.
public static void quick(String level) {
String l = level == null ? Arenas.DEFAULT : level;
MatchLobby r = new MatchLobby("{\"type\":\"" + GameProtocol.QUICK + "\",\"level\":" + Json.write(l) + "}",
false);
r.level = l;
r.waiting("Finding runners");
}
/// A party of one's own, on `level`.
public static void host(String level) {
host(level, null);
}
/// A party for a crew: the server pushes the invite to the other members.
public static void host(String level, String crew) {
String l = level == null ? Arenas.DEFAULT : level;
MatchLobby r = new MatchLobby("{\"type\":\"" + GameProtocol.HOST + "\",\"level\":" + Json.write(l)
+ (crew == null ? "" : ",\"crew\":" + Json.write(crew)) + "}", false);
r.level = l;
r.host = true;
r.party("PRIVATE LOBBY");
}
/// Someone else's party, from its link or code.
public static void join(String code) {
new MatchLobby("{\"type\":\"" + GameProtocol.JOIN + "\",\"code\":" + Json.write(code) + "}", false)
.party("Joining the party");
}
/// The admin console's spectator view of a live race.
public static void spectate(String raceId) {
new MatchLobby("{\"type\":\"" + GameProtocol.SPECTATE + "\",\"race\":" + Json.write(raceId) + "}", true)
.waiting("Connecting");
}
/// A "join with a code" prompt, for a friend who was told the code.
public static void enterCode() {
final Form f = Ui.form("Join a party", "joinParty");
Ui.back(f, Nav::home);
final TextField field = new TextField("", "Party code", 12, TextField.ANY);
field.setName("partyCode");
f.add(field);
f.add(Ui.primary("Join", "joinButton", e -> {
if (field.getText().trim().length() > 0) {
join(field.getText().trim().toUpperCase());
}
}));
f.show();
}
private void waiting(String title) {
form = Ui.form(title, "lobby");
Ui.back(form, this::cancel);
form.setLayout(BoxLayout.yCenter());
form.setUIID("OpsForm");
form.add(Ui.centered(new Label("SEARCHING FOR MATCH", "OpsTitle")));
form.add(Ui.centered(new Label(level == null ? "PLAYLIST: ANY YARD, ANY MODE" : "PLAYLIST: "
+ Ui.caps(com.codenameone.examples.splat.play.Arenas.arenaName(level)), "OpsCaption")));
form.add(Ui.centered(new InfiniteProgress()));
form.add(Ui.centered(new Label("BOTS FILL EMPTY SLOTS AFTER 10 SECONDS", "MenuItemSub")));
form.add(Ui.secondary("CANCEL", "cancel", e -> cancel()));
form.show();
link = GameLink.open(this);
}
private void party(String title) {
form = Ui.form(title, "party");
form.setLayout(new BorderLayout());
Ui.back(form, this::cancel);
Container top = Ui.column();
top.add(Ui.label("PRIVATE LOBBY // CODE", "SectionTitle"));
code.setName("partyCode");
top.add(Ui.centered(code));
top.add(url);
share = new ShareButton();
share.setText("INVITE SQUAD");
share.setUIID("PrimaryButton");
share.setName("shareParty");
share.addActionListener(e -> Telemetry.challengeShared());
// A material icon takes the button's own colour; the stock share
// image is a picture and sits on a light square on a dark button.
share.setMaterialIcon(FontImage.MATERIAL_SHARE);
Button copy = Ui.secondary("COPY LINK", "copyParty", e -> {
Display.getInstance().copyToClipboard(url.getText());
Ui.toast("Copied");
});
top.add(share);
top.add(copy);
// a rule under the invite, so the roster scrolling under it reads as
// a list going under a header rather than rows cut off mid-button
Label rule = new Label(" ", "SectionRule");
top.add(rule);
// The code and the invite stay put; the runners and the chat scroll
// under them, so the conversation is never squeezed into what the
// code card leaves and scrolling to a new line never hides the code.
Container below = Ui.column();
members.setName("members");
below.add(members);
chat.setName("chat");
below.add(chat);
page = BoxLayout.encloseY(Ui.centeredColumn(below));
page.setScrollableY(true);
form.add(BorderLayout.NORTH, Ui.centeredColumn(top));
form.add(BorderLayout.CENTER, page);
final TextField say = new TextField("", "Comms", 40, TextField.ANY);
say.setName("chatField");
say.setDoneListener(e -> {
String t = say.getText().trim();
if (t.length() > 0 && link != null) {
link.send("{\"type\":\"" + GameProtocol.CHAT + "\",\"text\":" + Json.write(t) + "}");
say.setText("");
}
});
final Button mic = Ui.button("", "HudButton", "mic", null);
mic.setMaterialIcon(FontImage.MATERIAL_MIC);
mic.setEnabled(Voice.available());
mic.addActionListener(e -> {
Voice v = Voice.current();
if (v != null) {
v.setMuted(!v.isMuted());
mic.setMaterialIcon(v.isMuted() ? FontImage.MATERIAL_MIC_OFF : FontImage.MATERIAL_MIC);
}
});
// The chat line and the buttons share the page's column, so their
// edges line up with the code card and the bubbles above them.
Container sayRow = new Container(new BorderLayout());
sayRow.setUIID("LobbySay");
sayRow.add(BorderLayout.CENTER, say);
sayRow.add(BorderLayout.EAST, mic);
Button leave = Ui.secondary("LEAVE", "cancel", e -> cancel());
// Both buttons in one row from the start: a grid built for one
// child grows a second row for the next, not a second column.
Container actions = host
? Ui.row(leave, Ui.primary("DEPLOY", "goButton", e -> link.send("{\"type\":\""
+ GameProtocol.GO + "\",\"level\":" + Json.write(level) + "}")))
: Ui.row(leave);
Container bottom = Ui.centeredColumn(Ui.column(sayRow, actions));
bottom.setSafeArea(true);
form.add(BorderLayout.SOUTH, bottom);
form.show();
link = GameLink.open(this);
}
Voice goes peer to peer over WebRTC, with the server passing only the offers and answers:
public final class Voice implements PeerConnection.Listener {
/// Who is talking, for the lobby to draw.
public interface Listener {
void peers(String[] connected);
void muted(String username, boolean on);
}
private static Voice current;
private final GameLink link;
private final String me;
private final Map<String, PeerConnection> peers = new HashMap<String, PeerConnection>();
private final Map<PeerConnection, String> owners = new HashMap<PeerConnection, String>();
private final List<String[]> early = new ArrayList<String[]>();
private String[] members = new String[0];
private LocalMedia media;
private boolean muted;
private boolean closed;
private Listener listener;
private Voice(GameLink link, String me) {
this.link = link;
this.me = me;
}
/// Whether this device can do voice at all.
public static boolean available() {
return WebRtc.isSupported();
}
/// Starts voice over `link` for the signed-in `username`, replacing any
/// earlier call. Asks for the microphone; nothing is sent until it is
/// granted.
public static Voice start(GameLink link, String username) {
stop();
final Voice v = new Voice(link, username);
current = v;
WebRtc.openUserMedia(true, false, false).ready(m -> {
if (v.closed) {
m.release();
return;
}
v.media = m;
v.connect();
for (String[] s : v.early) {
v.signal(s[0], s[1]);
}
v.early.clear();
}).except(err -> Log.p("Voice unavailable: " + err));
return v;
}
/// The call in progress, or null.
public static Voice current() {
return current;
}
public static void stop() {
if (current != null) {
current.close();
current = null;
}
}
public void setListener(Listener listener) {
this.listener = listener;
}
/// The party or race now has `usernames`; empty entries are bots.
public void members(String[] usernames) {
members = usernames == null ? new String[0] : usernames;
if (media != null) {
connect();
}
}
private void connect() {
List<String> keep = new ArrayList<String>();
for (String u : members) {
if (u == null || u.length() == 0 || u.equals(me)) {
continue;
}
keep.add(u);
if (!peers.containsKey(u) && me.compareTo(u) < 0) {
offer(u);
}
}
for (String u : new ArrayList<String>(peers.keySet())) {
if (!keep.contains(u)) {
PeerConnection pc = peers.remove(u);
owners.remove(pc);
pc.close();
}
}
notifyPeers();
}
private PeerConnection peer(String u) {
PeerConnection pc = peers.get(u);
if (pc == null) {
PeerConnectionConfig cfg = new PeerConnectionConfig()
.addIceServer(IceServer.stun("stun:stun.l.google.com:19302"));
pc = WebRtc.createPeerConnection(cfg, this);
MediaTrack audio = media.getAudio();
if (audio != null) {
audio.setEnabled(!muted);
pc.addTrack(audio);
}
peers.put(u, pc);
owners.put(pc, u);
}
return pc;
}
private void offer(final String u) {
final PeerConnection pc = peer(u);
pc.createOffer().ready(o -> pc.setLocalDescription(o).ready(ok -> send(u, "o\n" + o.getSdp())));
}
/// A `voice` message from `from`.
public void signal(String from, String data) {
if (closed || from == null || data == null || data.length() < 2) {
return;
}
if (media == null) {
early.add(new String[] {from, data});
return;
}
char kind = data.charAt(0);
String body = data.substring(2);
if (kind == 'o') {
final PeerConnection pc = peer(from);
final String to = from;
pc.setRemoteDescription(new SessionDescription(SessionDescription.OFFER, body)).ready(
ok -> pc.createAnswer().ready(a -> pc.setLocalDescription(a).ready(
done -> send(to, "a\n" + a.getSdp()))));
} else if (kind == 'a') {
PeerConnection pc = peers.get(from);
if (pc != null) {
pc.setRemoteDescription(new SessionDescription(SessionDescription.ANSWER, body));
}
} else if (kind == 'c') {
PeerConnection pc = peers.get(from);
int a = body.indexOf('\n');
int b = a < 0 ? -1 : body.indexOf('\n', a + 1);
if (pc != null && b > a) {
pc.addIceCandidate(new IceCandidate(body.substring(0, a),
Integer.parseInt(body.substring(a + 1, b)), body.substring(b + 1)));
}
}
}
/// A `mute` message: `username` turned their microphone on or off.
public void mutedBy(String username, boolean on) {
if (listener != null) {
listener.muted(username, on);
}
}
public void setMuted(boolean m) {
muted = m;
if (media != null && media.getAudio() != null) {
media.getAudio().setEnabled(!m);
}
link.send("{\"type\":\"" + GameProtocol.MUTE + "\",\"on\":" + m + "}");
}
public boolean isMuted() {
return muted;
}
private void send(String to, String data) {
if (!closed) {
link.send("{\"type\":\"" + GameProtocol.VOICE + "\",\"to\":" + Json.write(to) + ",\"data\":"
+ Json.write(data) + "}");
}
}
public void close() {
closed = true;
for (PeerConnection pc : peers.values()) {
pc.close();
}
peers.clear();
owners.clear();
if (media != null) {
media.release();
media = null;
}
}
@Override
public void iceCandidate(PeerConnection connection, IceCandidate c) {
String u = owners.get(connection);
if (u != null) {
send(u, "c\n" + c.getSdpMid() + "\n" + c.getSdpMLineIndex() + "\n" + c.getCandidate());
}
}
@Override
public void stateChanged(PeerConnection connection, String state) {
notifyPeers();
}
@Override
public void remoteTrack(PeerConnection connection, MediaTrack track) {
// Remote audio plays on its own once the track arrives.
WebRtc.setSpeakerphone(true);
}
private void notifyPeers() {
if (listener == null) {
return;
}
List<String> on = new ArrayList<String>();
for (Map.Entry<String, PeerConnection> e : peers.entrySet()) {
if (PeerConnection.CONNECTED.equals(e.getValue().getState())) {
on.add(e.getKey());
}
}
listener.peers(on.toArray(new String[on.size()]));
}
}
A replay is the seed and the human inputs, nothing more. The bots aren’t in the file: they think inside the rules, so they make the same choices again. The replay screen follows any player, pauses, plays at double speed and skips ahead.
The Mission Builder
Players build fields of their own. The editor shows one half of the field from above;
the other half is that half turned around, built by the same Arena.Builder.mirror the
built-in yards use, so a player-made field is fair by construction. A Mission in the
rules module holds the props, spawns, flag, mode and briefing, travels as JSON, and is
checked by Mission.problem() on the phone and again on the server before it’s
stored:
public final class MissionEditor {
static final String DRAFT = "splat-mission-draft.json";
/// The arena code a play-test runs under; publishing gives the real one.
static final String TEST_CODE = "draft";
private MissionEditor() {
}
public static void show() {
open(loadDraft());
}
static void open(final Mission mission) {
final Form f = Ui.form("MISSION EDITOR", "missionEditor");
f.setLayout(new BorderLayout());
f.setScrollableY(false);
Ui.back(f, MissionsForm::show);
final Label status = new Label(" ", "EditorStatus");
status.setName("editorStatus");
final MissionCanvas[] canvas = new MissionCanvas[1];
canvas[0] = new MissionCanvas(mission, () -> {
saveDraft(mission);
String p = mission.problem();
status.setText(p == null ? mission.props.size() + " PROPS // " + mission.spawns.size()
+ " SPAWNS // READY" : Ui.caps(p));
});
TextField name = new TextField(mission.name, "Operation name", 20, TextArea.ANY);
name.setName("missionName");
name.setUIID("EditorField");
name.addDataChangedListener((t, i) -> {
mission.name = name.getText();
saveDraft(mission);
});
Container modes = new Container(new GridLayout(1, 3));
final Button[] modeButtons = new Button[Mode.values().length];
for (int i = 0; i < modeButtons.length; i++) {
final Mode m = Mode.values()[i];
String label = m == Mode.TEAM_PAINT ? "TEAM" : m == Mode.CAPTURE_THE_FLAG ? "FLAG" : "SNACK BAR";
modeButtons[i] = Ui.button(label, mission.mode == m ? "EditorChipOn" : "EditorChip",
"mode-" + m.name(), e -> {
mission.mode = m;
for (int k = 0; k < modeButtons.length; k++) {
modeButtons[k].setUIID(Mode.values()[k] == m ? "EditorChipOn" : "EditorChip");
}
saveDraft(mission);
canvas[0].repaint();
f.revalidate();
});
modes.add(modeButtons[i]);
}
// The palette: one chip per prop, then the spawn and flag tools.
final String[] tools = new String[Mission.KINDS.length + 2];
System.arraycopy(Mission.KINDS, 0, tools, 0, Mission.KINDS.length);
tools[tools.length - 2] = MissionCanvas.SPAWN;
tools[tools.length - 1] = MissionCanvas.FLAG;
final Button[] chips = new Button[tools.length];
Container strip = new Container(BoxLayout.x());
strip.setScrollableX(true);
for (int i = 0; i < tools.length; i++) {
final String t = tools[i];
chips[i] = Ui.button(Ui.caps(t.replace('-', ' ')), t.equals(canvas[0].tool()) ? "EditorChipOn"
: "EditorChip", "tool-" + t, e -> {
canvas[0].setTool(t);
for (int k = 0; k < chips.length; k++) {
chips[k].setUIID(tools[k].equals(t) ? "EditorChipOn" : "EditorChip");
}
f.revalidate();
});
strip.add(chips[i]);
}
Container edit = new Container(new GridLayout(1, 2));
edit.add(Ui.secondary("TURN", "turnProp", e -> canvas[0].turnSelected()));
edit.add(Ui.secondary("REMOVE", "removeProp", e -> canvas[0].removeSelected()));
TextArea briefing = new TextArea(mission.briefing, 3, 30);
briefing.setName("missionBriefing");
briefing.setUIID("EditorField");
briefing.setHint("Briefing: what the narrator reads before the match");
briefing.addDataChangedListener((t, i) -> {
mission.briefing = briefing.getText();
saveDraft(mission);
});
Container go = new Container(new GridLayout(1, 2));
go.add(Ui.secondary("PLAY TEST", "playTest", e -> playTest(mission)));
go.add(Ui.primary("PUBLISH", "publishMission", e -> MissionsForm.publish(mission)));
if (CN.isDesktop()) {
// A desk has the room for an editor laid out like one: the field
// takes the whole window and the tools stand in a panel at the
// side. Squeezed into the reading column the other screens use,
// the field was a small square in the middle of a wide screen.
Container palette = new Container(new GridLayout(Math.max(1, (tools.length + 1) / 2), 2));
for (int i = 0; i < chips.length; i++) {
chips[i].remove();
palette.add(chips[i]);
}
Container side = BoxLayout.encloseY(name, modes, palette, edit, briefing);
side.setScrollableY(true);
Container panel = new Container(new BorderLayout());
panel.setUIID("EditorPanel");
panel.add(BorderLayout.CENTER, side).add(BorderLayout.SOUTH, go);
int w = Math.max(CN.convertToPixels(70), f.getWidth() > 0 ? f.getWidth() / 4 : CN.getDisplayWidth() / 4);
panel.setPreferredW(w);
Container field = new Container(new BorderLayout());
field.add(BorderLayout.CENTER, canvas[0]).add(BorderLayout.SOUTH, status);
f.add(BorderLayout.CENTER, field).add(BorderLayout.EAST, panel);
} else {
Container middle = new Container(BoxLayout.y());
middle.setScrollableY(true);
f.add(BorderLayout.NORTH, BoxLayout.encloseY(name, modes, strip));
middle.add(canvas[0]).add(status).add(edit).add(briefing);
f.add(BorderLayout.CENTER, middle);
f.add(BorderLayout.SOUTH, go);
}
f.show();
String p = mission.problem();
status.setText(p == null ? "DRAG TO MOVE // TAP GROUND TO PLACE" : Ui.caps(p));
}
/// Plays the draft at once against bots, as it would be played online.
static void playTest(Mission mission) {
String p = mission.problem();
if (p != null) {
Ui.toast(p);
return;
}
Mission test = Mission.fromJson(mission.toJson());
test.code = TEST_CODE;
test.register();
MatchForm.practice(test.arenaId() + ":" + test.mode.name(), () -> open(mission));
}
static Mission loadDraft() {
if (Storage.getInstance().exists(DRAFT)) {
try {
InputStream in = Storage.getInstance().createInputStream(DRAFT);
try {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] b = new byte[4096];
int n;
while ((n = in.read(b)) > 0) {
buf.write(b, 0, n);
}
return Mission.fromJson(new String(buf.toByteArray(), "UTF-8"));
} finally {
in.close();
}
} catch (IOException | IllegalArgumentException e) {
// an unreadable draft starts over
}
}
return Mission.blank();
}
static void saveDraft(Mission mission) {
try {
OutputStream out = Storage.getInstance().createOutputStream(DRAFT);
try {
byte[] b = mission.toJson().getBytes("UTF-8");
out.write(b);
} finally {
out.close();
}
} catch (IOException e) {
// the draft is a convenience; the editor still has the mission
}
}
}
Play-testing registers the draft as an arena and starts a practice match on it, so the
bots, the briefing and the camera see it as any other yard. Publishing stores it under a
code the server assigns, and the published list shows your missions and your friends'.
A mission is played online as the level m-<code>:<MODE>: before the server opens a
lobby for such a level it loads the mission and registers it, and from there the match,
the replay and the results are the same as for a built-in yard.
Engagement, moderation and the console
Results, trophies, coins and the season are settled on the server:
@Component
public class Results {
/// Trophies for a win, and lost for a loss; a draw moves nothing.
public static final int WIN_TROPHIES = 20;
public static final int LOSS_TROPHIES = 15;
/// Coins per paint, and the most a match can pay.
public static final int COINS_PER_PAINT = 3;
public static final int MAX_MATCH_COINS = 60;
/// What one runner got out of a race.
public static final class Outcome {
public final int trophies;
public final int coins;
public Outcome(int trophies, int coins) {
this.trophies = trophies;
this.coins = coins;
}
}
private final RaceRecordRepository races;
private final RaceEntryRepository entries;
private final ProfileRepository profiles;
private final Progression progression;
private final Referrals referrals;
public Results(RaceRecordRepository races, RaceEntryRepository entries, ProfileRepository profiles,
Progression progression, Referrals referrals) {
this.races = races;
this.entries = entries;
this.profiles = profiles;
this.progression = progression;
this.referrals = referrals;
}
/// The trophy change for a player whose team won (`place` 0) or lost, with
/// `humans` players in the match. A draw is `place` 0 for everyone and
/// `draw` true. Against bots alone it is halved and never negative: losing to
/// the server should not cost a ranking.
public static int trophies(int place, boolean draw, int humans) {
int change = draw ? 0 : place == 0 ? WIN_TROPHIES : -LOSS_TROPHIES;
if (humans <= 1) {
change = Math.max(0, change / 2);
}
return change;
}
@Transactional(rollbackFor = IOException.class)
Outcome[] record(MatchRoom room) throws IOException {
Outcome[] out = save(room);
// After every profile is saved: paying an inviter writes a profile,
// and that inviter may be another runner of this very race.
for (Seat s : room.seats) {
if (!s.isBot()) {
referrals.played(s.username);
}
}
return out;
}
@Transactional(rollbackFor = IOException.class)
Outcome[] save(MatchRoom room) throws IOException {
int n = room.seats.length;
int humans = room.humans();
int[] places = room.places();
boolean draw = room.match.winner() < 0;
int[] wipes = room.wipes();
Outcome[] out = new Outcome[n];
Instant now = Instant.now();
StringBuilder players = new StringBuilder();
StringBuilder names = new StringBuilder();
StringBuilder placeList = new StringBuilder();
StringBuilder scoreList = new StringBuilder();
StringBuilder trophyList = new StringBuilder();
for (int i = 0; i < n; i++) {
Seat s = room.seats[i];
Player r = room.match.player(i);
out[i] = new Outcome(0, 0);
if (!s.isBot()) {
Profile p = profiles.findById(s.username);
if (p != null) {
// A runner who walked out never wins, however well their
// team did without them: otherwise leaving while ahead is
// a free win.
int place = s.left ? Math.max(1, places[i]) : places[i];
int t = trophies(place, draw, humans);
t = Math.max(t, -p.getTrophies());
int coins = s.left ? 0 : Math.min(MAX_MATCH_COINS, r.paints() * COINS_PER_PAINT);
p.setTrophies(p.getTrophies() + t);
p.setCoins(p.getCoins() + coins);
if (draw) {
p.setDraws(p.getDraws() + 1);
} else if (place == 0) {
p.setWins(p.getWins() + 1);
} else {
p.setLosses(p.getLosses() + 1);
}
p.setLeftStreak(s.left ? p.getLeftStreak() + 1 : 0);
p.setLastSeen(now);
progression.afterMatch(p, !draw && place == 0, i == room.leader(), r.paints(), wipes[i]);
profiles.save(p);
out[i] = new Outcome(t, coins);
RaceEntry e = new RaceEntry();
e.setId(room.id + "|" + s.username);
e.setRaceId(room.id);
e.setUsername(s.username);
e.setLevel(room.level);
e.setPlace(places[i]);
e.setRunners(n);
e.setScore(r.paints());
e.setTrophyChange(t);
e.setTicks(room.match.tick());
e.setEndedAt(now);
entries.insert(e);
}
}
String sep = i == 0 ? "" : ",";
players.append(sep).append(s.username);
names.append(sep).append(s.name.replace(',', ' '));
placeList.append(sep).append(places[i]);
scoreList.append(sep).append(r.paints());
trophyList.append(sep).append(out[i].trophies);
}
RaceRecord m = new RaceRecord();
m.setId(room.id);
m.setLevel(room.level);
m.setPlayers(players.toString());
m.setNames(names.toString());
m.setPlaces(placeList.toString());
m.setScores(scoreList.toString());
m.setTrophies(trophyList.toString());
m.setRunners(n);
m.setHumans(humans);
m.setTicks(room.match.tick());
m.setEndedAt(now);
m.setRulesVersion(String.valueOf(RulesVersion.ID));
m.setReplay(room.replay.encode());
races.insert(m);
return out;
}
}
The server also watches for what a modified client would do. A client that sends inputs faster than the match ticks, or keeps turning faster than a wrist can, raises a flag. A marker can’t fire faster than its definition allows, because the server’s rules are the ones that fire it:
@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 RaceRecordRepository races;
private final GamePushes pushes;
private final AdminChannel channel;
private final List<String> adminNames;
public Flags(FlagRepository flags, ProfileRepository profiles, RaceRecordRepository races,
GamePushes pushes, AdminChannel channel, @Value("${splat.admin.notify:}") String adminNames) {
this.flags = flags;
this.profiles = profiles;
this.races = races;
this.pushes = pushes;
this.channel = channel;
this.adminNames = com.codenameone.examples.splat.account.Lists.split(adminNames);
}
/// A player reporting another runner of a race they ran in. `accused` is
/// the seat number, as the race's lists number them.
@Transactional(rollbackFor = IOException.class)
public FlagDto report(String reporter, String raceId, String accused, String reason) throws IOException {
RaceRecord m = raceId == null ? null : races.findById(raceId);
List<String> players = m == null ? new ArrayList<String>() : Lists.fields(m.getPlayers());
if (!players.contains(reporter)) {
throw new ResponseStatusException(404, "No such race");
}
int seat = accused == null ? -1 : players.indexOf(accused);
if (seat < 0) {
int n = accused != null && accused.length() == 1 ? accused.charAt(0) - '0' : -1;
seat = n >= 0 && n < players.size() ? n : -1;
}
String who = seat < 0 ? "" : players.get(seat);
if (who.length() == 0 || who.equals(reporter)) {
throw new ResponseStatusException(400, "Pick a runner from that race to report; bots cannot be");
}
if (flags.countByReporterAndRaceId(reporter, raceId) > 0) {
throw new ResponseStatusException(409, "You already reported this race");
}
String why = Text.required(reason, 255, "what happened");
return raise(who, REPORT, why, raceId, reporter);
}
/// Something the server noticed itself. Transactional on its own because
/// the demo seeding calls it with no transaction open; inside one (a race
/// being recorded) it joins that one.
@Transactional(rollbackFor = IOException.class)
public void auto(String username, String reason, String raceId) {
try {
raise(username, AUTO, reason, raceId == null ? "" : raceId, "");
} catch (IOException | RuntimeException failed) {
System.err.println("[flags] could not flag " + username + ": " + failed);
}
}
FlagDto raise(String username, String kind, String reason, String raceId, String reporter)
throws IOException {
Flag f = store(username, kind, reason, raceId, 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 raceId, String reporter)
throws IOException {
Flag f = new Flag();
f.setId(Ids.next());
f.setUsername(username);
f.setKind(kind);
f.setReason(reason);
f.setRaceId(raceId);
f.setReporter(reporter);
f.setCreatedAt(Instant.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.getRaceId(), f.getReporter(), f.getCreatedAt().toEpochMilli(), f.isPending());
}
}
Flags land in the operations console, where an admin can warn, mute, suspend or ban, and every action goes to the audit log:

@Component
public class Moderation {
public static final String WARN = "WARN";
public static final String MUTE = "MUTE";
public static final String UNMUTE = "UNMUTE";
public static final String SUSPEND = "SUSPEND";
public static final String BAN = "BAN";
public static final String UNBAN = "UNBAN";
public static final String RESET_TROPHIES = "RESET_TROPHIES";
public static final String REVOKE_ITEMS = "REVOKE_ITEMS";
static final String[] ACTIONS = {WARN, MUTE, UNMUTE, SUSPEND, BAN, UNBAN, RESET_TROPHIES, REVOKE_ITEMS};
private final Accounts accounts;
private final ProfileRepository profiles;
private final AdminActionRepository audit;
private final Rooms rooms;
public Moderation(Accounts accounts, ProfileRepository profiles, AdminActionRepository audit, Rooms rooms) {
this.accounts = accounts;
this.profiles = profiles;
this.audit = audit;
this.rooms = rooms;
}
public PlayerDto act(String admin, String username, AdminActionDto request) throws IOException {
if (request == null) {
throw new ResponseStatusException(400, "Choose an action");
}
String action = Text.oneOf(request.action(), ACTIONS, "", "the action");
if (action.length() == 0) {
throw new ResponseStatusException(400, "Choose an action");
}
if (admin.equals(username) && !WARN.equals(action)) {
throw new ResponseStatusException(400, "Not on your own account");
}
String reason = Text.required(request.reason(), 255, "the reason");
boolean closes = apply(admin, username, action, reason, request.days());
if (closes) {
rooms.kick(username);
}
return accounts.describe(username);
}
@Transactional(rollbackFor = IOException.class)
boolean apply(String admin, String username, String action, String reason, int days) throws IOException {
if (profiles.hold(username) == 0) {
throw new ResponseStatusException(404, "No such account");
}
Profile p = profiles.findById(username);
boolean closes = false;
if (MUTE.equals(action) || UNMUTE.equals(action)) {
p.setMuted(MUTE.equals(action));
} else if (SUSPEND.equals(action)) {
if (days < 1 || days > 365) {
throw new ResponseStatusException(400, "Suspend for 1 to 365 days");
}
p.setSuspendedUntil(Instant.now().plusMillis(days * Clock.DAY));
accounts.setEnabled(username, false);
closes = true;
} else if (BAN.equals(action)) {
p.setBanned(true);
accounts.setEnabled(username, false);
closes = true;
} else if (UNBAN.equals(action)) {
p.setBanned(false);
p.setSuspendedUntil(Instant.EPOCH);
accounts.setEnabled(username, true);
} else if (RESET_TROPHIES.equals(action)) {
p.setTrophies(0);
} else if (REVOKE_ITEMS.equals(action)) {
p.setOwned(Catalog.DEFAULT_OUTFIT + "," + Catalog.DEFAULT_EMOTES);
p.setOutfit(Catalog.DEFAULT_OUTFIT);
}
profiles.save(p);
AdminAction a = new AdminAction();
a.setId(Ids.next());
a.setAdmin(admin);
a.setUsername(username);
a.setAction(SUSPEND.equals(action) ? SUSPEND + " " + days + "d" : action);
a.setReason(reason);
a.setCreatedAt(Instant.now());
audit.insert(a);
return closes;
}
/// Lifts suspensions that have run their course.
@Scheduled(fixedDelay = 60000, initialDelay = 60000, lock = "splat-suspensions")
public void liftExpired() {
lift(Clock.now());
}
@Transactional
public int lift(long now) {
int lifted = 0;
for (Profile p : profiles.findAll()) {
if (!p.isBanned() && p.getSuspendedUntil().isAfter(Instant.EPOCH)
&& p.getSuspendedUntil().toEpochMilli() <= now) {
p.setSuspendedUntil(Instant.EPOCH);
profiles.save(p);
accounts.setEnabled(p.getUsername(), true);
lifted++;
}
}
return lifted;
}
@Transactional(readOnly = true)
public List<AuditDto> newest() {
return toDtos(audit.newest(new PageRequest(0, 200)));
}
@Transactional(readOnly = true)
public List<AuditDto> of(String username) {
return toDtos(audit.of(username, new PageRequest(0, 50)));
}
private static List<AuditDto> toDtos(List<AdminAction> rows) {
List<AuditDto> out = new ArrayList<AuditDto>();
for (AdminAction a : rows) {
out.add(new AuditDto(a.getId(), a.getAdmin(), a.getUsername(), a.getAction(), a.getReason(),
a.getCreatedAt().toEpochMilli()));
}
return out;
}
}
The engagement tools (campaign links, invitations, the home screen spotlight, pushes to an audience and scheduled pipelines) are the ones the Skirmish chapter walks through step by step, and they work in Splat unchanged.
Making it yours
Fields: add an
Arena.BuildertoCatalog.ARENAS. The mission list, the server’s playlist and the bots pick it up at once. Give a new box kind a color inArenaView.Markers: add a
MarkerDef, then sell it in the backend’s shopCatalog. The shipped markers are side-grades, not upgrades: the pump hits harder at range and the ramping marker sprays.Characters: every operator is a rigged glTF model,
model_op-*.glbincommon/src/main/resources, listed by team inModels.tools/pack-art.pypacks them from the CC0 sources inART.md, keeps the clips the game plays, and smooths their normals so a low-poly body shades as a round one;tools/pack-art.py --smoothre-shades models already packed. Swap a model by packing your own under the same name.The jokes: the briefings are in
Mode, the killfeed’s excuses inHud, and the emotes inMatchForm.The look: every color is a variable at the top of
theme.css:--bg: #0b0d10; --surface: #161a1f; --surface-light: #20262d; --edge: #2e353d; --edge-light: #4f5862; --coral: #f2b632; --coral-light: #ffd166; --coral-dark: #a8770f; --volt: #f2b632; --volt-dark: #a8770f; --cyan: #4aa8ff; --violet: #5c6670; --violet-glow: #aab3bd; --ice: #e8ecef; --ink: #0b0d10; --text: #f2f4f5; --text-muted: #8a949e; --danger: #f0443a;Store, ads and push: the product ids are in
AppConfig, and the receipt validator is chosen inServices:/// 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 = "splat.gems_80"; public static final String SKU_GEMS_500 = "splat.gems_500"; public static final String SKU_ROYAL_PASS = "splat.royal_pass";/// `splat.iap.provider` is one of: /// /// - `store`: receipts are checked with Apple (`splat.iap.apple.secret`) /// and Google Play (`splat.iap.google.package` and /// `splat.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("${splat.iap.provider:}") String chosen, @Value("${splat.iap.apple.secret:}") String appleSecret, @Value("${splat.iap.apple.bundle:}") String appleBundle, @Value("${splat.iap.google.package:}") String googlePackage, @Value("${splat.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("splat.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("splat.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 " + "splat.iap.apple.secret, or splat.iap.google.package and " + "splat.iap.google.token"); } if (appleSecret.trim().length() > 0 && appleBundle.trim().length() == 0) { // Without it, a genuine receipt from any other app could buy gems. return new MisconfiguredReceipts("splat.iap.apple.secret is set without " + "splat.iap.apple.bundle, the bundle id receipts must belong to"); } return new StoreReceipts(appleSecret.trim(), appleBundle.trim(), googlePackage.trim(), googleToken.trim()); }