Open-source GeoGuessr-style game built around custom 360deg panoramas with GPS data. Players guess each photo location on the map - the closer the guess, the more points they get.
Play solo or challenge friends — create a tournament lobby, share a link, and compete in real-time on the same set of panoramas. Live scores update as everyone plays.
- Next.js 16 (App Router) + React 19 + TypeScript + Tailwind v4
- Supabase — Postgres, Auth (anonymous sessions + Google OAuth), RLS
- Drizzle ORM — schema in
lib/db/schema.ts - Cloudflare R2 — tile storage (S3-compatible, public read)
- Vercel — hosting
- Node.js 20+, pnpm 9+
- A Supabase account (free tier is enough)
- A Cloudflare R2 account (free tier: 10 GB / 10 M requests/month)
git clone https://github.com/your-username/panopin.git
cd panopin
pnpm install- Create a new Supabase project
- Run migrations in this order in SQL Editor:
drizzle/migrations/0000_amusing_gladiator.sqldrizzle/migrations/0001_natural_ultimo.sqldrizzle/migrations/0002_little_doomsday.sqldrizzle/migrations/0003_tournaments.sqlsupabase/migrations/001_rls_policies.sqlsupabase/migrations/002_tournaments_rls.sql
- Enable anonymous sign-in: Authentication -> Providers -> Anonymous
- (Optional) Add Google OAuth: Authentication -> Providers -> Google - requires
Client IDandClient Secret - Set your user as admin:
UPDATE profiles SET role = 'admin' WHERE id = '<your-auth-uid>';
- Create bucket
panopin(or any name you prefer) - Enable Public access for the
tiles/folder - Configure CORS (allow
PUTfrom your app domains):[{"AllowedOrigins":["http://localhost:3000","https://your-domain.com"],"AllowedMethods":["GET","PUT"],"AllowedHeaders":["*"]}] - Generate an API token with
Object Read & Writepermissions
Create a .env.local file:
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
DATABASE_URL=postgresql://postgres.xxxx:[password]@aws-0-region.pooler.supabase.com:6543/postgres
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=panopin
R2_PUBLIC_BASE_URL=https://pub-xxxx.r2.dev
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Legal pages — shown in /privacy and /cookies
NEXT_PUBLIC_SITE_OWNER=Your Name or Organization
NEXT_PUBLIC_CONTACT_EMAIL=contact@your-domain.comNote:
NEXT_PUBLIC_SITE_OWNERandNEXT_PUBLIC_CONTACT_EMAILappear in the Privacy Policy and Cookie Policy pages. They are embedded in the static HTML at build time — set them before deploying. If omitted, the pages fall back to generic placeholders.
pnpm devOpen http://localhost:3000.
- Sign in as admin (Google or magic link)
- Go to
/admin/upload - Upload a 360deg photo with GPS metadata (EXIF lat/lng)
- Wait for tile generation (~10-30s)
- Play at
/play🎉
Pick difficulty and optional tags, then guess the location of 5 random panoramas. Score is calculated server-side: exponential decay by distance + time bonus (up to 30 s). After finishing, see your result on the map and compare to the global leaderboard.
- Create a lobby with optional difficulty/tag filters — get a 6-character join code
- Share the code or copy a direct invite link
- Join by entering the code (or clicking the link) and choosing a display name
- Host starts the game — all players get the same 5 panoramas
- Play at your own pace — live scores update in the header as everyone guesses
- Results screen shows a podium, full leaderboard, and a map of your own guesses
- Host can force-end the tournament at any time
Upload panoramas at /admin/upload. Tiles are generated in-browser (WebGL worker) and uploaded directly to R2 — no server load. Manage difficulty, tags, and map settings at /admin/*.
pnpm dev # development server (Turbopack)
pnpm build # production build
pnpm test:run # unit tests (vitest)
pnpm lint # ESLint
pnpm db:generate # generate SQL migration from current schema
pnpm db:studio # Drizzle Studio - database browserMIT - see LICENSE for details.