A gamified iOS app where the person being celebrated completes challenges to earn points and spends them to unlock sentimental rewards — video messages, audio notes, photo galleries, and heartfelt text from friends and family. Built entirely in SwiftUI with a real-time Firebase backend.
One person hosts an occasion — a birthday, anniversary, graduation, farewell, or bachelor/ette — and shares two invite links: one for contributors, one for the person being celebrated. Contributors each write a secret challenge. The celebrant earns points by completing challenges, then spends those points to unlock personalized gifts from the people who matter most.
A living timeline captures every moment — growing node by node with animated bezier paths as challenges are completed and rewards are unlocked. A mysterious final badge pulses at the bottom of the timeline, unlocking only when every reward has been claimed.
Each occasion is its own tenant. Everything lives under events/{eventId}, so one install can host or join several celebrations at once and none of them can see each other.
The app is the gift.
A friend's 30th was coming up and I didn't want to buy a thing. I wanted the people who care about them to be the gift — but a group chat full of "happy birthday!!" messages is forgettable, and a shared photo album is something you scroll once.
So I made the birthday person earn it. Five of us installed the app for a weekend. They completed challenges we'd written, and every unlock revealed something one of us had recorded. By Sunday the timeline had turned into a record of the whole weekend.
It worked. This repo is that app, generalized so it isn't hardcoded to one weekend and one group of friends, so you can make one for someone you like.
Rewards Carousel · Challenge Board · Living Timeline · Secret Dare · Profile
These were taken before the multi-occasion rework. The screens are the same; the navigation above them now starts from an occasion list rather than a character-select lobby.
Infinite-loop horizontal carousel with three card states: locked (frosted glass), affordable (pulsing gold glow), and unlocked (full color with playback). Unlocking triggers an atomic Firestore transaction that verifies the point balance, deducts points, marks the reward, and creates a timeline event — all in a single operation.
Challenges carry a point value, one of three difficulty tiers, and one of five categories (physical, social, creative, sentimental, adventure). Any challenge can be 2-in-1 — setting optionBTitle makes the detail view present Option A / Option B behind a toggle picker. Submission is universal: every challenge offers Photo, Text, or Done proof options.
A newly created occasion starts with zero challenges and zero rewards. The host authors challenges in-app, and each contributor authors one gift — a text letter, one or more photos, a video, or a recorded/imported voice note — then the host prices, orders, and can delete gifts, but cannot rewrite their content. See Customization.
Contributors each create one classified dare through a spy-themed dossier interface with scan-line overlays and monospaced typography. The celebrant discovers these through a hidden "???" entry point that reveals a dark, classified sheet. Secret challenges are created, delivered, and completed through Firestore with real-time sync.
Vertical animated path with color-coded nodes: blue gradients for challenges, golden halos for rewards. Each node entrance is staggered with spring animations. The newest node breathes with a pulsing glow. Bezier trail connectors wind organically between nodes in an S-curve pattern with decorative sparkles at midpoints. A bokeh particle field and twinkling sparkle layer create a living background behind the path. The timeline is append-only by security rule — nothing can be edited or deleted after the fact.
Progressive glow intensifies as more rewards are unlocked. When the last reward is claimed, confetti erupts, haptics fire, and the badge transforms — revealed through a celebration sequence.
Every challenge sets its own pointValue and every reward its own pointCost; nothing is derived from content type. Balance a set so the challenges cannot quite cover the rewards and the gap has to be closed by the secret challenges contributors write, and unlocking the final badge requires everyone to participate rather than just the celebrant. All point mutations use FieldValue.increment for safe concurrent updates, with transactions protecting balance-dependent operations.
- Occasion List — Active and past occasions, pull to refresh, create or join from the toolbar. Joining works from a pasted invite link or an incoming
birthdayquest://joinURL - Host Panel — Occasion management for the host: invite links, celebrant-joined status, force-completing challenges, force-unlocking rewards, adjusting points, triggering the final celebration, removing a participant, and reviewing content reports filed against gifts or challenges. Shown in the Profile tab only when
isHostis true - Skeleton Loading — Screen-matched shimmer placeholders on every data view
- Media Playback —
VideoPlayerViewandAudioPlayerViewwith buffering states, error recovery, and proper AVPlayer lifecycle management - Design System — Centralized
BQDesignnamespace with color palette, typography scale, spacing tokens, shadow presets, and animation curves
MVVM + Services with real-time Firestore synchronization.
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Views │────▶│ ViewModels │────▶│ Services │
│ (SwiftUI) │ │ (@MainActor) │ │ (Firestore) │
│ │◀────│ @Published │◀────│ Listeners │
└─────────────┘ └──────────────────┘ └─────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌─────────────────┐
│ AppSession │ auth + occasion list │ Firestore DB │
│ EventSession │◀─── Real-time sync ────────│ + Storage │
│ (@EnvObject) │ one occasion's scope │ │
└──────────────┘ └─────────────────┘
- AppSession —
@MainActorObservableObjectinjected as@EnvironmentObjectat the root. Owns auth state, the uid, and the user's occasion list, and drives root routing. Knows nothing about any single occasion. - EventSession — One occasion's scope: its
eventId, the currentParticipant, and theGameStatelistener. Created when an occasion is opened, destroyed on leaving, and it tears down only the listeners it registered. Views read game state and the current participant from here. - Event-scoped listener keys — Listener keys are composed as
name@eventIdin one place (ListenerKey.scoped) so registration and removal provably match. Two view models sharing a key would silently kill each other's listener. - Two-phase occasion creation — Firestore evaluates batched writes against committed state, so the event document is written alone first; the host participant, initial game state, and membership mirror follow in one batch. The rules gate that batch on
events/{id}.hostUidrather than on participant membership precisely so it can commit. - Atomic Operations — Reward unlocks use Firestore
Transactions(read balance → verify → write). Challenge completions use Transactions with idempotency guards (read completion status → bail if already done → write). - Manual GameState Parsing — Dictionary-based parsing with
NSNumber?.intValueinstead of Codable to handle Firestore's Int64/NSNumber type inconsistencies. - Timestamps —
Timestamp(date: Date())everywhere.FieldValue.serverTimestamp()is intentionally avoided because it breaks Codable decoding.
BirthdayQuest/
├── Models/ Occasion, Participant, Challenge, Reward, GameState, TimelineEvent, AvatarCatalog
├── Services/ GameBackend + AuthProviding (protocols), FirestoreService, AuthService,
│ AppSession, EventSession
├── ViewModels/ 12 @MainActor ObservableObject classes, each injected with a GameBackend
├── Views/
│ ├── Occasions/ Occasion list, create, join, per-event container
│ ├── Celebrant/ Rewards carousel, challenge board, submission flow
│ ├── Contributor/ Secret challenge creation (dossier UI), gift authoring
│ ├── Authoring/ Host challenge authoring, gift curation
│ ├── Timeline/ Animated path, bezier connectors, final badge
│ ├── Profile/ Stats, participant roster, host panel
│ └── Components/ Avatar, media players, skeleton loaders, particles
├── Extensions/ Color+Hex, View+Extensions
├── DesignSystem.swift BQDesign namespace (colors, typography, spacing, shadows, animations)
└── AppConstants.swift Collection paths, invite-code alphabet, listener keys, Storage paths
| Layer | Technology |
|---|---|
| UI | SwiftUI (iOS 26+) |
| Architecture | MVVM |
| Backend | Firebase Firestore (real-time listeners, transactions, batches) |
| Storage | Firebase Storage (media uploads, proof photos) |
| Identity | Firebase Anonymous Auth. The uid is the participant key, so it must survive — an Apple-link path exists in AuthService/AppSession but has no UI yet. See SECURITY.md |
| Enforcement | Security rules only. There are no Cloud Functions — firestore.rules and storage.rules carry the entire trust model |
| Animations | ConfettiSwiftUI, SwiftUI spring/easeInOut, Canvas particles |
| Media | AVKit + AVFoundation (video/audio playback with KVO observation) |
| Logging | OSLog Logger (structured logging with subsystem/category) |
| Avatars | Five bundled CC0 assets, resolved by avatarId or by a stable FNV-1a hash of a display name. No network calls |
All occasion content is a subcollection of events/{eventId}. Isolation is by path, not by query
discipline — a client cannot express a query reaching another occasion, because the path does not
exist for them.
| Path | Key Fields |
|---|---|
events/{eventId} |
name, occasionType, celebrantName, hostUid, occasionDate, isOpen, createdAt. Member-readable, so it carries no secrets |
events/{id}/private/codes |
contributorCode, celebrantCode. Host-read only |
events/{id}/participants/{uid} |
name, avatarId, mode (contributor|celebrant), isHost, usedCode. Read is host-or-self, not member |
events/{id}/challenges/{id} |
title, pointValue, difficulty, category, isSecret, isCompleted, proofUrl, optionBTitle |
events/{id}/rewards/{id} |
fromName, pointCost, contentType, contentUrl/contentUrls, isUnlocked, fetchedBy |
events/{id}/timeline/{id} |
type, referenceId, subtitle, badgeType, timestamp. Append-only by rule |
events/{id}/state/main |
currentPoints, challengesCompleted, rewardsUnlocked, finalBadgeUnlocked |
events/{id}/reports/{id} |
Content reports (App Store 1.2): contentType (reward|challenge), contentId, reportedByUserId, reason?, createdAt. Member-create, host-read only, append-only |
memberships/{uid}/events/{id} |
Thin mirror so a user can list their own occasions: role, isHost, joinedAt |
inviteCodes/{CODE} |
Uniqueness reservation and code→event resolution only. get is allowed, list is denied, so a code you hold resolves but the collection cannot be enumerated. Authorization does not flow through here — joins are authorized against events/{id}/private/codes |
Invite codes live at events/{id}/private/codes, host-read only — not on the member-readable event
document, and not in a client-readable collection — and joins are authorized against that document
with a rules-internal get(), which is privileged and bypasses these rules. The document ID under
participants is the Firebase uid, which is what makes impersonation structurally impossible: the
rules only permit writing your own document.
Field names are load-bearing. The rules compare against them literally, so renaming one fails at runtime with permission-denied rather than at compile time.
- Xcode 26 or newer (the deployment target is iOS 26.0)
- A Firebase project (console.firebase.google.com)
- Cloud Storage for Firebase requires the Blaze (pay-as-you-go) plan for any project created after 2024-10-30. Firestore works on Spark; media uploads do not.
git clone https://github.com/mit112/BirthdayQuest.git
cd BirthdayQuest
open BirthdayQuest/BirthdayQuest.xcodeproj-
Create a Firebase project and enable Cloud Firestore and Cloud Storage.
-
Enable the auth providers. Firebase Console → Authentication → Sign-in method → enable Anonymous. Nothing works without it: the app signs in anonymously on launch and every security rule keys off
request.auth.uid, so with the provider disabled you land on the empty state with a connection error. Enable Apple too if you intend to work on the account-linking path. -
Add the Sign in with Apple capability in Xcode → Signing & Capabilities. Only needed for the Apple-link path, which is currently service-layer only, but the link call fails without it.
-
Deploy the security rules. This repo ships
firestore.rulesandstorage.rules:npm install -g firebase-tools firebase login firebase use --add # select your project firebase deploy --only firestore:rules,storageWith no Cloud Functions in the project, these rules carry the whole enforcement burden — this step is not optional. If you leave Firestore in production mode without deploying them, every read is denied and the app opens to an empty occasion list.
If your project already has media under the old top-level
rewards/orproofs/paths, runtools/export_media.shBEFORE this deploy. The currentstorage.rulesonly grant access underevents/{eventId}/...and deny everything else, so anything left at the old paths becomes unreachable through the app and recoverable only from the Google Cloud console../tools/export_media.sh <your-storage-bucket> ~/Documents/birthdayquest-archive
The bucket name is the
STORAGE_BUCKETvalue fromGoogleService-Info.plist. Compare the file count it prints against the Firebase console before deploying. -
Add your Firebase config. Download
GoogleService-Info.plistfrom the Firebase Console (Project Settings → Your apps → iOS) and save it toBirthdayQuest/BirthdayQuest/. SeeGoogleService-Info.plist.examplefor the expected keys.The project uses Xcode folder-synced groups, so dropping the file in that directory is enough — no Xcode configuration needed. Because it is not a build input, the app builds fine without it and then dies at launch on a raw Firebase assertion from the bare
FirebaseApp.configure()call — an unhelpful crash with no message pointing here. If the app crashes immediately on launch, this is why. -
Set your Team ID in Xcode → Signing & Capabilities (Xcode prompts automatically). Please leave that change out of any commit.
-
Build and run, then create an occasion. Tap Create an occasion, fill in the occasion name, who's being celebrated, the type and date, and your own display name. That writes the event, your host participant document, the initial game state, and two invite codes. Share the contributor link with friends and the celebrant link with the guest of honour — the host panel in the Profile tab has both, and flags it prominently while the celebrant hasn't joined.
A new occasion has no challenges and no rewards. Nothing seeds them, so the board and the carousel are empty until the host and contributors author some. That's the next section.
To personalize an occasion:
- Challenges — The host adds them in-app through the challenge authoring screen: title, description, point value, difficulty, category, and an optional Option B for a 2-in-1 challenge.
- Gifts (rewards) — Each contributor authors one gift in-app — a text letter, photos, a video, or a voice note (recorded in-app or imported) — the host prices it, sets its order, and can delete it, but cannot rewrite its content.
- Bulk setup — For seeding many challenges or rewards at once, you can still add documents
directly under
events/{eventId}/challenges/events/{eventId}/rewards(seeModels/Challenge.swift/Models/Reward.swiftfor field names). A hand-written document does not movetotalChallenges/totalRewards— only the in-app create/delete paths do, so a bulk-seeded occasion needs those counters corrected separately. - Avatars — Replace the images in
Assets.xcassets/avatar-0{1..5}.imagesetwith your own illustrations.AvatarCatalog.allis the list of valid ids - Occasion types —
OccasionTypeinModels/Occasion.swift, including thecelebrantLabeleach one shows instead of a gendered default - Design — All visual tokens live in
DesignSystem.swiftunder theBQDesignnamespace
xcodebuild -project BirthdayQuest/BirthdayQuest.xcodeproj \
-scheme BirthdayQuest \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
buildTwo tiers, and both need to stay green.
# 1. Swift unit tests (view models, sessions, models, invite codes)
xcodebuild -project BirthdayQuest/BirthdayQuest.xcodeproj \
-scheme BirthdayQuest \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-only-testing:BirthdayQuestTests \
test
# 2. Security rules, against the Firebase emulators
cd firebase-tests && npm install && npm testAlways scope the first one to BirthdayQuestTests. A bare test also runs BirthdayQuestUITests,
which boots a simulator and launches the app — slow, memory-hungry, and prone to hanging in
teardown. CI passes -skip-testing:BirthdayQuestUITests for the same reason. The rules suite needs
Java 17 for the emulators.
Linting runs from the repo root, so it picks up .swiftlint.yml:
swiftlintThe visual language follows a "Headspace meets Duolingo" philosophy — warm, playful, and alive.
Color palette: Soft purples (#7C5CFC), warm pinks (#FF6B9D), golden accents (#F5A623), cream backgrounds (#FBF7F4). Primary actions use a purple-to-pink gradient. Rewards glow gold. Secret challenges use a dark navy palette with red accents.
Typography: Rounded design system (SF Rounded) with a clear hierarchy from 34pt hero titles down to 11pt captions. Serif taglines for personality.
Motion: Spring animations for entrances, breathing glows for active states, staggered reveals for lists, confetti + haptics for celebrations. Background particle systems (bokeh circles, twinkling sparkles) with deterministic positioning to prevent flicker on re-renders.
A few implementation details worth noting:
- Path-based tenancy — Cross-occasion access isn't blocked by a query filter, it's inexpressible: every document lives under
events/{eventId}, andGameBackendhas no method that omits theeventId - Atomic Firestore operations — Reward unlocks and challenge completions use transactions with idempotency guards, preventing double-tap exploits and partial-failure corruption
- Real-time sync —
EventSessionholds one game state listener per open occasion, observed by every view in that occasion through@EnvironmentObject, so points and progress stay consistent across tabs - Infinite carousel — 5x loop multiplier with silent boundary-jump logic for seamless infinite scrolling without the memory cost of inflating hundreds of views
- Canvas-rendered overlays — Scan-line effects use
Canvasdraw calls instead of hundreds ofRectangleviews - Structured logging — All services use
OSLog.Loggerwith subsystem/category for filterable, level-aware logging - Deterministic avatars and animations — Avatars map a name to a face through an explicit FNV-1a hash rather than
hashValue, which is per-process seeded and would reshuffle every launch. Background particles derive positions and durations from seed indices, notrandom(), so nothing jumps on tab re-entry
Being upfront, because these will bite you if you deploy it:
- Members are trusted with the score. The rules gate
state/mainandrewards.isUnlockedon membership only, so any participant in an occasion can rewrite the point balance or flip a reward to unlocked. That's the cost of having no Cloud Functions, and it was a family-sized assumption that now applies to anyone holding an invite code. See SECURITY.md. - Sign in with Apple isn't reachable. Identity is a Firebase anonymous uid.
AuthServiceandAppSessionimplement linking to an Apple ID in place, preserving the uid, but nothing in the UI calls it — so losing the device still means losing every occasion. birthdayquest://is registered.Info.plistdeclaresCFBundleURLTypesfor the scheme, so tapping a shared invite link opens the app directly, in addition to pasting it into the join sheet.- Accessibility is largely handled. All typography is semantic text styles (Dynamic Type reaches every screen), every perpetual animation is gated behind Reduce Motion / Low Power Mode, and text sits only on tokens that pass AA against the app's cream background. Light mode only — every color is a fixed hex with no dark variant. Visual reflow at the largest accessibility text sizes is still unverified; there are no snapshot tests for it.
- iOS 26.0 minimum. Set during development to use the newest SwiftUI APIs; it does limit who can build it.
- Avatar illustrations generated with DiceBear — Open Peeps by Pablo Stanley and Lorelei by Lisa Wischofsky, both CC0 1.0 (public domain, no attribution required — credited anyway). They ship as bundled assets; the app makes no network call for them.
- Confetti by ConfettiSwiftUI (MIT)
- Backend by Firebase (Apache-2.0)
Built with heavy AI assistance (Claude Code) for scaffolding, code review, and documentation. The architecture, design direction, and every product decision are mine.
MIT License. See LICENSE for details.
Contributions welcome — see CONTRIBUTING.md.
Built with SwiftUI + Firebase.




