Step 1 — Register your game in the database
Before the SDK can create sessions for your game, it must be registered in the Play Hub shared Supabase
project (zbmkhvpokopvhnochcjr). Create a migration file under supabase/migrations/
and apply it once.
snake_case. Season IDs follow
{game_id}_{mode_id}_season_{n}. Keep them stable — they appear in frontend constants,
RPCs, and result records.
-- supabase/migrations/YYYYMMDDHHMMSS_register_my_game.sql
insert into public.games (id, name, description, is_enabled, created_at, updated_at)
values ('my_game', 'My Game', 'A brief description.', true, now(), now())
on conflict (id) do update
set name = excluded.name, description = excluded.description, updated_at = now();
insert into public.game_modes (game_id, id, name, min_players, max_players, is_enabled, created_at, updated_at)
values ('my_game', 'casual', 'Casual', 2, 2, true, now(), now())
on conflict (game_id, id) do update
set is_enabled = true, updated_at = now();
insert into public.leaderboard_seasons (id, game_id, mode_id, name, starts_at, is_active, created_at, updated_at)
values ('my_game_casual_season_1', 'my_game', 'casual', 'Season 1', now(), true, now(), now())
on conflict (id) do update
set is_active = true, updated_at = now();
Apply it to the remote project:
supabase db push --project-ref zbmkhvpokopvhnochcjr
Verify:
select * from public.games where id = 'my_game';
select * from public.game_modes where game_id = 'my_game';
Step 2 — Install the SDK
npm install @mythicalb/sdk
Define your game constants in one place:
// src/playhub.ts
export const GAME_ID = 'my_game'
export const MODE_ID = 'casual'
export const SEASON_ID = 'my_game_casual_season_1'
export const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL
export const SUPABASE_ANON = import.meta.env.VITE_SUPABASE_ANON_KEY
// src/sdk.ts
import { createMythicalSDK } from '@mythicalb/sdk'
import { GAME_ID, MODE_ID, SEASON_ID, SUPABASE_URL, SUPABASE_ANON } from './playhub'
export const mythical = createMythicalSDK({
supabase: { url: SUPABASE_URL, anonKey: SUPABASE_ANON },
game: { id: GAME_ID, modeId: MODE_ID, seasonId: SEASON_ID },
// Optional: add if your game uses Ardor wallets
ardor: { hostOrigin: 'https://store.mythicalbeings.io' },
// Optional: add if your game uses Polygon/MetaMask
polygon: { provider: window.ethereum }
})
Step 3 — Auth & profile
Every player needs a Play Hub profile before creating or joining sessions. Guests can play immediately; they just cannot save scores or claim rewards.
import { mythical } from './sdk'
// Option A: guest (no signup, loses data on session end)
const user = await mythical.auth.signInAsGuest('Guest Player')
console.log(user.isGuest) // true
// Option B: email magic link (persistent account)
await mythical.auth.signInWithEmail('player@example.com')
// → player receives email, clicks link, session is created
const user = await mythical.auth.getUser()
console.log(user.isGuest) // false
// Always create/fetch profile after auth
const profile = await mythical.profile.getOrCreate()
console.log(profile.id, profile.username)
getOrCreate() on every app load after auth is restored.
It is idempotent — safe to call multiple times.
Step 4 — Sessions
Sessions are the core multiplayer unit. One player creates, another joins, both wait for
active status, then the game runs. Finish by recording results.
Host flow
// Create session and share the code with other players
const session = await mythical.sessions.create()
console.log('Share this code:', session.session_code)
// Subscribe to real-time updates
mythical.sessions.subscribe(session.session_id, {
onSessionChange: (s) => {
if (s.status === 'active') startGame()
},
onParticipantsChange: (participants) => {
updateLobbyUI(participants)
}
})
Guest/joiner flow
// Join by code
const session = await mythical.sessions.join('ABC123')
mythical.sessions.subscribe(session.session_id, {
onSessionChange: (s) => {
if (s.status === 'active') startGame()
}
})
Start and finish
// Host marks everyone ready
await mythical.sessions.setReady(session.session_id, true)
// When game ends, record results (non-guests only save to leaderboard)
await mythical.sessions.finish(session.session_id, {
results: [
{ user_id: winnerId, score: 1500, placement: 1 },
{ user_id: loserId, score: 800, placement: 2 }
]
})
Session statuses
| Status | Meaning |
|---|---|
waiting | Created, waiting for players to join |
active | All players ready, game is running |
completed | Game finished, results recorded |
cancelled | Session ended without results |
Step 5 — Leaderboards
Leaderboards are per-game, per-mode, per-season. Guest scores are not recorded.
// Get current season standings
const standings = await mythical.leaderboards.getStandings({
seasonId: SEASON_ID,
limit: 10
})
// [{ rank: 1, profile_id, username, score }, ...]
// Get player's own rank
const myRank = await mythical.leaderboards.getPlayerRank({
seasonId: SEASON_ID,
profileId: profile.id
})
Step 6 — Wallet rewards (optional)
To claim rewards, a player must have an email account and a linked wallet for the reward chain. The SDK handles the wallet linking flow.
Link a wallet
// Polygon (MetaMask or compatible)
await mythical.wallets.connect('polygon')
// Ardor (via Mythical Store embedded wallet)
await mythical.wallets.connect('ardor')
// Check what's linked
const linked = await mythical.wallets.getLinked()
// [{ chain: 'polygon', address: '0x...' }, ...]
Check and claim rewards
const eligibility = await mythical.rewards.canClaim({ chain: 'polygon' })
if (!eligibility.canClaim) {
console.log(eligibility.reason)
// e.g. "email_required" | "wallet_not_linked" | "already_claimed"
return
}
const claim = await mythical.rewards.claim({
chain: 'polygon',
rewardId: 'season_1_top_10'
})
console.log('Claimed:', claim.id)
Step 7 — Game-specific state (optional)
If your game needs to share real-time state beyond what sessions provide (dealt cards, board state, selected units), create a dedicated table prefixed by your game ID.
-- Example: my_game_session_state
create table public.my_game_session_state (
session_id uuid primary key references public.game_sessions(id),
board jsonb,
turn int not null default 1,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- RLS: only session participants can read/write
alter table public.my_game_session_state enable row level security;
create policy "participants only" on public.my_game_session_state
for all using (
exists (
select 1 from public.session_participants sp
where sp.session_id = my_game_session_state.session_id
and sp.user_id = auth.uid()
)
);
Subscribe to state changes via Realtime:
import { supabase } from './supabaseClient'
supabase
.channel(`game-state:${sessionId}`)
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'my_game_session_state',
filter: `session_id=eq.${sessionId}`
}, (payload) => {
applyStateUpdate(payload.new)
})
.subscribe()
Pre-launch checklist
- Migration applied to remote project
zbmkhvpokopvhnochcjr - Verified
gamesandgame_modesrows exist andis_enabled = true - Season created in
leaderboard_seasonswithis_active = true - Frontend constants (
GAME_ID,MODE_ID,SEASON_ID) match the migration playhub_create_sessionsucceeds in production Supabaseplayhub_join_sessionsucceeds with a second user- Session result recorded after finishing
- Guest play tested — guest must NOT appear in leaderboard
- Bot / local training mode does NOT call
playhub_create_sessionor write results - If using wallets: wallet linking tested end-to-end with challenge + signature
- No SQL errors in
supabase db lint
Common errors
| Error | Likely cause | Fix |
|---|---|---|
Game mode is not available. |
games or game_modes row missing, or is_enabled = false |
Run the migration and verify with select * from games where id = '...' |
FunctionsHttpError on wallet link |
Edge function is outdated after a schema change | supabase functions deploy playhub-link-wallet --project-ref zbmkhvpokopvhnochcjr |
| Guest score not saved | Expected — guests are blocked from permanent results | Prompt the player to sign in with email before finishing the session |
Session stuck in waiting |
Test session not cleaned up | Call playhub_leave_session or delete the row in dev |
unique constraint profile_wallets_chain_address_key |
Wallet already linked to another profile | Inform the player — one wallet per chain per account |
| Realtime not firing | Supabase Realtime disabled on the table, or filter wrong | Enable replication on the table in the Supabase dashboard; check the filter syntax |
Account levels reference
| Level | How to reach it | What it unlocks | What is blocked |
|---|---|---|---|
| Guest | auth.signInAsGuest() |
Play, join sessions, try features | Permanent scores, leaderboard, rewards |
| Email account | Magic link via auth.signInWithEmail() |
Persistent profile, leaderboard, session results | On-chain rewards (need wallet) |
| + Ardor wallet | wallets.connect('ardor') after email login |
Ardor asset transfers, Ardor rewards | Polygon rewards |
| + Polygon wallet | wallets.connect('polygon') after email login |
Polygon signatures, Polygon rewards | Ardor rewards |