# Technical Documentation: srizzon/git-city

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/srizzon/git-city)

### Overview (`README.md`)

<h1 align="center">Git City</h1>

<p align="center">
  <strong>Your GitHub profile as a 3D pixel art building in an interactive city.</strong>
</p>

<p align="center">
  <a href="https://thegitcity.com">thegitcity.com</a>
</p>

<p align="center">
  <img src="public/og-image.png" alt="Git City — Where Code Builds Cities" width="800" />
</p>

---

## 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](https://nextjs.org) 16 (App Router, Turbopack)
- **3D Engine:** [Three.js](https://threejs.org) via [@react-three/fiber](https://github.com/pmndrs/react-three-fiber) + [drei](https://github.com/pmndrs/drei)
- **Database & Auth:** [Supabase](https://supabase.com) (PostgreSQL, GitHub OAuth, Row Level Security)
- **Styling:** [Tailwind CSS](https://tailwindcss.com) v4 with pixel font (Silkscreen)
- **Hosting:** [Vercel](https://vercel.com)

## 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](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](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](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`](.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](LICENSE) — You can use and modify Git City, but any public deployment must share the source code. The trailer kit (`.claude/skills/gg/`) is [MIT](.claude/skills/gg/kit/LICENSE).

---

<p align="center">
  Built by <a href="https://x.com/samuelrizzondev">@samuelrizzondev</a>
</p>

---

### 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](https://www.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](https://thegitcity.com) 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](https://www.derek-lieu.com/blog)) 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](https://www.gdcvault.com/play/1025673/More-Feelings-Fewer-Features-Showcasing)).
- 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 <path>/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 ---
- Files Extracted: 5
- Estimated Token Budget: ~9842 tokens
- Canonical Reference: https://codewiki.google/github.com/srizzon/git-city
