All resources
TutorialAugust 16, 2026

Make your own music Universe

Explore, discover, and experience your entire music collection like never before.

Make your own music Universe

Step 1: Prepare your project folder

  • Create a new empty folder, e.g. music-universe
  • Open it in VS Code.
  • Open Claude Code in that folder.
Step 2: Paste this prompt
# Build prompt: RECPLAY (infinite visual music universe)

Build an open-source, no-login, publicly-shared web app called RECPLAY: an infinite, cinematic visual space of floating album artwork that plays real YouTube-backed music. Everyone who opens the link explores and listens to the same shared universe of songs — there are no accounts, no per-user libraries, no sign-in screen. It has two interchangeable ways to browse the same universe: an endless 2D map and a 3D rotating globe, both wallpapered edge-to-edge in album covers.

## 1. Tech stack

- Next.js (latest, App Router) + TypeScript + Tailwind CSS
- Zustand for client state (no Redux/Context-heavy state)
- Two hand-built Canvas2D rendering engines (no game engine, no map library like Mapbox/Leaflet, no Three.js) — plain `<canvas>` + `CanvasRenderingContext2D`, driven by `requestAnimationFrame`
- YouTube IFrame Player API for playback (official, embeddable, no download/extraction of any kind)
- YouTube Data API v3 (server-side only) for validating/fetching metadata of songs being added to the catalog
- A single shared JSON file as the "database" (no hosted DB, no ORM) — see §3
- No authentication system anywhere

## 2. Core product idea

- One shared, permanent universe of songs. No per-user playlists, no accounts.
- Two views over the *same* underlying song list:
  1. **Flat map** — an infinite, tileable 2D canvas of floating album-art squares, each with a permanent, deterministic (x, y) position.
  2. **Globe** — a 3D-look sphere (drawn in 2D canvas via manual perspective projection) completely wallpapered in album covers, like a planet made of music.
- Tapping/clicking any artwork plays that song immediately. Only one song plays at a time, via one shared YouTube player instance.
- Zooming out far enough on the flat map hands off to the globe; zooming in far enough on the globe hands off to the flat map — they feel like one continuous space at different scales, not two separate screens.

## 3. Data model & storage

A song record has at minimum: a stable id, YouTube video id, title, artist/channel name, thumbnail URL, duration in seconds, a deterministic world position (`world_x`, `world_y`), a validation status (e.g. `validated`/`pending`/`rejected`), and a created-at timestamp.

Storage: a single JSON file (e.g. `data/songs.json`) read/written by one small server-only module that exposes functions like `getAllSongs`, `getSongCount`, `findSongByVideoId`, `findSongById`, `querySongsInBounds`, `searchSongs`, `sampleSongs`, `insertSong`, `deleteSong`. Serialize writes through a single in-process queue so concurrent requests can't interleave and corrupt the file. Cache the parsed contents in memory after first read.

Explicitly document (in code comments and README) that a JSON file needs a writable, persistent disk — fine on a VPS or any host with a persistent volume, but on ephemeral/serverless hosts the filesystem resets on every deploy/instance, so writes won't durably persist there. Design the storage access behind one small module/interface so it could be swapped for a real hosted database later without touching the rest of the app.

## 4. World coordinates & infinite tiling (flat map)

- Every song gets a **permanent** world position the moment it's added, deterministically hashed from its own id (same id → same position, forever — never randomized per session).
- Positions live inside one **canonical period**: a square region whose size scales with the total song count (`getWorldPeriod(songCount)`), so a 20-song universe and a 200,000-song universe both get comfortable, non-overlapping spacing.
- To make even a small library feel endless, the renderer tiles copies of that canonical period across all of space like wallpaper (repeating it in both directions), applying a small deterministic per-repetition jitter (slight rotation/offset/scale, itself hashed from the repetition coordinates) so repeated copies don't look identical.
- Spatial queries (`GET /api/songs?minX=&maxX=&minY=&maxY=`) never load the whole catalog. Given a viewport, the server: figures out which repetitions of the canonical period intersect the viewport, wraps the viewport into local (period-relative) coordinates per repetition (splitting any wrap-around into up to 4 rectangles), filters the store down to just those rectangles, then re-projects results back into absolute world space and dedupes. This same code path should work whether the catalog has 1,000 or 500,000 songs.
- Client-side: snap fetch requests to a coarse tile grid with a buffer margin, so panning slightly doesn't refire a fetch, and cache fetched tiles by a bounds key so the same region is never re-fetched twice in a session.
- On first load, the camera automatically centers on the middle of the period and zooms to fit it on screen, so the very first thing shown is a screen full of artwork, never empty space in a corner.
- Zoom-out floor and zoom-in cap should both be derived from the current period size (not fixed absolute numbers) — zooming out should never show more than about one period (plus a sliver of wraparound) at once, and zooming in should stop at a cap scaled off "period fitted to screen," so "as far in as you can go" always lands on a real cluster of covers, never a blank void between sparse points.
- Camera controls: drag/flick to pan with momentum/friction, pinch or wheel to zoom anchored at the cursor/pinch midpoint, all eased (not snapped) toward a target value every frame.

