A front of your own
Everything hub-core does is a few HTTP calls and a few postMessage calls. This page lists them, in the order a
page runs them, so a front in any language embeds Gloop! with the handshake alone.
1. Read the catalog
Section titled “1. Read the catalog”GET /v1/catalog/{site} is public: no token, no header. ?locale=en narrows the texts to one locale. The
answer carries Cache-Control: public, max-age=300, stale-while-revalidate=600 and an ETag; a 304 has no
body.
curl 'https://api.filudi.com/v1/catalog/filudi?locale=en'const catalog = await (await fetch('https://api.filudi.com/v1/catalog/filudi?locale=en')).json();const gloop = catalog.games.find((game) => game.slug === 'gloop');// gloop.bundleUrl is the iframe address; null means no live version.import requests
catalog = requests.get('https://api.filudi.com/v1/catalog/filudi', params={'locale': 'en'}).json()gloop = next(game for game in catalog['games'] if game['slug'] == 'gloop')bundle_url = gloop['bundleUrl'] # None without a live versionEach game has slug, order, featured, categories, tags, orientation, minutes, mobile, daily,
coverColor, bundleUrl, version, publishedAt, boards, texts per locale, media and rating.
Categories and tags come with their own texts. site.navCategories is the top menu; site.otherSites the
sister sites of the footer. The Catalog schema has every field.
2. Create the player, in the browser
Section titled “2. Create the player, in the browser”A visitor is a guest from the first page. POST /v1/sdk/players { site, locale } answers { player, token }.
Keep the token in localStorage, never in a cookie, a URL or a log; it has no expiry and opens the whole
account. Create it from the browser: the route allows 20 guests per hour per IP, and a server would spend that
on its own address.
curl -X POST 'https://api.filudi.com/v1/sdk/players' \ -H 'content-type: application/json' \ -d '{ "site": "filudi", "locale": "en" }'const KEY = 'filudi:player:filudi';let token = localStorage.getItem(KEY);if (!token) { const response = await fetch('https://api.filudi.com/v1/sdk/players', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: 'filudi', locale: 'en' }), }); token = (await response.json()).token; localStorage.setItem(KEY, token);}# A server does not create guests: every visitor would share its IP.# A backend creates players by externalId with a server key instead:# see "A backend with server keys".A 401 on any later call means the API refused the token: forget it and create a new guest. Sign-in by email
link, code or password goes through /v1/account/* with the same token; the guest’s saves and scores merge
into the account.
3. Exchange it for a game token
Section titled “3. Exchange it for a game token”The frame never sees the player’s token. Before the game asks, exchange it:
POST /v1/sdk/game-tokens { game: '<slug>' } with the player token answers { token, expiresAt }, an fg1
token valid 24 hours for that game only. The API answers 404 unless the game is on your site’s catalog. Do the
exchange when you create the iframe: the game waits 1.5 s for the answer once it says filudi:ready.
curl -X POST 'https://api.filudi.com/v1/sdk/game-tokens' \ -H "authorization: Bearer $FILUDI_PLAYER_TOKEN" \ -H 'content-type: application/json' \ -d '{ "game": "gloop" }'const exchange = fetch('https://api.filudi.com/v1/sdk/game-tokens', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ game: 'gloop' }),}).then((response) => response.json());// exchange resolves to { token: 'fg1....', expiresAt }# From a backend, with a server key and one of your players:response = requests.post( 'https://api.filudi.com/v1/server/game-tokens', headers={'authorization': f'Bearer {site_key}'}, json={'playerId': player_id, 'game': 'gloop'},)game_token = response.json()['token']4. Create the frame
Section titled “4. Create the frame”Use bundleUrl from the catalog as it is. Do not build it from the slug and do not copy the bundle to your
origin. The iframe needs sandbox="allow-scripts allow-same-origin allow-pointer-lock" and
allow="fullscreen".
<iframe id="game" src="https://gloop.<play domain>/gloop/1.4.0/index.html" title="Gloop!" sandbox="allow-scripts allow-same-origin allow-pointer-lock" allow="fullscreen"></iframe>Your page’s CSP must allow the frame and the API:
frame-src https://play.filudi.com https://*.<play domain>connect-src 'self' https://api.filudi.comimg-src 'self' data: https://play.filudi.commedia-src https://play.filudi.com5. The handshake, message by message
Section titled “5. The handshake, message by message”Every message is a postMessage with a type that starts with filudi:. Post to the bundle’s exact origin,
read from new URL(bundleUrl).origin, never to *. Accept a message only when event.source is the frame’s
contentWindow and event.origin is that origin.
From the game to your page:
| Message | Payload | When |
|---|---|---|
filudi:ready | { sdk: '1' } | At sdk.init(). Answer with filudi:init within 1.5 s. The game may repeat it after a reload |
filudi:loaded | {} | The first frame is drawn. Hide your loader. Send frame_loaded to POST /v1/sdk/events with the player token if you want the funnel’s first step |
filudi:gameplay | { running: boolean } | Real play starts or stops |
filudi:fullscreen | { request: true } | The game asks for fullscreen. Call requestFullscreen() on the iframe if the player just clicked inside it, then answer filudi:fullscreen { ok } |
filudi:ad | { id, kind, phase: 'request' } | An ad request. Without an ad network, answer { id, phase: 'result', result: { status: 'unavailable', reason: 'host' } } |
filudi:save | { version } | A save was written. A claim: read saves from the API if you show them |
filudi:score | { board, score, rank } | A score was sent. A claim: the rank that counts is in the API |
filudi:track | { event } | A custom event. A claim; no listener needed |
filudi:unauthorized | {} | The API refused the game token. Exchange a new one and reload the frame, once per load |
From your page to the game:
| Message | Payload | When |
|---|---|---|
filudi:init | see below | In answer to filudi:ready |
filudi:token | { token } | A fresh game token, every 12 hours while the frame lives |
filudi:consent | { consent: { ads, analytics } } | The visitor’s consent changed |
filudi:locale | { locale } | The page locale changed |
filudi:pause, filudi:resume | {} | You cover or uncover the game |
filudi:fullscreen | { ok: boolean } | In answer to the game’s request |
filudi:ad | { id, phase: 'started' }, then { id, phase: 'result', result } | Around an ad you show |
filudi:init carries everything the game needs:
interface FiludiInit { type: 'filudi:init'; apiUrl: string; // https://api.filudi.com token: string; // the fg1 game token, never the player's fp1 gameId: string; // the slug this frame was opened for player: { id: string; nickname: string; guest: boolean }; locale: string; site: string; // your site's slug policy: { personalizedAds: boolean; ageBand: 'all' | 'kids' | 'teen' | 'adult'; accounts: boolean }; consent: { ads: boolean; analytics: boolean }; gameVersion: string; // the catalog's version}player comes from GET /v1/sdk/players/me with the player token. policy.personalizedAds is true only when
the site allows it, the visitor consented and the age band is adult.
const frame = document.querySelector('#game');const origin = new URL(gloop.bundleUrl).origin;const me = await (await fetch('https://api.filudi.com/v1/sdk/players/me', { headers: { authorization: `Bearer ${token}` },})).json();
window.addEventListener('message', async (event) => { if (event.source !== frame.contentWindow || event.origin !== origin) return; const message = event.data; if (message?.type === 'filudi:ready') { const game = await exchange; // { token, expiresAt } from step 3 frame.contentWindow.postMessage( { type: 'filudi:init', apiUrl: 'https://api.filudi.com', token: game.token, gameId: 'gloop', player: { id: me.id, nickname: me.nickname, guest: me.guest }, locale: 'en', site: 'filudi', policy: { personalizedAds: false, ageBand: catalog.site.policy.ageBand, accounts: catalog.site.policy.accountsAllowed }, consent: { ads: false, analytics: false }, gameVersion: gloop.version, }, origin, ); } if (message?.type === 'filudi:loaded') hideLoader(); if (message?.type === 'filudi:ad' && message.phase === 'request') { frame.contentWindow.postMessage( { type: 'filudi:ad', id: message.id, phase: 'result', result: { status: 'unavailable', reason: 'host' } }, origin, ); } if (message?.type === 'filudi:fullscreen' && message.request) { frame.contentWindow.postMessage({ type: 'filudi:fullscreen', ok: false }, origin); }});type FromGame = | { type: 'filudi:ready'; sdk: string } | { type: 'filudi:loaded' } | { type: 'filudi:gameplay'; running: boolean } | { type: 'filudi:fullscreen'; request: true } | { type: 'filudi:ad'; id: string; kind: 'rewarded' | 'interstitial'; phase: 'request' } | { type: 'filudi:save'; version: number } | { type: 'filudi:score'; board: string; score: number; rank: { day: number; week: number; all: number } | null } | { type: 'filudi:track'; event: string } | { type: 'filudi:unauthorized' };
function isFromGame(event: MessageEvent, frame: HTMLIFrameElement, origin: string): event is MessageEvent<FromGame> { return event.source === frame.contentWindow && event.origin === origin && typeof event.data?.type === 'string';}Without filudi:init within 1.5 s the SDK goes standalone: local saves, no scores, no filudi:loaded. A late
filudi:init is ignored. Posting the game token early, from memory, is what keeps the deadline.
6. Keep the token fresh
Section titled “6. Keep the token fresh”A game token lasts 24 hours. While the frame lives, exchange a new one every 12 hours and post
filudi:token { token }; the SDK uses it from its next request, without a reload. On filudi:unauthorized,
exchange once and reload the frame; if the exchange itself answers 401, the API refused the player’s own token:
forget it, create a new guest, start again.
7. Your domains
Section titled “7. Your domains”The frame loads only when your page’s origin is in the bundle’s frame-ancestors, and the API answers your
page only when that origin is allowed. Both lists are the approved domains of approved sites. Until staff
approves yours, build on the development origins (Start here).