Docs

Add PuzzParty to your game

PuzzParty gives your web game a player account and the player's friends, with one script tag. No auth server, no passwords, and it works on static sites.

PuzzParty gives you

  • Who the player is: a stable ID, username, name and photo
  • Their friends who also play your game
  • The sign-in flow and the “Continue with PuzzParty” button

Your game builds

  • Scores, streaks and progress, in your own storage
  • Leaderboards, challenges and anything social
  • The game itself!

You'll need a client ID for your game. We onboard games personally: tell us about yours and send us the URLs players will return to after signing in. We'll give you one client ID for production and one for testing on localhost.

Quick start

Add this to your game's page. Replace YOUR_CLIENT_ID with your client ID.

<script src="https://puzzparty.com/sdk/v1/puzzparty.js"></script>

<div id="login"></div>

<script>
  const puzzparty = PuzzParty.init({ clientId: "YOUR_CLIENT_ID" });

  puzzparty.onChange((player) => {
    const login = document.getElementById("login");
    login.replaceChildren();
    if (player) {
      login.textContent = `Playing as @${player.username}`;
    } else {
      puzzparty.renderButton(login);
    }
  });

  puzzparty.handleRedirect().catch(console.error);
</script>

That's it. Players tap Continue with PuzzParty, sign in with a code sent to their email, approve your game once, and come back signed in. On later visits they're already signed in.

  • Call handleRedirect() on every page load, on every page players return to. It finishes sign-in when they come back, and reports who's signed in otherwise.
  • onChange runs once the SDK knows who's playing, and again whenever they sign in or out.
  • Players who tap Cancel come back signed out. That's not an error, and your game should keep working for them.

Button options

// Default: dark, for light backgrounds.
puzzparty.renderButton("#login");

// Light, for dark backgrounds. Also: size "small" or "icon", and label "Sign in with PuzzParty".
puzzparty.renderButton("#login", { theme: "light", size: "small" });

Buttons

Use the official button so players recognise it across games. renderButton() draws it for you, or place it yourself with the kit below.

HTML

Add the classes puzzparty-btn--light, puzzparty-btn--small or puzzparty-btn--icon for the other styles.

<button type="button" class="puzzparty-btn" id="puzzparty-signin">
  <svg class="puzzparty-btn-mark" viewBox="0 0 22 22" aria-hidden="true">
    <rect width="10" height="10" rx="3" fill="#e2d2ff"/><rect x="12" width="10" height="10" rx="3" fill="#ffd9b8"/>
    <rect y="12" width="10" height="10" rx="3" fill="#c8ecd3"/><rect x="12" y="12" width="10" height="10" rx="3" fill="#c6e1ff"/>
  </svg>
  Continue with PuzzParty
</button>

<script>
  document.getElementById("puzzparty-signin").onclick = () => puzzparty.signIn();
</script>

CSS

