Inspiration

Everyone knows the routine. You sit down to watch a movie with friends, open three streaming apps, and scroll for 40 minutes. Then you give up and rewatch something you've all seen. 🍿

Plenty of "Tinder for movies" apps already exist. Most of them show you a poster and a synopsis, which is a pretty bad way to decide whether you'd enjoy a two-hour film. Most also make everyone create an account before the group can start. We wanted three things instead:

  • Show the actual trailer, not a poster.
  • Let a friend join in seconds by scanning a QR code, with no signup.
  • End with a real decision, a big "It's a Match!" moment, instead of endless scrolling.

What it does

MovieMatch is a mobile app that turns picking a movie into a quick swipe game you can play alone or with a group.

  1. Pick a few genres to start. On first launch you choose a username and tap the genres you're in the mood for. The app uses those picks to shape your deck before you've swiped once.
  2. Swipe through trailers. Each card autoplays a muted trailer full-screen, like a vertical video feed. Where we could find one, it's an official vertical YouTube Short. Swipe right to like, left to pass, or save a title to Watch Later. Tap to pause, tap the speaker to unmute.
  3. The deck learns as you swipe. Every swipe updates your taste profile and re-ranks the deck. Each card also shows a one-sentence "why you'd like this" note from Gemini, plus three vibe tags (like neon-noir or slow burn) that Gemini picked out by looking at the poster.
  4. Tell it your mood. Type something like "funny but not dumb" and Gemini turns it into real TMDB filters, then rebuilds your deck around it.
  5. Start a group. The Group tab gives you a 4-letter code and a QR code. Friends scan it with their phone camera, which opens a join page at movienight.tech, and they're in. No accounts, and the member list updates live.
  6. It's a Match! When the group lands on a movie, the reveal fires on every phone at once. It shows the poster, where to stream it, and Gemini's explanation of the compromise it made between everyone's tastes.
  7. Top picks and Saved. Rounds are capped at 30 cards. When one ends you get the algorithm's top 3 picks instead of a dead end, and "Keep swiping" starts another round. Everything you liked, saved, or matched on lives in the Saved tab, synced live across devices.

How we built it

The stack

Layer What we used
App Expo (SDK 57), React Native, Expo Router, TypeScript
Animation Reanimated, Gesture Handler, expo-blur
Movie data TMDB API (details, streaming providers, trailer keys)
Trailers YouTube IFrame API via react-native-youtube-iframe
Accounts Firebase Anonymous Auth (no login screen)
Realtime data Cloud Firestore with security rules
Join links Firebase Hosting behind movienight.tech
AI Gemini 3.5 Flash through Firebase AI Logic (no API key in the app)

Everything runs on Firebase's free Spark plan, with no custom server.

For trailers, we oversize and center the YouTube player so the video fills the card edge to edge. On web, we skip the library's broken bridge and use a plain YouTube embed. We also wrote a script that finds vertical YouTube Shorts for our curated catalog and favors official studio channels.

The taste profile

Every movie \(m\) is described by a set of features \(F(m)\): its genres, its TMDB keywords, and its top three billed cast members. Each user has a taste vector \(v\) that starts empty. When you swipe, every feature of that movie moves by one:

$$ v_f \leftarrow v_f + \delta \quad \text{for each } f \in F(m), \qquad \delta = \begin{cases} +1 & \text{swipe right} \\ -1 & \text{swipe left} \end{cases} $$

A movie's score is the average weight of its features:

$$ s(m) = \frac{1}{\lvert F(m) \rvert} \sum_{f \in F(m)} v_f $$

Averaging keeps movies with lots of keywords from outscoring movies with only a few.

Keeping the deck interesting

If we always showed the highest-scoring movie, the deck would collapse into one genre. So the next card is chosen \(\varepsilon\)-greedy, with \(\varepsilon = 0.2\):

$$ \text{next card} = \begin{cases} \arg\max_{m} s(m) & \text{with probability } 1 - \varepsilon \\ \text{a random other unseen movie} & \text{with probability } \varepsilon \end{cases} $$

To keep the deck from running dry, we also pull in titles related to the three movies you liked most, using TMDB's recommendations.

Group matching: Average Without Misery

A plain average lets a group outvote the one person who really hates a movie. We used a strategy called Average Without Misery instead. Each member \(u\) has a set of liked movies \(L_u\), a set of passed movies \(D_u\), and their own score \(s_u\). A member vetoes a movie if they passed on it, or if they haven't liked it and their predicted score is negative:

$$ \text{veto}_u(m) = \big( m \in D_u \big) \lor \big( m \notin L_u \land s_u(m) < 0 \big) $$

