TorontoGuessr is a Toronto-only Street View guessing game with a Next.js frontend, a small Node backend, and Supabase for persistence.
Players get five Toronto Street View locations, place guesses on a map, and earn points based on geographic accuracy. The app also includes leaderboards, daily gameplay stats, SEO metadata routes, and an admin review tool for manually approving or rejecting cached Street View locations.
- Frontend (
frontend/): Next.js, React, Tailwind CSS - Backend (
backend/): Node.js, TypeScript, Vercel Functions - Database: Supabase (PostgreSQL)
- External APIs: Google Maps JavaScript API, Google Street View Metadata API
flowchart TB
subgraph Frontend["Frontend (Next.js on Vercel)"]
Pages["Frontend Pages<br/>(landing, game, leaderboard, admin)"]
GameUI["Game UI<br/>(Street View panorama + guess map)"]
ApiLayer["API Layer"]
Pages --> ApiLayer
GameUI --> ApiLayer
end
subgraph Backend["Backend API (Node.js on Vercel Functions)<br/>Stateless Serverless Functions"]
RestApi["REST API<br/>(request routing)"]
GameEngine["Game Engine<br/>Session Management<br/>Scoring Engine<br/>Leaderboard"]
LocationService["Location Service<br/>Random Round Selection<br/>Panorama Validation<br/>Admin Review Workflow"]
RestApi --> GameEngine
RestApi --> LocationService
end
subgraph DataLayer["Database & External APIs"]
Postgres[("PostgreSQL Database<br/>verified_locations<br/>game_sessions")]
MapsJS["Google Maps JavaScript API<br/>(browser-side map & Street View rendering)"]
MetadataAPI["Google Street View Metadata API<br/>(panorama validation)"]
end
ApiLayer -- "REST API" --> RestApi
GameUI -- "render Street View" --> MapsJS
LocationService -- "metadata validation" --> MetadataAPI
GameEngine --> Postgres
LocationService --> Postgres
Design Principle: The frontend is responsible for rendering the user experience, while the backend contains all business logic, persistence, and location validation through stateless serverless functions.
Key flows:
- Starting a game selects five verified locations and creates a session; only panorama ids and headings reach the browser, never the answer coordinates.
- The browser renders Street View and the guess map directly through the Google Maps JS API; the backend is not in the rendering path.
- Guesses are scored server-side (haversine distance, 0 to 5,000 points) so scores cannot be forged client-side.
- Leaderboards and stats aggregate finished
game_sessionsrows by period. - Admin review routes are gated by an
x-admin-tokenheader checked againstADMIN_REVIEW_TOKEN.
The frontend uses a custom design system ("Cartographic Premium") built on Tailwind CSS and a small set of reusable components.
- Theming: semantic HSL design tokens drive both a cool paper-white light theme and a deep-navy dark theme (the default). Toronto brand accents (navy, azure, sky, red, gold) live in dedicated tokens. A floating control in the bottom-right corner toggles the site between light and dark.
- Toronto identity: an ambient backdrop layers a cartographic grid, a soft azure spotlight, and color blooms, with a Toronto skyline anchoring the footer and a CN Tower-inspired logo mark, understated, not touristy. Drop a
cn-tower-logo.pngortoronto-skyline.pngintofrontend/public/to swap in your own art; the built-in vector versions are used otherwise. - Map-first gameplay: an immersive Street View stage with a floating glass HUD (round, score, animated timer ring) and a floating guess panel; round and final results animate scores with count-ups and reveal transitions.
- Readable maps: the guess and results maps have their own light/dark appearance, independent of the site theme and toggled from a button on the map (remembered across sessions), so the map stays legible in either mode.
- Shared components: global chrome (
AppShell,Navbar,Footer,TorontoBackdrop) plus building blocks (SectionHeading,StatCard,Reveal,CountUp,Spinner,LoadingScreen,EmptyState,ErrorCard,GameHUD,GuessPanel,LeaderboardPodium) live infrontend/components/site/andfrontend/components/. - Accessibility & motion: semantic HTML, labeled controls, a skip-to-content link, visible focus rings, and a global
prefers-reduced-motionguard that disables animations for users who opt out. - Responsive: every page is tuned for desktop, tablet, and mobile, including a stacked mobile gameplay layout that keeps Street View and the guess map usable on small screens.
Gameplay, scoring, leaderboards, statistics, authentication, the admin review workflow, and all backend APIs are unchanged. This was a presentation-layer redesign.
.
├── frontend/
├── backend/
├── scripts/
└── package.json
- Node.js 20+
- npm 10+
- A Supabase project
- A Google Maps API key with the APIs your app uses enabled
-
Install dependencies:
npm install
-
Copy the env examples:
cp backend/.env.example backend/.env cp frontend/.env.example frontend/.env
-
Create a Supabase project.
-
For a fresh database, run backend/supabase/schema.sql in the Supabase SQL editor.
This schema enables Row Level Security (RLS) on the app tables. The backend uses the Supabase
service_rolekey, so it continues to work while anonymous access stays blocked by default.If you already had an older TorontoGuessr schema, also run backend/supabase/add_verified_location_review_columns.sql to add the manual review fields.
If Supabase is warning that RLS is disabled on an existing project, also run backend/supabase/enable_row_level_security.sql.
Also run backend/supabase/add_stats_function_and_indexes.sql. It adds the
daily_game_statsSQL aggregate (exact stats regardless of row volume) and leaderboard indexes. Without it the backend falls back to a slower row scan whose counts cap at 1,000 sessions per range.Also run backend/supabase/add_pick_game_rounds_function.sql. It adds the
pick_game_roundssampler so game starts select rounds in SQL instead of scanning the whole location table (which caps at 1,000 rows). The backend falls back to the scan until it is applied.Optional: backend/supabase/add_submit_guess_function.sql adds the
submit_guessfunction, which scores a guess in one atomic DB call instead of a read-then-write. It only activates when the backend hasGUESS_RPC_ENABLED=true; leave that unset until you have applied the migration and verified a real game. With the flag off (default) the backend uses the original two-call path.Also run backend/supabase/add_game_modes_and_deadlines.sql. It adds the daily-challenge mode columns and the
round_started_attimestamp used to enforce round deadlines server-side. Until it is applied, deadlines are skipped and daily-challenge games are recorded as classic. -
Fill in
backend/.envusing backend/.env.example:PORT=3001 SUPABASE_URL=https://your-project-ref.supabase.co SUPABASE_SERVICE_ROLE_KEY=your-service-role-key LOCATION_GENERATION_ENABLED=false NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your-google-maps-api-key ADMIN_REVIEW_TOKEN=your-long-random-admin-token
-
Fill in
frontend/.envusing frontend/.env.example:NEXT_PUBLIC_API_BASE_URL=http://localhost:3001/api SITE_URL=http://localhost:3000 NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your-google-maps-api-key
-
Start the app:
npm run dev
Frontend runs on http://localhost:3000 and the backend runs on http://localhost:3001.
For normal app usage, the backend needs:
SUPABASE_URLSUPABASE_SERVICE_ROLE_KEY
You can find both in the Supabase dashboard:
SUPABASE_URL: project dashboard ->Connect->Project URLSUPABASE_SERVICE_ROLE_KEY:Settings->API Keys->service_role
Use the project URL itself, for example:
SUPABASE_URL=https://your-project-ref.supabase.coDo not use NEXT_PUBLIC_SUPABASE_* variables here. This app talks to Supabase from the backend, not from the browser.
Because the public tables have RLS enabled, the backend should use SUPABASE_SERVICE_ROLE_KEY, not an anon key.
The Supabase schema creates two main tables:
verified_locations: cached Toronto Street View locations withlat,lng,pano_id,manually_verified, andreview_statusgame_sessions: persisted game state, results, and finished sessions used for leaderboards and stats
Notes:
manually_verified = truemeans a location was explicitly approved in the admin review tool.review_statusis one ofpending,accepted, orrejected.- Gameplay prefers manually verified locations first and excludes rejected ones.
- Leaderboards are derived from finished
game_sessionsrows.
Main gameplay behavior:
- each game starts a five-round session
- rounds are selected from cached verified locations in Supabase
- if generation is enabled and the cache is too small, the backend can generate more verified Toronto locations using Street View metadata
- scores are based on guess distance from the true location
Leaderboard behavior:
lifetime,daily,weekly, andmonthlyperiods are supported- the frontend shows a top-5 preview first
lifetimeexpands to a top-25 viewdaily,weekly, andmonthlykeep pagination after the preview
The admin review page lives at /admin/review-locations.
It is designed to manually filter out bad Street View cache entries, especially indoor or unusable panoramas. The review UI includes:
- Street View panorama preview
- 2D map preview with a marker on the saved coordinates
Accept,Reject,Previous,Next, andUndo Last Action
Review behavior:
Accept: setsmanually_verified = trueandreview_status = acceptedReject: setsreview_status = rejectedand removes the row from the active review queueUndo Last Action: restores the most recently accepted or rejected row topendingand returns the queue to that specific location
Admin access is protected by ADMIN_REVIEW_TOKEN, which must be sent to the backend through the review UI.
There are now two ways verified locations can be added:
- Runtime generation during gameplay if
LOCATION_GENERATION_ENABLED=true - Manual bulk generation with backend/scripts/generate-verified-locations.ts
To generate 100 more verified locations:
npm run generate:verified-locations --workspace backendTo generate a custom number:
npm run generate:verified-locations --workspace backend -- 25The script validates Street View coverage, skips duplicate panoramas already stored in Supabase, and inserts only newly verified rows.
The backend exposes a gameplay stats endpoint at GET /api/stats/games.
It returns:
- total games started
- total games finished
- a daily time-series for the requested range
Query parameters:
days: optional,1-365, defaults to30timeZone: optional, defaults toAmerica/Toronto
The frontend also includes Next.js metadata routes for:
robots.txtsitemap.xml
These use SITE_URL when available. The /game route is excluded from crawling because it auto-starts a game session.
From the repo root:
npm run dev: start frontend and backend togethernpm run build: build-check backend and frontendnpm run start: start frontend and backend in production mode
Workspace-specific:
npm run dev --workspace frontendnpm run dev --workspace backendnpm run generate:verified-locations --workspace backend
Main backend routes:
GET /api/healthPOST /api/games/startPOST /api/games/:sessionId/guessPOST /api/games/:sessionId/nextPOST /api/games/:sessionId/usernameGET /api/leaderboard?period=lifetime|daily|weekly|monthly&page=1&limit=10GET /api/stats/games?days=30&timeZone=America/TorontoGET /api/admin/review-locations?index=0GET /api/admin/review-locations?locationId=<uuid>PATCH /api/admin/review-locations/:locationIdwith{ "action": "accept" | "reject" | "undo" }
This repo is easiest to deploy as two Vercel projects.
- import the same Git repo into Vercel
- set the root directory to
backend - framework preset:
Other - build command:
npm run build
Backend env vars:
SUPABASE_URL=https://your-project-ref.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
LOCATION_GENERATION_ENABLED=false
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your-google-maps-api-key
ADMIN_REVIEW_TOKEN=your-long-random-admin-tokenNotes:
NEXT_PUBLIC_GOOGLE_MAPS_API_KEYon the backend is only needed if the backend still performs Street View metadata lookups for migration, generation, or pano backfill.- If
verified_locationsis already fully seeded withpano_idvalues and generation is disabled, the backend can usually run without that key.
- import the same Git repo again
- set the root directory to
frontend - framework preset:
Next.js
Frontend env vars:
NEXT_PUBLIC_API_BASE_URL=https://your-backend-project.vercel.app/api
SITE_URL=https://www.torontoguessr.ca
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your-google-maps-api-keyDeploy order:
- deploy the backend project first
- copy its production URL
- set
NEXT_PUBLIC_API_BASE_URLin the frontend project - deploy the frontend project
The frontend Google Maps JavaScript key should allow the domains you actually use, for example:
https://www.torontoguessr.ca/*https://torontoguessr.ca/*http://localhost:3000/*
If you use preview deployments, also add the preview domain pattern.
Right now the repo can use the same Google key on both frontend and backend, but a cleaner long-term setup is:
- browser key for frontend Maps / Street View UI, restricted by HTTP referrer
- server key for backend Street View metadata requests
- The frontend always needs a Google Maps API key because gameplay and review tooling both use Google Maps and Street View.
flowchart TB
subgraph Frontend["Frontend (Next.js on Vercel)"]
Pages["Frontend Pages<br/>(Landing, Game, Leaderboard, Admin)"]
UI["Game UI<br/>(Street View Panorama + Guess Map)"]
Client["API Layer<br/>(Backend Communication)"]
Pages --> Client
UI --> Client
end
subgraph Backend["Backend API (Node.js deployed as Vercel Functions)<br/>Stateless Serverless Functions"]
API["REST API<br/>(Request Routing + Admin Auth)"]
Game["Game Engine<br/>Game Session Management<br/>Scoring Logic<br/>Leaderboard"]
Location["Location Service<br/>Random Round Selection<br/>Street View Validation<br/>Admin Review Workflow"]
API --> Game
API --> Location
end
subgraph DataLayer["Database & External APIs"]
Postgres[("PostgreSQL Database<br/>verified_locations<br/>game_sessions")]
Maps["Google Maps JavaScript API<br/>(Map + Street View Rendering)"]
Metadata["Google Street View Metadata API<br/>(Panorama Validation)"]
end
Client -- "REST + JSON" --> API
UI -- "Loads panoramas" --> Maps
Location -- "Validates panoramas" --> Metadata
Game --> Postgres
Location --> Postgres
Design Principle: The frontend is responsible for rendering the user experience, while the backend contains all business logic, persistence, and location validation through stateless serverless functions.
sequenceDiagram
participant User
participant Frontend
participant Backend
participant Database
participant Google
User->>Frontend: Click "Start Game"
Frontend->>Backend: POST /api/game/start
Backend->>Database: Fetch 5 verified locations
Database-->>Backend: Locations + panorama IDs
Backend-->>Frontend: Game session
Frontend->>Google: Request Street View panorama
Google-->>Frontend: Panorama rendered
User->>Frontend: Submit guess
Frontend->>Backend: POST /api/game/guess
Backend->>Database: Store guess and compute score
Database-->>Backend: Updated session
Backend-->>Frontend: Score and next round
flowchart LR
A["1. Player clicks 'Start Game'"] --> B["2. Frontend sends POST /api/game/start"]
B --> C["3. Backend Game Engine"]
C --> D["4. Query PostgreSQL<br/>Fetch 5 verified locations"]
D --> E["5. Create game session"]
E --> F["6. Return locations + session ID"]
F --> G["7. Frontend loads Street View<br/>using Google Maps JS API"]
G --> H["8. Player submits guess"]
H --> I["9. POST /api/game/guess"]
I --> J["10. Backend computes distance<br/>using Haversine formula"]
J --> K["11. Update session & score<br/>in PostgreSQL"]
K --> L["12. Return score<br/>and next round"]