.puzzparty-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  height: 48px;
  padding: 0 22px 0 16px;
  border: 0;
  border-radius: 999px;
  background: #1d1d1f;
  color: #fff;
  font: 600 16px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  letter-spacing: -0.01em;
  white-space: nowrap;
  cursor: pointer;
  transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.2s;
}
.puzzparty-btn:active { transform: scale(0.97); }
.puzzparty-btn:disabled { opacity: 0.6; cursor: default; }
.puzzparty-btn:focus-visible { outline: 3px solid #5e5ce6; outline-offset: 2px; }
.puzzparty-btn-mark { width: 20px; height: 20px; flex: none; }

/* Light: for dark backgrounds */
.puzzparty-btn--light { background: #fff; color: #1d1d1f; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); }

/* Small: for toolbars and tight spaces */
.puzzparty-btn--small { height: 36px; gap: 8px; padding: 0 16px 0 12px; font-size: 14px; }
.puzzparty-btn--small .puzzparty-btn-mark { width: 16px; height: 16px; }

/* Icon only: add aria-label="Continue with PuzzParty" */
.puzzparty-btn--icon { width: 48px; padding: 0; }
  • Keep the label as “Continue with PuzzParty” or “Sign in with PuzzParty”, and the colours and logo as they are.
  • Use dark on light backgrounds, and light on dark ones.
  • Icon-only buttons need aria-label="Continue with PuzzParty", so screen readers can announce them.

What you get

getUser() and each entry of getFriends() return a player:

{
  "id": "0b6f2c1e-8d7a-4f5e-9c3b-2a1d4e5f6a7b",
  "username": "manav",
  "display_name": "Manav",
  "avatar_url": "https://…/avatar.webp"
}
FieldNotes
idNever changes. Use it as the player's key in your own data.
usernameUnique and safe to show publicly, e.g. on global leaderboards. Players can change it, so don't use it as a key.
display_nameCan be null. May be a real name: show it only to the player and their friends.
avatar_urlCan be null. May be a real photo: same rule as the name.

Every player also has a public friend link, https://puzz.party/<username>, where anyone can send them a friend request. Link to it from your game, for example “Add @manav as a friend”, to help players connect.

Already have player accounts?

Keep them. Players sign in to your game as they do today, then connect PuzzParty (for example, from your settings page) to bring their friends in. Your server links the two accounts after checking both sign-ins, so nobody can connect someone else's account.

This example uses Supabase Auth for your game's accounts. With Firebase, Auth0 or your own login, only step 2's “who's signed in to your game?” check changes.

1. Store the PuzzParty ID on your existing profiles:

-- In your game's database: remember each player's PuzzParty ID on their existing profile.
alter table public.profiles
  add column puzzparty_user_id uuid unique;

2. A function that links the accounts:

// supabase/functions/connect-puzzparty/index.ts (in your game's Supabase project)
import { createClient } from "npm:@supabase/supabase-js@2";

const PUZZPARTY_API = Deno.env.get("PUZZPARTY_API_URL") ?? "https://api.puzzparty.com";
const cors = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, apikey, content-type, x-client-info",
};

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: cors });
  const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);

  // 1. Who's signed in to your game? functions.invoke() sends their session automatically.
  const gameToken = (req.headers.get("Authorization") ?? "").replace(/^Bearer /, "");
  const { data: { user } } = await admin.auth.getUser(gameToken);
  if (!user) return Response.json({ error: "not_signed_in" }, { status: 401, headers: cors });

  // 2. Who are they on PuzzParty?
  const { puzzparty_token } = await req.json();
  const me = await fetch(`${PUZZPARTY_API}/v1/me`, {
    headers: { Authorization: `Bearer ${puzzparty_token}` },
  });
  if (!me.ok) return Response.json({ error: "invalid_puzzparty_token" }, { status: 401, headers: cors });
  const player = await me.json();

  // 3. Save the link. The unique column stops two of your players claiming one PuzzParty account.
  const { error } = await admin
    .from("profiles")
    .update({ puzzparty_user_id: player.id })
    .eq("id", user.id);
  if (error?.code === "23505") {
    return Response.json({ error: "already_connected_elsewhere" }, { status: 409, headers: cors });
  }
  if (error) return Response.json({ error: "connect_failed" }, { status: 500, headers: cors });

  return Response.json({ connected: true }, { headers: cors });
});

3. The connect button, with <div id="connect-puzzparty"></div> on your settings page:

// On your game's settings page, for players signed in to your own accounts:
// show the button, then link the accounts when they come back from PuzzParty.
puzzparty.renderButton("#connect-puzzparty", { size: "small" });

puzzparty.onChange(async (player) => {
  if (!player) return;
  const { data: { session } } = await game.auth.getSession();
  if (!session) return; // not signed in to your game

  const puzzparty_token = await puzzparty.getAccessToken();
  const { error } = await game.functions.invoke("connect-puzzparty", { body: { puzzparty_token } });
  if (error) console.error("Couldn't connect PuzzParty", error);
});

puzzparty.handleRedirect().catch(console.error);