## 5. The globe view — detailed rendering approach

This is the most involved part of the app; get the details right, not just the vibe.

**Grid, not scatter.** The globe is a fixed latitude/longitude grid of cells covering the *entire* sphere, not a scatter of song positions. A real "planet wallpapered in album art" look needs full, gapless coverage, which a per-song random scatter can never guarantee (with only a few dozen songs, randomness always leaves visible bare patches). A grid instead covers every cell by construction; each cell gets at most one song from a deterministic hash-based assignment (never repeated across cells), so an under-populated universe simply leaves some cells bare rather than faking coverage by repeating art.

**Row/column layout.** Rows are evenly spaced in colatitude (`phi` from 0 at the north pole to π at the south pole). Each row's column count scales with `sin(phi)` — the local circumference at that latitude — so cells end up roughly square everywhere instead of being squished/stretched near the poles (the standard equal-area sphere-gridding trick). Skip rows whose computed column count drops below a small usable minimum (e.g. 4) rather than flooring it up — flooring up produces a handful of giant 90°+-wide wedge cells right at each pole, which look badly stretched and create a "pinwheel" artifact when you look straight down at a pole. Skipping instead leaves a small bare circular cap at each pole (see §5f for what to do with it).

**Sizing the grid to the song count.** Pick the row count so total cell count tracks the song count — few songs get a coarse grid of big tiles; the grid quietly subdivides into more, smaller tiles as songs are added. A reasonable formula: `rows ≈ round(sqrt(songCount / 1.27))`, clamped to a sane min/max (e.g. 6..60 rows) so the grid doesn't get absurdly coarse or heavy. Important correction: that formula is only an *estimate* of total cell count — the actual generator also skips near-pole rows (previous paragraph), so real capacity comes in a little under the formula's estimate. If you don't correct for that, a small number of songs will permanently get no cell at all. Fix it by actually computing the grid's real capacity for a candidate row count and nudging rows up until real capacity ≥ song count, instead of trusting the formula alone.

**Assigning songs to cells — avoid clustering.** Each song's preferred cell index is `floor(hash(songId) * totalCells)` (deterministic, permanent). When two songs hash to the same cell, you need collision resolution — but be careful here: at the load factor this grid runs at by design (very close to 100% full, since capacity is sized to just barely cover the song count), resolving collisions by simply walking to the next index (`idx + 1`, wrapping around) causes classic *primary clustering*: a few early collisions in one neighborhood chain into an ever-longer run of filled cells, which keeps deflecting later collisions past that same neighborhood instead of spreading out. The empty cells that are left over end up bunched into a visible patch/band on the sphere instead of scattered evenly and unnoticeably — this reads as an obvious rendering bug ("black hole on the globe") even though the underlying data is fine. Use double hashing instead: each song's collision probe step is *itself* a hash of that song's id (not a fixed +1), nudged if necessary so it's coprime with the total cell count (otherwise the probe sequence can cycle through only a fraction of the table and falsely report "no room" while empty cells exist elsewhere). This sends overflow to an effectively unrelated part of the grid instead of the next-door cell, which breaks up the clustering.

**Cells with no song.** With the grid sized to just barely cover the catalog, a small percentage of cells will always end up with no song assigned, by design. Don't leave those completely unpainted (a true gap reads as broken) — give each empty cell a quiet decorative fill instead, deterministically seeded from the cell's own row/column (not from a song), dim/desaturated enough to read as part of the sphere's base texture rather than competing with real artwork.

**Loading placeholder.** Likewise, a cell whose song is assigned but whose thumbnail image hasn't finished loading yet (or never resolves) should not render as flat near-black — against a black background that reads as a hole too, especially on a slow connection where many tiles sit in that state at once for a while. Give it a per-song deterministic color placeholder (e.g. an HSL color from a hash of the song's id) instead, so the whole sphere reads as fully wallpapered from the very first frame; the real artwork just replaces the color patch underneath once it loads.

