Skip to content

Repository files navigation

DuelTracker

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.

What it does

  • 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. See scoring/Scoring.kt (wilsonLowerBound), covered by ScoringTest (including the Mihaly-vs-Tamas case). Strictness is set once at creation and never changes (Tournament.wilsonZ, Room schema v2).

How sync works

  1. Discovery — each device advertises a _dueltrack._tcp service 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).
  2. 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.
  3. Handshake — on connect both sides exchange a Hello (device id + name), dedupe self / duplicate connections, then send a full Snapshot so both converge.
  4. 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.
  5. Keepalive & reconnect — a heartbeat keeps links healthy and recovers from flaky Wi-Fi (below).

Cadence & liveness (keepalive / auto-reconnect)

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 a Ping; the peer replies Pong. 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_KEEPALIVE is set as an OS-level backstop, and half-open connections that never send Hello are reaped after HANDSHAKE_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: after MAX_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.

Data model

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.

Visibility of ended tournaments (introduction guard)

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.

Building

Docker (reproducible, no local SDK needed) — verified

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

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

Android Studio

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

Testing the sync

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.

Known limitations / scope notes

  • Sync runs while the app is foregrounded. For reliable background sync, promote SyncManager to 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.

Project layout

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

About

DuelTracker android app - for tracking your duels

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages