git-city Documentation

Your GitHub profile as a 3D pixel art building in an interactive city

5,840 stars TypeScript TypeScript 5 files ยท ~9,921 tokens #3d#github#nextjs#pixel-art#react#react-three-fiber#supabase#three-js
RAW Doc

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

Getting Started

bash
# 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 dev

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

bash
# 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/supabase

Start the stack (spins up Postgres/Auth/Storage/Studio and applies every
migration from scratch):

bash
supabase start          # first run pulls images; prints your local URL + keys
supabase status         # re-print the URL and keys at any time

Point .env.local at the printed local values:

bash
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:

bash
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_KEY
  • GITHUB_TOKEN
  • ADMIN_GITHUB_LOGINS if 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 URL
  • NEXT_PUBLIC_SUPABASE_ANON_KEY: the public anon key
  • SUPABASE_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

  1. Generate your API key at thegitcity.com (click "coding now" in the top bar)
  2. Install this extension
  3. Run Cmd+Shift+P > "Pulse: Connect" and paste your key
  4. 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 entirely
  • gitCity.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

bash
npx skills add srizzon/git-city --skill gg

Then 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?].

  • stage is which world it happens in; "both" is a split screen, with each half showing the middle of its own world.
  • kind picks the shot recipe that draws it.
  • trim opens the take that many beats into its own action. freeze holds 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:

  1. The BPM and the takes. Pick the BPM your music will use, then write the takes on that beat grid.
  2. The stages. One per world or scene; a "both" take shows them side by side.
  3. The pictures. Replace DemoStage with your own: DOM, a canvas, your game's scene. Each frame it reads shotFor(SHOTS, stage, beatOf(clock), BEAT) and draws the shot at time t.
  4. The film object. Its scenes, sounds, titles, flashes, and song if you have music (a file in public/).
  5. The look. Replace PlainTitles with 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:

  1. Add its kind, and write its recipe.
  2. Add a line to TAKES.
  3. Put its sounds and title on its moments, with momentOf.
  4. 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/ and tools/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 Freeze clamps to the composition's length (we started with Remotion and dropped it for live recording).

Export

bash
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.2

Add 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 ?export in 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 song and sounds, 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 as trailer.jpg and 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.

  1. BROWSER="Brave Browser" tools/capture.sh http://localhost:3000/your-film opens 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.
  2. 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.
  3. 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 in tools/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.mjs is Git City's arcade synthwave (full, and the teaser cuts soon and demo). It's an example of one project's sound; write yours.
  • node tools/sfx.mjs public/trailer/sfx writes 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:

js
// 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 song instead, 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_entries column 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_due for 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

sql
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:

sql
ALTER TABLE developers
  ADD COLUMN arcade_free_entries INTEGER DEFAULT 3;

Webhook idempotency table:

sql
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.ts with mode: '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_events before processing
  • Return 200 immediately (inline processing acceptable for Git City's volume)

Pro Status Check

typescript
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

typescript
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:

  1. Fetch all subscriptions where status = 'active' and current_period_end < now - 1 day
  2. Verify status against Stripe API
  3. 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 ---