Any vetoed movie is removed for the whole group. The survivors are ranked by the group's average score across all \(n\) members:

$$ S(m) = \frac{1}{n} \sum_{u=1}^{n} s_u(m) $$

Ties go to the movie more members explicitly liked. A match fires in one of two ways:

  • Unanimous: a surviving movie is in every member's likes, \(m \in L_1 \cap L_2 \cap \cdots \cap L_n\).
  • Fallback: every member has swiped at least 8 cards, so the top survivor wins. Without this, a group with very different tastes could swipe forever.

The match is written to one shared Firestore document, so every phone reveals the same movie at the same moment instead of each phone deciding on its own.

Five jobs for Gemini

Every Gemini call returns structured JSON that matches a schema, and nothing it returns reaches the screen without being checked.

# Job How we keep it honest
1 Why lines: one sentence per card Capped at 14 words and 90 characters, trimmed locally so it always fits
2 Mood filters: plain language to TMDB filters Genres limited to a fixed list, keywords confirmed through TMDB search
3 Group compromise: explains the match Its pick must be a movie that actually survived the group ranking
4 Backup recommender: suggests titles when the deck runs low Each title is looked up on TMDB, and anything not real and playable is dropped
5 Poster vibe tags: reads the poster image Exactly three short lowercase tags, pre-generated for the catalog to save quota

All five share one quota tracker. If one feature hits a rate limit, the others back off for 60 seconds instead of burning through the rest of the free tier.

How we worked as a team

We split 37 planned issues across three owners: mobile, backend, and AI/demo. Each issue listed its dependencies and acceptance criteria, and a GitHub Actions workflow rebuilt a live dependency board every time an issue changed. A shared AGENTS.md file of project rules and hard-won gotchas kept our AI coding assistants consistent with each other.


Challenges we ran into

  • Trailers wouldn't autoplay on Android. Videos loaded to YouTube's paused screen and never started. After three attempted fixes, we traced it through the source code of both libraries. The WebView sent play commands to document, but the YouTube player listened on window, so every command was silently dropped. The fix was a small injected script that forwards those messages, timed to run after the page loads.
  • Two "optimizations" broke every video. Preloading the next trailer in a hidden player, and bundling the player page locally, each made every trailer fail on device. We reverted both and documented why.
  • Keeping phones in sync. If each phone decided on a match from its own swipes, one could reveal a beat early or pick a different movie. Having every device react to one shared match document solved it.
  • Free-tier limits. Gemini's free tier allows about 15 requests per minute. We cached aggressively, pre-generated image tags, set request timeouts, and made every Gemini feature optional. If Gemini is slow or unavailable, the app still works and just skips the extra line.
  • Venue Wi-Fi. The app ships with a 68-movie curated seed catalog, so swiping, ranking, and matching still work if TMDB is unreachable.
  • Performance. The swipe screen's animated background was decoding three full-screen GIFs at once. Cutting that to one smaller GIF made swiping noticeably smoother.

Accomplishments that we're proud of

  • The full loop works end to end: scan a QR code, swipe trailers on two phones, and watch "It's a Match!" fire on both with Gemini's explanation.
  • Group sessions need zero accounts. A friend goes from scanning the code to swiping in seconds.
  • Gemini does five different jobs instead of one generic chatbot call, and none of its output is trusted without a check.
  • The whole app runs on free tiers.
  • We merged over 100 pull requests in a weekend and kept a test suite running alongside the code.

What we learned

  • For a decision app, a trailer tells you far more than a poster does.
  • The best way to fix a hard bug was to read the source code of the libraries involved, not to keep guessing.
  • AI output needs guardrails. Schemas, fixed lists of allowed values, and checking results against TMDB made Gemini dependable enough to demo live.
  • A simple, explainable model like a feature vector plus Average Without Misery was easier to debug under time pressure than anything fancier.
  • Planning issues with clear dependencies up front let three people build in parallel without blocking each other.

What's next for MovieMatch

  • [ ] Universal links, so scanning the QR code opens the app directly (this needs a standalone build, not Expo Go)
  • [ ] App Store and Google Play releases
  • [ ] Filtering by the streaming services you actually subscribe to
  • [ ] Sharing the match result or a watch-party invite straight to a group chat
  • [ ] A smarter recommender once we have more than a weekend's worth of swipe data

Built with: Expo · React Native · TypeScript · Expo Router · Reanimated · Gesture Handler · Firebase (Anonymous Auth, Firestore, Hosting, AI Logic) · Gemini 3.5 Flash · TMDB API · YouTube IFrame API · react-native-qrcode-svg · Jest · GitHub Actions · movienight.tech

Share this project:

Updates

Submission history