Tutorial 03

Global Leaderboard with Supabase

A real cloud leaderboard — players worldwide, live rankings, country flags — with zero backend code and zero monthly cost. This is what transforms a local game into a competitive experience.

Free Tier Supabase No Backend PostgreSQL ~30 min

1Why a Leaderboard Changes Everything

A leaderboard is not just a table of scores. It is the single most powerful replayability mechanism a game can have. When a player sees that someone in Brazil scored 4,200 points and they just got 3,800, the only rational response is to play again immediately.

Competition with real humans is categorically different from competing with your own previous score. It creates a social dimension — a reason to tell people about your game, a reason to come back, a reason to care about your ranking. For a solo indie developer, a leaderboard is the cheapest form of community.

The traditional barrier to adding a leaderboard was needing a backend server: set up a Node.js or Python API, host it on a VPS, manage a database, handle authentication, worry about security. Supabase eliminates all of that. You get a managed PostgreSQL database with a REST API that your game can talk to directly from the browser.

Architecture Overview — No Backend Required
Browser (your game) LeaderboardManager.js ipapi.co city / country lookup REST API Supabase anon key · no auth PostgreSQL leaderboard table UptimeRobot keepalive ping every 5 min localStorage player_id UUID · geo cache

2Setting Up Supabase

Supabase provides a hosted PostgreSQL database with a REST API, automatically generated from your table schema. The free tier is generous: 500MB of database storage, unlimited API calls, and projects that stay active as long as they receive traffic.

Step 1

Create a Free Account

Go to supabase.com and sign up with GitHub. Create a new project. Choose a region close to your likely player base (US East or EU West covers most of the world adequately).

Step 2

Create the Leaderboard Table

In the Supabase dashboard, open the SQL Editor and run the table creation script below. This creates the table with all the columns your leaderboard needs.

Step 3

Copy Your API Keys

Under Settings → API, find your Project URL and your anon (public) key. The anon key is safe to use in client-side code — it only allows what your Row Level Security rules permit.

Step 4

Enable Row Level Security

In the Table Editor, enable RLS on your leaderboard table. Add a policy: "Allow public INSERT" and "Allow public SELECT". This means anyone can submit a score and read scores, but nobody can delete or modify entries.

sqlSupabase SQL Editor — run once
-- Create the leaderboard table
CREATE TABLE leaderboard (
  id           uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  player_id    text         NOT NULL,  -- persistent device UUID
  initials     text         NOT NULL,  -- 1–3 chars, player-chosen
  score        integer      NOT NULL,  -- primary ranking metric
  stage        integer      DEFAULT 1, -- tiebreaker: level reached
  city         text,
  country      text,
  country_code text,                  -- ISO 3166-1 alpha-2 (e.g. 'AR')
  created_at   timestamptz  DEFAULT now()
);

-- Enable Row Level Security
ALTER TABLE leaderboard ENABLE ROW LEVEL SECURITY;

-- Allow anyone to read scores
CREATE POLICY "public read" ON leaderboard
  FOR SELECT USING (true);

-- Allow anyone to submit a score
CREATE POLICY "public insert" ON leaderboard
  FOR INSERT WITH CHECK (true);

-- Useful queries for monitoring
SELECT * FROM leaderboard
ORDER BY score DESC, stage DESC, created_at ASC
LIMIT 100;

3The LeaderboardManager

All leaderboard logic lives in a single file: LeaderboardManager.js. It handles three things: generating a persistent anonymous player identity, looking up the player's location by IP, and reading/writing scores to Supabase.

javascriptsrc/LeaderboardManager.js
// ── Config — replace with your project values ─────────────────
const SUPABASE_URL = 'https://YOUR_PROJECT.supabase.co'
const SUPABASE_KEY = 'your_anon_key_here'
const TABLE        = 'leaderboard'

// ── Player Identity ───────────────────────────────────────────
// Generates a UUID once per device and stores it in localStorage.
// This gives each player a persistent identity across sessions
// without requiring login or any account system.
function getPlayerId() {
  let id = localStorage.getItem('kp_player_id')
  if (!id) {
    id = crypto.randomUUID()
    localStorage.setItem('kp_player_id', id)
  }
  return id
}

// ── Geolocation ───────────────────────────────────────────────
// Looks up city/country from IP address. Result cached in
// localStorage so we only make the API call once per device.
async function getGeo() {
  const cached = localStorage.getItem('kp_geo')
  if (cached) return JSON.parse(cached)
  try {
    const res  = await fetch('https://ipapi.co/json/')
    const data = await res.json()
    const geo  = { city: data.city, country: data.country_name, code: data.country_code }
    localStorage.setItem('kp_geo', JSON.stringify(geo))
    return geo
  } catch { return { city: '', country: '', code: '' } }
}

