An offline-first Android app for tracking pair-duel tournaments, with peer-to-peer sync across all devices on the same Wi-Fi network. No server, no accounts.
- Create tournaments. Any device can create one; it appears on every device on the network. At
creation you pick two settings, fixed for the life of the tournament and synced with it:
- Duel format — Best of 3 (up to 3 games per duel) or Single game (one game per duel).
- Ranking strictness — Lenient (80%), Standard (95%), or Strict (99%) — see winner rule.
- Record duels. For a best-of-3 tournament, enter each of up to 3 games as A wins / Draw / B wins (or not played); for a single-game tournament you enter one game. The app derives the duel outcome (more game-wins wins; equal = tie).
- Anyone, for anyone. Players are just names — they don't need the app. Any device can record a result on anyone's behalf, and it syncs to everyone.
- Edit / delete results freely while the tournament is active. Conflicts across devices resolve by last-write-wins.
- End the tournament. A public button, guarded by a two-step confirmation. Ending is final and syncs everywhere.
- Remove from this device. The ⋮ menu on a tournament deletes it locally only — no tombstone is broadcast, so peers keep their copy. If a peer still has it and it's active, a later sync may re-introduce it here; an ended one stays gone (see the introduction guard below).
- Absolute winner is ranked by a confidence-adjusted rating — the lower bound of the Wilson
score interval on each player's duel win-rate (a duel win = 1, a tie = ½), at the tournament's
chosen strictness (80/95/99%). Ranking on duels rather than games is deliberate: one 3–0 sweep is
a single data point, so it's weak evidence and ranks low, while winning many duels — even at modest
game margins — ranks high. Playing and winning more duels always raises your rating. Games won are
kept as a secondary statistic (e.g.
13/24 games), not the ranking basis. Ties produce co-winners. Seescoring/Scoring.kt(wilsonLowerBound), covered byScoringTest(including the Mihaly-vs-Tamas case). Strictness is set once at creation and never changes (Tournament.wilsonZ, Room schema v2).
- Discovery — each device advertises a
_dueltrack._tcpservice via Android NSD (mDNS/DNS-SD) and browses for the same service type. Discovered peers are shown in the Network sync screen (Wi-Fi icon, top-right of the tournament list). - Peer list — resolved peers are dialed over TCP. Every device is both a server (accepts connections) and a client (dials others), so the mesh self-heals.
- Handshake — on connect both sides exchange a
Hello(device id + name), dedupe self / duplicate connections, then send a fullSnapshotso both converge. - Live updates — thereafter each local change (
upsertTournament/upsertResult) is pushed to all peers and re-gossiped one hop further. Because merges are last-write-wins and idempotent, duplicate delivery and multi-hop propagation converge and stop. - Keepalive & reconnect — a heartbeat keeps links healthy and recovers from flaky Wi-Fi (below).
There is no polling of tournament data. Sync is event-driven: one full Snapshot when a
connection is established, then an incremental push the instant anything changes (sub-second on a
LAN). Idle apps exchange no tournament data.
The only periodic traffic is a small keepalive, which exists purely to detect failures fast and self-heal:
- Heartbeat — every
HEARTBEAT_INTERVAL_MS(10 s) each side sends aPing; the peer repliesPong. Every inbound message (including these) refreshes the connection's activity timestamp. - Dead-link detection — a connection with no traffic for
DEAD_TIMEOUT_MS(30 s, ≈3 missed beats) is force-closed. This is what catches a peer that dropped off Wi-Fi without a clean TCP shutdown — something the OS otherwise won't report for minutes.SO_KEEPALIVEis set as an OS-level backstop, and half-open connections that never sendHelloare reaped afterHANDSHAKE_TIMEOUT_MS(15 s). - Auto-reconnect — every peer resolved via NSD is remembered (host + port) and kept even across
NSD "lost" events. A loop runs every
RECONNECT_INTERVAL_MS(5 s) and re-dials any known peer we aren't currently connected to. Dials are deduped by endpoint (no duplicate links in steady state). Retries are bounded: afterMAX_RECONNECT_ATTEMPTS(12, ~1 min) a peer is dropped until NSD resolves it again, so a genuinely-gone device isn't hammered; a successful handshake resets the budget.
All timings are constants at the bottom of sync/SyncManager.kt.
Both Tournament and DuelResult carry updatedAt + lastEditedBy (device id) and a deleted
tombstone flag. Merge rule: newer updatedAt wins; ties broken by lastEditedBy. Deletes are
tombstones so they propagate. See data/Repository.kt and sync/SyncManager.kt.
A tournament (and its results) is only ever introduced to a device that doesn't already have it
while it is still open. Once ended, it will not appear on a device that wasn't present while
it was open — the merge simply drops the unknown, ended record (and any orphan results whose parent
tournament is absent). Devices that already have the tournament keep receiving every update,
including the end event and any late edits: a device that was present while it was open, went away,
and reconnects after it ended will still get the final results, because it already knows the
tournament. This lives in Repository.applyRemoteTournament / applyRemoteResult and is covered by
SyncPolicyTest.
A Dockerfile provides a full build environment (JDK 17 + Android SDK 34 + Gradle 8.7). This is the
path used to verify the project: it compiles the app and runs the JVM unit tests.
cd DuelTracker
docker build -t dueltracker-build .
# Compile the debug APK + run unit tests (a named volume caches Gradle deps between runs):
docker run --rm -v "$PWD":/workspace -v dueltracker-gradle-cache:/root/.gradle dueltracker-buildOutput: app/build/outputs/apk/debug/app-debug.apk (build artifacts are written as root by the
container; sudo chown -R "$USER" app/build if you need to touch them from the host). To run just the
tests: append gradle --no-daemon testDebugUnitTest as the docker run command.
Last verified run:
BUILD SUCCESSFUL,app-debug.apk(17 MB), unit tests 24/24 passed (ScoringTest+SyncPolicyTest+SyncNetworkTest).
Network communication is tested for real. SyncNetworkTest spins up multiple SyncCore nodes,
each behind a real ServerSocket, and connects them over actual loopback TCP — no mocks of the
transport. It verifies the handshake, connect-time snapshot back-fill, live gossip, multi-hop
re-gossip (Alice→Bob→Carol with Alice and Carol not directly connected), and the ended-tournament
introduction guard, all over the wire. Only NSD/mDNS discovery is out of scope for JVM tests (it's
an Android OS service and needs an emulator); the sockets are wired up directly, exactly as
SyncManager does once a peer is discovered.
Open the project in Android Studio (Koala or newer) and let it sync — it will download the correct Gradle and SDK components, and generate the Gradle wrapper jar. Then Run on a device.
From the command line you first need the wrapper (the binary gradle-wrapper.jar is not checked in):
cd DuelTracker
gradle wrapper --gradle-version 8.7 # requires a local Gradle install; Android Studio does this for you
./gradlew assembleDebug- minSdk 26 (Android 8.0), targetSdk / compileSdk 34
- Kotlin 2.0.20 · Jetpack Compose (BOM 2024.09) · Room 2.6 · kotlinx.serialization
Install on two or more devices on the same Wi-Fi, open the app on each, and create a tournament on one — it should appear on the others within a few seconds. Add a duel on any device and watch it propagate. Some networks with AP/client isolation ("guest" Wi-Fi) block peer-to-peer traffic; use a normal LAN or a phone hotspot if discovery doesn't find peers.
- Sync runs while the app is foregrounded. For reliable background sync, promote
SyncManagerto a foreground Service with a notification (the class is structured to allow this). - Last-write-wins uses wall-clock timestamps; badly skewed device clocks could pick the "wrong" edit. A logical clock (or vector clock) would be more robust.
- Player identity is by name (case-insensitive). Two different people with the same name are treated as one competitor.
- NSD resolve is best-effort; on some OEM builds concurrent resolves are flaky, so peers may take a little longer to appear.
app/src/main/java/com/feastoffire/dueltracker/
├── DuelApp.kt Application + manual DI (Repository, SyncManager, DeviceIdentity)
├── MainActivity.kt
├── data/ Room entities, DAOs, database, Repository (source of truth + LWW merge)
├── sync/ DeviceIdentity, NSD discovery, TCP Session, wire Protocol,
│ SyncCore (transport-agnostic protocol) + SyncManager (Android adapter)
├── scoring/ Wilson-rating standings + winner calculation
└── ui/ Compose navigation, screens, view models, theme