4. Use friends in your game. Match PuzzParty friends to your own players:

// Turn PuzzParty friends into your own players, e.g. for a friends leaderboard.
async function friendsInMyGame() {
  const friends = await puzzparty.getFriends();
  const { data, error } = await game
    .from("profiles")
    .select("id, puzzparty_user_id")
    .in("puzzparty_user_id", friends.map((f) => f.id));
  if (error) throw error;
  return data; // your own profile rows for friends who've connected PuzzParty
}

Recipes

Copy-paste examples of what you can build. They use a puzzparty object from the quick start, and are written in plain JavaScript that works on any page.

Player badge

Show who's playing, with their photo and a way to sign out. Add <div id="player"></div> to your page. The avatar() helper is reused by the other recipes.

// A round photo, or the first letter of their name when there's no photo.
function avatar(person, size = 32) {
  if (person.avatar_url) {
    const img = document.createElement("img");
    img.src = person.avatar_url;
    img.alt = "";
    img.width = img.height = size;
    img.style.borderRadius = "50%";
    return img;
  }
  const initial = document.createElement("span");
  initial.textContent = (person.display_name || person.username)[0].toUpperCase();
  initial.style.cssText = `display:inline-grid;place-items:center;width:${size}px;height:${size}px;` +
    "border-radius:50%;background:#e2d2ff;color:#1d1d1f;font-weight:700";
  return initial;
}
function renderPlayer(player) {
  const el = document.getElementById("player");
  el.replaceChildren();

  if (!player) {
    puzzparty.renderButton(el);
    return;
  }

  const name = document.createElement("strong");
  name.textContent = player.display_name || player.username;

  const handle = document.createElement("span");
  handle.textContent = ` @${player.username} · `;

  const signOut = document.createElement("button");
  signOut.textContent = "Sign out";
  signOut.onclick = () => puzzparty.signOut();

  el.append(avatar(player), " ", name, handle, signOut);
}

puzzparty.onChange(renderPlayer);
puzzparty.handleRedirect().catch(console.error);

Friends who play

“Maya, Jo, Sam and 2 more play this”, with their photos. Add <div id="friends"></div>.

async function renderFriends() {
  const el = document.getElementById("friends");
  const friends = await puzzparty.getFriends();

  if (friends.length === 0) {
    el.textContent = "None of your friends play yet. Invite them!";
    return;
  }

  const names = friends.slice(0, 3).map((f) => f.display_name || f.username);
  const others = friends.length - names.length;
  const who = others > 0 ? `${names.join(", ")} and ${others} more` : names.join(", ");

  el.replaceChildren(...friends.slice(0, 5).map((f) => avatar(f, 28)));
  el.append(` ${who} ${friends.length === 1 ? "plays" : "play"} this`);
}

puzzparty.onChange((player) => {
  if (player) renderFriends();
});

Only friends who have also connected your game are included. Friends who haven't played it yet stay private.

Friends leaderboard

Today's scores from the player and their friends. PuzzParty tells you who the friends are. Your game stores the scores. This recipe uses a Supabase project for the game's own data, but any backend works the same way.

1. A table for scores, keyed by PuzzParty ID:

-- In your game's database: one score per player per day.
create table public.daily_scores (
  puzzparty_user_id uuid not null,
  day date not null,
  score int not null,
  primary key (puzzparty_user_id, day)
);

alter table public.daily_scores enable row level security;

