# A front on hub-core

Install @filudi/hub-core, read the catalog with fetchCatalog, run a game with GameFrame, keep the player with PlayerClient, style with tokens.css, test in the playground.

`@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

<CodePanel title="Install">
  <Fragment slot="javascript">

    ```sh
    pnpm add -E @filudi/hub-core preact
    ```

  </Fragment>
  <Fragment slot="typescript">

    ```sh
    pnpm add -E @filudi/hub-core preact
    # Types ship with the package. `preact` is a peer, needed for the `/preact` hooks only.
    ```

  </Fragment>
</CodePanel>

`-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

`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.

<CodePanel title="Catalog">
  <Fragment slot="javascript">

    ```js
    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);
    }
    ```

  </Fragment>
  <Fragment slot="typescript">

    ```ts
    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);
    ```

  </Fragment>
</CodePanel>

A game with `bundleUrl: null` has no live version: show it without a play button, or leave it out.

## 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.

<CodePanel title="Player">
  <Fragment slot="javascript">

    ```js
    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);
    ```

  </Fragment>
  <Fragment slot="typescript">

    ```ts
    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;
    }
    ```

  </Fragment>
</CodePanel>

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

`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`.

<CodePanel title="GameFrame">
  <Fragment slot="javascript">

    ```js
    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
    });
    ```

  </Fragment>
  <Fragment slot="typescript">

    ```tsx
    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} />;
    }
    ```

  </Fragment>
</CodePanel>

What to know:

- Call `player.ensure()` before mounting, so the token exists when the frame asks for the game token.
- `loading` turns `unavailable` after 20 s without `filudi:ready`. Draw an indeterminate bar from
  `loadStartedAt`; progress is not visible from the parent. Call `frame.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`, `score` and `track` events are claims made by the game. Read the real values from the API with the
  player's token; never grant anything on an event.
- `ads` defaults to `NoAds`: every ad request answers `unavailable`. Implement `AdBridge` to run a network.
- Pass the visitor's real consent once your consent manager has an answer, then `frame.setConsent()` on change
  (`useGameFrame` does it). `@filudi/hub-core/cmp` is a TCF 2.2 CMP if you want one.
- `GameFrame` sends `frame_loaded` to the API when the bundle posts `filudi:loaded`, which is the first step of
  the funnel in your statistics.
- `frame.destroy()` when the page goes away.

## 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.com
img-src 'self' data: https://play.filudi.com
media-src https://play.filudi.com
```

`GameFrame` 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

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

`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.
