A front on hub-core
@filudi/hub-core is public on npmjs.com under the MIT licence. It is the package filudi.com is built on: the
mechanics of a site front, with no appearance. You draw every screen.
Install
Section titled “Install”pnpm add -E @filudi/hub-core preactpnpm add -E @filudi/hub-core preact# Types ship with the package. `preact` is a peer, needed for the `/preact` hooks only.-E pins the exact version. The package is ESM with four entry points: @filudi/hub-core,
@filudi/hub-core/preact (hooks for islands), @filudi/hub-core/cmp (a TCF 2.2 consent manager) and
@filudi/hub-core/tokens.css. Import tokens.css once in your stylesheet: it defines the colours, type scale,
spacing and radii as CSS variables, with a light theme under [data-theme='light'].
Read the catalog
Section titled “Read the catalog”fetchCatalog(apiUrl, site, { locale }) is a typed GET /v1/catalog/{site}. It answers the site row, its
categories and tags, and the games the site lists and shows, in site order, with texts per locale, media and
rating. The helpers read it: textsFor(game, locale), navCategories(catalog), tagsOf(catalog),
filterGames(...) for category pages, search(...) for accent-insensitive search.
import { fetchCatalog, textsFor } from '@filudi/hub-core';
const API = 'https://api.filudi.com';const SITE = 'string'; // your site's slug
const catalog = await fetchCatalog(API, SITE, { locale: 'en' });for (const game of catalog.games) { const texts = textsFor(game, 'en'); console.log(game.slug, texts.title, game.bundleUrl);}import { fetchCatalog, textsFor, type Catalog, type CatalogGame } from '@filudi/hub-core';
const API = 'https://api.filudi.com';const SITE = 'string'; // your site's slug
const catalog: Catalog = await fetchCatalog(API, SITE, { locale: 'en' });const playable: CatalogGame[] = catalog.games.filter((game) => game.bundleUrl !== null);for (const game of playable) console.log(game.slug, textsFor(game, 'en').title);A game with bundleUrl: null has no live version: show it without a play button, or leave it out.
Keep the player
Section titled “Keep the player”PlayerClient.for(apiUrl, site) is one instance per page. ensure() creates a guest on the first visit and
keeps the token in localStorage under filudi:player:<site>. The same object signs the player in by magic
link, by the code of that email or by password, sets the nickname, lists the saves, rates, comments and sends
feedback. A 401 forgets the token and the next call creates a new guest.
import { PlayerClient } from '@filudi/hub-core';
const player = PlayerClient.for(API, SITE);await player.ensure();
await player.setNickname('Pebble');await player.requestMagicLink(email, '/account/');// On /account/verify?t=<token>:const { merged, redirect } = await player.consumeMagicLink(token);import { ApiError, PlayerClient } from '@filudi/hub-core';
const player = PlayerClient.for(API, SITE);await player.ensure();
try { await player.signIn(email, password);} catch (error) { if (error instanceof ApiError && error.status === 401) showWrongPassword(); else throw error;}The sign-in email links to https://<your first domain>/account/verify?t=<token>, and to
/<locale>/account/verify for a locale other than your first. You build those pages; they call
consumeMagicLink(t). Serve them with Cache-Control: private, no-store. The email also shows a 6-digit code,
for a link that opens in another browser: offer a code field on the page that asked for the link, and call
consumeCode(email, code).
Run a game
Section titled “Run a game”GameFrame creates the sandboxed iframe and runs the parent side of the handshake: it answers filudi:ready
with filudi:init, exchanges the player token for a game token through gameSession, refreshes that token
every 12 hours, forwards consent and locale, and reports the frame state. It draws nothing: you draw the
loading, unavailable and offline screens from frame.state.
import { fetchCatalog, framePolicy, GameFrame, PlayerClient, textsFor } from '@filudi/hub-core';
const NO_CONSENT = { ads: false, analytics: false };const catalog = await fetchCatalog(API, SITE, { locale: 'en' });const game = catalog.games.find((entry) => entry.slug === 'gloop');const player = PlayerClient.for(API, SITE);await player.ensure();
const frame = new GameFrame(document.querySelector('#game'), { game: game.slug, bundleUrl: game.bundleUrl, apiUrl: API, site: SITE, locale: 'en', policy: framePolicy(catalog.site.policy, NO_CONSENT), consent: NO_CONSENT, gameVersion: game.version ?? '', gameSession: (slug) => player.gameSession(slug), renewPlayer: async () => { player.signOut(); await player.ensure(); }, title: textsFor(game, 'en').title, orientation: game.orientation,});frame.on('state', ({ state }) => { // loading, ready, playing, unavailable, offline});import { useRef } from 'preact/hooks';import { framePolicy, PlayerClient, type CatalogGame } from '@filudi/hub-core';import { useGameFrame } from '@filudi/hub-core/preact';
export function Frame({ game, policy }: { game: CatalogGame; policy: Policy }) { const ref = useRef<HTMLDivElement>(null); const player = PlayerClient.for(API, SITE); const consent = { ads: false, analytics: false }; const { state, loadStartedAt, retry, fullscreen } = useGameFrame(ref, { game: game.slug, bundleUrl: game.bundleUrl, apiUrl: API, site: SITE, locale: 'en', policy: framePolicy(policy, consent), consent, gameVersion: game.version ?? '', gameSession: (slug) => player.gameSession(slug), isCurrent: (session) => player.isCurrent(session), renewPlayer: async () => { player.signOut(); await player.ensure(); }, title: game.slug, orientation: game.orientation, }); return <div ref={ref} data-state={state} />;}What to know:
- Call
player.ensure()before mounting, so the token exists when the frame asks for the game token. loadingturnsunavailableafter 20 s withoutfiludi:ready. Draw an indeterminate bar fromloadStartedAt; progress is not visible from the parent. Callframe.retry()on a “Try again” button.frame.fullscreen()needs a user gesture. A game’s own request is honoured only when the player just clicked inside it.save,scoreandtrackevents are claims made by the game. Read the real values from the API with the player’s token; never grant anything on an event.adsdefaults toNoAds: every ad request answersunavailable. ImplementAdBridgeto run a network.- Pass the visitor’s real consent once your consent manager has an answer, then
frame.setConsent()on change (useGameFramedoes it).@filudi/hub-core/cmpis a TCF 2.2 CMP if you want one. GameFramesendsframe_loadedto the API when the bundle postsfiludi:loaded, which is the first step of the funnel in your statistics.frame.destroy()when the page goes away.
Your page’s policy
Section titled “Your page’s policy”Your pages send a CSP that lets the frame load and the page call the API, and nothing lets a bundle onto your origin:
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.comGameFrame throws on a bundle URL from the page’s own origin: with allow-same-origin, such a bundle could
read your localStorage.
Test in the playground
Section titled “Test in the playground”The SDK playground of the monorepo (pnpm --filter @filudi/sdk-playground dev, http://localhost:5173)
plays the host around a bundle and logs every SDK call and every host message. Load Gloop! or your own bundle
on the filudi host and read what filudi:init carries and what the game posts back; that is what your front
exchanges with the frame. Its “Mock API” answers /v1/sdk/* in memory, so a front can be built before a key or
an approved domain exists.
SEO and analytics
Section titled “SEO and analytics”seo helpers give you VideoGame, WebSite, CollectionPage and BreadcrumbList JSON-LD, OpenGraph tags,
hreflang links, sitemap.xml, robots.txt and canonical URLs. Analytics sends page-level events (share,
search, fullscreen, feedback sent) to a CausalIT domain through your own proxy, gated by consent when you pass
it. The API counts game_start, account_linked and the other server events itself, so a front never sends
them twice.