CineGacha
A card game for people who like movies. You open packs of five cards made from TMDB films, actors, and directors, and build a collection.
I built and deployed it alone. All 98 commits in the repository are mine.

The problem
A gacha game depends on players trusting the odds, so the draws could not run in the browser, where anyone can edit them. I also wanted people to play on the first visit, without an account, and keep their cards if they signed up later.
Decisions
Game rules in the database
I wrote the draw logic in PL/pgSQL instead of TypeScript. It is harder to test and read, but the roll, the pack count, and the inventory update happen in one transaction under a row lock, so two open tabs or an edited request cannot open the same pack twice or change the odds.
Cache TMDB, freeze rarity
Cards are copied from TMDB into Supabase ahead of time, and each card keeps the rarity it got when it entered the pool; a refresh adds new cards and leaves existing ones alone. Opening a pack does not depend on TMDB being up, and a card someone owns does not lose rarity when an actor’s popularity drops. The cost is that the pool only grows when I trigger a refresh.
Anonymous first, account later
Every visitor without a session gets an anonymous Supabase user, so the first pack is one click away. Sign-in tries to link GitHub to that same user, and a merge function covers the case where the GitHub account already exists. That second path added code, but nobody loses a collection by signing in.
What I built
- The gacha engine as PostgreSQL functions: a row lock against double opens, pack regeneration by elapsed time, weighted rolls across seven rarity tiers, a pity rule (after ten packs in a row without a Super Rare or better, every card in the next pack rolls at that level), stars for duplicates with a cap, and a fallback to lower tiers when a tier is empty.
- A card-pool pipeline in TypeScript that calls TMDB through a p-queue rate limiter (35 requests per second, with retries that respect Retry-After), scores films with one formula and actors and directors with another, and assigns rarity by percentile within each card type so every type gets the same tier distribution.
- Play without an account through Supabase anonymous sessions, then GitHub sign-in that tries to link to the same user. When the GitHub account already belongs to another user, the OAuth callback calls a migration function that merges the guest’s cards, pack count, and pity progress into that account.
- The pack-opening screen in React with Motion: a pack you tap to tear, five cards revealed in sequence, and foil sweeps that get stronger with rarity, with a static version for people who turn on reduced motion.
- A collection page with filters by card type and rarity, sorting, progress counts, a detail modal, and a card image you can download as a JPEG, made with html-to-image. TMDB images go through a small proxy route that only accepts the TMDB image host, so the export canvas is not blocked by CORS.
- A GitHub Actions workflow that runs lint and a production build on every push and pull request to main.
How it works
Next.js (App Router) renders the pages and runs server actions. Supabase handles sign-in, and its PostgreSQL database holds the game state: player profiles with pack counts and pity progress, the card pool, each player’s cards, and genre albums. Row-level security lets a player read only their own profile and cards. There is no write policy on the cards table; every write goes through database functions.
Opening a pack goes like this. The browser calls a server action, the action reads the signed-in Supabase user, and then it calls an open_pack function written in PL/pgSQL. That function locks the player’s profile row, adds the packs regenerated since the last visit (one every two minutes, up to ten), rolls a rarity for each of the five cards with weighted odds, picks a random card of that rarity, turns duplicates into stars, and returns the result. The tear animation plays while the request is in flight; the browser only shows what comes back.
TMDB stays out of that path. A separate builder, behind admin routes that require a bearer secret, fetches movies and credits from TMDB, validates the responses with Zod, checks that each image loads, scores and ranks every entry, and writes the cards to the pool in batches of 100. A refresh adds only cards that are not in the pool yet.



Built with
- Front end
- React 19, Next.js 16 (App Router), TypeScript, Tailwind CSS v4, Motion, html-to-image
- Back end
- Next.js server actions and route handlers, Supabase Auth (anonymous sessions, GitHub OAuth), Zod, p-queue
- Data
- Supabase PostgreSQL, PL/pgSQL functions, row-level security, TMDB API
- Infrastructure and tooling
- Vercel, GitHub Actions, ESLint, Prettier, React Compiler