Guess where they're from. A geography guessing game where you identify a person's origin based on their photo — powered by Leaflet maps, PostGIS scoring, and Supabase.
Think GeoGuessr, but for people and cultures.
| Feature | Description |
|---|---|
| 🎮 Core Game | View a person's image, click a world map to guess their origin |
| 📏 Haversine Scoring | Exponential decay scoring (max 5000 pts) based on distance accuracy |
| 🏆 Global Leaderboard | Top 50 players, auto-aggregated scores, medal rankings |
| 🔐 GitHub OAuth | One-click sign-in via GitHub — no passwords needed |
| 🛠️ Admin Panel | Create levels: upload images, pick locations on map, set difficulty |
| 🗺️ Leaflet + OpenStreetMap | Interactive maps for both guessing and admin location selection |
| 🛡️ Anti-Cheat | Rate limiting (15 guesses/min), unique constraint (1 guess/level/user) |
| 📍 PostGIS | Spatial geography types, spatial indexes, precise coordinate storage |
| 🔒 Row Level Security | All tables protected with Supabase RLS policies |
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router) |
| Language | TypeScript |
| Styling | TailwindCSS v4 |
| Maps | Leaflet + OpenStreetMap |
| Database | PostgreSQL + PostGIS (via Supabase) |
| Auth | Supabase Auth (GitHub OAuth) |
| Storage | Supabase Storage (level images) |
| Deployment | Vercel / any Node.js host |
src/
├── app/
│ ├── layout.tsx # Root layout (Inter font, dark theme)
│ ├── page.tsx # Landing page (hero + features)
│ ├── globals.css # Tailwind + custom dark styles
│ ├── login/page.tsx # GitHub OAuth sign-in
│ ├── game/page.tsx # Game page (random unplayed level)
│ ├── leaderboard/page.tsx # Global leaderboard (top 50, ISR)
│ ├── admin/page.tsx # Admin panel (role-gated)
│ ├── auth/
│ │ ├── callback/route.ts # OAuth code exchange
│ │ └── signout/route.ts # Sign out handler
│ └── api/
│ ├── guess/route.ts # Guess submission API
│ └── admin/levels/route.ts # Level CRUD API (admin only)
├── components/
│ ├── Navbar.tsx # Navigation with auth state
│ ├── GameMap.tsx # Leaflet map for guessing
│ ├── GameClient.tsx # Game UI (image, controls, score)
│ ├── AdminMap.tsx # Leaflet map for admin location picker
│ └── AdminClient.tsx # Admin UI (level creation + management)
├── lib/
│ ├── supabase/
│ │ ├── client.ts # Browser Supabase client
│ │ ├── server.ts # Server Supabase client
│ │ └── middleware.ts # Session refresh + route protection
│ └── scoring.ts # Haversine distance + score calculation
├── types/
│ └── database.ts # TypeScript interfaces
└── middleware.ts # Root middleware
supabase/
└── schema.sql # Full database schema + triggers
- Node.js 18+
- npm 9+
- A Supabase project (free tier works)
- A GitHub OAuth App
git clone https://github.com/TAG-IIIT/Ethnoguessr.git
cd Ethnoguessr
npm installCopy the example and fill in your Supabase credentials:
cp .env.local.example .env.localNEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-keyFind these in Supabase Dashboard → Settings → API
Run supabase/schema.sql in Supabase Dashboard → SQL Editor. This creates:
profiles— user profiles (auto-created on GitHub login)levels— game levels with PostGIS locationsguesses— user guesses with distance/scoreleaderboard— auto-aggregated scores via trigger
Then create the coordinate extraction function:
create or replace function get_level_coords(p_level_id uuid)
returns table(lat double precision, lng double precision)
language sql security definer
as $$
select
ST_Y(location::geometry) as lat,
ST_X(location::geometry) as lng
from public.levels
where id = p_level_id and is_active = true;
$$;- Create a GitHub OAuth App at github.com/settings/developers
- Homepage URL:
http://localhost:3000 - Callback URL:
https://<your-supabase-url>/auth/v1/callback
- Homepage URL:
- Enable GitHub in Supabase Dashboard → Authentication → Providers
- Paste the Client ID and Client Secret
- Go to Supabase Dashboard → Storage
- Create a bucket named
level-images(set to Public) - Add storage policies (run in SQL Editor):
create policy "Allow authenticated uploads"
on storage.objects for insert to authenticated
with check (bucket_id = 'level-images');
create policy "Allow public reads"
on storage.objects for select to public
using (bucket_id = 'level-images');
create policy "Allow authenticated updates"
on storage.objects for update to authenticated
using (bucket_id = 'level-images');
create policy "Allow authenticated deletes"
on storage.objects for delete to authenticated
using (bucket_id = 'level-images');npm run devOpen http://localhost:3000 and sign in with GitHub.
After your first login, run in SQL Editor:
update public.profiles set role = 'admin' where username = 'YOUR_GITHUB_USERNAME';You'll now see the Admin link in the navbar.
Uses the Haversine formula to calculate great-circle distance, then applies exponential decay:
score = round(5000 × e^(-distance_km / 2000))
| Distance | Score |
|---|---|
| 0 km (exact) | 5000 |
| 100 km | 4756 |
| 500 km | 3894 |
| 1000 km | 3033 |
| 2000 km | 1839 |
| 5000 km | 410 |
| 10000 km | 34 |
- Load → Server fetches a random active level the user hasn't played
- View → User sees the person's image (signed URL, 1hr expiry)
- Guess → User clicks on the Leaflet map to place their guess
- Score → API calculates Haversine distance and exponential decay score
- Result → Shows distance, score bar, correct/guessed markers with dashed line
- Next → Proceeds to the next unplayed level
erDiagram
auth_users ||--o| profiles : creates
profiles ||--o{ guesses : makes
profiles ||--o| leaderboard : aggregates
levels ||--o{ guesses : receives
profiles {
uuid id PK
text username UK
text avatar_url
text role
timestamptz created_at
}
levels {
uuid id PK
text image_url
geography location
text hint
text difficulty
boolean is_active
uuid created_by FK
timestamptz created_at
}
guesses {
uuid id PK
uuid user_id FK
uuid level_id FK
geography guessed_location
float distance_km
int score
timestamptz created_at
}
leaderboard {
uuid user_id PK
bigint total_score
int games_played
timestamptz updated_at
}
- Row Level Security (RLS) on all tables
- GitHub OAuth only — no password storage
- Rate limiting — 15 guesses per minute per user
- Unique constraint — one guess per level per user
- Admin role check — server-side verification for all admin operations
- Signed URLs — level images use 1-hour expiry signed URLs
- Middleware — session refresh + route protection on every request
MIT
Built with ❤️ using Next.js, Supabase, and Leaflet