**Perspective projection.** Project each cell's four corners (not just its center) through a standard spherical→cartesian→camera-rotation→screen pipeline, so each tile follows the sphere's curvature — skewed/foreshortened near the limb, roughly square near the center — instead of looking like a flat sticker floating in front of a ball. Warp the (center-cropped-to-square) album art onto that quad as two textured triangles using an affine transform per triangle. As a performance optimization, tiles very close to dead-center (where perspective distortion is imperceptible) can skip the expensive two-triangle warp and just do one plain scaled image draw into the bounding box — with a grid sized for hundreds of songs, most visible tiles fall in this bucket at any moment, so this materially matters for keeping rotation smooth.

**Limb darkening.** Tiles near the edge of the visible hemisphere should read as dimmer, like the shaded far side of a lit sphere — the cheapest way to sell 3D curvature with flat 2D art. Do this as a plain semi-transparent black rectangle painted over the finished tile (using the same quad path), not as a canvas filter (`ctx.filter = "brightness(...)"` looks fine but is notoriously unaccelerated in most browsers — with hundreds of tiles on screen, paying that cost per tile is a real, measurable source of stutter during zoom/rotate).

**Camera.** Yaw (left/right) and pitch (up/down) rotate the sphere; support drag-to-rotate (with the vertical axis in the intuitive "grab and drag" direction — dragging down tilts the *view* down, i.e. the near pole comes toward you, not away — this is an easy sign-flip bug to introduce, so pay attention to it and test it explicitly), pinch/wheel to zoom, momentum/friction on release, and a slow constant idle auto-rotation so the globe is always gently alive even when untouched. Clamp pitch well short of ±90° (e.g. ±60-65°) so the poles' inherent grid-convergence "pinwheel" look — an artifact of any lat/long-gridded sphere — stays mostly out of reach rather than being the first thing a curious drag reveals.

**Zoom range.** Zoomed all the way out, the *entire* sphere should fit on screen with no part clipped by the viewport edges (reserve screen space for any fixed UI chrome like a player bar or top pill when computing this, so the globe never renders partly behind them). Zoomed all the way in, the surface should fill the screen corner-to-corner with no background visible on any side. Getting both ends right simultaneously is a real geometric constraint (a circle can't touch all four edges of a wide rectangle *and* fit entirely within a shorter height at the same time) — size the base radius off `min(viewport width, viewport height)`, and treat "zoom all the way in fills every edge" as a property of the *max zoom multiplier*, not of the base size.

**Pole decoration.** The small bare cap left at each pole (§5, row-skipping) can be filled with a procedural decorative graphic (e.g. a stylized speaker cone, rim bolts, a radial-gradient dust cap) rather than left blank. Nice touch: reveal it with a fade only once the camera has actually rotated toward that pole past some threshold, so it's invisible at the default resting pitch and feels like a small discovery rather than a static logo stuck to the top of the planet.

**Ambient decoration.** A drifting starfield behind the globe (small dots with per-star twinkle phase/speed, and slow per-star drift velocities that wrap at the viewport edges so it loops forever instead of running out of stars) and a restrained sprinkling of "mirror ball" glint points scattered across the sphere's own surface (seeded in spherical coordinates so they rotate naturally with the globe, each with its own twinkle phase) both help sell an alive, cinematic feel without being distracting — keep the glint effect subtle, not a strobing disco ball.

**Rim glow.** A soft glow just outside the sphere's silhouette, slowly cycling through hues over tens of seconds, adds production value cheaply. Keep its outward extent modest (e.g. barely past the sphere's own radius) — it's easy to overdo and have it look like a giant colored halo rather than a subtle rim light.

**Decorative "beat pulse."** If you want the rim glow / pole decoration to visually pulse in time with playback, be honest about what's achievable: a cross-origin YouTube iframe embed exposes no usable Web Audio API surface for real beat/frequency analysis. Implement (and clearly comment as) a *simulated* generic decorative pulse (e.g. a fixed ~120-130bpm envelope) that only runs while something is actually playing — don't pretend it's reactive to the actual audio content, and don't silently build real audio analysis by bypassing the official player to get one; that's a product decision the project owner should make explicitly, not something to slip in quietly.

## 6. Flat-map tile rendering

Simpler than the globe: each song is a rounded square at its world position, rotated by a small deterministic per-song jitter angle, center-cropped from its (likely 16:9) thumbnail into a square. Same loading-placeholder and hover/selection treatment as the globe (color placeholder while loading, orange glow ring + slight scale-up on hover/selection/highlight). No perspective warping needed here — it's a flat plane.

## 7. Playback

- One single YouTube IFrame Player instance for the whole app, reused across every song (never spin up a new embed per song) — mount it off-screen/invisibly since the video frame itself is never shown, only audio matters.
- Force the lowest available video quality once the player is ready and again on any quality-change event (the API auto-adjusts quality based on bandwidth and can silently bump it back up) — this is the closest the *official* IFrame API gets to "audio only" (it has no true audio-only mode). Be explicit in comments that a real audio-only pipeline would require pulling a raw stream URL out from under YouTube's official player, which is a Terms-of-Service and reliability gray area that should never be implemented "quietly" — flag it as a decision for the project owner, don't just do it.
- `playsinline` should be set so mobile browsers don't force fullscreen video playback.
- Track play/pause/progress/duration state in a central store; reconcile against the player's actual reported state (don't trust a single state-change event blindly — e.g. a "paused" event can spuriously fire the instant a new video starts loading, before playback actually begins; guard against treating that as a real user pause).
- "Next"/"previous" should navigate the shared universe *spatially* — the nearest other songs to the current one by world position (wrapping across the canonical period, so a song near one edge can be "close" to one near the opposite edge) — rather than an arbitrary fixed playlist order.
- Repeat modes: play-through-all, repeat-one, and a third "loop a short segment" style mode if desired; a small icon on the player bar cycles between them.
- Integrate the Media Session API (track title/artist/artwork + play/pause/next/prev handlers) so the OS lock screen and notification shade show real controls and metadata — this is also the standard, correct way to make a browser more willing to keep playing once the tab is backgrounded, though be honest in comments that this is best-effort: mobile Safari in particular is known to aggressively suspend backgrounded video elements (which a YouTube embed technically is) and no page-side code can override that with certainty.
- Global spacebar toggles play/pause (skip this when focus is inside a text input/textarea/contenteditable element). Do **not** make clicking anywhere on the background toggle play/pause — only actual artwork clicks should trigger playback actions; a global click-to-toggle is surprising and easy to trigger by accident while just exploring the map/globe.
- In fullscreen mode, auto-hide the player bar after a few seconds of inactivity, and bring it back on any mouse movement or click — but the globe/map's own size and centering should **not** shift when the player bar hides or reappears; keep their layout fixed regardless of the player bar's visibility. Resizing/recentering the canvas every time the bar fades in or out reads as distracting jitter, not a nice-to-have.

## 8. Adding songs to the catalog (curator-managed, not public)

- No public "add song" button in the UI — this is meant to be a curated shared space, not a free-for-all. Keep this out of the default UI on purpose.
- Provide two ways to add music behind the scenes:
  1. A seed script that inserts a curated list of verified real music videos directly into the JSON store (no live API validation needed for this path — useful for quickly bootstrapping a catalog from a hand-vetted list).
  2. A small service layer that, given a YouTube URL, resolves its metadata via the YouTube Data API, runs it through a validation pipeline (music-category check, duration sanity check, title/channel heuristics to reject compilations/mixes/movies/trailers/interviews, dedup against existing catalog), and only inserts it if it passes. Optionally, for genuinely ambiguous cases with no strong signal either way, allow a second-pass AI classification step — but make this fully optional; the app should work with zero AI dependency.
  3. Expose the service layer's add/bulk-add/preview functions as API routes, but don't link them from the UI. A "preview" endpoint should resolve+validate without saving, for a "here's what we found, confirm to add" flow if a UI is ever built on top.
- Content-curation rule for any bulk seeding work: never fabricate video IDs or metadata — verify everything against real fetched data (e.g. via `yt-dlp` or the Data API) before inserting. Explicitly skip compilations, jukeboxes, full movies, trailers, interviews, dialogue/dramatic-scene clips, and duplicate cuts of the same song — bulk channel dumps in particular need a denylist of title patterns (words like "scene", "movie clip", "climax", quoted exclamatory dialogue-style titles, "jukebox", "full movie", etc.) since these slip through duration/category checks alone.
- Any endpoint that *removes* content (delete-by-id) should not be publicly reachable without authorization, even though there's no account system — gate it behind a single shared secret token (an environment variable checked against a bearer token header), and make the route behave as if it doesn't exist (404) when that token isn't configured. There's no legitimate reason for an unauthenticated visitor to ever be able to delete a shared song.

## 9. Other UX pieces

