Dash is an endless runner built around the things a game engine leaves to you. Friends race on the same track at the same moment and talk while they run, their voices going peer to peer over WebRTC (Races in lockstep). A player picks two points on a real street map and runs that route through their own city, the street names overhead and the landmarks beside the track (City runs). And every score on a board is a run the server played again from its inputs, by the same Java rules module the game runs. In other game frameworks each of those is a plugin, a second language or a separate backend. Here they’re ordinary Codename One code in one project, beside the levels that are data, the camera that swings from side on into 3D halfway down a track, and the console that runs the game.
Dash shares the module layout, the accounts and sign-in, push notifications and the operations console with Skirmish. This chapter shows where each part lives and what to change to make the game yours.
Running it
The project is in scripts/dash, 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:
mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase
The demo accounts are player@, rival@, trouble@ and admin@dash.example, each
with the password dash-demo. For the native iOS app, generate the Xcode project on a
Mac and open it:
mvn package -DskipTests -Dcodename1.platform=ios -Dcodename1.buildTarget=ios-source -Dopen=false
The tests run in three layers: the rules on their own, the server with whole races over its rooms, and the app in the simulator against a real server:
mvn -pl rules test
mvn -pl rules,shared,backend -Dcodename1.platform=backend test
./run-e2e.sh
What the sample does
A new player is taken straight into First Steps, a short level in which a coach
introduces one move at a time: jump the hurdle, slide under the barrier, change lanes
around the hauler. On a touch screen four buttons sit under the thumbs — left
with slide above it in one corner, right with jump above it in the other — drawn by the framework’s
TouchControls; swipes and taps work anywhere else, and the settings can hide the
buttons. On a desktop the keyboard (arrows, WASD and space) drives the same four
inputs.



The home screen leads to the Levels. Each official level shows its stars and has three buttons: Run plays this week’s track for it, Race the best runs it against the ghost of the week’s best run, and Scores opens its boards — all time, this week, today, friends, and one per crew. Any row on a board opens that run’s replay, with a scrubber, a pause, speeds, and a choice of whom the camera follows.








Quick race drops the player into a race with whoever is waiting for that level, and fills the empty lanes with bots after a few seconds. The Race friends button opens a party with a code and a link to share. Everyone who joins can talk: the microphone button connects each pair of players directly, and a muted player shows as muted to the rest. When the host starts, every phone runs the same seed in lockstep, the other runners appear in their own lanes, and the result screen ranks the places and pays out trophies and coins. The Daily dash is one endless track a day, the same for everyone, with its own board.



Track editor is where players make levels. A pack holds levels and the pieces they use, and is published for others to play, rate, race, and remix. Every level in a pack has its own board.





The rest — crews, friends and invitations, the shop, trophies, the runner choice and the settings — is the same kind of screen as the rest of the app, styled by one CSS file.






