Alcides Oliveira

Full-stack software engineer

All projects

CineGacha

Side project · Mar–Apr 2026 · Visit site · Source code

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.

CineGacha, screenshot 1
A sealed booster pack waiting to be tapped open.

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.

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.

  • 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.

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.

CineGacha, screenshot 2
The five cards from one pack, each with type, rarity, and ATK/DEF stats.
CineGacha, screenshot 3
The collection page with progress counts, type and rarity filters, sorting, and a prompt to sign in and save.
CineGacha, screenshot 4
A card’s detail view with stats, collection date, and the share button.
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