Technical Documentation: srizzon/git-city
โน๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference
Overview (`README.md`)
Git City
What is Git City?
Git City transforms every GitHub profile into a unique pixel art building. The more you contribute, the taller your building grows. Explore an interactive 3D city, fly between buildings, and discover developers from around the world.
Features
- 3D Pixel Art Buildings โ Each GitHub user becomes a building with height based on contributions, width based on repos, and lit windows representing activity
- Free Flight Mode โ Fly through the city with smooth camera controls, visit any building, and explore the skyline
- Profile Pages โ Dedicated pages for each developer with stats, achievements, and top repositories
- Achievement System โ Unlock achievements based on contributions, stars, repos, referrals, and more
- Building Customization โ Claim your building and customize it with items from the shop (crowns, auras, roof effects, face decorations)
- Social Features โ Send kudos, gift items to other developers, refer friends, and see a live activity feed
- Compare Mode โ Put two developers side by side and compare their buildings and stats
- Share Cards โ Download shareable image cards of your profile in landscape or stories format
How Buildings Work
| Metric | Affects | Example |
|---|---|---|
| Contributions | Building height | 1,000 commits โ taller building |
| Public repos | Building width | More repos โ wider base |
| Stars | Window brightness | More stars โ more lit windows |
| Activity | Window pattern | Recent activity โ distinct glow pattern |
Buildings are rendered with instanced meshes and a LOD (Level of Detail) system for performance. Close buildings show full detail with animated windows; distant buildings use simplified geometry.
Tech Stack
- Framework: Next.js 16 (App Router, Turbopack)
- 3D Engine: Three.js via @react-three/fiber + drei
- Database & Auth: Supabase (PostgreSQL, GitHub OAuth, Row Level Security)
- Styling: Tailwind CSS v4 with pixel font (Silkscreen)
- Hosting: Vercel
Getting Started
# Clone the repo
git clone https://github.com/srizzon/git-city.git
cd git-city
# Install dependencies
npm install
# Set up environment variables
# Linux / macOS
cp .env.example .env.local
# Windows (Command Prompt)
copy .env.example .env.local
# Windows (PowerShell)
Copy-Item .env.example .env.local
# Fill in your environment variables
# Run the dev server
npm run devOpen http://localhost:3001 to see the city.
Local Supabase (no remote project needed)
You can run the whole backend locally โ no Supabase account, no GitHub OAuth app.
Prerequisites: a container runtime and the Supabase CLI.
# macOS (Colima is a lightweight, free Docker alternative)
brew install colima docker supabase/tap/supabase
colima start
# ...or use Docker Desktop instead of Colima, then just:
# brew install supabase/tap/supabaseStart the stack (spins up Postgres/Auth/Storage/Studio and applies every
migration from scratch):
supabase start # first run pulls images; prints your local URL + keys
supabase status # re-print the URL and keys at any timePoint .env.local at the printed local values:
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=<Publishable key from `supabase status`>
SUPABASE_SERVICE_ROLE_KEY=<Secret key from `supabase status`>Then npm run dev. The city renders straight from your local database โ no
snapshot step needed in local dev.
Logging in (zero config): the GitHub OAuth provider isn't configured for a
local stack (it needs a client secret that can't ship in the repo). Instead,
clicking Sign in with GitHub locally takes you to a built-in dev login โ
type any GitHub username and you're in, with a building created from that
account. You can also go straight to
http://localhost:3001/api/dev/login.
This route is automatically disabled in production.
Useful commands:
supabase db reset # re-apply all migrations on a clean database
supabase stop # shut the stack down (data is preserved)Studio (DB browser) runs at http://127.0.0.1:54323.
Environment Setup
After copying .env.example to .env.local, fill in these values:
NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEYGITHUB_TOKENADMIN_GITHUB_LOGINSif you want access to/admin/ads
Where to find the Supabase values
Open your Supabase project dashboard, then go to Project Settings -> API.
NEXT_PUBLIC_SUPABASE_URL: your project URLNEXT_PUBLIC_SUPABASE_ANON_KEY: the public anon keySUPABASE_SERVICE_ROLE_KEY: the service role key for server-side admin access
For local GitHub login to work, you also need to configure the GitHub OAuth provider in Supabase and add your local callback URL if required by your setup.
Where to find the GitHub token
Open GitHub and go to Settings -> Developer settings -> Personal access tokens.
- Fine-grained tokens are recommended if you only want to grant the minimum repository/profile access this app needs.
- Classic tokens also work if that fits your setup better.
Create a token, copy it once, and place it in GITHUB_TOKEN inside .env.local.
Making trailers
Git City's trailers are played live in the engine: a film is a timeline of takes on the music's beat grid, a studio page plays it, and the export renders it frame by frame to an mp4. Music and sound effects are synthesized, no samples. The kit ships inside the gg Claude skill ("You made a game. gg.") and is MIT licensed, so any web game can use it: npx skills add srizzon/git-city --skill gg. See .claude/skills/gg/kit/README.md for the kit, how to add a take, and the craft notes (story, cutting, end cards) from making the Towns teaser. The films run at /trailer/minimal, /trailer/demo and /trailer/towns.
License
AGPL-3.0 โ You can use and modify Git City, but any public deployment must share the source code. The trailer kit (.claude/skills/gg/) is MIT.
Overview (`packages/vscode-extension/README.md`)
Git City: Pulse
Your building's transmitter. When you code, it pulses. The city lights up.
How it works
- Generate your API key at thegitcity.com (click "coding now" in the top bar)
- Install this extension
- Run
Cmd+Shift+P> "Pulse: Connect" and paste your key - Start coding. Your building starts transmitting in ~30 seconds.
When you're coding, your building glows in the city and you appear in the live feed. Stop coding and it fades after a few minutes.
Commands
| Command | Description |
|---|---|
Pulse: Connect |
Link your building to the city |
Pulse: Disconnect |
Unlink your building |
Pulse: Toggle |
Pause/resume your transmitter |
Pulse: Open City |
Open Git City in your browser |
Status bar
The status bar shows your transmitter state:
$(broadcast) Pulse: Transmitting- Your building is live in the city$(circle-outline) Pulse: Standby- Waiting for you to code$(circle-slash) Pulse: Off- Transmitter paused$(plug) Pulse: Disconnected- No API key set
Click the status bar item to toggle your transmitter on/off.
Privacy
You control what gets transmitted.
| Data | Public? | Can disable? |
|---|---|---|
| Username | Yes (your GitHub login) | No (identifies your building) |
| Language | Yes (e.g. "TypeScript") | Yes, via gitCity.privacy.shareLanguage |
| Project name | No, never public | Yes, via gitCity.privacy.shareProject |
| Branch name | No, never public | Excluded with project |
| File paths | Never sent | N/A |
| Code contents | Never sent | N/A |
What is never collected: file contents, code, diffs, clipboard, terminal output, file paths, or any intellectual property.
Project names are stored server-side for your personal analytics only. They are never shown to other users, never included in any public API, and never broadcast. You can disable sending them entirely.
Privacy settings
Open VS Code Settings (Cmd+,) and search for "Git City":
gitCity.privacy.shareLanguage- Share the programming language (default: on)gitCity.privacy.shareProject- Send project name for personal analytics (default: on)gitCity.privacy.excludeProjects- List of project names to never track (e.g.["secret-project", "client-work"])gitCity.enabled- Disable all transmission entirelygitCity.idleTimeout- Seconds of inactivity before standby (default: 300)
Overview (`.claude/skills/gg/kit/README.md`)
Trailer kit
Make your web game's trailer inside the game itself, or your library's or app's launch film inside the real product. A film is data (takes on the music's beat grid), a studio page plays it live in your engine, and the export renders it frame by frame to an mp4, music included. No video editor, no screen recorder.
It came out of making the Git City Towns teaser, and it ships inside the gg Claude skill ("You made a game. gg."), so Claude can set it up in your project and build the film with you. MIT licensed (see LICENSE).
Install
npx skills add srizzon/git-city --skill ggThen tell Claude "let's /gg this". The skill studies your project and films like it, picks a visual direction for yours, copies this kit in and builds the film in that direction.
To set it up by hand, copy src/ (without examples/gitcity/) into your app (for example src/trailer-kit/), copy tools/ anywhere, and mount src/examples/MinimalFilm.tsx on a page. The studio needs React 18+ and nothing else: no Tailwind, no three.js (your pictures can use them). The export needs Playwright and ffmpeg (see "Export"). In Next.js App Router the components are client components already.
What's here
| File | What it is |
|---|---|
src/clock.ts |
The clock every shot reads, in beats from the first frame. Never frame deltas: that's what keeps cuts on the beat. |
src/film.ts |
The film as data: takes, shots, moments, scenes, sounds, titles, flashes. Pure, no React. |
src/film.test.ts |
Its tests (vitest). |
src/Studio.tsx |
The editor. Plays any Film; the pictures come from its children, the title cards from its titles slot. |
src/examples/MinimalFilm.tsx |
The smallest film, in plain HTML, with no look on purpose. The template for your own. |
src/examples/gitcity/ |
Git City's titles (a word on a slanted bar) and end card (the name stamped on, a stamp after it). One project's treatment, to read as an example, not to reuse. |
tools/music.mjs |
A tiny synthesizer that writes the film's music at its BPM, from a brief or an arrangement. |
tools/arrangements/ |
brief.mjs (a song from a JSON brief), gitcity.mjs (Git City's synthwave, an example), and how to write your own. |
tools/sfx.mjs |
Synthesized effects: explosion, whoosh, crumble, key click, horn. |
tools/export.mjs |
Renders a film from the studio to an mp4, frame by frame, with its sound mixed in. |
tools/capture.sh |
macOS: opens a clean browser window, for recording by hand. |
Styling. The studio carries its own CSS. To match your project, give it a className that sets the variables: --tk-font, --tk-bg, --tk-panel, --tk-line, --tk-line-hi, --tk-text, --tk-muted, --tk-accent, --tk-rec, and --tk-flash (the color of film.flashes). That CSS styles the editor only: the stage inherits nothing from it but the font, so your components and buttons on it keep their own styles.
Titles. film.titles says when each card is on and where (place); how it looks is yours. Pass a renderer: <Studio titles={(cues) => <MyTitles cues={cues} />}>, drawn over the stage in container units (cqw) so it scales. Without one, titles don't show.
The studio
A 16:9 stage, play and scene-to-scene buttons, a ruler to scrub (its marks are the cuts), the scenes on the right (a picked scene loops), slow motion, volume, and Export mp4 at the top, which copies the export command for this page. Keys:
| Key | Does |
|---|---|
| Space | Play / pause |
| โ โ | One frame (Shift: one beat) |
| J L | One second |
| Home End | Scene start / end |
| 1โ9, 0 | Pick a scene, the whole film |
| [ ] | Slower / faster (1ร, 0.5ร, 0.25ร) |
| - + | Volume down / up (the studio's only: the export mixes at full level) |
| M | Mute |
| Shift R | Record by hand: full window, no cursor, 1s of black, the whole film once |
| Esc | Stop recording |
How a film is built
A take is [name, stage, kind, beats, trim, freeze?].
stageis which world it happens in;"both"is a split screen, with each half showing the middle of its own world.kindpicks the shot recipe that draws it.trimopens the take that many beats into its own action.freezeholds the picture from a beat of the action on.- A take's moments (the hit, the launch) are counted in beats from its untrimmed start.
momentOf(shot, beats)puts them on the timeline, so trimming a take never moves a hit off its beat.
A shot recipe gets t, the seconds into its action, and places things as functions of t. That's what makes scrubbing, looping and slow motion show the same picture. Anything with memory (particles, destruction) only advances while the clock moves, or a paused frame fills up with smoke. Events that fire when a beat is crossed fire while playing forward, not when you scrub past them.
Anything a take breaks lives in state the studio resets through onReset: when a take is picked, when it loops, when you seek backwards, and when a recording or an export starts.
Sound only plays at 1ร. At 0.5ร and 0.25ร the studio is silent: music and effects are synced to real time. (The export doesn't care: it mixes the sound from the film's cues.)
Stages stay mounted. Each world is its own element (or canvas) that never remounts: a cut only changes which one is visible. A WebGL canvas that's mounted and dropped loses its context after a few loops. The end card's button slot follows the same rule.
A film of your own
Copy src/examples/MinimalFilm.tsx and change five things:
- The BPM and the takes. Pick the BPM your music will use, then write the takes on that beat grid.
- The stages. One per world or scene; a
"both"take shows them side by side. - The pictures. Replace
DemoStagewith your own: DOM, a canvas, your game's scene. Each frame it readsshotFor(SHOTS, stage, beatOf(clock), BEAT)and draws the shot at timet. - The film object. Its scenes, sounds, titles, flashes, and
songif you have music (a file inpublic/). - The look. Replace
PlainTitleswith titles designed for your project, and add an end card of your own. The film's direction (type, palette, motion, sound) comes from your project and launch films like it, not from this template or from Git City's.
To add a take:
- Add its
kind, and write its recipe. - Add a line to
TAKES. - Put its sounds and title on its moments, with
momentOf. - Pick it in the studio, step through it beat by beat at 0.25ร, and fix what the frames show.
Examples in Git City
The kit lives in Git City's repo, and Git City plays three films with it (all under src/app/trailer/):
/trailer/minimal:src/examples/MinimalFilm.tsx, straight from this kit.- Both films below use Git City's own direction (
src/examples/gitcity/andtools/arrangements/gitcity.mjs): pixel type, stepped motion, a stamp, 8-bit hits and synthwave, because that's Git City's language. Yours gets its own. /trailer/demo: a 3D street made in code (day and night), the game's car, a burnout on a split screen, a drift, a jump that freezes mid-air, and the end card with the car as its button. Runs on any fork, no data./trailer/towns: the Towns "coming soon" teaser, in the game's real towns.
The craft
What we learned making the Towns teaser, mostly from Derek Lieu's trailer writing (derek-lieu.com) and from getting it wrong first. These are principles; how each looks in your film comes from your project's direction.
Direction before story.
- Study the project first: brand, logo, palette, type, how its UI moves, its copy. Then watch launch films of products like it (a UI library: Linear, Vercel, shadcn/ui, Raycast, Framer; a game: trailers in its genre).
- Derive the direction from those: type and its motion, palette, how titles appear, how the end card lands, the hits, the music. Write it down before the takes.
- Not a game? The engine is the real product UI: the actual components and screens, driven by code as functions of
t.
Story before takes.
- A montage of features reads as a list. Sell the feeling, not the feature (GDC: More Feelings, Fewer Features).
- Give the takes cause and effect and someone to root for.
- Decide first what the viewer should feel at the end. For Towns it was "I want to jump in and play."
- Structure: hook, then escalation, then climax, then logo, then a button (a short gag after the logo).
Teaser vs trailer.
- A teaser is one idea and withholds. Ours: a split-screen hook, three flashes of play cut at their peak, the car freezing mid-air as the music drops to silence, then the name and "coming soon".
- A trailer carries a story. Three skeletons that fit a game with two rival sides:
- Invasion: one protagonist crosses into the rival world, which is the act break. Escalating chaos, an escape, the logo, and a button where the rival shows up in your world.
- Grudge match: call and response. One side acts, the other answers, the exchanges get shorter, and it ends on a freeze.
- Scoreboard: a running score replaces the title cards and ticks once more after the logo.
Text.
- One-word cards per take (DRIFT, FIREโฆ) are feature cards in disguise.
- Use 3โ4 cards that carry the stakes, 2โ4 words each, one bar on screen each.
- A teaser can go with none at all.
Cutting.
- Each take is 1โ2.4s and opens in the action (
trim). The action lands on a beat of the music. - Keep screen direction across a cut: if the cars leave going away from the camera, the next take shouldn't come at it.
- When a take is too short, rethink how it opens. Don't just let it run longer at the end.
- Show a mechanic twice, three times at most.
- Before calling a shot done, check that nothing sits between the camera and the subject.
The end card.
- Cut hard to the card on the last hit, after a moment of silence.
- Then the name alone, with nothing around it.
- At most one short line ("coming soon", "v2 is out") and an optional button (a short gag after the logo).
- No footers, chips, frames or URLs: the post carries the link. A social card is not a film ending; use it for its colors and type, not its layout.
- Every move lands on a beat and a sound. Its motion is the project's: eased for a UI that eases, stepped for pixel art.
- Git City's, as one example: black, the name stamped on letter by letter in pixel type, the sub-title stamped on its corner like a sequel's "2", a spaced line, then the game's car bumps the Y, which wobbles, and honks.
Cameras that worked (Git City's TownsRig and DemoRig):
| Shot | Camera |
|---|---|
| Burnout | Low and close behind each car, split screen, then the launch |
| Drift | Chase camera whose heading lags a quarter second, so the car slides across the lens and shows its flank |
| Missile | Low behind and to the side of the shooter, moving with both cars |
| Smash | Chase camera through a building's corner, with the chunks scaled down so they don't fill the frame |
| Jump | Low beside the ramp's lip, the car crossing the frame, the lens closing in as it leaves the ramp |
| Top-down | High and a little behind, turning with the car |
Pitfalls that cost us time.
- Screenshot a shot beat by beat before showing it, and look at every frame: a take once silently lost its code, and only the capture showed the empty street.
- A custom shader that fails can draw nothing with no error. Check that the thing is on screen, not just that the page loads.
- In a timeline file, a constant used before it's declared breaks the page at runtime, not at build time.
- A raw
<script>in a Next.js root layout trips React 19 in dev. - Remotion's
Freezeclamps to the composition's length (we started with Remotion and dropped it for live recording).
Export
npm i -D playwright && npx playwright install chromium # once
brew install ffmpeg # or npm i -D ffmpeg-static
node tools/export.mjs http://localhost:3000/your-film --poster 9.2Add it to your scripts as "trailer:export": "node /tools/export.mjs": that's the command the studio's Export button copies.
- It opens the studio with
?exportin a headless browser. The studio waits (window.__gg), and the export swaps the page's clocks for a virtual one:performance.now,Date.now,requestAnimationFrame, timers and CSS animations. Each frame is drawn at its exact time and screenshotted, so a slow machine still gets every frame, and three.js, particles and titles all come out as they play in the studio. - The sound isn't recorded: it's mixed from the film's
songandsounds, so it lands on the same beats as the picture. Sounds your game plays by itself aren't in the file, so cue them in the film. --poster <s>picks the thumbnail: a settled frame (the name on the end card, a freeze). It's written astrailer.jpgand baked in as frame 0, which is what X, Slack and Discord show before the video plays.- Options:
--out gg-output/trailer.mp4(default),--fps 30(60 for fast motion),--size 1920x1080. - A
<video>inside the stage plays on real time, not the virtual clock, and randomness that isn't seeded changes between runs.
Recording by hand
The export is the way to go. If you'd rather use a screen recorder, the studio's Record plays the film full window with no cursor. The rest is the macOS setup we used.
BROWSER="Brave Browser" tools/capture.sh http://localhost:3000/your-filmopens a clean window: its own profile, no address bar, a 1280ร720 page. The default browser is Chrome.- It sizes the window through System Events, so your terminal needs macOS Accessibility permission.
- It moves every window of that browser whose title doesn't end the way your everyday windows do (" - Google Chrome", " - Brave"). Close other app-mode windows of that browser first.
- In OBS:
- Source: macOS Screen Capture โ Window โ that window, with "Show cursor" off. Crop 16 px left and right, 74 px top and 10 px bottom, which gives 2560ร1440 at 2ร.
- Video: 2560ร1440 (or 1920ร1080), 60 FPS.
- Output: MOV, Apple VT HEVC hardware, 60โ80 Mbps, keyframe every 1s.
- Sound: add an application audio capture of the browser if you want the music and effects in the file.
- Start recording in OBS, press Shift R in the studio, and stop when the card goes black.
Music and sound effects
node tools/music.mjs public/trailer/song.wav song.json: a song from a brief (tempo, key, drums, bass, harmony, timbre, the hits on the end card's beats). The fields are intools/arrangements/README.md, with starting points per direction.node tools/music.mjs public/trailer/song.wav ./song.mjs [cut]: an arrangement module of your own, for what a brief can't say. The synth: kick, snare, hats, crash, a tone voice (saw, square, pulse, triangle, sine), a lead, risers and impacts, with sidechain ducking and an echo.- Arrangements are functions of beats, so a new BPM retimes everything. The film has to use the same BPM.
tools/arrangements/gitcity.mjsis Git City's arcade synthwave (full, and the teaser cutssoonanddemo). It's an example of one project's sound; write yours.node tools/sfx.mjs public/trailer/sfxwrites the effects. Use your game's own sounds for the rest.- Nothing here needs samples or a license: the output is yours.
License
MIT, for everything in this folder (the rest of Git City is AGPL-3.0). Git City's films use the game's own assets, which aren't part of the kit: Kenney's car and sounds (CC0).
Overview (`.claude/skills/gg/kit/tools/arrangements/README.md`)
Arrangements
music.mjs is a synthesizer. An arrangement decides what it plays. Each film gets its own, written for its project's sound. gitcity.mjs is Git City's arcade synthwave, kept as an example: read it, don't reuse it.
Two ways to get a song:
1. A brief (fastest)
Write the music direction from the plan as JSON, then run node music.mjs public/trailer/song.wav song.json. Every field is optional.
| Field | Values | Default |
|---|---|---|
bpm |
the film's BPM | 120 |
key, scale |
CโฆB; major, minor, dorian, mixolydian, lydian, phrygian |
A, minor |
chords |
scale degrees, one per bar, looped | [1,6,3,7] ([1,5,6,4] in major) |
beats |
length in beats | 64 |
intro |
beats before drums and bass come in (pads only, a riser into the drop) | 4 |
freeze, card |
silence from freeze to card, then only the hits |
none |
drums |
four (four on the floor), half (half-time), broken, none |
four |
bass |
pump (offbeat octaves), sub (one long note a bar), pluck, none |
pump |
harmony |
pad, keys (two chords a bar), stabs (offbeats), none |
pad |
arp |
eighths, sixteenths, none |
none |
timbre |
warm (triangle, sine), bright (saw, pulse), glass (sine, airy), 8bit (square) |
bright |
brightness, space |
0โ1: filter opening; echo amount | 0.5, 0.4 |
lead |
[[beat, degree, beats], โฆ], degree 1 = the root, 8 = an octave up |
none |
hits |
[{ beat, kind, gain? }], kind: impact, thud, chime, stab, boom, riser (the 4 beats before) |
none |
Translate the direction into fields, don't stop at the defaults. Some starting points, each a sketch to push from:
| Direction | Brief |
|---|---|
| Calm product film (dev tools, a UI library) | 90โ110 BPM, major or lydian, half or none, sub, keys, glass or warm, space 0.6, chime hits |
| Tense, dark | 80โ100 BPM, phrygian or minor, broken, sub, pad, warm, low brightness, boom hits |
| Arcade, retro game | 140โ160 BPM, minor, four, pump, sixteenths, 8bit, impact hits |
| Big launch | 120โ128 BPM, four, pump, pad, bright, a riser into an impact on the climax |
2. An arrangement module (when the brief can't say it)
A file that exports one function. music.mjs calls it with the cut from the command line and gives play the synth:
// song.mjs: node music.mjs public/trailer/song.wav ./song.mjs [cut]
export default function song({ cut }) {
return {
bpm: 100,
bars: 12, // length; the file gets 2.5s more for tails
echo: { beats: 0.75, feedback: 0.45, mix: 0.6 }, // optional
play(s) {
const { at, BEAT, kick, snare, hat, crash, tone, lead, riser, engine, impact, silence } = s;
for (let bar = 1; bar < 8; bar++) kick(at(bar, 0));
tone(at(0), 4 * BEAT, 57, { wave: "triangle", cutoff: 1200, env: [0.2, 0.5, 0.7, 0.4] });
silence(28, 32); // beats: a hard gate before the name
},
};
}at(bar, beat)is seconds; every voice takes seconds.tone(t, dur, midi, opts):wave(saw,square,pulse,triangle,sine),gain,cutoff,env[attack, decay, sustain, release],detune(cents, three voices),glideFrom,vib,pan,sc(ducked by the kick, default on),echo(send amount).- Drums:
kick,snare,hat(t, open),crash. Effects:riser(t0, t1),impact(t),engine(t0, t1)(a revving growl). - The synth has no samples. For a sound it can't make (a real piano, a choir), use a licensed or CC0 file as the film's
songinstead, and say where it's from in the plan.
Plans 2026 03 16 Pro Plan Design (`docs/plans/2026-03-16-pro-plan-design.md`)
Git City Pro Plan
Status: Approved
Created: 2026-03-16
Priority: High
Overview
Subscription plan ($2.99/mo launch, $4.99/mo post-E.Arcade) that expands gameplay limits, adds visual identity perks, and gates multiplayer (E.Arcade) behind Pro. Not pay-to-win. Focused on generating predictable MRR.
Decisions
- Traditional Pro plan with perks across the whole game (not zone-based like Offshore)
- Focus on MRR as primary goal
- $2.99/mo at launch (early adopter pricing), $4.99/mo when E.Arcade launches
- Monthly billing only at launch, annual plan later
- No pay-to-win: expands limits and content, does not make players stronger
- Target: both grinders (more raids/kudos) and casuals (visual status)
- E.Arcade (multiplayer) is 100% Pro-only, with 3 free lifetime entries for everyone
- Stripe only for subscriptions (no AbacatePay/NOWPayments for recurring billing)
Free vs Pro
| Feature | Free | Pro |
|---|---|---|
| Price | $0 | $2.99/mo (launch) / $4.99/mo (post-E.Arcade) |
| Raids | 3/day | 5/day |
| Kudos | 5/day | 10/day |
| Pro cosmetics | Can see, can't equip | 2-3 exclusive auras/effects |
| Badge | - | "Pro" badge on building card |
| Building glow | - | Gold border/outline visible in city |
| Card highlight | - | Premium visual when others visit your building |
| E.Arcade | 3 entries lifetime | Unlimited access |
E.Arcade Access
- Multiplayer has real infrastructure cost (PartyKit/WebSocket connections)
- Free players get 3 lifetime entries to experience it, then it's Pro-only
- Free entries are consumed permanently (no reset, no refund if player subscribes later)
- If player had Pro, canceled, and used entries during Pro period, those don't count against the 3 free ones
- Tracked via
arcade_free_entriescolumn on developers table
Visual Identity
Badge
- Small icon next to username on building card (shield or star with "PRO")
- Gold/yellow color
- Appears everywhere the username shows: raid log, kudos, leaderboards, activity feed
Building Glow
- Subtle gold border/outline on the building in the 3D city
- Distinct from shop auras (auras = effects around building, glow = border on the building itself)
- Visible from a distance to create "what's that building?" curiosity
Card Highlight
- When someone visits a Pro building, the card has a subtle premium visual (gradient background, gold border, or different header color)
- Understated, not flashy
Pro-Only Cosmetics
- 2-3 auras/effects at launch that only Pro can equip
- Shown in shop with "PRO" tag, free players can see them but not buy/equip
- Creates in-game marketing: "I want that effect" -> subscribe
- Rotate/add new ones every 1-2 months to keep freshness
Home Screen Presence
- Building glow in city: Pro buildings glow gold, visible to all players. Passive marketing, no UI needed
- HUD button: Small "โฆ" icon in corner of HUD, opens Pro panel with perks and subscribe/manage CTA
- No aggressive popups or paywalls mid-action
Contextual Nudges
- When player hits a limit (3/3 raids), show subtle banner: "Want more? Go Pro"
- E.Arcade card shows "Pro members only" + online player count
- Profile page has "Pro" section (status, manage subscription, or upgrade CTA)
Edge Cases
Subscribes mid-day
- Player already used 3 raids today, subscribes at 15h
- Gets the 2 extra raids immediately (don't wait for next day)
Subscription expires with Pro cosmetic equipped
- Cosmetic is auto-unequipped
- Item stays in inventory but greyed out with "Pro required" badge
- Player doesn't lose the item, just can't use it
E.Arcade: 3 free entries + subscribe and cancel
- Free entries already used are gone permanently
- Subscribing doesn't refund them
- During active Pro, entries are not consumed (unlimited access)
Resubscribe after canceling
- Loses grandfather pricing. New price applies
- Incentivizes not canceling
Cancellation flow
cancel_at_period_end = true- Player keeps Pro perks until end of billing cycle
- At cycle end: perks revoked, cosmetics unequipped, limits reset to free
Payment failure (grace period)
- Status:
past_duefor 7 days - Player keeps Pro perks during grace period
- In-app banner: "Payment failed. Update your card."
- Stripe Smart Retries attempts ~8 retries over 2 weeks
- If recovered: banner disappears, status back to
active - If not recovered after 7 days: status
canceled, perks revoked
Refund / Chargeback
- Pro deactivated immediately
- Cosmetics unequipped
- Same as cancellation but instant
Gift Pro
- Not supported at launch. Evaluate later based on demand.
Technical Architecture
Database
New table: subscriptions
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
developer_id BIGINT NOT NULL REFERENCES developers(id) UNIQUE,
stripe_subscription_id TEXT UNIQUE NOT NULL,
stripe_customer_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
plan TEXT NOT NULL DEFAULT 'pro',
price_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'usd',
current_period_start TIMESTAMPTZ NOT NULL,
current_period_end TIMESTAMPTZ NOT NULL,
cancel_at_period_end BOOLEAN DEFAULT false,
grandfathered BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_stripe_sub_id
ON subscriptions(stripe_subscription_id);
CREATE INDEX idx_subscriptions_status
ON subscriptions(developer_id, status);New column on developers:
ALTER TABLE developers
ADD COLUMN arcade_free_entries INTEGER DEFAULT 3;Webhook idempotency table:
CREATE TABLE webhook_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW()
);Stripe Setup
- Create Product "Git City Pro" in Stripe Dashboard
- Price IDs stored in env vars:
STRIPE_PRO_MONTHLY_PRICE_ID - When price increases, create new Price in Stripe, update env var
- Existing subscribers stay on old price automatically (Stripe handles this)
Checkout Flow
- Reuse existing
src/lib/stripe.tswithmode: 'subscription' - Metadata:
{ developer_id, github_login, plan: 'pro' } - Reuse existing currency detection (USD/BRL)
- Redirect to existing success/cancel URLs
Webhook Events
Expand existing handler at src/app/api/webhooks/stripe/route.ts:
| Event | Action |
|---|---|
checkout.session.completed (mode=subscription) |
Create subscriptions row, activate perks |
customer.subscription.updated |
Update status, period_end. Handles active/past_due transitions |
invoice.paid |
Renew period_end, confirm active status |
invoice.payment_failed |
Mark past_due, send in-app notification |
customer.subscription.deleted |
Mark canceled, unequip Pro cosmetics, revoke perks |
charge.refunded |
Same as deleted: revoke everything |
All events check webhook_events table for idempotency before processing.
Webhook Security
- Already has signature verification via
stripe.webhooks.constructEvent()with raw body - Add idempotency: check/insert
webhook_eventsbefore processing - Return 200 immediately (inline processing acceptable for Git City's volume)
Pro Status Check
async function getProStatus(developerId: string): Promise<{
isPro: boolean
isGracePeriod: boolean
expiresAt: Date | null
}> {
const sub = await db.subscriptions.findByDeveloperId(developerId)
if (!sub) return { isPro: false, isGracePeriod: false, expiresAt: null }
if (sub.status === 'active') {
return { isPro: true, isGracePeriod: false, expiresAt: sub.current_period_end }
}
if (sub.status === 'past_due') {
return { isPro: true, isGracePeriod: true, expiresAt: sub.current_period_end }
}
if (sub.status === 'canceled' && sub.current_period_end > new Date()) {
return { isPro: true, isGracePeriod: false, expiresAt: sub.current_period_end }
}
return { isPro: false, isGracePeriod: false, expiresAt: null }
}Dynamic Limits
const LIMITS = {
raids: { free: 3, pro: 5 },
kudos: { free: 5, pro: 10 },
} as const
function getLimit(type: keyof typeof LIMITS, isPro: boolean): number {
return LIMITS[type][isPro ? 'pro' : 'free']
}API Routes
| Route | Method | Description |
|---|---|---|
/api/subscription |
GET | Current status (isPro, expiresAt, isGracePeriod) |
/api/subscription/checkout |
POST | Create Stripe checkout session (subscription mode) |
/api/subscription/portal |
POST | Redirect to Stripe Customer Portal (manage/cancel) |
Subscription Management
- V1: Stripe Customer Portal for all billing management (cancel, update card, invoices)
- No custom billing UI needed at launch
- Future: custom UI if user feedback demands it
Reconciliation (Safety Net)
Daily cron job:
- Fetch all subscriptions where
status = 'active'andcurrent_period_end < now - 1 day - Verify status against Stripe API
- Update DB if divergent
Catches any missed webhooks.
Rollout Strategy
Phase 1: Soft Launch (Week 1)
- Pro Plan available, no big announcement
- Price: $2.99/mo
- All perks except E.Arcade (shows "Coming soon - Pro members get access first")
- Goal: test billing flow with small user group, catch bugs
Phase 2: Announcement (Week 2-3)
- Discord post, tweet, dev-log entry
- Early adopter messaging: "Lock in $2.99/mo forever. Price goes up when E.Arcade launches"
- Goal: first conversion wave
Phase 3: E.Arcade Launch (when ready)
- Pro members get immediate unlimited access
- Free players get 3 lifetime entries
- Price increases to $4.99/mo for new subscribers
- Grandfathered users keep $2.99/mo
- Second big announcement: "Multiplayer is live"
- Early adopter cosmetic reward for Phase 1-2 subscribers (exclusive item, never available again)
Metrics
| Metric | Target |
|---|---|
| Conversion rate (free -> pro) | 3-5% of active players |
| MRR | $100+ first month |
| Monthly churn | <10% |
| Grace period recovery | >50% of past_due |
--- METRICS ---
- Files Extracted: 5
- Estimated Token Budget: ~9842 tokens
- Canonical Reference: https://codewiki.google/github.com/srizzon/git-city