Appearance
Game requirements
This page defines the environment, layout and gameplay requirements for games hosted by Fah. Use Getting started to run an example and SDK API to implement it. Content staging and host services described here are supplied by the platform, not by the SDK package.
Runtime environment
- A game is a folder of static files served at
<site>/g/<id>/, entryindex.html. Relative URLs only — never a leading/, never a host name. The same folder must work at any base path of any origin. - Shared files, also by relative URL from the game folder:
../../kit/portal-kit.js(the kit) and, for games that use the shared three.js,../../lib/three/(r180build/folder) and../../lib/three-addons/(examples/jsm/). An import map inindex.htmlnames them:three,three/addons/,portal-kit. A fork may bundle its own three.js instead. - The game document is shown in an
<iframe>of the shell (same origin), or as the top-level page of a native web view, or opened directly in a browser tab (stand-alone, for development). The kit hides which one it is. - Zero requests to any other origin. No CDN, analytics, remote API, web font or remote decoder.
- No navigation: no
locationchange,window.open, reload,alert/confirm/prompt, no access toparent/topexcept through the kit. - The staged site enforces both. The pipeline writes a Content-Security-Policy into every staged document (the shell's, the game's and the notices' policy; the game's allows scripts, styles, images, media, fonts, workers and requests of its own origin,
blob:anddata:only) and the game harness serves a game with the same policy as a response header, so a request to another origin is refused and is a console error. A game that needs more than the game policy gives fails its stage with the directive named. The pipeline also scans a game's files for member access on a bareparent,toporframeElement; what a fork needs of them is allow-listed, rule by rule, in itsPROVENANCE.md. - What a game is trusted with. The game's frame is same-origin with the shell (same-origin calls needs it), so the policy bounds its requests, not its reach into the shell's page. A game therefore ships only from a commit the publisher vetted, built from its lockfile without lifecycle scripts, with the scan above clean or allow-listed. The shell keeps nothing personal in its storage (likes, bests, the order).
Site tree
<site>/index.html the shell
<site>/catalog.json generated from every game's game.json
<site>/kit/portal-kit.js the kit (ES module, one file)
<site>/lib/three/... three.js r180 build folder
<site>/lib/three-addons/... three.js r180 examples/jsm (only the files in use, once staged)
<site>/g/<id>/index.html a game
<site>/g/<id>/game.json its manifest
<site>/g/<id>/preview/poster.webp, preview/loop.mp4 captured by the pipeline
<site>/g/<id>/preview/first.mp4 the small first file of the loop, by the pipelineThis is the tree a game is written against (games/<id>/ beside kit/ and lib/). The stage puts each game under a folder named by its content (g/<id>/<16 hex>/, the path of its catalogue entry), the kit and three.js under hashed folders too, and rewrites the import map; a game never names a hashed path itself. The staged site also has notices.html (every licence text and credit), which the shell's info sheet links.
The rectangle a game gets
- Portrait, always. Reference viewport P-ref 440 x 956 CSS px, DPR 3 (iPhone Pro Max class). Also test 402 x 874 and 375 x 667. The game must lay out for any aspect between 9:21 and 3:4 and follow
resizeat any time. On a desktop or a landscape phone the shell still gives the game a portrait rectangle. - The game's canvas fills its whole document (full-bleed, under the status bar area and under the shell's strip).
- The shell tells the game its insets (CSS px from each edge of the game's document):
top(status bar / Dynamic Island),bottom(the shell's strip plus the home-indicator inset),left,right. At P-ref the defaults are top 62, bottom 90 (56 strip + 34), left 0, right 0. Controls, HUD and anything the player must read or touch stay inside the insets. Art may and should run under them. - Touches that start below the bottom inset belong to the shell and never reach the game.
Gameplay requirements
Startup
Start in play. From start to a frame that answers input: one frame. From page load to ready on P-ref with a warm cache: 2 s target, 4 s limit. Startup bytes to ready: 8 MB limit, 2 MB target ("light").
Gameplay and payoff
First payoff within 5 s of the first input (a reward with a sound and a visual). The goal must be clear without reading. Escalate within 60 s. A played run lasts 20 s to 3 min unless the manifest says the game is long; for session: "snack" the lower bound is 10 s (runSeconds, manifest fields).
Touch targets
One thumb in portrait wherever the genre allows; primary controls in the lower 60 % of the screen, inside the insets; touch targets 44 x 44 px or more (primary 64); nothing permanent in the top inset.
Input
Input rules: pointer events; act on pointerdown; handlers record, the frame loop applies; pressed look drawn in the handler; release everything on pointercancel, lostpointercapture, pause, blur, hidden; touch-action: none on the game's surface; no zoom, scroll, selection, callout or context menu. Every drawn control has data-portal-control="<id>", primary ones also data-portal-primary.
Audio
Sound: every game has sound (effects at least; music welcome). No sound before start. One AudioContext, created or resumed inside the start gesture path the kit provides. Respect mute; suspend on pause; close on dispose. Game sound goes only through the kit's audio object: no new AudioContext, no media element (new Audio, <audio>) for game sound, nodes only from the factory methods of audio.ctx, and an audio library is handed audio.ctx (three.js: AudioContext.setContext). In the feed "suspend on pause; close on dispose" is done by the kit as lifecycle and shared audio say; the game calls neither on audio.ctx. The pipeline's check sound.own reads a game's files for it.
Graphics
Use the graphics helpers for renderer sizing, camera fitting, quality adaptation and GPU cleanup.
Graphics: hold 55 fps or more on P-ref at a render scale of 2x device pixels or better where the game can; the kit's governor lowers resolution before the game drops frames. No visible popping when the tier changes.
HUD
One HUD voice: score, hints, toasts and control glyphs are drawn with the kit's HUD and input pieces, not the game's own DOM styles. No desktop remnants (key hints, cursor, hover states), no debug text, no text under 13 px.
Errors
Clean console: no uncaught exception, rejected promise or console.error in load, play, pause, over, restart, dispose.
Storage
Storage: only through the kit (best, small settings), namespaced by game id. Never required to play.
Attribution
Rights: every shipped file has a source and a licence in the game's PROVENANCE.md. Original games use code-made or self-made art and sound. No brand, character, song or logo that is not ours.
In-game navigation
No links, no ads, no sign-in, no leaderboards, no share buttons, no exit button inside the game.
Cooperative loading
Cooperative loading. The game shares a thread with the feed. After init, no single main-thread task of the game's is longer than 50 ms in WebKit on the test Mac at 1x CPU (three frames), in loading and in play. Loading is cut into steps with a yield between them (await ctx.breathe(); the kit's loader and warmUp do it themselves), shaders are compiled a few at a time without waiting for the driver (the kit's warmUp does it; a three.js older than r158 cannot, and compiles in warmUp's draw steps), textures uploaded one per step, and no step starts while __ppKit.busy is on. A game that cannot do this (a fork with a blocking boot) says so in its manifest ("boot": "blocking"). The shell loads nothing of such a game while its card is only watched and never warms it ahead: it boots on the tap, the cue changes to "Starting" within 100 ms of the tap, and the touch is handed over when the game is ready. T_control of a blocking game is therefore its whole boot, measured with the GPU on, and must still be inside the gate's 5 s. From kit 0.2.1 on the 50 ms limit also holds at the starting tap (the kit builds its AudioContext, suspended, before the first touch).
Motion and flashing
Nothing flashes. No more than three flashes in any second over a large part of the screen (a quarter or more), in play, in over and in the preview; the kit's juice.flash() keeps to it by itself. With init.reducedMotion true a game holds strobing, flashing and screen shake steady (a constant light instead of a pulse) and keeps its play otherwise.