-- Anyone can read scores (they're keyed by an ID, not a name).
create policy "Scores are readable" on public.daily_scores
  for select using (true);

-- No insert/update policies: only the save-score function below writes scores.

-- New Supabase projects don't expose tables automatically, so grant access explicitly.
grant select on public.daily_scores to anon, authenticated;
grant select, insert, update on public.daily_scores to service_role;

2. A function that saves scores, checking the player with PuzzParty first so nobody can post scores as someone else:

// supabase/functions/save-score/index.ts (in your game's Supabase project)
import { createClient } from "npm:@supabase/supabase-js@2";

const PUZZPARTY_API = Deno.env.get("PUZZPARTY_API_URL") ?? "https://api.puzzparty.com";

const cors = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, apikey, content-type, x-client-info",
};

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: cors });

  const { puzzparty_token, score } = await req.json();
  if (!puzzparty_token || !Number.isInteger(score) || score < 0 || score > 1000000) {
    return Response.json({ error: "invalid_request" }, { status: 400, headers: cors });
  }

  // Ask PuzzParty who this token belongs to. Never trust an ID sent by the browser.
  const me = await fetch(`${PUZZPARTY_API}/v1/me`, {
    headers: { Authorization: `Bearer ${puzzparty_token}` },
  });
  if (!me.ok) return Response.json({ error: "not_signed_in" }, { status: 401, headers: cors });
  const player = await me.json();

  const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
  const day = new Date().toISOString().slice(0, 10);
  const { error } = await admin
    .from("daily_scores")
    .upsert({ puzzparty_user_id: player.id, day, score });
  if (error) return Response.json({ error: "save_failed" }, { status: 500, headers: cors });

  return Response.json({ saved: true }, { headers: cors });
});

Deploy it with supabase functions deploy save-score --no-verify-jwt. Players don't need an account in your game, so the function checks them with PuzzParty itself.

3. Save a score when a round ends:

// `game` is your game's own Supabase client:
// const game = supabase.createClient("https://YOUR-PROJECT.supabase.co", "YOUR_PUBLISHABLE_KEY");

async function saveScore(score) {
  const puzzparty_token = await puzzparty.getAccessToken();
  if (!puzzparty_token) return; // signed out: keep the score on this device only

  const { error } = await game.functions.invoke("save-score", {
    body: { puzzparty_token, score },
  });
  if (error) console.error("Couldn't save score", error);
}

4. Show the leaderboard. Add <ol id="leaderboard"></ol>:

async function renderLeaderboard() {
  const me = await puzzparty.getUser();
  if (!me) return;

  const friends = await puzzparty.getFriends();
  const people = new Map([me, ...friends].map((p) => [p.id, p]));

  const today = new Date().toISOString().slice(0, 10);
  const { data: scores, error } = await game
    .from("daily_scores")
    .select("puzzparty_user_id, score")
    .eq("day", today)
    .in("puzzparty_user_id", [...people.keys()])
    .order("score", { ascending: false });
  if (error) throw error;

  const list = document.getElementById("leaderboard");
  list.replaceChildren(...scores.map((row, i) => {
    const person = people.get(row.puzzparty_user_id);
    const li = document.createElement("li");
    li.textContent = `${i + 1}. ${person.display_name || person.username} · ${row.score}`;
    if (person.id === me.id) li.style.fontWeight = "700";
    return li;
  }));
}

Challenge a friend

Let players share a “beat my score” link. Add <p id="challenge"></p> where the challenge should appear.

// Share a link that opens your game with a challenge from this player.
async function shareChallenge(score) {
  const me = await puzzparty.getUser();
  const url = new URL(location.origin + location.pathname);
  url.searchParams.set("challenge", me.username);
  url.searchParams.set("score", score);

  const text = `I scored ${score} today. Can you beat me?`;
  if (navigator.share) await navigator.share({ text, url: url.href });
  else await navigator.clipboard.writeText(`${text} ${url.href}`);
}

// When someone opens the link, show the challenge.
// Remember it, because signing in returns to the page without its query string.
const params = new URLSearchParams(location.search);
if (params.has("challenge")) {
  sessionStorage.setItem("challenge", JSON.stringify({
    from: params.get("challenge"),
    score: Number(params.get("score")),
  }));
}
const challenge = JSON.parse(sessionStorage.getItem("challenge") || "null");
if (challenge) {
  document.getElementById("challenge").textContent =
    `@${challenge.from} scored ${challenge.score}. Your turn!`;
}

