Skip to content
Back to PlayState

Case study

PlayState

A live, signed-in web app that turns a personal game library into three explainable picks, with Steam import, a Discover page, and parties for finding the multiplayer games a group already owns.

Role:
Solo designer and full-stack developer
Timeline:
Oct 2026 - present
Stack:
TypeScript, Next.js, React, Tailwind CSS, Clerk, PostgreSQL, Prisma, Supabase, Vercel

The problem

Most people own more games than they have evenings to play. The usual fix is to browse a storefront, which points toward games you do not have yet. PlayState starts from the other direction: the only candidates are games already in the player's own library, and the player says how long they have, what sounds good, and whether they want to play alone or with others.

The hard part is trust. A picker that returns an unexplained list, or an invented 94 percent match, gives the player no reason to follow it. Every suggestion here comes with plain reasons such as "Matches Adventure" or "Supports single-player", and the scoring is small enough to read in a minute.

Most of the data that would make a pick good is missing. IGDB lists genres, themes, and play modes, but nothing about how long a sitting lasts, so the app has to combine what the player tells it with the best evidence available instead of pretending to know.

Constraints and tradeoffs

  • Constraint

    Picks have to be explainable, and the catalog has no data on how long a single sitting lasts.

    Decision

    A small point system replaces machine learning. Genre and theme matches add points, a supported play mode adds a point, and session length adds one or two points from the best evidence available: the player's own tag first, then genre and theme heuristics, then IGDB's overall length. Time only adds points and never removes a game, so a short evening never produces an empty result. The cost is that the app cannot learn taste over time. The benefit is that every pick can be explained and tested.

  • Constraint

    A library is personal data, and parties have to compare libraries without exposing them.

    Decision

    The user ID always comes from the verified Clerk session, never from the request body or URL, and every library query is filtered by it. Row level security is on for every table, so the database is reachable only through the API. A party shows members only the multiplayer games they have in common, plus their own games, and never another member's unshared games or account ID. This adds scoping rules and tests to every query, which is the price of never trusting the browser.

  • Constraint

    The app runs on free tiers, so the API sleeps when idle and the database can pause.

    Decision

    A scheduled job pings the API twice a day to keep Supabase awake, and the README warns that the first request after idle can take up to a minute. Rate limits live in the API's memory, which suits a single instance but resets on restart and would need a shared store before the API scaled out.

  • Constraint

    Discover suggests games the player has never seen, and part of IGDB's catalog is sexual content.

    Decision

    Mature games are hidden unless the player opts in. A game counts as mature when any one signal fires: IGDB's Erotic theme, an ESRB Adults Only rating, an ESRB Mature or PEGI 18 rating whose descriptions mention sexual content, or a whole-word keyword match on the title and tags as a backstop for untagged games. Layered checks will occasionally hide a borderline game, which is the safer failure for a page that recommends unfamiliar titles.

  • Constraint

    Party pages have to stay current for every member without running a realtime server.

    Decision

    Each party page polls a cheap version check every few seconds. The version is a fingerprint of what members can see change (the name, the host, who is in, the latest roll, and the host's settings), so the check reads one party and no libraries, and the page reloads the full data only when the fingerprint changes. Updates arrive a few seconds late instead of instantly, in exchange for no websocket infrastructure to run.

How it works

The repository is an npm workspace with a Next.js frontend on Vercel and an Express API on Render. Every API request follows one path: route, controller, service, then Prisma or IGDB. Routes map URLs to controllers and attach authentication, controllers validate input with Zod and call a single service, services hold the logic, and one error middleware gives every failure the same JSON shape. The browser never talks to IGDB or Twitch; their credentials exist only on the API server.

Deep dive

From three questions to three picks

The picker asks how much time you have, which types of game sound good (any number, or none), and whether you want single-player, multiplayer, or either. The API only ever considers games in the signed-in player's library.

A game that matches a selected type directly earns 2 points per type, a related genre or theme earns 1, support for the requested mode earns 1, and a session length that fits earns 1 or 2. If types were selected, a game has to match at least one. If a mode was requested and a game lists its modes without it, the game is left out, while games with no mode data stay in. Results are sorted by points, ties are shuffled, and the top three come back. The picker also sends the games it has already shown, so Pick again never repeats them.

Session length is the weakest signal, so it uses an evidence ladder. The player's own tag on the game page is worth 2 points, and a tag that does not match means no time points at all. Without a tag, genres and themes give a guess worth 1 point (puzzle and racing lean short, role-playing and strategy lean long, and a shooter counts as short only when it has multiplayer). IGDB's overall length is used only when the genres say nothing.

The scoring pipeline. Time only adds points; it never removes a game.

One request path, three trust rules

Three rules hold on every route. The user ID comes only from the verified Clerk session, never from a request body or URL. Every library query is scoped to that ID, with a party as the single exception, and even a party shows members only what they have in common. IGDB and Twitch credentials exist only on the API server.

Input is validated with Zod before a service runs, and any failure comes back in the same JSON shape. Rate limiters sit in front of the routes that cost something: the Steam import is limited to five loaded libraries an hour, and a security pass (#10) made the catalog and import routes require sign-in and added limits to them. A limiter can count only successful requests, so a typo or a failed lookup does not use up the allowance.

A request from the browser to the data. The user ID never travels in the request body.

The data model

Game metadata is stored once and shared by every user. Everything personal sits in rows that carry the player's Clerk user ID, and every query filters on it. Party data cascades: deleting a party deletes its members, messages, reactions, and nominations with it. Fourteen Prisma migrations record how the schema grew, from the first four tables through parties, nominations, reactions, chat, and cosmetics.

The main tables. Row level security is enabled on all of them.

Parties: the games everyone owns

A party lets friends compare libraries without sharing them. The host starts one and shares an invite link. The code in the link is 72 random bits, because it is the only thing protecting the invite, and up to 12 players can join. A player can be in three parties at once. Someone holding the link sees only the party's name and size before joining: no member names and no games.

Inside, the server works out which multiplayer games every member owns, which all but one member owns (and who is missing each), and the viewer's own multiplayer games that fit neither list. Games too small for the group are filtered using IGDB's player limit unless the host chooses to ignore limits, and a missing figure never hides a game. Each member can nominate up to two games. When the host rolls, the server re-checks that the chosen game is really owned and playable by everyone instead of trusting the browser, and every member's screen starts the roll animation together using the server's clock.

A party lasts 24 hours by default and the host can choose another length. Expired parties are deleted whenever someone lists, starts, or joins one, so there is no scheduled cleanup job. If the host leaves, the longest-standing member takes over, and a host who is alone ends the party. When an account is deleted, the parties it hosted pass to the next player.

Joining, staying in sync, and rolling. The server decides the result.

Discover: new games from your own taste

Discover suggests well-rated Steam games the player does not own. It reads only the signed-in player's library and starts from up to eight seed games: the ones the player tagged with a session length first, then the most recently added. From those it builds a taste profile of the top three genres, four themes, and eight keywords.

Candidates need a rating of at least 65 and between 3 and 2,000 ratings, because a score from a handful of ratings is not reliable and a game rated by thousands is no secret. The score adds taste (a game similar to a seed counts most), quality, and a bonus for being known by fewer players. Each result carries one plain sentence, such as "Because you play Fortnite and Minecraft", and mature games stay hidden unless the player opts in.

How a Discover list is built, from the library out to the page.

How the build unfolded

The first commit landed on Oct 7. The picker, API, and sign-in were working by Oct 10, and from there the app grew in layers: Steam import and library views, then parties, then error monitoring, rate limits, link previews, and Discover. One early feature did not survive. Saved picks and pick history were added on Oct 10 and removed on Oct 11, along with their database table, which kept the picker stateless: a pick is computed from the library on demand, so there is no history to keep consistent.

Work by theme, from the git history.

Product screens

PlayState home page with the headline Find the right game for right now and a grid of game covers with three highlighted picks
1 / 6

Results and takeaways

189
API test cases
14
Database migrations
13,000+
TypeScript source lines
14 days
First commit to latest
  • Cutting saved picks after a day kept the picker stateless. Computing a pick from the library on demand means there is no history to migrate or keep consistent.
  • Rate limits and Sentry arrived on Oct 13, a day after the Steam import shipped. Any route that calls a quota-limited third-party API should get its limit and its sign-in requirement in the same change that adds it.
  • The dice roll runs in the host's browser, but the API re-checks that the chosen game is owned and playable by every member before recording it. A client can be wrong or hostile, so the server decides.
  • Polling a one-row fingerprint gave parties live behavior without a realtime service. The limits are a few seconds of delay and in-memory rate limits that assume one API instance, and both are known and acceptable at this size.