Collaborative scoreboards you can share with a QR code, sync in real time across devices, and keep end‑to‑end encrypted. Built with Vue 3 + Vite, powered by Nostr relays, and packaged for Android with Capacitor.
Open source, made by DevForth.
- Real‑time collaborative scoreboards (CRDT‑based) that converge across peers
- End‑to‑end encryption (AES‑GCM) for board metadata, join requests, and snapshots
- Easy invite and onboarding via share code and QR (format:
lik::<id>::<secret>) - Owner‑approved editors; guests see read‑only until approved
- Offline‑friendly: local state in IndexedDB with automatic persistence
- Android app (Capacitor) + PWA, responsive UI with Tailwind
Details
<img src="assets/Untitled.png" alt="Main UI" width="640" />
You can see live screenshots on the Play Store listing: https://play.google.com/store/apps/details?id=io.devforth.lik&hl=en
- Scoreboards
- Create multiple boards; add, rename, and reorder categories
- Add/rename/remove participants
- Increment/decrement per‑participant scores per category
- One‑tap “priority/star” per category using a CRDT shadow counter (
::prio)
- Sharing & access control
- Share code & QR:
lik::<boardId>::<secret> - Owner receives encrypted join requests and can approve editors
- Non‑approved users have read‑only access after scanning the QR
- Share code & QR:
- Security & privacy
- End‑to‑end encrypted join requests, board metadata PRE (
lik::brd::<id>), and CRDT snapshots (lik::crdt::<id>) using AES‑GCM - Secret is derived locally and never published to relays
- Local storage only: IndexedDB holds boards, snapshots, secrets, and minimal caches
- End‑to‑end encrypted join requests, board metadata PRE (
- Nostr integration
- Uses public relays with
nostr-toolsSimplePool - Profiles (kind 0) published/de‑duplicated per relay; avatars generated locally
- PREs (kind 30078) carry encrypted board metadata and CRDT snapshots
- Uses public relays with
- UX niceties
- Top‑of‑screen activity toasts for remote edits
- Backup reminders for your private key (nsec), key reveal & import
- Mobile camera QR scanner with graceful fallback to manual entry
Tech stack
- Vue 3 + TypeScript + Vite (
src/) - Pinia for state stores (
src/stores/*) - Tailwind CSS + Reka UI + Vaul for the UI
- Nostr via
nostr-toolsSimplePool (src/nostr.ts) - Capacitor for Android packaging (
capacitor.config.ts,android/)
Data & persistence
- IndexedDB via a small wrapper (
src/lib/idb.ts)- Stores:
user,scoreboards,profiles,rejectedScoreboardRequests
- Stores:
- No server/database: Nostr relays are used as the transport and durable broadcast channel
End‑to‑end encryption
- Each board has a symmetric secret:
sha512Hex("${ownerPrivHex}::${boardId}") - What’s encrypted with that secret:
- Join requests (kind 1 note content)
- Board metadata PRE:
d = lik::brd::<id>(content = encrypted JSON) - CRDT snapshot PRE:
d = lik::crdt::<id>(content = encrypted JSON)
- Secret is stored only locally with the board object in IndexedDB and never published to Nostr
Nostr events and channels
- Profiles: kind 0 metadata published and verified per‑relay
- Join requests: kind 1 notes with tag
t = lik::sb-join-req::<id>(encrypted body) - PREs: kind 30078 with
dtagslik::brd::<id>andlik::crdt::<id> - Default relays: see
RELAYSinsrc/nostr.ts
CRDT model (scoreboards)
ScoreboardCRDT(src/crdt.ts)- Per‑category PN‑counters per participant for scores
- Last‑writer‑wins (by timestamp) for category
name,vis,order - Event log embedded in snapshot for lightweight activity feed
- Priority is implemented as a shadow PN‑counter at key
${categoryKey}::prio
- Snapshots are serialized canonically and encrypted before being published as PREs
Mobile & build
- Capacitor Android app id
io.devforth.lik; QR scanning via native camera (Browser BarcodeDetector) - Dockerized Android release build in
build/Dockerfileandbuild/build.sh->.aaboutput indist-android/
Key modules (by file)
src/nostr.ts— relay pool, publish/subscribe helpers, PRE utilitiessrc/nostrToCRDT.ts— bridges Nostr PREs to local CRDT and backsrc/stores/*— Pinia stores: user, scoreboards, profiles, reminders, etc.src/lib/utils.ts— hashing, canonical JSON, AES‑GCM helpers, short idssrc/lib/idb.ts— IndexedDB wrappersrc/time-sync.ts— worldtimeapi‑based UTC time alignment
Prerequisites
- Node.js 24+ (
nvm usepicks it from.nvmrc; enforced bypackage.jsonengines) - pnpm (
corepack enablepicks the version from package.json)
Install & run (web/PWA)
- Install deps
pnpm install
- Start dev server
pnpm dev
- Open the printed local URL (Vite default is http://localhost:5173)
Build (web)
- Type check + build:
pnpm build - Preview build locally:
pnpm preview
Run as PWA
- Open in a modern browser, then “Install”/“Add to home screen”. Icons & manifest are included under
public/.
Option A — Android Studio
- Build the web app:
pnpm build - Sync native project:
pnpm exec cap sync android - Open
android/in Android Studio and run on device/emulator or assemble a release
Option B — Docker one‑shot release build
- Ensure Docker and a JDK (
keytool,jarsigner) are available - Run the helper script:
AAB_PASS=xxxxxx build/build.sh - Find the signed
.aabindist-android/and upload it to Play Console
AAB_PASS is the password of the upload keystore build/my-release-key.jks (git-ignored: keep a backup of the file and the password; store and key password must be the same).
Without it the bundle stays unsigned and Play Console rejects it ("All uploaded bundles must be signed").
Lost the password or the keystore? Play Console → App integrity → request an upload key reset.
Play Store
Lik keeps your board content private by encrypting everything sensitive end‑to‑end:
- Secret derivation:
sha512Hex("${ownerPrivHex}::${boardId}") - Encrypted with AES‑GCM using the derived secret:
- Join requests (content of kind‑1 events)
- Board metadata PRE (
d = lik::brd::<id>, content = encrypted JSON) - CRDT snapshot PRE (
d = lik::crdt::<id>, content = encrypted JSON)
- The secret never leaves the device. Only the PRE
dtag reveals the board id; contents remain opaque on relays. - Recovery: knowing
boardIdand the secret allows decrypting historical PREs.
- State is persisted to IndexedDB; stores debounce saves to reduce churn.
- Profile metadata (kind 0) is published only to relays that need updates (hash comparison).
- CRDT snapshot publishes are debounced; rapid score taps aggregate into
+N/-Nlog entries. - Time is normalized from
worldtimeapi.orgto improve LWW conflict resolution.
Issues and pull requests are welcome. Please keep PRs focused and include a brief rationale and testing notes.
MIT © Devforth.io — see LICENSE.