The modules
| Module | What it holds |
|---|---|
| The deterministic simulation: tracks, runners, obstacles, pickups, camera
cues, the bot, replays and level packs. No floating point, no clock, no
|
| The data transfer objects and |
| The app: the 3D track, the menus, the track editor, the race lobby and voice, the store, the admin console. |
| The server: accounts, re-simulated scores and leaderboards, the lockstep race rooms, level packs and moderation, progression, receipts, push. |
One simulation, two cameras
A level is a LevelDef: a number of lanes, a speed curve, a length or a goal, the
obstacles and pickups placed along it, a procedural tail built from chunks that get
harder with distance, the coach’s lines, and camera cues. Here is the official level
that starts as a side-scroller and becomes a 3D runner:
/// Opens side-on and swings into the chase camera at 300 metres, where the
/// lanes appear: the faux-2D-to-3D level.
public static LevelDef causeway() {
LevelDef d = new LevelDef(CAUSEWAY, "Neon Causeway");
d.theme = "neon";
d.length = 1500 * Track.UNIT;
d.stars = new int[] {900, 1400, 1800};
d.cue(0, ViewMode.SIDE_2D, 0);
d.cue(300, ViewMode.CHASE_3D, 40);
for (int m = 60; m < 280; m += 45) {
d.place(m, (m / 45) % 2 == 0 ? HURDLE : BARRIER, Placement.ALL_LANES);
d.place(m + 15, COIN, Placement.ALL_LANES);
}
d.dialog.add(new DialogLine(290 * Track.UNIT, "Vee", "The road opens up. Swipe sideways!", Input.LEFT));
d.proceduralFrom = 340 * Track.UNIT;
d.difficultyStep = 180 * Track.UNIT;
d.chunks.addAll(chunks());
return d;
}
The cues never reach the simulation. A runner always has a lane, a height and a
distance, and a hurdle is cleared the same way whether the player sees it from the
side or from behind. The camera is the renderer’s business: RunView reads the cue
under the runner, eases between the two framings, and leaves the rules alone. Because
of that, a level can change its view anywhere without changing who wins.
/// Places the camera for `mode`, into `out`: eye x, y, z, target x, y, z,
/// field of view, then the up vector's z (0 for upright, -1 looking down).
private static void framing(ViewMode mode, float x, float y, float z, float[] out) {
if (mode == ViewMode.SIDE_2D) {
// Off to the right, at the deck's height and tilted up: the runner
// a third in from the left with the track ahead in view, the deck
// in the lower third, and open sky over the skyline. A portrait
// frame is narrow along the track, so the camera stands well back
// to see enough of it ahead.
out[0] = 24f;
out[1] = 0.9f + y * 0.3f;
out[2] = -z - 1.3f;
out[3] = 0f;
out[4] = 4.6f + y * 0.3f;
out[5] = -z - 1.3f;
out[6] = 42f;
out[7] = 0f;
} else if (mode == ViewMode.TOP_DOWN) {
out[0] = x * 0.4f;
out[1] = 26f;
out[2] = -z + 2f;
out[3] = x * 0.4f;
out[4] = 0f;
out[5] = -z - 6f;
out[6] = 40f;
out[7] = -1f;
} else {
out[0] = x * 0.6f;
out[1] = 3.4f + y * 0.5f;
out[2] = -z + 6.5f;
out[3] = x * 0.6f;
out[4] = 1.0f + y * 0.3f;
out[5] = -z - 9f;
out[6] = 62f;
out[7] = 0f;
}
}
private final float[] from = new float[8];
private final float[] to = new float[8];
private final float[] blended = new float[8];
private void camera(Run run, Runner me, float x, float y, float z) {
Track t = run.track();
int mz = me.z;
float k = t.blendAt(mz) / 1000f;
k = k * k * (3f - 2f * k);
framing(t.previousViewAt(mz), x, y, z, from);
framing(t.viewAt(mz), x, y, z, to);
float[] v = blended;
for (int i = 0; i < 8; i++) {
v[i] = from[i] + (to[i] - from[i]) * k;
}
GameCamera cam = getCamera();
cam.setPerspective(v[6], 0.5f, 400f);
cam.setPosition(v[0], v[1], v[2]);
cam.setTarget(v[3], v[4], v[5]);
cam.setUp(0f, 1f + v[7], v[7]);
}
The view runs the rules on a fixed tick of 60 per second, whatever the frame rate, and draws between ticks by interpolating. A slow phone drops frames, never ticks, so the run stays the same run:
@Override
protected void update(double dt) {
if (device == null || getWidth() <= 0 || getHeight() <= 0) {
return;
}
time += (float) dt;
input();
if (driver.countdownTicks() > 0) {
accumulator = 0;
} else {
accumulator += dt * driver.speedPercent() / 100.0;
}
int steps = 0;
while (accumulator >= TICK && steps < MAX_STEPS_PER_FRAME) {
if (!driver.canStep()) {
// Online and waiting for the server: hold at the last tick.
accumulator = Math.min(accumulator, TICK);
break;
}
remember();
if (beforeTick != null) {
beforeTick.run();
}
driver.step();
events();
accumulator -= TICK;
steps++;
}
alpha = (float) Math.min(1, accumulator / TICK);
Gestures and keys become the four input bits in one place, and the same bits reach
every kind of run — solo, race, replay and playtest — through a Driver:
/// Swipes, the on-screen buttons and keys become [Input] bits. A tap jumps;
/// a swipe goes the way it points. Arrow keys and WASD do the same, Space
/// jumps.
private void input() {
if (!driver.interactive()) {
return;
}
GameInput in = getInput();
int bits = 0;
if (in.wasPointerPressed()) {
boolean button = onButton(in.getPointerX(), in.getPointerY());
pressX = button ? -1 : in.getPointerX();
pressY = in.getPointerY();
}
if (in.wasPointerReleased() && pressX >= 0) {
int dx = in.getPointerX() - pressX;
int dy = in.getPointerY() - pressY;
int min = CN.convertToPixels(SWIPE / 8f);
if (Math.abs(dx) < min && Math.abs(dy) < min) {
bits |= Input.JUMP;
} else if (Math.abs(dx) > Math.abs(dy)) {
bits |= dx < 0 ? Input.LEFT : Input.RIGHT;
} else {
bits |= dy < 0 ? Input.JUMP : Input.SLIDE;
}
pressX = -1;
}
bits |= edge(0, in.isGameKeyDown(Display.GAME_LEFT) || in.isKeyDown('a') || in.isKeyDown('A'), Input.LEFT);
bits |= edge(1, in.isGameKeyDown(Display.GAME_RIGHT) || in.isKeyDown('d') || in.isKeyDown('D'), Input.RIGHT);
bits |= edge(2, in.isGameKeyDown(Display.GAME_UP) || in.isKeyDown('w') || in.isKeyDown('W')
|| in.isKeyDown(' '), Input.JUMP);
bits |= edge(3, in.isGameKeyDown(Display.GAME_DOWN) || in.isKeyDown('s') || in.isKeyDown('S'), Input.SLIDE);
if (bits != 0) {
driver.input(bits);
}
}
The server is the referee
A run is never reported as a number. When it ends, the app sends the seed and the input log — a few hundred bytes for a long run — and the server builds the same level, plays the log through the same rules, and reads the score off its own simulation. A score that was typed in, or a log edited to dodge an obstacle the player hit, simply doesn’t reproduce:
Run played;
try {
played = LevelRun.verify(pack, levelId, replay);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(400, e.getMessage());
}
int won = played.stars(0);
int score = played.runner(0).score();
int paidBefore = paidStars(username, p.getId(), levelId);
LevelStars row = record(username, p.getId(), levelId, won, score);
int newStars = row.getPaidStars() - paidBefore;
p.setPlays(p.getPlays() + 1);
packs.save(p);
int coins = OFFICIAL_STAR_COINS * newStars;
int xp = OFFICIAL_STAR_XP * newStars;
if (username.equals(p.getOwner())) {
// Testing your own level is free, and pays nothing.
coins = 0;
xp = 0;
} else if (!p.isOfficial()) {
coins = Math.min(coins, COMMUNITY_LEVEL_COINS * newStars / 3);
xp = Math.min(xp, COMMUNITY_LEVEL_XP * newStars / 3);
coins = spendDailyAllowance(username, coins);
}
The verified run is then entered on every board it qualifies for. The endless
stretch of a level is built from the seed, so no board takes a seed the app picked
for itself: all takes only the seeds the server hands out, and week and day
count only the board’s own seed, so everyone is running the same track there:
@Component
public class Scores {
public static final String ALL = "all";
public static final String WEEK = "week";
public static final String DAY = "day";
public static final String FRIENDS = "friends";
public static final String CREW = "crew:";
/// The daily challenge is always this level, on the day's seed.
public static final String DAILY_LEVEL = Content.ENDLESS;
private final ScoreRepository scores;
private final ProfileRepository profiles;
private final CrewMemberRepository members;
private final Referrals referrals;
private final GamePushes pushes;
public Scores(ScoreRepository scores, ProfileRepository profiles, CrewMemberRepository members,
Referrals referrals, GamePushes pushes) {
this.scores = scores;
this.profiles = profiles;
this.members = members;
this.referrals = referrals;
this.pushes = pushes;
}
/// Days since the epoch, UTC: the day number the app counts in too
/// (`LevelsForm.week`), so both derive the same weekly seed. Not
/// [Clock#dayOf], which answers the day's first millisecond.
public static int today() {
return (int) (Clock.now() / Clock.DAY);
}
public static String weekBoard() {
return "w" + (today() / 7);
}
public static String dayBoard() {
return "d" + today();
}
public DailyDto daily() {
long next = (today() + 1L) * Clock.DAY;
return new DailyDto(today(), DAILY_LEVEL, Long.toString(Content.dailySeed(today())),
(int) ((next - Clock.now()) / 1000));
}
/// Enters a verified run on every board it qualifies for, and answers the
/// run's rank on `all` (1 is first).
@Transactional(rollbackFor = IOException.class)
public int submit(String username, String level, Replay replay, Run played) throws IOException {
Profile p = profiles.findById(username);
if (p == null || p.isBanned()) {
return 0;
}
Runner r = played.runner(0);
if (fairSeed(level, replay.seed(), played.level().contentHash())) {
int before = enter(ALL, level, p, replay, played);
if (before != NOT_BETTER) {
tellFriendsBeaten(p, level, before, r.score());
}
}
if (replay.seed() == Content.weeklySeed(today() / 7, level)) {
enter(weekBoard(), level, p, replay, played);
}
if (DAILY_LEVEL.equals(level) && replay.seed() == Content.dailySeed(today())) {
enter(dayBoard(), level, p, replay, played);
}
Long above = scores.above(ALL, level, r.score());
return above == null ? 1 : above.intValue() + 1;
}
/// Whether `seed` is one the app was handed rather than one it picked.
/// The endless stretch of a level is built from the seed, so a board
/// that took any seed would let a player search offline for an easy
/// track. The app plays this or last week's seed (a run can straddle the
/// change), today's or yesterday's daily seed, a pack level's content
/// hash, or the fixed seed of the tutorial and the guest run.
static boolean fairSeed(String level, long seed, long contentHash) {
int week = today() / 7;
return seed == FIXED_SEED || seed == contentHash
|| seed == Content.weeklySeed(week, level) || seed == Content.weeklySeed(week - 1, level)
|| DAILY_LEVEL.equals(level)
&& (seed == Content.dailySeed(today()) || seed == Content.dailySeed(today() - 1));
}
/// The seed the tutorial and the guest run always play.
static final long FIXED_SEED = 1L;
/// What [#enter] answers when the run is no better than the player's
/// best on that board.
private static final int NOT_BETTER = -2;
/// The most friends one improved score is pushed to.
private static final int MAX_BEATEN_PUSHES = 20;
/// Friends whose best the new score passed, and that the player's old best
/// had not: an improvement that passes nobody new sends nothing.
private void tellFriendsBeaten(Profile p, String level, int before, int score) throws IOException {
int sent = 0;
for (String f : friends(p.getUsername())) {
if (sent >= MAX_BEATEN_PUSHES) {
return;
}
if (f.equals(p.getUsername())) {
continue;
}
Score theirs = scores.findById(ALL + "|" + level + "|" + f);
if (theirs != null && theirs.getScore() < score && theirs.getScore() >= before) {
pushes.scoreBeaten(f, p.getDisplayName(), level, score);
sent++;
}
}
}
/// Answers the player's previous best on the board (-1 when there was
/// none), or [#NOT_BETTER] when the run did not improve on it.
private int enter(String board, String level, Profile p, Replay replay, Run played) {
Runner r = played.runner(0);
String id = board + "|" + level + "|" + p.getUsername();
Score s = scores.findById(id);
if (s != null && s.getScore() >= r.score()) {
return NOT_BETTER;
}
int before = s == null ? -1 : s.getScore();
boolean fresh = s == null;
if (fresh) {
s = new Score();
s.setId(id);
s.setBoard(board);
s.setLevel(level);
s.setUsername(p.getUsername());
}
s.setDisplayName(p.getDisplayName());
s.setScore(r.score());
s.setDistance(r.z / 1000);
s.setCoins(r.coins);
s.setStars(played.stars(0));
s.setSeed(replay.seed());
s.setCreatedAt(Instant.now());
s.setReplay(replay.encode());
if (fresh) {
scores.insert(s);
} else {
scores.save(s);
}
return before;
}
/// A board, best first. `friends` is the caller, the players they
/// invited and everyone in their crews; `crew:<code>` is one crew.
@Transactional(readOnly = true)
public List<ScoreDto> top(String username, String level, String board, int limit) throws IOException {
int n = limit < 1 || limit > 100 ? 50 : limit;
String key = boardKey(board);
Set<String> only = null;
if (FRIENDS.equals(board)) {
only = friends(username);
} else if (board != null && board.startsWith(CREW)) {
only = new HashSet<String>();
for (CrewMember m : members.members(board.substring(CREW.length()), new PageRequest(0, 100))) {
only.add(m.getUsername());
}
}
List<ScoreDto> out = new ArrayList<ScoreDto>();
int rank = 1;
int page = 0;
while (out.size() < n && page < 20) {
List<Score> rows = scores.top(key, level, new PageRequest(page, only == null ? n : 100));
for (Score s : rows) {
if (only != null && !only.contains(s.getUsername())) {
continue;
}
if (out.size() < n) {
out.add(new ScoreDto(rank, s.getDisplayName(), s.getScore(), s.getDistance(), s.getCoins(),
s.getStars(), s.getId()));
}
rank++;
}
if (only == null || rows.size() < 100) {
break;
}
page++;
}
return out;
}
/// The run just above the caller's best: a ghost close enough to chase.
@Transactional(readOnly = true)
public GhostDto ghost(String username, String level, String board) throws IOException {
String key = boardKey(board);
Score mine = scores.findById(key + "|" + level + "|" + username);
int target = mine == null ? 0 : mine.getScore();
Score pick = null;
Score best = null;
// Best first, so the last one above the target is the closest.
for (Score s : scores.top(key, level, new PageRequest(0, 100))) {
if (s.getUsername().equals(username)) {
continue;
}
if (best == null) {
best = s;
}
if (s.getScore() > target) {
pick = s;
}
}
if (pick == null) {
pick = best;
}
if (pick == null) {
return null;
}
return new GhostDto(pick.getDisplayName(), pick.getScore(), pick.getReplay());
}
public Score byId(String id) {
return scores.findById(id);
}
static String boardKey(String board) {
if (WEEK.equals(board)) {
return weekBoard();
}
if (DAY.equals(board)) {
return dayBoard();
}
return ALL;
}
private Set<String> friends(String username) throws IOException {
Set<String> out = new HashSet<String>();
out.add(username);
out.addAll(referrals.invitees(username));
for (CrewMember mine : members.crewsOf(username, new PageRequest(0, 20))) {
for (CrewMember m : members.members(mine.getCode(), new PageRequest(0, 100))) {
out.add(m.getUsername());
}
}
return out;
}
}
A better score on the all board tells the friends whose best it just passed, and
only them, with a push that opens that level’s board:
private void tellFriendsBeaten(Profile p, String level, int before, int score) throws IOException {
int sent = 0;
for (String f : friends(p.getUsername())) {
if (sent >= MAX_BEATEN_PUSHES) {
return;
}
if (f.equals(p.getUsername())) {
continue;
}
Score theirs = scores.findById(ALL + "|" + level + "|" + f);
if (theirs != null && theirs.getScore() < score && theirs.getScore() >= before) {
pushes.scoreBeaten(f, p.getDisplayName(), level, score);
sent++;
}
}
}
Races in lockstep
A race is run by the server in rooms. Every phone sends its inputs as they happen; the room collects them per tick, steps its own copy of the run, and broadcasts each tick’s inputs to every seat. A phone only advances when it has the server’s tick, so they all see the same race, and the server’s copy is the one that decides the places:
final class RaceRoom {
/// Inputs one seat may send in [#FLOOD_WINDOW] ticks. A thumb manages
/// about eight a second; a script manages sixty.
static final int FLOOD_LIMIT = 16;
static final int FLOOD_WINDOW = Run.TICKS_PER_SECOND;
final String id;
final String level;
final Seat[] seats;
final Run run;
final Replay replay;
final Bot[] bots;
/// When tick 0 runs: the end of the countdown.
final long startedAt;
final List<WebSocketSession> spectators = new ArrayList<WebSocketSession>();
/// Every input in stamp order, as `{tick, seat, bits}`; what TICKS sends.
final List<int[]> log = new ArrayList<int[]>();
private final Map<Integer, int[]> pending = new HashMap<Integer, int[]>();
/// The server's hash every [GameProtocol#HASH_EVERY] ticks, for SYNC.
final Map<Integer, Long> hashes = new HashMap<Integer, Long>();
int sent;
int lastSentTick;
RaceRoom(String id, String level, LevelDef def, Catalog catalog, long seed, Seat[] seats, long startedAt) {
this.id = id;
this.level = level;
this.seats = seats;
this.run = new Run(def, catalog, seed, seats.length);
this.replay = new Replay(def.id, def.contentHash(), seed, seats.length);
this.startedAt = startedAt;
this.bots = new Bot[seats.length];
for (int i = 0; i < seats.length; i++) {
if (seats[i].isBot()) {
// Bots of mixed skill, seeded from the race so a replay of the
// room makes the same choices.
bots[i] = new Bot(seed + 977L * i, 55 + 12 * (i % 4));
}
}
}
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. Answers false when it is dropped: a flood, a runner
/// that is out, or a race that has not started.
boolean intent(int seat, int bits) {
if (seat < 0 || run.over() || !run.runner(seat).running()) {
return false;
}
Seat s = seats[seat];
int now = run.tick();
int inWindow = 0;
for (int i = 0; i < s.recentCount; i++) {
if (now - s.recent[i] < FLOOD_WINDOW) {
inWindow++;
}
}
if (inWindow >= FLOOD_LIMIT) {
s.floods++;
return false;
}
s.recent[s.recentCount % s.recent.length] = now;
s.recentCount = s.recentCount < s.recent.length ? s.recentCount + 1 : s.recentCount;
int at = now + GameProtocol.INPUT_DELAY;
int clean = bits & Input.ALL;
if (clean == 0) {
return false;
}
int[] row = pending.get(Integer.valueOf(at));
if (row == null) {
row = new int[seats.length];
pending.put(Integer.valueOf(at), row);
}
row[seat] |= clean;
log.add(new int[] {at, seat, clean});
return true;
}
void step() {
int now = run.tick();
int[] row = pending.remove(Integer.valueOf(now));
if (row == null) {
row = new int[seats.length];
}
for (int i = 0; i < seats.length; i++) {
if (bots[i] != null) {
int b = bots[i].decide(run, i);
if (b != 0) {
row[i] |= b;
log.add(new int[] {now, i, b});
}
}
if (row[i] != 0) {
replay.record(now, i, row[i]);
}
}
run.step(row);
if (run.tick() % GameProtocol.HASH_EVERY == 0) {
hashes.put(Integer.valueOf(run.tick()), Long.valueOf(run.stateHash()));
hashes.remove(Integer.valueOf(run.tick() - GameProtocol.HASH_EVERY * 20));
}
}
/// Over when the rules say so, or when no player is still running: bots
/// racing each other is not worth simulating.
boolean isOver() {
if (run.over()) {
return true;
}
for (int i = 0; i < seats.length; i++) {
if (!seats[i].isBot() && !seats[i].left && run.runner(i).running()) {
return false;
}
}
return true;
}
/// Each seat's place, 0 first: runners who finished by when they did,
/// then everyone else by score.
int[] places() {
int n = seats.length;
int[] order = new int[n];
for (int i = 0; i < n; i++) {
// Insertion sort: six runners at most, and no comparator object.
int j = i;
while (j > 0 && ahead(i, order[j - 1])) {
order[j] = order[j - 1];
j--;
}
order[j] = i;
}
int[] place = new int[n];
for (int p = 0; p < n; p++) {
place[order[p]] = p;
}
return place;
}
private boolean ahead(int a, int b) {
Runner ra = run.runner(a);
Runner rb = run.runner(b);
if (ra.finished != rb.finished) {
return ra.finished;
}
if (ra.finished && ra.endTick != rb.endTick) {
return ra.endTick < rb.endTick;
}
return ra.score() > rb.score();
}
int humans() {
int h = 0;
for (Seat s : seats) {
if (!s.isBot()) {
h++;
}
}
return h;
}
/// The seat in front right now, for the live list.
int leader() {
int best = 0;
for (int i = 1; i < seats.length; i++) {
if (run.runner(i).z > run.runner(best).z) {
best = i;
}
}
return best;
}
}
On the phone, RaceDriver is the other half. It also sends a hash of its state every
second. A phone whose hash disagrees with the server’s is sent the race’s log to catch
up, and one that keeps disagreeing is flagged for a moderator — a modified client
can’t stay in a race unnoticed:
public final class RaceDriver extends Driver {
/// What the race screen wants to hear besides the ticks.
public interface Listener {
void emote(String from, String id);
void chat(String from, String text);
/// The race is over: `places[i]` is runner i's place, 0 for the winner.
void ended(int[] places, int[] scores, int trophies, int coins, String raceId);
void lost();
}
private final GameLink link;
private final boolean spectator;
private Listener listener;
private Levels.Playable level;
private String raceId;
private String[] names = new String[0];
private int allowed;
private long startsAt;
private int[] log = new int[96];
private int logSize;
private int next;
private int[] inputs;
private int lastSent;
private int sentThisTick;
public RaceDriver(GameLink link, Levels.Playable level, Map<String, Object> start, boolean spectator) {
this.link = link;
this.level = level;
this.spectator = spectator;
restart(start);
}
public void setListener(Listener listener) {
this.listener = listener;
}
public String raceId() {
return raceId;
}
/// A `start` message: the first one, or the server's answer to a hash
/// that did not match. Either way the run is rebuilt from its log.
private void restart(Map<String, Object> m) {
raceId = GameLink.text(m, "race");
me = spectator ? 0 : GameLink.number(m, "you");
names = strings(m.get("names"));
run = new Run(level.level, level.catalog, Long.parseLong(GameLink.text(m, "seed")),
Math.max(1, names.length));
inputs = new int[run.runnerCount()];
logSize = 0;
next = 0;
append(GameLink.text(m, "log"));
allowed = GameLink.number(m, "tick");
startsAt = System.currentTimeMillis() + GameLink.number(m, "startIn");
// Catch up at once to where the server already is.
while (run.tick() < allowed && !run.over()) {
stepRun();
}
}
/// Everything the server sends during a race 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, "i"));
allowed = Math.max(allowed, GameLink.number(m, "t"));
} 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)) {
listener.ended(ints(GameLink.text(m, "places")), ints(GameLink.text(m, "scores")),
GameLink.number(m, "trophies"), GameLink.number(m, "coins"), GameLink.text(m, "race"));
} else if (GameProtocol.KICKED.equals(type)) {
listener.lost();
}
}
@Override
public boolean canStep() {
return run.tick() < allowed && !run.over();
}
@Override
public void step() {
stepRun();
if (!spectator && run.tick() % GameProtocol.HASH_EVERY == 0) {
link.send("{\"type\":\"" + GameProtocol.SYNC + "\",\"t\":" + run.tick() + ",\"h\":\""
+ Long.toHexString(run.stateHash()) + "\"}");
}
}
private void stepRun() {
for (int i = 0; i < inputs.length; i++) {
inputs[i] = 0;
}
int tick = run.tick();
while (next < logSize && log[next * 3] <= tick) {
int seat = log[next * 3 + 1];
if (log[next * 3] == tick && seat >= 0 && seat < inputs.length) {
inputs[seat] |= log[next * 3 + 2];
}
next++;
}
run.step(inputs);
}
/// Only asks. The input takes effect when the server stamps it and sends
/// it back -- to every runner at once. At most one message a tick: a
/// swipe fires once, not once per frame it is held.
@Override
public void input(int bits) {
if (spectator || bits == 0 || run.over()) {
return;
}
if (lastSent == run.tick() && (sentThisTick & bits) == bits) {
return;
}
if (lastSent != run.tick()) {
sentThisTick = 0;
}
lastSent = run.tick();
sentThisTick |= bits;
link.send("{\"type\":\"" + GameProtocol.INPUT + "\",\"b\":" + bits + "}");
}
@Override
public boolean interactive() {
return !spectator;
}
@Override
public int countdownTicks() {
long left = startsAt - System.currentTimeMillis();
return left <= 0 ? 0 : (int) (left * Run.TICKS_PER_SECOND / 1000);
}
public void emote(String id) {
link.send("{\"type\":\"" + GameProtocol.EMOTE + "\",\"id\":\"" + id + "\"}");
}
public void leave() {
link.send("{\"type\":\"" + GameProtocol.LEAVE + "\"}");
}
public GameLink link() {
return link;
}
@Override
public String name(int runner) {
if (!spectator && runner == me) {
return "You";
}
return runner < names.length ? names[runner] : super.name(runner);
}
@Override
public void dispose() {
link.close();
}
/// `tick,seat,bits;...`, appended in the order the server stamped them.
private void append(String encoded) {
List<String> items = Text.split(encoded, ';');
for (int i = 0; i < items.size(); i++) {
List<String> f = Text.split(items.get(i), ',');
if (f.size() != 3) {
continue;
}
if ((logSize + 1) * 3 > log.length) {
int[] n = new int[log.length * 2];
System.arraycopy(log, 0, n, 0, logSize * 3);
log = n;
}
log[logSize * 3] = Integer.parseInt(f.get(0));
log[logSize * 3 + 1] = Integer.parseInt(f.get(1));
log[logSize * 3 + 2] = Integer.parseInt(f.get(2));
logSize++;
}
}
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;
}
static String[] strings(Object list) {
if (!(list instanceof List)) {
return new String[0];
}
List<?> l = (List<?>) list;
String[] out = new String[l.size()];
for (int i = 0; i < out.length; i++) {
Object v = l.get(i);
out[i] = v == null ? "" : v.toString();
}
return out;
}
}
Voice is peer to peer. The game’s own socket carries the offers, answers and ICE candidates, and Codename One’s WebRTC API connects each pair of players in the party directly; the server never carries audio:
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 mesh suits a party of four. For larger groups, put a media server (an SFU) behind the same signalling messages; the client code above doesn’t change.
The track editor
The editor draws the level as a strip: distance runs left to right, one row per lane, an all-lanes row on top, and the camera cues as flags along the bottom. Tapping a cell puts the selected piece there:
public final class TrackTimeline extends Component {
/// What a tap does.
public interface Tool {
/// Applies the tool at `metres` in `lane` ([Placement#ALL_LANES] for
/// the top row); returns true when the level changed.
boolean apply(LevelDef level, int metres, int lane);
}
/// Hears about every change, to refresh the problems list.
public interface Changed {
void changed();
}
private static final int STEP_METRES = 5;
private final LevelDef level;
private final Catalog catalog;
private Tool tool;
private Changed changed;
private int pressX;
private int pressY;
public TrackTimeline(LevelDef level, Catalog catalog) {
this.level = level;
this.catalog = catalog;
setUIID("Timeline");
setFocusable(true);
setName("timeline");
}
public void setTool(Tool tool) {
this.tool = tool;
}
public void setChanged(Changed changed) {
this.changed = changed;
}
private int ppm() {
return Math.max(4, CN.convertToPixels(1.6f));
}
private int rowH() {
return CN.convertToPixels(6);
}
private int lanes() {
return Math.max(1, level.lanes);
}
/// Metres the strip shows: the level's length, or the hand-built part of
/// an endless one plus room to keep building.
private int metres() {
int end = level.length > 0 ? level.length / Track.UNIT : 0;
for (Placement p : level.placements) {
end = Math.max(end, p.z / Track.UNIT + 40);
}
for (ViewCue c : level.cues) {
end = Math.max(end, c.at / Track.UNIT + 40);
}
return Math.max(end, 200);
}
@Override
protected Dimension calcPreferredSize() {
return new Dimension(metres() * ppm() + rowH(), rowH() * (lanes() + 2));
}
@Override
public void paint(Graphics g) {
int x0 = getX();
int y0 = getY();
int ppm = ppm();
int rh = rowH();
int w = metres() * ppm;
g.setColor(0x1d2233);
g.fillRect(x0, y0, w + rh, rh * (lanes() + 2));
Font small = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_SMALL);
g.setFont(small);
for (int r = 0; r <= lanes(); r++) {
g.setColor(r == 0 ? 0x2b3350 : r % 2 == 0 ? 0x252b40 : 0x2a3148);
g.fillRect(x0, y0 + r * rh, w + rh, rh - 1);
}
for (int m = 0; m <= metres(); m += 10) {
g.setColor(m % 50 == 0 ? 0x5a6488 : 0x3a4262);
g.drawLine(x0 + m * ppm, y0, x0 + m * ppm, y0 + rh * (lanes() + 1));
if (m % 50 == 0) {
g.setColor(0xaab4d8);
g.drawString(m + " m", x0 + m * ppm + 2, y0 + rh * (lanes() + 1) + 2);
}
}
if (level.length > 0) {
int fx = x0 + level.length / Track.UNIT * ppm;
g.setColor(0xffffff);
g.fillRect(fx, y0, 3, rh * (lanes() + 1));
}
for (Placement p : level.placements) {
int px = x0 + p.z / Track.UNIT * ppm;
int row = p.lane == Placement.ALL_LANES ? 0 : 1 + Math.min(p.lane, lanes() - 1);
ObstacleDef o = catalog.obstacle(p.defId);
int width = o == null ? ppm * 2 : Math.max(ppm, o.depth / Track.UNIT * ppm);
g.setColor(color(p.defId));
if (row == 0) {
g.fillRect(px, y0 + 2, width, rh * (lanes() + 1) - 4);
g.setColor(0x000000);
} else if (o == null) {
g.fillArc(px, y0 + row * rh + rh / 4, rh / 2, rh / 2, 0, 360);
} else {
g.fillRect(px, y0 + row * rh + 3, width, rh - 6);
}
}
for (ViewCue c : level.cues) {
int cx = x0 + c.at / Track.UNIT * ppm;
int by = y0 + rh * (lanes() + 1);
g.setColor(0xffd34d);
g.drawLine(cx, y0, cx, by + rh);
int bw = Math.max(ppm, c.blendMillis / Track.UNIT * ppm);
g.setAlpha(90);
g.fillRect(cx, by, bw, rh / 2);
g.setAlpha(255);
g.drawString(modeName(c.mode), cx + 3, by + rh / 2);
}
}
private int color(String defId) {
ObstacleDef o = catalog.obstacle(defId);
if (o != null) {
if (o.clearance == Clearance.JUMP) {
return o.height == 0 ? 0x101018 : 0xff9a3c;
}
return o.clearance == Clearance.SLIDE ? 0xe0405a : 0x4a78c8;
}
PickupDef k = catalog.pickup(defId);
return k != null && k.kind == PickupKind.COIN ? 0xffcc33 : 0x3ce0ff;
}
static String modeName(ViewMode m) {
return m == ViewMode.SIDE_2D ? "2D" : m == ViewMode.TOP_DOWN ? "Top" : "3D";
}
@Override
public void pointerPressed(int x, int y) {
super.pointerPressed(x, y);
pressX = x;
pressY = y;
}
@Override
public void pointerReleased(int x, int y) {
super.pointerReleased(x, y);
int slop = CN.convertToPixels(2);
if (tool == null || Math.abs(x - pressX) > slop || Math.abs(y - pressY) > slop) {
return;
}
int lx = x - getAbsoluteX() + getScrollX();
int ly = y - getAbsoluteY() + getScrollY();
int metres = (lx / ppm()) / STEP_METRES * STEP_METRES;
int row = ly / rowH();
int lane = row == 0 ? Placement.ALL_LANES : Math.min(lanes() - 1, row - 1);
if (tool.apply(level, metres, lane)) {
setShouldCalcPreferredSize(true);
getParent().revalidateLater();
repaint();
if (changed != null) {
changed.changed();
}
}
}
}
The palette is every piece the pack can place — the game’s own and the pack’s custom obstacles and pickups — plus one tool per camera mode and an eraser:
private static Container palette(final LevelDef l, final Catalog catalog, final TrackTimeline timeline) {
Container c = new Container(new FlowLayout());
ButtonGroup group = new ButtonGroup();
List<String> ids = new ArrayList<String>();
ids.add(Content.HURDLE);
ids.add(Content.BARRIER);
ids.add(Content.HAULER);
ids.add(Content.GAP);
ids.add(Content.COIN);
ids.add(Content.GEM);
ids.add(Content.MAGNET);
ids.add(Content.SHIELD);
ids.add(Content.BOOST);
for (Object d : PackEditor.pack.defs) {
if (d instanceof ObstacleDef) {
ids.add(((ObstacleDef) d).id);
} else if (d instanceof PickupDef) {
ids.add(((PickupDef) d).id);
}
}
boolean first = true;
for (final String id : ids) {
RadioButton b = tool(group, id, "tool-" + id);
b.addActionListener(e -> timeline.setTool((level, metres, lane) -> place(level, catalog, id, metres, lane)));
if (first) {
b.setSelected(true);
timeline.setTool((level, metres, lane) -> place(level, catalog, id, metres, lane));
first = false;
}
c.add(b);
}
ViewMode[] modes = ViewMode.values();
for (int i = 0; i < modes.length; i++) {
final ViewMode m = modes[i];
RadioButton b = tool(group, "Cue " + TrackTimeline.modeName(m), "tool-cue-" + m.name());
b.addActionListener(e -> timeline.setTool((level, metres, lane) -> cue(level, m, metres)));
c.add(b);
}
RadioButton erase = tool(group, "Erase", "tool-erase");
erase.addActionListener(e -> timeline.setTool((level, metres, lane) -> place(level, catalog, ERASE, metres,
lane)));
c.add(erase);
return c;
}
Playtest runs the draft as a player would, on the same rules, and is never submitted. Publishing runs the validator the server runs too, and that includes sending the bot down the level: a level that the bot can’t finish can’t be published, so every published level can be beaten.
City runs
Run your city on the levels screen opens a street map. The player taps a start and a finish, a routing service finds the way between them, and the route becomes a level: every street a stretch of track, every turn a corner taken on the side it turns to, with the next street’s name called out before it. What lies between the corners is drawn from the game’s own chunks, harder as the run goes on.
The map is Codename One’s MapView, which draws OpenStreetMap data itself on every
platform, and the route comes from Routing. CityForm reduces the route to
streets and turns (CityForm.toRoute), reading each turn off the geometry where
two streets meet, and hands the result to the rules as a CityRoute:
CityRoute route = new CityRoute("Old Harbour")
.leg(420, CityRoute.STRAIGHT, "Quay Street")
.leg(310, CityRoute.LEFT, "Rope Walk");
LevelDef level = route.level();
level() is deterministic, so the server needs no map. A city run’s result
carries the encoded route next to its replay; the server builds the same level
from it and plays the replay on it as it would any other. The level’s id is a hash
of the route, so each route has a board of its own, and editing a route into an
easier one names a different level with a different board. Since any edit makes a
new level, a city run’s coins and experience both come out of the community
levels' daily allowance; a new route never pays beyond it.
Street names and landmarks
The run follows the map, not just its turns. The street the runner is on sits under the score and changes at every corner, a green street sign spans the track where a new street begins, and the places along the way — churches, museums, stations, parks, monuments — stand beside the track with their names on a board as the runner passes, and the coach calls them out ("Passing Guildhall."). The small map in the corner shows how far along the route the runner is.
The places come from OpenStreetMap through the Overpass API: Landmarks asks for
named tourist, historic, religious, station and park features within 70 meters of
the route, projects each onto the line, keeps them at least 60 meters apart and at
most twelve, and adds them to the CityRoute. They’re part of the route the server
receives, so they’re part of the level and its hash, and the server places the
same boards:
CityRoute withPlace = new CityRoute("Old Harbour")
.leg(420, CityRoute.STRAIGHT, "Quay Street")
.landmark(180, CityRoute.MUSEUM, "Maritime Museum");
The public Overpass server is shared and rate limited. A game with players points
Landmarks.setSource at an Overpass instance of its own, or at any other source of
places: the interface is one method that takes the route’s path.




Before you ship this, point Routing at a routing server of your own:
Routing.setService(new OsrmRouteService("https://osrm.example.com")). The
default is OSRM’s public demo server, which is rate limited, has no service
guarantee and serves only its car profile, whatever travel mode the request
asks for. The map data is OpenStreetMap’s, whose license
asks for credit wherever it’s shown; the city screen carries that line, and a
reskin has to keep it.
Engagement and live operations
Players come back for what changes: three quests a day, picked per player from
Goals (run 500 meters, collect 100 coins, win a race…), a daily dash with one
track for everyone and its own board, chests that open over time, a level and
trophies to climb, achievements, and boards that reset every week. Rivals already
on the boards from the first run keep a new player company: on a development server
DemoAccounts seeds a field of runners whose scores are the rules' bot playing each
week’s track, submitted and verified like anyone’s.
The operator’s side is in the admin console:
Reports chart players, runs, races, purchases and invitations over time.
Query finds players by what the game knows about them — days away, races, purchases, invitations, trophies — with conditions such as
daysAway >= 7.Pipelines turn a query into an action that runs every hour: a push to players away for a week that opens the shop, 20 gems to a player who reached 300 trophies and never bought anything. Each player is acted on once per cool-down, and a push carries the campaign
pipeline-<name>, so its opens show in Codename One analytics.Promote sends a push to a segment (broadcast) and sets the banner on every player’s home screen (spotlight), which can open a level, the shop or the editor.
/// 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 = "dash-pipelines")
@Transactional(rollbackFor = IOException.class)
public void runAll() throws IOException {
for (Pipeline p : pipelines.byName()) {
if (p.isEnabled()) {
run(p.getId());
}
}
}
On a phone the console is a list of pages; on a desktop or a tablet it’s a sidebar and a page, and the Promote tab is shown:














Moderation
Published packs, reports and automatic flags — a run that doesn’t re-simulate, a race hash that disagrees — land in the admin console. A moderator can hide, restore or promote a pack, look at its files, and act on a player:
@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 = "dash-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 console also lists every race being run, and any of them can be watched live from the same lobby a player uses to spectate.
Making it yours
Levels:
Contentinrulesholds the official levels and the chunks the procedural tails are built from. Change one and runmvn -pl rules test: the golden replays will tell you which recorded runs no longer end the same way, and the bot test whether every level can still be finished. BumpRulesVersion.IDwhen you change the rules themselves, so old replays aren’t judged by new rules.Obstacles and pickups: add a definition in
Content.catalog(), a colour or a model inRunView, and the editor offers it at once.Runners: there are no model files.
Rigbuilds every runner from capsules, rounded boxes and glowing trim and poses each joint in code, andPortraitspaints the same figure flat for the menus. Both take their colours from anOutfit, so a new runner is one line:/// The outfit for a shop runner id; `fallback` picks one for a bot. public static Outfit of(String runnerId, int fallback) { if ("runner-nova".equals(runnerId)) { return new Outfit(runnerId, 0xff6c4dff, 0xffc8ff3d, 0xff1a1440); } if ("runner-kite".equals(runnerId)) { return new Outfit(runnerId, 0xff0fb5a8, 0xffffe14d, 0xff0b2f33); } if ("runner-dash".equals(runnerId) || runnerId == null && fallback == 0) { return new Outfit("runner-dash", 0xffff5a3c, 0xff35e8ff, 0xff241326); } return CROWD[Math.abs(fallback) % CROWD.length]; }The track:
Themeholds each level theme’s sky, light, surface, rails and skyline, and paints the textures it needs at startup.The look: every colour is a variable at the top of
theme.css:--bg: #0b0e1d; --surface: #151a33; --surface-light: #1f2647; --edge: #2c3563; --edge-light: #4a5594; --coral: #ff4d5e; --coral-light: #ff8a4c; --coral-dark: #c22745; --volt: #d4ff3a; --volt-dark: #8fb800; --cyan: #2ee6ff; --violet: #8a5cff; --violet-glow: #c3b0ff; --ice: #eaf6ff; --ink: #0b0e1d; --text: #ffffff; --text-muted: #93a0c8; --danger: #ff3b5c;Store, ads and push: these work as they do in Skirmish. The product ids are in
AppConfig, the receipt validator is chosen inServices, and the push key is read the same way:/// 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 = "dash.gems_80"; public static final String SKU_GEMS_500 = "dash.gems_500"; public static final String SKU_ROYAL_PASS = "dash.royal_pass";/// `dash.iap.provider` is one of: /// /// - `store`: receipts are checked with Apple (`dash.iap.apple.secret`) /// and Google Play (`dash.iap.google.package` and /// `dash.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("${dash.iap.provider:}") String chosen, @Value("${dash.iap.apple.secret:}") String appleSecret, @Value("${dash.iap.google.package:}") String googlePackage, @Value("${dash.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("dash.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("dash.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 " + "dash.iap.apple.secret, or dash.iap.google.package and " + "dash.iap.google.token"); } return new StoreReceipts(appleSecret.trim(), googlePackage.trim(), googleToken.trim()); }
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 Dash unchanged.