LyricsFlip is an on-chain, card-based music guessing game built on Stellar (Soroban). A card shows a snippet of song lyrics, and players race to guess the song, artist, or year before the card flips. Rounds can be played solo or against friends, and players can wager tokens and earn NFT badges. Every round, answer, and stat is recorded on the Stellar blockchain.
- 🎨 Figma design: https://www.figma.com/design/6phOWkHKQgLRhRwmBBQDXB/LyricsFlip?node-id=0-1&t=0U8SlbaJijr7XNeG-1
- 🗂️ Issue backlog:
ISSUES.md(125 scoped issues with tasks and acceptance criteria) - 🤝 Contributing:
CONTRIBUTING.md- Start here to contribute!
- What is LyricsFlip?
- How the game works
- Architecture
- Smart contracts (Soroban)
- Frontend (Next.js)
- Backend (NestJS)
- Repository structure
- Getting started
- Environment variables
- Testing & CI
- Project status & known limitations
- Roadmap
- Contributing
- Related repositories
LyricsFlip turns "name that tune" into a Web3 game:
| Feature | Description |
|---|---|
| Lyric cards | Each card holds a lyric snippet plus its genre, artist, title, and release year. |
| Timed guessing | A card flips after 15 seconds unless the player guesses correctly first. Correct guesses get instant feedback, such as confetti. |
| Multiple-choice questions | The contract builds a question with the right answer and three distractors, then shuffles them. |
| Genres & decades | 13 on-chain genres (Hip Hop, Pop, Rock, R&B, Electronic, Classical, Jazz, Country, Blues, Reggae, Afrobeat, Gospel, Folk). Cards are also indexed by artist and year, so categories like "90s R&B" are possible. |
| Solo & multiplayer rounds | Create a round, invite friends by round ID or link, get everyone "ready", and play the same cards. |
| Token wagering | Players stake tokens on a round and the winner takes the pot. The contract fields exist; escrow is on the roadmap. |
| NFT rewards | A companion NFT contract mints reward badges for milestones. |
| Stats & streaks | On-chain per-player stats: total rounds, rounds won, current streak, and best streak. |
| Leaderboards & social | A backend adds profiles, leaderboards, notifications, chat, and more on top of on-chain data. |
- Connect a wallet. LyricsFlip uses Stellar Wallets Kit, so Freighter, xBull, Albedo, Lobstr, and Hana all work. Reads work without a wallet; any action that changes state needs one.
- Pick a mode on the home page:
- Quick Game: casual play.
- Wager (Single Player): choose genre, difficulty, duration, odds, and stake, then play against the clock.
- Wager (Multi Player): create or join a round and play against other wallets.
- Create or join a round. Creating a round picks a random set of cards (
cards_per_round, set by an admin) and makes you the round admin. Friends join with the round ID. - Get ready. Each player signs a "ready" transaction. When every player is ready, the round starts on-chain.
- Play cards. For each card:
- a card is drawn (
next_card) - the lyric is shown with four options (
build_question_card) - the player submits an answer (
submit_answer), and the contract says whether it was correct and updates their streak - the card flips to reveal the answer.
- a card is drawn (
- Finish. After the last card, a result modal shows the score. With wagering and rewards enabled, winners claim the pot and milestone NFTs are minted.
sequenceDiagram
autonumber
actor A as Player A (creator)
actor B as Player B
participant C as LyricsFlip contract
participant N as LyricsFlip NFT contract
A->>C: create_round(caller=A, genre, seed)
C-->>A: round_id (event RoundCreated)
B->>C: join_round(caller=B, round_id)
Note over C: event RoundJoined
A->>C: start_round(caller=A, round_id)
Note over C: event PlayerReady (1/2)
B->>C: start_round(caller=B, round_id)
Note over C: event PlayerReady (2/2) → RoundStarted
loop for each card in the round
A->>C: next_card(round_id)
C-->>A: Card
A->>C: build_question_card(card, seed)
C-->>A: QuestionCard (lyric + 4 shuffled options)
A->>C: submit_answer(caller, round_id, Answer::Title(..))
B->>C: submit_answer(caller, round_id, Answer::Title(..))
C-->>A: true / false (streak updated)
end
Note over C,N: Planned: finalize round, pay out wager, mint milestone NFT
C-->>N: mint(caller=game contract, recipient=winner)
LyricsFlip is a monorepo with three main pieces:
flowchart LR
subgraph Browser
UI["Next.js 14 app<br/>(React, Tailwind, Zustand)"]
W["Stellar wallet<br/>(Freighter / xBull / Albedo / Lobstr / Hana)"]
end
subgraph Stellar["Stellar network (Soroban)"]
GC["lyricsflip contract<br/>rounds · cards · answers · stats"]
NFT["lyricsflip-nft contract<br/>minter-gated rewards"]
end
subgraph Server
API["NestJS API<br/>REST + Socket.IO"]
DB[("PostgreSQL")]
R[("Redis")]
end
UI -- "sign tx" --> W
UI -- "Soroban RPC<br/>(simulate / send)" --> GC
UI -- "Soroban RPC" --> NFT
GC -. "cross-contract mint (planned)" .-> NFT
UI -- "HTTP / WebSocket" --> API
API --> DB
API --> R
API -. "event indexer (planned)" .-> GC
| Layer | Responsibility | Source of truth for |
|---|---|---|
Soroban contracts (onchain/) |
Game rules, round state, card catalogue, answer checking, player stats, NFT ownership | Everything that involves value or fairness |
Frontend (frontend/) |
UI, wallet connection, building and signing contract calls, animations and timers | Nothing persistent. It reads from the chain and the API |
Backend (backend/) |
Profiles, leaderboards, notifications, chat, real-time multiplayer relay, song curation, and (planned) indexing of contract events | Off-chain social and metadata |
The contracts live in a Cargo workspace under onchain/ and use soroban-sdk 27. They were ported from the project's earlier Cairo/Starknet contracts. See onchain/README.md for build and deployment commands.
| Type | Fields | Notes |
|---|---|---|
Card |
card_id: u64, genre: Genre, artist: String, title: String, year: u64, lyrics: String |
One lyric card. The id is assigned by add_card |
Genre |
HipHop=0, Pop=1, Rock=2, RnB=3, Electronic=4, Classical=5, Jazz=6, Country=7, Blues=8, Reggae=9, Afrobeat=10, Gospel=11, Folk=12 |
Encoded as a plain u32. Keep in sync with frontend/src/lib/stellar/types.ts → GENRE_VALUES |
Round |
round_id, admin, genre, wager_amount: i128, start_time, is_started, is_completed, end_time, next_card_index: u32 |
One game round |
QuestionCard |
lyric, timestamp, option_one … option_four |
What the player sees: the lyric plus 4 shuffled title options |
Answer |
Artist(String) · Year(u64) · Title(String) |
A player's guess |
PlayerStats |
total_rounds, rounds_won, current_streak, max_streak |
Per-address stats |
Role |
Admin=0 |
The single admin role |
| Key | Storage | Value |
|---|---|---|
Owner |
instance | Contract owner address |
Admin(Address) |
instance | bool, whether the address is an admin |
RoundCount, CardsCount, CardsPerRound |
instance | Counters and configuration |
Card(u64) |
persistent | Card |
GenreCards(Genre) / ArtistCards(String) / YearCards(u64) |
persistent | Vec<u64> card-id indexes |
Round(u64) |
persistent | Round |
RoundPlayers(u64) / RoundCards(u64) |
persistent | Players and the drawn card ids for a round |
RoundReady((u64, Address)) / RoundReadyCount(u64) |
persistent | Readiness tracking |
PlayerStats(Address) |
persistent | PlayerStats |
The contract exposes admin functions (configuration, card management, roles), gameplay functions (creating, joining, readying, and starting rounds; drawing cards; building question cards; submitting answers; finalizing rounds), and read-only views (round info, scores, cards, player stats, role checks).
The full, generated reference — every function signature, its auth requirements, and what it does — lives in onchain/docs/api.md. That file is generated from the contract source; this README intentionally does not duplicate it.
get_random_numbers builds a SHA-256 hash of (seed, ledger sequence, ledger timestamp, i) for each draw, reduces it modulo the pool size, and skips duplicates until it has enough unique values. build_question_card shuffles options with a linear-congruential Fisher–Yates shuffle. This is a straight port of the Cairo logic. It is not manipulation-resistant, because the caller supplies seed (see LF-009).
The contract defines a set of numbered errors (surfaced by the SDK as Error(Contract, #<code>)) and emits events for round creation, joining, readiness, start, and completion. The complete, generated list of error codes and event topics/data is in onchain/docs/api.md.
A small, minter-gated NFT. The intended minter is the game contract's address, so rewards can only come from gameplay. Its functions, errors, and events are documented in onchain/docs/api.md.
Located in frontend/.
| Concern | Technology |
|---|---|
| Framework | Next.js 14 (App Router), React 18, TypeScript |
| Styling | Tailwind CSS, Radix UI primitives, class-variance-authority, Framer Motion |
| State | Zustand (with immer / persist), TanStack React Query |
| Chain | @stellar/stellar-sdk (contract.Client), @creit.tech/stellar-wallets-kit |
| Real-time | socket.io-client |
| Testing | Jest + Testing Library |
All chain access goes through src/lib/stellar/:
stellarConfig.tsreads the RPC URL, network passphrase, and the two contract IDs fromNEXT_PUBLIC_*env vars (defaults to testnet).StellarProvider.tsx(loaded client-side only fromapp/layout.tsx):- initialises Stellar Wallets Kit with the Freighter, xBull, Albedo, Lobstr, and Hana modules
- restores a previously connected address
- exposes
connect()/disconnect(), the currentaccount, and configwarnings.
client.ts→createSystemCalls(config, publicKey)returns a typedSystemCallsobject (createRound,joinRound,startRound,nextCard,submitAnswer,addCard,getRound,getPlayerStat,mintNft, …):- it uses
contract.Client.from(), which fetches the deployed contract's spec at runtime, so no codegen step is needed - read-only calls simulate the transaction and return
assembled.result - mutating calls use
signAndSend(), which asks the connected wallet to sign throughStellarWalletsKit.signTransaction.
- it uses
types.tsmirrors the Rust types and converts between wire and UI shapes (genreToWire/genreFromWire, and theAnswer.title()/artist()/year()helpers).hooks/useStellar.tsis what components use:const { systemCalls, account, connect } = useStellar().
| Route | File | Purpose |
|---|---|---|
/ |
app/page.tsx |
Welcome and game-mode selection. Opens the Quick Game or Wager modals |
/single-player?roundId= |
app/single-player/page.tsx |
Single-player round: lyric card, stats panel, song options |
/multiplayer |
app/multiplayer/page.tsx |
Join a round by ID, ready up, play with others |
/admin |
app/admin/page.tsx |
Admin configuration (cards_per_round) |
/set-username |
app/set-username/page.tsx |
Choose a display name |
/sign-in |
app/sign-in/page.tsx |
Sign-in landing page |
components/atoms/: buttons, inputs, cards, dialogs, selects, badgescomponents/molecules/: navbar, game card, song options, statistics panel, timer display, share buttoncomponents/organisms/:LyricCard(flip card),WagerModal,WagerSummaryModal,GameResultModal/GameResultPopup, challenge create/invite, waiting-for-opponent,AdminConfigfeatures/game/hooks/:useGameTimer,useSinglePlayerhooks/:useGameService(create round),use-multiplayer-room,useLeaderboard,useSongs,useCategoriesstore/: Zustand stores (game state, modals, theme, user/game slices)
Located in backend/. It provides the off-chain side of the product.
| Concern | Technology |
|---|---|
| Framework | NestJS 10 (TypeScript) |
| Database | PostgreSQL via TypeORM (autoLoadEntities) |
| Cache | Redis (cache-manager, ioredis) |
| Real-time | Socket.IO gateways (config/socket-io.config.ts) |
| Auth | JWT access and refresh tokens (@nestjs/jwt, Passport), bcrypt, a global AccessTokenGuard with a @Public() opt-out |
| Docs | Swagger at /api/docs (non-production) |
| Rate limiting | @nestjs/throttler |
| Module | Folder | Purpose |
|---|---|---|
| Auth & users | auth/, user/ |
Sign-in, tokens, password reset, user accounts |
| Game sessions & modes | game-session/, game-mode/, game/ |
Sessions, classic / time-attack / endless / battle-royale modes, matchmaking |
| Songs & questions | songs/, song/, song-genre/, questions/ |
Song catalogue, genres and tags, question bank |
| Scoring & results | scoring/, game-results/, leaderboard/ |
Points, results, rankings |
| Wagers & rewards | wager/, reward/, power-ups/ |
Wager records, rewards, in-game power-ups |
| Social | social/, chat-room/, notification/, referral/ |
Friends, challenges, activity feed, chat, notifications, referrals |
| Progression | achievement/, tournament/, music-education/ |
Achievements, tournaments, music lessons and quizzes |
| Infrastructure | config/, logger/, redis/, common/, interceptors/, state-recovery/, game-insights/ |
Configuration, logging, pagination, guards, recovery, analytics |
Sample requests for most endpoints are in backend/src/http-yac/*.http, for use with the httpYac or REST Client VS Code extensions.
⚠️ Backend status: the backend currently does not compile.main.tsandapp.module.tshave errors, about 100 imports point to files that don't exist, and several packages are used but not declared inpackage.json. The backend also contains duplicate and experimental modules. Getting it building is tracked in LF-073 – LF-081.
lyricsflip/
├── onchain/ # Rust / Soroban smart contracts (Cargo workspace)
│ ├── Cargo.toml # workspace + release profile (opt-level z, LTO)
│ └── contracts/
│ ├── lyricsflip/src/ # game contract: lib.rs, types.rs, errors.rs, events.rs, test.rs
│ └── lyricsflip-nft/src/ # reward NFT contract: lib.rs, test.rs
├── frontend/ # Next.js 14 web app
│ └── src/
│ ├── app/ # routes (/, /single-player, /multiplayer, /admin, …)
│ ├── components/ # atoms / molecules / organisms
│ ├── features/game/hooks/ # game timer, single-player logic
│ ├── hooks/ # data + service hooks
│ ├── lib/stellar/ # Stellar provider, contract client, types, config
│ ├── services/ # REST (axios) + WebSocket clients
│ ├── store/ # Zustand stores
│ └── __tests__/ # Jest tests
├── backend/ # NestJS API + Socket.IO gateways
│ ├── src/ # feature modules (see table above)
│ └── test/ # e2e tests
├── docs/ # design hand-off, Notion link
├── .github/workflows/onchain.yml # CI: fmt, WASM build, tests for contracts
├── .tool-versions # rust 1.98.1
├── ISSUES.md # 125-item backlog
└── ContributionGuidelines.md
| Tool | Version | For |
|---|---|---|
| Rust | 1.98.1 (see .tool-versions) + wasm32v1-none target |
Contracts |
| Stellar CLI | latest | Deploying and invoking contracts |
| Node.js | 20 LTS recommended | Frontend and backend |
| PostgreSQL / Redis | 14+ / 6+ | Backend |
| A Stellar wallet | Freighter recommended, set to Testnet | Playing |
git clone https://github.com/Stellar-songifi/lyricsflip.git
cd lyricsflipcd onchain
rustup target add wasm32v1-none
cargo test # unit tests
cargo build --target wasm32v1-none --release # → target/wasm32v1-none/release/*.wasmDeploy to testnet (create and fund an identity first with stellar keys generate --global me --network testnet --fund):
# Game contract
stellar contract deploy \
--wasm target/wasm32v1-none/release/lyricsflip.wasm \
--source me --network testnet \
-- --owner $(stellar keys address me)
# NFT contract (minter = game contract ID from the previous step)
stellar contract deploy \
--wasm target/wasm32v1-none/release/lyricsflip_nft.wasm \
--source me --network testnet \
-- --owner $(stellar keys address me) --minter <LYRICSFLIP_CONTRACT_ID> \
--token_name "LyricsFlip" --token_symbol "LFLIP" --base_uri "https://example.com/nft/"Configure the game and add some cards:
stellar contract invoke --id <LYRICSFLIP_CONTRACT_ID> --source me --network testnet \
-- set_cards_per_round --caller $(stellar keys address me) --value 5
stellar contract invoke --id <LYRICSFLIP_CONTRACT_ID> --source me --network testnet \
-- add_card --caller $(stellar keys address me) \
--card '{"card_id":0,"genre":1,"artist":"Demo Artist","title":"Demo Song","year":1999,"lyrics":"Demo lyric line"}'
build_question_cardneeds at least 4 distinct titles (3 wrong options plus the right one);create_roundneeds at leastcards_per_roundcards.
cd frontend
npm install
cat > .env.local <<'ENV'
NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org
NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
NEXT_PUBLIC_LYRICSFLIP_CONTRACT_ID=<LYRICSFLIP_CONTRACT_ID>
NEXT_PUBLIC_LYRICSFLIP_NFT_CONTRACT_ID=<LYRICSFLIP_NFT_CONTRACT_ID>
NEXT_PUBLIC_API_URL=http://localhost:4000
ENV
npm run dev # http://localhost:3000cd backend
npm install
cp .env.example .env.development # fill in the values (see variables below); use a port other than 3000 so it doesn't clash with Next.js
npm run start:dev # Swagger at http://localhost:<PORT>/api/docsThe backend doesn't build yet. See Project status.
| Variable | Default | Description |
|---|---|---|
NEXT_PUBLIC_STELLAR_RPC_URL |
https://soroban-testnet.stellar.org |
Soroban RPC endpoint |
NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE |
Test SDF Network ; September 2015 |
Network passphrase (also picks the wallet-kit network) |
NEXT_PUBLIC_LYRICSFLIP_CONTRACT_ID |
— | Deployed game contract ID (C…) |
NEXT_PUBLIC_LYRICSFLIP_NFT_CONTRACT_ID |
— | Deployed NFT contract ID (C…) |
NEXT_PUBLIC_API_URL |
http://localhost:3000/api |
Backend base URL |
| Variable | Required | Description |
|---|---|---|
NODE_ENV |
no | development / production / test / staging |
PORT |
no | HTTP port (default 4000) |
DATABASE_URL |
yes | Postgres connection string |
JWT_SECRET |
yes | Secret used to sign access/refresh JWTs |
JWT_REFRESH_SECRET |
no | Secret used by the legacy /auth/refresh flow |
JWT_TOKEN_AUDIENCE, JWT_TOKEN_ISSUER |
no | JWT aud/iss claims |
JWT_ACCESS_TOKEN_TTL, JWT_REFRESH_TOKEN_TTL |
no | Token lifetimes, in seconds |
STELLAR_NETWORK |
yes | testnet / futurenet / mainnet |
SOROBAN_RPC_URL / STELLAR_RPC_URL |
no | Soroban RPC endpoint used by the event indexer and /health |
LYRICSFLIP_CONTRACT_ID |
no | Deployed game contract ID the indexer watches |
REDIS_HOST, REDIS_PORT |
no | Redis (defaults localhost:6379) |
REDIS_URL |
no | Redis connection URL (cache/throttler storage) |
RATE_LIMIT_TTL, RATE_LIMIT_LIMIT |
no | Global throttling window (ms) and limit |
CORS_ORIGINS |
no | Comma-separated allowed origins, or * |
SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, MAIL_FROM |
no | Outgoing email (password reset) |
APP_URL |
no | Public base URL, used to build shareable links |
ANALYTICS_API_KEY |
no | Shared secret for the x-analytics-key header |
DB_SYNCHRONIZE, DB_MIGRATIONS_RUN |
no | TypeORM schema sync / auto-run migrations on boot |
PASSWORD_RESET_TTL_MINUTES, LOG_LEVEL |
no | Password reset token lifetime, logger verbosity |
| Package | Command | Notes |
|---|---|---|
| Contracts | cd onchain && cargo test |
11 game-contract tests and 4 NFT tests using soroban-sdk testutils |
| Frontend | cd frontend && npm test |
Jest + jsdom (src/__tests__/) |
| Backend | cd backend && npm test / npm run test:e2e |
Jest (*.spec.ts) |
GitHub Actions (.github/workflows/onchain.yml) runs cargo fmt --check, a release WASM build, and cargo test on every push to main and on every PR. Frontend and backend CI are planned (LF-106, LF-107).
LyricsFlip is under active development, following its migration from Starknet to Stellar. Rather than listing individual bugs here (they go stale quickly), progress is tracked in GitHub milestones:
| Area | Status | Tracked in |
|---|---|---|
Smart contracts (onchain/) |
Both contracts build to WASM and pass their unit tests in CI. Rounds, cards, answers, and streaks work on-chain; wagering, payouts, and NFT rewards are in progress. | Contracts milestone · LF-001 – LF-034 |
Frontend (frontend/) |
Wallet connection and typed contract calls work; screens are being wired to real on-chain data. | Frontend milestone · LF-035 – LF-072 |
Backend (backend/) |
Does not compile yet; being rebuilt module by module. | Backend milestone · LF-073 – LF-105 |
| DevOps & docs | Contract CI runs on every PR; frontend/backend CI and deployment are planned. | DevOps milestone · LF-106 – LF-125 |
For the current list of open work, see ISSUES.md and the repository's milestones.
ISSUES.md holds 125 issues grouped by area, each with a description, a task list, and acceptance criteria:
| Range | Area |
|---|---|
| LF-001 – LF-034 | Smart contracts: correctness, security, wagering, NFTs, upgradeability, tests |
| LF-035 – LF-072 | Frontend: wiring real gameplay, wallet UX, multiplayer, admin, accessibility, tests |
| LF-073 – LF-105 | Backend: getting it to build, wallet auth, event indexer, leaderboards, real-time protocol |
| LF-106 – LF-115 | DevOps: CI for every package, deployments, tooling |
| LF-116 – LF-125 | Docs & community: contributing guide, architecture, game rules, licensing |
Suggested order: P0 contract fixes → frontend build fixes + wallet UX → real on-chain gameplay → backend build + indexer → wagering → NFTs.
Contributions are welcome! Please read ContributionGuidelines.md first. In short:
- Pick an issue from
ISSUES.md(or GitHub Issues) and comment to claim it. Issues tagged for an event must not be applied for before the event starts. - Create a branch (never push to
main). - Keep PRs focused. Include a clear description, link the issue, and show test evidence or screenshots.
- Make sure
cargo fmt --check && cargo test(contracts) andnpm run lint && npm test(frontend/backend) pass. - Update the README (and any package README) if behavior changed. Contract API details belong in the generated
onchain/docs/api.md, not hand-copied into the README.
Design references: Figma, docs/design/.
📱 Mobile app: as part of the move to a multi-repo architecture, the LyricsFlip mobile app is being migrated to its own repository: LyricsFlip Mobile. This keeps mobile-specific code isolated and easier to maintain.
- #487: LF-070 · Fix misspelled file and component names
- #485: LF-068 · Add
frontend/.env.example
- #430: LF-013 · Implement real token wagering (escrow) for rounds