If the friend isn't on PuzzParty yet, send them the player's friend link as well (https://puzz.party/${me.username}), so they can add each other after they play.

Keep progress from before sign-in

If your game already saves results under an anonymous ID in localStorage, link that ID to the player's PuzzParty ID when they sign in, so their history comes with them. As with scores, your server checks the player with PuzzParty before trusting the ID.

-- In your game's database: which anonymous player IDs belong to which PuzzParty player.
create table public.puzzparty_links (
  anonymous_id      uuid primary key,  -- the ID your game kept in localStorage
  puzzparty_user_id uuid not null,
  linked_at         timestamptz not null default now()
);
create index on public.puzzparty_links (puzzparty_user_id);

alter table public.puzzparty_links enable row level security;
-- No policies: only the link-puzzparty function below writes to it.
grant select, insert, update on public.puzzparty_links to service_role;
// supabase/functions/link-puzzparty/index.ts (in your game's Supabase project)
import { createClient } from "npm:@supabase/supabase-js@2";

const PUZZPARTY_API = Deno.env.get("PUZZPARTY_API_URL") ?? "https://api.puzzparty.com";
const cors = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, apikey, content-type, x-client-info",
};

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response(null, { headers: cors });

  const { anonymous_id, puzzparty_token } = await req.json();
  const me = await fetch(`${PUZZPARTY_API}/v1/me`, {
    headers: { Authorization: `Bearer ${puzzparty_token}` },
  });
  if (!anonymous_id || !me.ok) {
    return Response.json({ error: "invalid_request" }, { status: 400, headers: cors });
  }
  const player = await me.json();

  const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
  const { error } = await admin
    .from("puzzparty_links")
    .upsert({ anonymous_id, puzzparty_user_id: player.id });
  if (error) return Response.json({ error: "link_failed" }, { status: 500, headers: cors });

  return Response.json({ puzzparty_user_id: player.id }, { headers: cors });
});
puzzparty.onChange(async (player) => {
  if (!player) return;
  const anonymous_id = localStorage.getItem("my_game_player_id");
  if (!anonymous_id) return;

  const puzzparty_token = await puzzparty.getAccessToken();
  await game.functions.invoke("link-puzzparty", { body: { anonymous_id, puzzparty_token } });
});

One player can have several anonymous IDs (one per device or browser), all linked to the same PuzzParty ID. To get their full history, join your results through puzzparty_links.

SDK reference

PuzzParty.init(options)

OptionDefaultNotes
clientIdrequiredYour game's client ID.
redirectUricurrent page, without its query stringWhere players return after signing in. Must exactly match a URL registered for your game.

Methods

MethodReturnsNotes
handleRedirect()Promise<Player | null>Call on every page load. Safe to call more than once.
onChange(fn)unsubscribe functionfn(player | null) runs on the first known state and on every sign-in or sign-out.
renderButton(target, options)HTMLButtonElementtarget is an element or selector. theme: "dark" (default) or "light". size: "regular" (default), "small" or "icon". label: "Continue with PuzzParty" (default) or "Sign in with PuzzParty".
signIn()Promise<void>Starts sign-in. The page navigates to PuzzParty.
getUser()Promise<Player | null>Cached after the first call.
getFriends()Promise<Player[]>Friends who also connected your game, sorted by username. [] when signed out.
isSignedIn()booleanWhether this browser has a PuzzParty session for your game.
getAccessToken()Promise<string | null>For your server to verify the player (see the recipes). Refreshed automatically.
signOut()voidSigns the player out of your game only. They stay signed in to PuzzParty.

If a player disconnects your game from their PuzzParty account, the SDK signs them out and onChange fires with null.

Pin a version

/sdk/v1/ always serves the latest v1 release, so you get fixes automatically. To pin an exact version instead, use its fixed URL with an integrity hash. The browser then refuses the file if it's ever modified:

<script src="https://puzzparty.com/sdk/v1.1.0/puzzparty.js"
        integrity="sha384-GKG9cP2MZId8BkqSS+sD7DpFrqjnlM633bxizts8OpIB8UWEnr4SaC9GTrO2YpQf"
        crossorigin="anonymous"></script>

REST API

The SDK calls these for you. Call them directly from your server to check who a player is.

Base URL: https://api.puzzparty.com · Send the player's access token as Authorization: Bearer … · Any origin may call it (CORS *) · OpenAPI spec

GET /v1/me

The signed-in player. Returns a player object.

curl https://api.puzzparty.com/v1/me \
  -H "Authorization: Bearer PLAYER_ACCESS_TOKEN"

GET /v1/me/friends

The player's friends who also connected your game, sorted by username.

{
  "friends": [
    { "id": "7c1d9e2f-…", "username": "bob", "display_name": "Bob", "avatar_url": null }
  ]
}

Errors

{ "error": "game_not_connected" }
StatuserrorMeaning
401missing_tokenNo bearer token sent.
401invalid_tokenExpired or not a PuzzParty token. Refresh it and retry once.
403game_not_connectedThe player disconnected your game, or your game isn't verified yet. Treat them as signed out.
404profile_not_foundThe account was deleted. Treat them as signed out.
404 / 405not_found / method_not_allowedUnknown path, or not a GET.

Signing in without the SDK

Games with their own server, or native games, can use any standard OAuth 2.1 library, then call the REST API with the access token.

Discoveryhttps://api.puzzparty.com/.well-known/oauth-authorization-server
Authorizehttps://auth.puzzparty.com/auth/v1/oauth/authorize
Tokenhttps://api.puzzparty.com/oauth/token
Revokehttps://api.puzzparty.com/oauth/revoke
FlowAuthorization code with PKCE (S256), no client secret, on servers too
Scopesopenid profile
  • Access tokens (ppa_…) last an hour and only work on the PuzzParty API. There's no ID token: call GET /v1/me for the player.
  • Each refresh returns a new refresh token (ppr_…). Save it and drop the old one: an old refresh token used again ends that player's sign-in.
  • Refresh tokens expire after 90 days without use, or when the player disconnects your game.

Testing

  • Use your testing client ID while developing on localhost, and your production client ID on your real site. Each only works with the URLs registered for it.
  • Sign in with your own PuzzParty account, and ask a friend to connect your game too, so getFriends() has someone to return.
  • If your game writes to a production database while you test, remember those rows will point at your real players.

Rules

The short version of our developer terms:

  • Use PuzzParty data only for features inside your game. Don't sell or share it, use it for ads, or combine it with other games' data.
  • Show names and photos only to the player and their friends. Use usernames for anything public.
  • Check players on your server with GET /v1/me before trusting an ID, and keep tokens private (no logs, no URLs).
  • When a player disconnects your game or deletes their account (you'll get a 403 or 404), delete what you got from PuzzParty within 30 days.
  • Use the official button, from renderButton() or the button kit, without changing its look.

Going live checklist

  • Your production client ID is in PuzzParty.init.
  • Every page players return to is registered with us, exactly (including https and trailing slashes), and calls handleRedirect().
  • Your server checks tokens with GET /v1/me before trusting a PuzzParty ID.
  • Players who cancel, sign out or disconnect your game can still play.
  • Optional: pin the SDK version with an integrity hash.

Troubleshooting

PuzzParty shows an error instead of the sign-in screen
The page's URL isn't registered for your client ID. The URL must match exactly, and by default it's the current page without its query string. Ask us to add it, or set redirectUri.
Players come back but aren't signed in
The page they return to must call handleRedirect() when it loads.
getFriends() is empty
Friends only appear once they've accepted the friend request and connected your game themselves.
Safari players are signed out after a while away
Safari clears a site's storage after about 7 days without a visit. Players tap Continue again (usually one tap, with no code) and they're back.
It works on localhost but not on my site
Check you're using the production client ID there, and that the page's exact URL is registered.

Still stuck? Email support@puzzparty.com.