// ── Submit Score ──────────────────────────────────────────────
export async function submitScore({ initials, score, stage = 1 }) {
  const [player_id, geo] = await Promise.all([getPlayerId(), getGeo()])
  const body = JSON.stringify({
    player_id, initials, score, stage,
    city: geo.city, country: geo.country, country_code: geo.code
  })
  await fetch(`${SUPABASE_URL}/rest/v1/${TABLE}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'apikey': SUPABASE_KEY,
      'Authorization': `Bearer ${SUPABASE_KEY}`,
      'Prefer': 'return=minimal'
    },
    body
  })
}

// ── Fetch Top Scores ──────────────────────────────────────────
export async function getTopScores(limit = 10) {
  const url = `${SUPABASE_URL}/rest/v1/${TABLE}` +
    `?select=*&order=score.desc,stage.desc,created_at.asc&limit=${limit}`
  const res = await fetch(url, {
    headers: { 'apikey': SUPABASE_KEY, 'Authorization': `Bearer ${SUPABASE_KEY}` }
  })
  return res.json()
}
🔑
Is it safe to put the API key in client code?

Yes — the Supabase anon key is designed to be public. It identifies your project but grants only the permissions your Row Level Security policies allow. With the policies above (public SELECT and INSERT, nothing else), the worst a bad actor can do is submit fake scores. For a game leaderboard, that is an acceptable tradeoff. Never put your service_role key in client code — that one bypasses RLS entirely.

4Preventing Free-Tier Pauses

Supabase's free tier automatically pauses projects after 7 consecutive days with zero API activity. When paused, your leaderboard goes offline until you manually resume it in the dashboard. For a game that might not be played every day, this is a real problem.

The solution is UptimeRobot — a free monitoring service that pings your Supabase REST endpoint every 5 minutes. Each ping counts as API activity, so the 7-day inactivity timer never triggers. UptimeRobot's free tier includes 50 monitors with 5-minute intervals.

UptimeRobot Keepalive — How It Works
UptimeRobot every 5 minutes HTTP GET Supabase REST counts as activity resets timer Always On never pauses 7-day inactivity timer (never reaches 7)
textUptimeRobot monitor URL format
https://YOUR_PROJECT.supabase.co/rest/v1/leaderboard?select=id&limit=1&apikey=YOUR_ANON_KEY

Monitor type:    HTTP(S)
Interval:        Every 5 minutes
Alert on down:   Yes (email)

5Displaying the Leaderboard In-Game

The leaderboard display is a DOM overlay that appears on the game-over or win screen. Fetch the top 10 scores, render them as a styled HTML table, and add a country flag emoji based on the country code. The entire render function is about 30 lines.

javascriptrendering the leaderboard — example
import { getTopScores } from './LeaderboardManager.js'

export async function renderLeaderboard(containerEl) {
  const scores = await getTopScores(10)
  const rows = scores.map((entry, i) => {
    // Country code → flag emoji (e.g. 'AR' → 🇦🇷)
    const flag = entry.country_code
      ? [...entry.country_code.toUpperCase()]
          .map(c => String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65))
          .join('')
      : '🌐'
    return `<tr class="${i === 0 ? 'top' : ''}">
      <td>${i + 1}</td>
      <td>${entry.initials}</td>
      <td>${entry.score.toLocaleString()}</td>
      <td>${flag} ${entry.city || entry.country || '—'}</td>
    </tr>`
  }).join('')

  containerEl.innerHTML = `
    <table class="lb-table">
      <thead><tr><th>#</th><th>NAME</th><th>SCORE</th><th>LOCATION</th></tr></thead>
      <tbody>${rows}</tbody>
    </table>
  `
}
🏳️
Country Flag Trick

Country flag emojis are generated from ISO 3166-1 alpha-2 country codes using Unicode regional indicator symbols. The code 'AR' becomes 🇦🇷, 'DE' becomes 🇩🇪, and so on. No image assets needed — they are just Unicode characters that every modern OS renders as flag emojis.

6Ranking Logic

The ranking query uses a multi-column sort that handles ties cleanly. Primary sort is score descending (higher score = better rank). Ties are broken by stage or level reached. Remaining ties — two players with identical scores who reached the same level — go to the earlier submission, which rewards players who achieved the score first.

sqlranking query
SELECT
  initials,
  score,
  stage,
  city,
  country_code,
  created_at,
  -- Compute rank position including ties
  RANK() OVER (
    ORDER BY score DESC, stage DESC, created_at ASC
  ) AS rank
FROM leaderboard
LIMIT 100;