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. onChangeruns 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" });
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"
}
| Field | Notes |
|---|---|
id | Never changes. Use it as the player's key in your own data. |
username | Unique and safe to show publicly, e.g. on global leaderboards. Players can change it, so don't use it as a key. |
display_name | Can be null. May be a real name: show it only to the player and their friends. |
avatar_url | Can 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)
| Option | Default | Notes |
|---|---|---|
clientId | required | Your game's client ID. |
redirectUri | current page, without its query string | Where players return after signing in. Must exactly match a URL registered for your game. |
Methods
| Method | Returns | Notes |
|---|---|---|
handleRedirect() | Promise<Player | null> | Call on every page load. Safe to call more than once. |
onChange(fn) | unsubscribe function | fn(player | null) runs on the first known state and on every sign-in or sign-out. |
renderButton(target, options) | HTMLButtonElement | target 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() | boolean | Whether 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() | void | Signs 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" }
| Status | error | Meaning |
|---|---|---|
| 401 | missing_token | No bearer token sent. |
| 401 | invalid_token | Expired or not a PuzzParty token. Refresh it and retry once. |
| 403 | game_not_connected | The player disconnected your game, or your game isn't verified yet. Treat them as signed out. |
| 404 | profile_not_found | The account was deleted. Treat them as signed out. |
| 404 / 405 | not_found / method_not_allowed | Unknown 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.
| Discovery | https://api.puzzparty.com/.well-known/oauth-authorization-server |
| Authorize | https://auth.puzzparty.com/auth/v1/oauth/authorize |
| Token | https://api.puzzparty.com/oauth/token |
| Revoke | https://api.puzzparty.com/oauth/revoke |
| Flow | Authorization code with PKCE (S256), no client secret, on servers too |
| Scopes | openid profile |
- Access tokens (
ppa_…) last an hour and only work on the PuzzParty API. There's no ID token: callGET /v1/mefor 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/mebefore 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
httpsand trailing slashes), and callshandleRedirect(). - Your server checks tokens with
GET /v1/mebefore 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
localhostbut 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.