- **Loading screen**: while the initial song list is being fetched (and, ideally, while artwork prefetching gets a head start in the background), show a branded preloader with a percentage counter that climbs smoothly and quickly (not in jumpy discrete steps) toward 100%, capping just short of 100% until the real data has actually arrived, then completing and dismissing immediately.
- **Search**: a simple overlay/panel to search the shared catalog by title/artist, merged into a small branded pill in a corner of the screen alongside a search icon.
- **Favorites**: a personal convenience stored only in the browser's local storage — never sent to any server, so it needs no identity/account either. Long-press (or equivalent) an artwork to toggle it, with a small toast confirming the action.
- **Toasts**: a small stack of transient notifications for confirmations (favorited/unfavorited, errors, etc.).
- **Credit line**: a small, unobtrusive "made by [x]" (or similar) text pinned in a corner, low-contrast so it doesn't compete with the artwork.
- **Reduced motion**: respect the OS-level "prefers reduced motion" setting by disabling/shortening decorative animation everywhere (starfield drift, idle rotation, twinkle, pulses) — treat this as a real accessibility requirement, not an afterthought.
- **Bandwidth awareness**: use the browser's Network Information API (where available; it's Chromium/Android-only, no fallback elsewhere, so treat "unknown" as "assume normal speed") to detect a slow connection and skip bulk background thumbnail-prefetching in that case, falling back to loading only what's actually being drawn at the moment — bulk-prefetching hundreds of images competes with the actual song's own audio/video stream for the same limited bandwidth, and can make buffering worse specifically on a slow connection.

## 10. Visual identity

- Dark theme (near-black background), one warm accent color (e.g. an orange) used consistently for the active/brand color, a lighter "glow" variant of it for highlights, and a muted gray text hierarchy (primary/secondary/faint).
- A small distinctive logo mark that ties into the "universe of playable music" concept (e.g. a planet/record shape with a play-triangle notch, orbited by a thin ring with a small dot) rather than a generic music-note icon — reuse the exact same mark for the in-app brand pill, the favicon/app icon (including a mobile home-screen icon size), and a social share-preview image (so a shared link gets a real preview card instead of a blank one), so the identity is consistent everywhere it appears.

## 11. Non-goals (explicitly do NOT build these)

- No accounts, no login, no per-user song libraries.
- No public "add song" UI/button by default.
- No click-anywhere-on-background play/pause toggle — only actual artwork taps and (optionally) a spacebar shortcut should control playback.
- No real audio-frequency/beat analysis (not achievable via a standard YouTube iframe embed without bypassing the official player) — any "reacts to the music" visual effect should be clearly simulated/decorative, not claimed as real analysis.
- No silently bypassing YouTube's official player to extract a raw audio-only stream — if true audio-only playback is ever wanted, that's a deliberate, explicitly-decided trade-off (Terms of Service + reliability risk) for the project owner to opt into, not a default implementation choice.
- Don't resize or recenter the main canvas view in reaction to transient UI chrome changes (e.g. a player bar auto-hiding) — keep the primary view's size/position stable and let overlays float on top of it instead.

## 12. Deployment notes

- Keep the JSON-file storage module small and swappable (a handful of clearly-named functions) so it can be replaced with a real hosted database later without touching rendering/UI code.
- Document clearly that this JSON-file approach needs a persistent, writable disk to durably keep any *runtime* writes (e.g. via the add-song API) — that's fine on a plain VPS or any host offering a persistent volume, but not on fully ephemeral/serverless hosts, where the filesystem resets between deploys/instances. Read-only serving of a catalog that was baked in at deploy time (e.g. via the seed script, committed to source control) works fine anywhere, including serverless hosts — it's only *runtime writes* that need a persistent disk.
- Keep real environment secrets out of source control (`.env.local` style file, gitignored, with a committed `.env.example` template); nothing in this app requires secrets for basic playback (only the optional add-song/service-layer path needs a YouTube Data API key, and the optional AI classifier needs its own key) — someone should be able to clone it, seed some songs, and run it with zero API keys configured.

---

Build this as a complete, working app: project scaffold, data layer, both rendering engines, the player, search/favorites/UI polish, and a small curated starter catalog of real, verified songs (never fabricated) to seed it with. Prioritize getting the two rendering engines and the shared-universe/no-login model right — those are the heart of the idea; everything else is in service of them.

Important: Don't ask Claude to rewrite the entire project every time. Always tell Claude to modify and improve the existing implementation. This prevents it from breaking features that are already working.

After completing each step, ask Claude what the next step should be to make the application fully functional, production-ready, and eventually live on the internet. Claude should first inspect the current project, identify what is already working and what is missing, then recommend the next most important step before making changes.

Continue this process step-by-step until the entire application is working properly and ready to deploy. At the final stage, ask Claude to guide you through production deployment, domain setup, environment variables, database configuration, API keys, security, performance optimization, and making the web app live.

Never assume the project is finished. After every major implementation, test the current version, fix any errors, and then ask: “What should we do next to make this fully working and live?”