A PlayStation trophy hunting site: accounts with PSN linking, trophy tracking, live PSN profile lookups, a game database built from PSN, community guides, leaderboards and search.
npm install
# put a PostgreSQL connection string in .env as DATABASE_URL (see "Database" below)
npm run setup # creates the tables and seeds demo data
npm run dev # http://localhost:3000
Demo login: demo@trophypilot.com / trophyhunter. Every seeded user has the same password. npm run db:reset wipes the
database and reseeds it. On a live (real PSN) database, npm run demo:user creates just the demo login. The demo account
can’t link PSN or be deleted, because its password is public.
Check everything works with npm run smoke (or npm run smoke -- https://your-site): it loads every page and API route
as a visitor and as the demo user and reports anything that errors.
Without a PSN token the site runs in demo mode: a simulated PSN provider and a fictional game catalogue. Nothing talks to Sony, which is why real players (for example GamingWithFlacy) and real games (for example Hollow Knight) don’t show up.
Sony has no public API or “Sign in with PlayStation” for other sites. Like other trophy sites, TrophyPilot reads PSN with a service account: a normal PSN account whose session token (the NPSSO) the server uses to call Sony’s mobile API.
{"npsso":"<64 characters>"}. Copy the 64-character value. If you see an error instead, you aren’t signed in; repeat step 2..env:
PSN_NPSSO="paste-the-64-characters-here"
npm run psn:check -- GamingWithFlacy
You should see OK: signed in and the player’s level and trophy counts.
npx prisma db push --force-reset
Use npm run setup:live instead on a new, empty database.
npm run psn:import -- GamingWithFlacy AnotherHunter
npm run psn:import -- --trophies GamingWithFlacy # also fetch every trophy list (slow, one request per game)
npm run dev) so it picks up the new env.Now search finds real PSN players with their avatars, /psn/<OnlineID> shows any public profile, and members can link and
sync their real trophies.
Things to know
[psn] auth .... Then repeat steps 2 to 5 and run npm run psn:check. Put a reminder in your calendar
every seven weeks. Signing out of playstation.com in the browser you took the token from also ends it early.psn-api. Sony can change it at any
time, and using it may break PlayStation Network’s terms for the service account. Keep that in mind before launching publicly.Sony doesn’t offer “Sign in with PlayStation” to other sites. Its OAuth is only available to companies with a signed partner agreement (that’s how Discord and similar integrations work). So TrophyPilot does what PSNProfiles, Exophase and TrueTrophies do: it proves ownership instead of signing in.
HUNT-3F9A1C.Once linked, players appear on the leaderboards, and anyone can compare two members at /compare (or with the Compare
button on a profile). A player’s trophies must be visible to “Anyone” in their PSN privacy settings for syncing to work.
Never ask players for their own NPSSO token or PSN password. A token gives full control of their PSN account.
Syncing big libraries: a run processes at most PSN_SYNC_TITLES_PER_RUN changed trophy lists (default 200),
PSN_SYNC_CONCURRENCY at a time (default 10), and remembers where it stopped. The runs of one import share the game
list they fetched first, and Settings shows “N of M games imported” while it runs. Right after linking, and after a Sync now
that leaves a backlog, the server keeps running batches in the background until the library is imported. After that, a
sync only reads PSN’s game list up to the last synced game (PSN sorts it newest first) and fetches the lists that
changed. Measured against real PSN: a 10-game library imports in about 3 s, a routine sync with nothing new takes under
half a second, and big libraries import at about 0.13 s per game (a 4,500-game library in about 10 minutes).
Sync schedule and plans: src/lib/plans.ts sets how often each plan syncs. Free accounts sync automatically every 7
days and can press Sync now once an hour. Premium (User.plan = "PREMIUM", not sold yet) syncs automatically every hour
and can press Sync now every minute. GET /api/cron/sync with Authorization: Bearer $CRON_SECRET syncs up to
PSN_SYNC_PER_RUN accounts (default 10) that are due, plus any part-way through an import. It answers straight away and
syncs after the response, so call it every 10 minutes from any scheduler. Every sync uses the site’s one PSN token, so
very short automatic intervals for many accounts would need more tokens.
Sony has no public API that lists the best players, so the site ranks every player it has seen. Each time a PSN profile
is fetched (a lookup on /psn/<OnlineID>, psn:import, psn:track or a member’s sync), its public summary is stored
in PsnPlayer: level, trophy counts, avatar and the country of the PSN account (decoded from the profile’s npId).
Players whose PSN trophies are private are never ranked. Members who are private, friends-only or opted out of leaderboards stay off the public boards.
PsnPlayerSnapshot), so they include non-members too. A player first seen mid-week counts from then.The boards (and the home page feeds) are only as full as the set of players the site knows, so fill it up:
npm run psn:track -- ikemenzi GamingWithFlacy # add or refresh specific players (totals, 50 recent games, platinum dates)
npm run psn:track -- --file hunters.txt # one Online ID per line
npm run psn:discover -- --max 200 # add players from the public friends lists of the top tracked players
Then keep them fresh with the players cron: GET /api/cron/players with Authorization: Bearer $CRON_SECRET, for
example every 10 minutes. Each refresh reads the player’s full games list (games played and average completion for the
leaderboards) and counts ultra rares in up to 40 more of their lists: lists they haven’t started and finished lists we
already hold are counted for free, the rest cost one PSN request each, so a big library fills in over a few days (the board
shows “123+” until it’s done). npm run psn:player-stats -- 50 300 catches the top 50 players up in one go. Each call
refreshes the PSN_REFRESH_PER_RUN stalest players (default 10) and, if
PSN_DISCOVERY=on, discovers up to PSN_DISCOVER_PER_RUN new ones (default 20). Discovery is off by default: it collects
public profiles of people who never visited the site, so decide whether you want that and keep the privacy policy in step.
To honour a removal request, run npm run psn:track -- --hide TheirOnlineId: they disappear from lookups, search and
leaderboards.
“Latest platinums”, “Top this week” and “Most played this month” merge members’ synced history with tracked players’ recent
games (PsnPlayerTitle) and snapshots. Platinum dates are exact: they’re read from the trophy list, a few per refresh.
“Popular guides” only has what members write; while it’s empty it lists the most-played games that still need a guide.
Trophy pages live at /games/<game>/<trophy>, for example /games/hollow-knight/watcher. Slugs are unique within a
list; hidden trophies use hidden-<number> so the address doesn’t spoil them, and repeated names get the trophy number
added (src/lib/trophy-slug.ts). Old /trophies/<id> links redirect. npm run db:trophy-slugs fills in any trophy
without a slug and runs in the Render build.
The site is light by default. Members can switch to dark in Settings, Appearance (User.theme); it never follows the
device’s own light or dark setting. Dark colours are in globals.css under :root[data-theme="dark"] and meet the
same contrast rules as the light ones.
/community): short updates from members, with tabs for everyone, friends and people you
follow, and your clubs. Members can follow anyone (no approval) as well as add friends./clubs): groups around an interest. Members join to see and post updates; the owner or an admin can delete
a club. A club is deleted with its owner’s account./messages): private conversations and group chats (up to 20 people). Members choose who can message
them in Settings (anyone, friends and people they follow, or nobody). Only a conversation’s members can open it./forums/user/<name>.PROFILE_CARDS and src/components/ProfileCardArt.tsx), a picture (PSN avatar, ten built-in ones in
src/components/avatars.tsx, or a game icon), a banner from one of the member’s games, an accent colour
(src/lib/profile-themes.ts), YouTube/Twitch/other stream links, “Playing now” (set on a game page, clears after 3
hours), a trophy log of the last 50 trophies with exact times, saved guides, and filters for platform, completion and
order on the games list./forums has sections, each with optional sub-sections, holding threads and replies. A thread can be tagged with a game,
and the game’s page lists its threads under Discussion. Admins create, edit, reorder and delete sections at
/forums/manage, and can pin, lock, move and delete threads and posts. Members can edit and delete their own posts.
Make someone an admin with npm run user:role -- <username> ADMIN (USER to undo). npm run forums:seed creates the
standard sections and sub-sections (safe to run again; it leaves existing ones alone). The forum also has Rules
(/forums/rules) and Staff (/forums/staff, everyone with the admin role) tabs. Never make the shared demo account
an admin: its password is public. Posts stay up, credited to a deleted user, when an account is deleted.
The home page’s “New trophy lists” are the games with the highest PSN list ids (NPWR12345_00): Sony hands them out in
order, so they’re the most recently created lists. “New DLC” first shows DLC packs found when a list we already had grew
(PSN’s game listings report each list’s trophy total; when it rises, the list is fetched again and the new pack is marked
addedLater), then DLC of the newest games. The players cron loads a few of these lists per run
(PSN_NEW_LISTS_PER_RUN, PSN_GROWN_LISTS_PER_RUN); npm run psn:lists -- 40 loads a batch by hand.
These come from guides. When someone posts a guide, the game’s difficulty becomes the average of its guides’ ratings and
the hours and playthroughs the median, shared by all of the game’s trophy lists (PS4, PS5, regions). Games without a
guide show “Needs a guide”. npm run db:estimates recalculates every game, for example after importing guides.
PSN only has trophy data. Release dates, descriptions, genres, developer, publisher, screenshots and trailers come from IGDB, which is free through a Twitch developer app:
http://localhost,
category Website Integration, client type Confidential..env (and in Render, Environment) as IGDB_CLIENT_ID and IGDB_CLIENT_SECRET.npm run igdb:import -- "Hollow Knight". Then fill the whole catalogue with npm run igdb:import
(about 4 games a second; safe to stop and run again).After that, each game page looks itself up on its first visit, and GET /api/cron/games (same CRON_SECRET header)
catches the rest, 100 games per call. Only names that match exactly are used, so a few games stay without details
rather than getting the wrong trailer. Existing values are never overwritten.
PSN gives every platform, and often every region, its own trophy list. Rainbow Six Siege has a PS4 list and a PS5 list;
small games often have four regional lists per platform. Each list is its own row in the Game table because progress,
trophies and rarity differ between them.
The catalogue only contains lists the site has seen on someone’s profile, because PSN has no public game search. To
fill gaps, the first view of a game page probes neighbouring list ids in the background (lists released together get
consecutive NPWR numbers) and adds any with the same title. Some cross-gen games (Fall Guys, for example) have no
separate PS5 list at all: PS5 players earn the PS4 list.
Lists of the same game share a titleKey (the title lowercased with punctuation, trademark symbols and accents removed,
see titleKey() in src/lib/utils.ts). Search and the games page show one entry per titleKey with its platforms and
list count, and every game page has a switcher between its lists. After changing titleKey(), run
npm run db:backfill-titles to recompute keys for existing rows.
| Layer | Choice |
|---|---|
| Framework | Next.js 15 (App Router, React 19, Server Components, Server Actions) |
| Language | TypeScript (strict) |
| Styling | Tailwind CSS v4. Design tokens live in src/app/globals.css (@theme) |
| Type | IBM Plex Mono throughout |
| Database | Prisma 6 + PostgreSQL (Neon free tier works) |
| Auth | bcrypt password hashes and an HS256 JWT in an httpOnly cookie (jose) |
| Validation | Zod in every server action |
| PSN | psn-api behind a TrophyProvider interface, with real and mock providers |
| Area | Where |
|---|---|
| Accounts, PSN link and sync, privacy, data export, account deletion | /register, /login, /settings, /api/account/export |
| Profiles, platinum tracker, milestones, friends | /u/[username], /u/[username]/[game], /friends |
| Live PSN profile lookup (any public player) | /psn/[onlineId], src/lib/psn/lookup.ts |
| Game database, DLC pages, trophy pages | /games, /games/[slug], /games/[slug]/dlc/[group], /trophies/[id] |
| Release dates, screenshots, trailers (IGDB) and guide-based estimates | src/lib/igdb.ts, src/lib/estimates.ts |
| Guides with roadmaps, missables, collectibles, tips | /guides, /guides/[slug], /guides/new |
| Compare two members side by side | /compare |
| Leaderboards (global, country, friends; all time, weekly, monthly) | /leaderboards, src/lib/leaderboard.ts |
| Search (games, PSN players, members, trophies, guides, sessions) | /search |
| Co-op and boosting sessions | /sessions |
| Forums with sections, sub-sections and game threads | /forums, /forums/manage (admins) |
| Terms and privacy policy | /terms, /privacy (the operator name comes from SITE_OPERATOR_NAME) |
| Contact form (Formspree) | /contact, ?topic=removal or ?topic=privacy preselects a topic |
The app uses PostgreSQL. The free tier of Neon is enough; any PostgreSQL 14+ works. Put the connection
string in DATABASE_URL and run npx prisma db push to create the tables.
Use Neon’s direct connection string (turn “Connection pooling” off in Neon’s Connect dialog). The pooled one breaks
prisma db push.
Moving from an old SQLite prisma/dev.db: point DATABASE_URL at an empty PostgreSQL database, run
npx prisma db push, then npm run db:from-sqlite. It copies every table, then prints row counts.
Render hosts the website, Neon holds the data. Both have free plans that don’t need a card.
.env, set DATABASE_URL to that string, then run
npx prisma db push and npm run db:from-sqlite.TheRealSouls/huntresser (the repository keeps its original name).DATABASE_URL is the Neon string, PSN_NPSSO is your token, and
NEXT_PUBLIC_SITE_URL is https://trophypilot.onrender.com (or the address Render shows), or https://trophypilot.com once your domain points at Render./api/health should show "mode":"live".https://<your-site>/api/ping every 10 minutes. Render’s
free plan puts the site to sleep after 15 minutes without visitors and waking it takes about 40 seconds; the ping stops
that. It doesn’t touch the database, so Neon still sleeps when nobody’s around. One always-on free service fits inside
Render’s 750 free hours a month.Authorization: Bearer <CRON_SECRET> (copy it from Render, Environment):
https://<your-site>/api/cron/sync and https://<your-site>/api/cron/players. With IGDB keys set, add
https://<your-site>/api/cron/games every 30 minutes too.Without the ping, free Render sites sleep after 15 minutes without visitors and the first request afterwards takes up to a minute. The data is in Neon, so nothing is lost while it sleeps.
Speed. The free plan has a tenth of a CPU, so the site avoids repeating work: the home page is built once a minute and
served to everyone; shared data (leaderboards, the games list, a game’s details) is cached in memory for a minute or two
and refreshed in the background (src/lib/cache.ts); and pages don’t read the login cookie, so the navbar fills in from
/api/me after the page arrives (src/components/session.tsx). Light or dark is applied in the browser before the
page paints.
Local development can use the same Neon database, but then everything you do locally happens on the live site, and
npm run db:reset would wipe it. Safer: in Neon, Branches, Create branch (call it dev), and put the dev
branch’s connection string in your local .env. A branch starts as a copy of the live data.
Every push to main redeploys automatically.
The target is WCAG 2.2 AA, and /accessibility is the public statement (reports go to the contact form’s
“Accessibility problem” topic). What keeps it there:
src/app/globals.css are chosen for contrast: every text colour reaches 4.5:1 on white and on both
grey surfaces, white reaches 4.5:1 on the red, and form fields use line-strong (3:1). Check new colours before adding
them, and don’t lower text contrast with opacity..link), not told apart by colour alone.h1 and headings in order; landmarks (header, nav, main, footer) and a skip link come from the layout.aria-label; decorative images and icons are aria-hidden or have empty alt.
TrophyIcon announces its grade, and ProgressBar takes a label.aria-expanded), closes on Escape and returns focus. Revealing a hidden
trophy moves focus to it. Tables that scroll sideways are focusable regions.prefers-reduced-motion, and targets are at least 24 by 24 px.Home, games, a game, a trophy, guides, the forum index, leaderboards, search, sessions, a PSN profile, a member profile,
compare, settings, the guide editor, login, sign-up, contact, privacy and accessibility pages were checked with axe-core
(WCAG 2.2 A/AA rules plus best practices) with no violations. Forum section and thread pages weren’t, because no live
threads existed yet. Rerun it after
UI changes: load the page, inject axe from cdnjs in the browser console, and call axe.run().
SESSION_SECRET, CRON_SECRET, NEXT_PUBLIC_SITE_URL and SITE_OPERATOR_NAME. No email address is shown on the site; people reach you through the contact form.NEXT_PUBLIC_FORMSPREE_FORM_ID (default xljdozjl). In the Formspree dashboard,
restrict the form to your production domain and turn on its spam filtering. The form also sends a _gotcha honeypot.CAPTCHA_SITE_KEY is set, and sends its token as
g-recaptcha-response. Formspree checks the token: in the form’s settings, turn on reCAPTCHA with a custom key and
paste the secret key there. In the reCAPTCHA admin console, list every domain the site runs on (localhost, the
Render address, trophypilot.com).src/lib/rate-limit.ts is in-memory. Replace it with Redis (or similar) if you run more than one instance.next.config.ts. Serve the site over HTTPS only.prisma/ schema, fictional demo catalogue, seed script
scripts/ psn-check, psn-import, psn-track, psn-discover, demo-user, smoke, recompute-progress, backfill-titles, sqlite-to-postgres
src/actions/ server actions (auth, account and PSN, friends, community)
src/lib/ db, auth, trophy maths, stats, leaderboards, privacy, rate limiting, PSN providers, lookup and catalogue
src/components/ UI kit, skeletons, trophy list, tips, generated art
src/app/ routes
.next cache warnings in dev. Moving the repo outside
OneDrive gives a smoother dev loop.