Aller au contenu

omnisense/omniplay

Omniplay: games under any PHP host, behind contracts by capability. On the stores: a game's record (texts, dates, studios, platforms, price, pictures, scores, identifiers on other stores), a search by title, a player's library and whether a player owns a game, read only. Online play, whatever the game's engine or netcode: a fleet of game servers kept by their heartbeats, a player seated on a fitting server with a short-lived signed ticket the server checks by itself, hosting providers that start and stop servers, a player's identity verified by their platform. Providers are packages of their own (omniplay/steam, omniplay/gog, omniplay/itch, omniplay/igdb, omniplay/rawg, omniplay/docker, omniplay/edgegap, and inside the game server omniplay/dotnet, mirror, netcode, godot and node); the ticket's format and its vectors are documented for a server in any language; the Symfony and Laravel bridges live in omnibridge.

Étage
Famille
Version
1.x
Vérifié en réel
Vérifié en partie
Licence
MIT
composer require omnisense/omniplay:1.x-dev

Omniplay is one PHP contract for games, on two sides that share one registry:

  • On the stores: a game's store record (texts, release date, studios, platforms, price in a country, pictures, scores, its identifiers on other stores), a search by title, a player's library and whether a player owns a game. Everything is read: nothing is bought, granted or written. An application asks every store or catalogue the same questions and gets the same Game back.
  • Online play, on the platform side: which game servers are up (the fleet, kept by their heartbeats), which one a player goes on (seating), the short-lived ticket that lets them in, the hosting providers that start and stop servers, and a player's identity verified by their platform. It knows no engine and no netcode: a server is an address, a capacity, a count of players and a health; what it runs is a string the application declares (engine).

Each store, host or platform is a package of its own (omniplay/steam, omniplay/docker...) that implements the contracts it can fulfil; the packages that run inside the game server (omniplay/dotnet, /mirror, /netcode, /godot, /node) verify the tickets and send the heartbeats.

$stores->search('gog')->search(new Query('witcher', 'fr', 'FR'));                   // Game[], best first
$stores->catalogue('steam')->game(GameRef::steam(620), 'fr', 'FR');                 // ?Game, with its French price
$stores->game(GameRef::steam(620));                                                 // the first provider that finds it
$stores->library('steam')->library('76561197960287930');                            // OwnedGame[]: playtime, last played
$stores->ownership('itch')->owns('12345', GameRef::itch('leafo/x-moon'));           // bool

$seat = $seating->seat($game, new SeatRequest('player-42', 'eu', ['ranked']));      // ?Seat: address, ticket, expiry
$fleet->heartbeat('eu-1', 5);                                                       // a server says it holds 5 players
$fleet->retireStale();                                                              // the servers fallen silent, removed
$heartbeats->receive($request->headers->get('Authorization', ''), $request->toArray());   // a server's own heartbeat
$stores->identityFor('steam')?->verify($steamTicket);                               // Identity "steam:7656...", or refused

The design is the one of the other Omnisense families: a provider's factory fills a configuration with its name, its required options and the defaults of the others, then builds the provider; a registry holds the application's providers by name. A provider says what it can be asked in its capabilities - which contracts it answers (search, catalogue, library, ownership, hosting, identity), the identifiers it finds a game by, what a player is to it, what its terms ask to be shown, the services and regions it starts servers of - and an identifier it cannot resolve is refused before any call. The core needs no framework: it requires symfony/http-client-contracts and nothing else.

Published as omnisense/omniplay. The namespace is Omniplay\; providers keep the omniplay/ vendor and the Omniplay\<Provider>\ namespace. The stores' side was the omnigame family (omnisense/omnigame, omnigame/*), merged into omniplay on 2026-11-09: Omnigame\ is now Omniplay\, omnigame/<provider> is omniplay/<provider>.

Online play: the flow

  1. A server starts - by hand, or through a hosting provider (HostingInterface::start()), which hands it its ticket secret in its environment - and is registered in the fleet.
  2. It sends heartbeats (players, health) to the application's route, which hands them to Heartbeats::receive(); a server the fleet does not know registers itself with its address.
  3. A player asks the application to play - named by the application, or verified by their platform (IdentityInterface). Seating::seat() finds the fullest server of the service that still has a seat (a ready one first, then one starting), or starts one, counts the player on it, and mints a ticket whose room is the server's id.
  4. The client connects to the address and presents the ticket; the server verifies it with the secret (signature, room, expiry) and lets the player in or not. It never calls the application.
  5. Fleet::retireStale() removes the servers fallen silent; the application stops those a provider started.

When to use it

  • A studio's or a blog's page that shows a game with its price on Steam and GOG, kept current.

  • A catalogue page fed by IGDB or RAWG, which also give a game's Steam, GOG, itch.io or Epic identifiers, leading to the store pages (Registry::game() asks each provider in turn).

  • A community site that shows a player's Steam library and playtime, or a studio that checks whether a player owns its itch.io game before granting a role or a download.

  • A game whose dedicated servers run as containers on one machine or a few (omniplay/docker), or as Edgegap deployments near the players (omniplay/edgegap), started as players come.

  • A relay or a lobby service (a WebSocket server) that must let in only the players the site signed in: one Service with its public URL, the tickets alone.

  • A Steam game whose players are seated under their verified SteamID (omniplay/steam's identity).

It sells nothing and grants nothing: no purchase, no key handed out, nothing written to a store. It does no netcode, no matchmaking by skill, no billing. It stores nothing either: keeping records and prices (each store's terms say for how long) and the fleet's store are the application's job.

Providers

One list: each provider is a package of its own and implements the contracts it can fulfil. Those on the platform are called by the application; those inside the game server call the application (heartbeats) and check the tickets it minted - omniplay never calls them. The capabilities are each provider's capabilities(); library and ownership are offered only with the key they need, identity only with the app id it needs.

Package Runs Factory name Contracts Credentials
omniplay/steam the platform steam search, catalogue by steam; library and ownership with key (a 64-bit Steam ID); identity with appid (a session ticket, AuthenticateUserTicket) none for the store; a Steam Web API key for players' libraries; the publisher key for identity
omniplay/gog the platform gog search, catalogue by gog, prices none
omniplay/itch the platform itch catalogue by itch page (no search API), prices; library (the key's own account) and ownership (user id, e-mail, download key) with key none for pages; a server-side API key
omniplay/igdb the platform igdb search, catalogue by igdb, steam, gog a Twitch application's client id and secret
omniplay/rawg the platform rawg search, catalogue by rawg (number or slug) an API key
omniplay/docker the platform docker hosting: servers as containers, Docker Engine API on a unix socket or TCP (TLS) the engine's socket or certificates
omniplay/edgegap the platform edgegap hosting: servers as Edgegap deployments near the players an Edgegap API token (by reference)
omniplay/dotnet (NuGet Omniplay.Dotnet, UPM io.omniplay.dotnet) inside the game server: any .NET (a .NET server, Unity, Godot's C#) - tickets (verify, mint), heartbeat the ticket secret, from the server's environment
omniplay/mirror (UPM io.omniplay.mirror) inside a Unity server with Mirror - tickets (a NetworkAuthenticator), heartbeat, over omniplay/dotnet idem
omniplay/netcode (UPM io.omniplay.netcode) inside a Unity server with Netcode for GameObjects - tickets (the connection approval), heartbeat, over omniplay/dotnet idem
omniplay/godot (addon omniplay_godot) inside a Godot 4 server, GDScript - tickets, heartbeat idem
omniplay/node (npm @omniplay/node) inside a Node.js server - tickets (verify, mint), heartbeat idem

The fleet, seating, the tickets' format and the heartbeats belong to no provider: they are the core's (Fleet, Seating, Tickets, Heartbeats). Each provider that reads tickets is checked against Tests/vectors.json, of which it holds a byte-identical copy; a server written in anything else follows Tickets and the heartbeat.

Cross identifiers: IGDB gives a game's Steam, GOG, Epic and itch.io identifiers; RAWG its Steam, itch.io and Epic ones (read from the links of the stores that sell it). The core's test provider, Omniplay\Testing\FixedProvider, answers the four store contracts; FixedHosting and FixedIdentity start nothing and vouch for a fixed list of players (Testing).

Epic Games Store: not written. It publishes no catalogue API; a player's entitlements are read through Epic Online Services by the game's own product, with that product's credentials and the player's sign-in. IGDB and RAWG give a game's Epic identifier meanwhile (Scheme::Epic), which no provider resolves.

How this manual is organised

Proposer une modification docs/index.md

Installation

composer require omnisense/omniplay omniplay/steam omniplay/gog     # one package per provider

Requirements

  • PHP 8.2 or later.
  • omnisense/omniplay requires symfony/http-client-contracts (^3.0) and PHP's mbstring extension (ext-mbstring: the core shortens error bodies with mb_substr()), nothing else.
  • Every provider requires symfony/http-client (^6.4, ^7.0 or ^8.0), a library that stands alone and loads no framework: its factory takes the application's client (Symfony\Contracts\HttpClient\HttpClientInterface) and, given none, makes its own (HttpClient::create(['timeout' => 20])).
  • PHP's mbstring extension, present in most builds: Composer refuses to install on a PHP without it.

Omniplay runs the same in plain PHP, in a worker, in Laravel and in Symfony; there is no bundle or Laravel provider yet (Symfony, Laravel).

Plain PHP

A whole script with no store and no network: the core's FixedProvider answers from records and a library it was given, as a store would have returned them.

<?php // bare.php

require __DIR__.'/vendor/autoload.php';

use Omniplay\Model\Game;
use Omniplay\Model\GameRef;
use Omniplay\Model\Identifiers;
use Omniplay\Model\OwnedGame;
use Omniplay\Model\Platform;
use Omniplay\Model\Price;
use Omniplay\Model\Query;
use Omniplay\Model\Scheme;
use Omniplay\Registry;
use Omniplay\Testing\FixedProviderFactory;

// Records a store would have returned, and one player's library.
$games = [
    new Game('Portal 2', Identifiers::of(['steam' => 620, 'igdb' => 72]), 'fixed',
        developers: ['Valve'], platforms: Platform::fromLabels(['PC (Microsoft Windows)', 'osx', 'SteamOS']),
        price: new Price(219, 'EUR', 999, 78)),
    new Game('X-Moon', Identifiers::of(['itch' => 'leafo/x-moon']), 'fixed', price: new Price(0, 'USD')),
];
$libraries = ['76561197960287930' => [new OwnedGame(Identifiers::of(['steam' => 620]), 'Portal 2', 1260)]];

$stores = new Registry([new FixedProviderFactory($games, $libraries)], ['store' => ['factory' => 'fixed']]);

foreach ($stores->search('store')->search(new Query('portal')) as $game) {
    echo $game->title, ' ', $game->ref(Scheme::Steam), "\n";                   // "steam:620"
}

$portal = $stores->game(GameRef::steam(620));                                   // the first provider that resolves Steam numbers
printf("%s: %.2f %s (was %.2f, -%d%%), on %s\n", $portal->title, $portal->price->amount / 100, $portal->price->currency, $portal->price->initial / 100, $portal->price->discount, implode(', ', array_map(static fn ($p) => $p->value, $portal->platforms)));
var_dump($stores->game(GameRef::itch('leafo/x-moon'))?->price->isFree());

foreach ($stores->library('store')->library('76561197960287930') as $owned) {
    echo $owned->title, ': ', intdiv($owned->playtime, 60), " h played\n";
}
var_dump($stores->ownership('store')->owns('76561197960287930', GameRef::steam(620)));
$ php bare.php
Portal 2 steam:620
Portal 2: 2.19 EUR (was 9.99, -78%), on windows, mac, linux
bool(true)
Portal 2: 21 h played
bool(true)

(run on 2026-10-27 in the harness, PHP 8.4, with the harness's autoloader)

The pieces:

  • a factory per provider package (FixedProviderFactory, SteamFactory, GogFactory...), given the HTTP client to call with;
  • the registry, built from the factories and the providers' options, by name: get(), the typed search(), catalogue(), library() and ownership(), and game(), which asks each provider in turn;
  • references: a GameRef names a game by a store and its identifier there (GameRef::steam(620), GameRef::itch('leafo/x-moon'));
  • the record: a Game, with what the store gives and nothing guessed - prices in minor units, platforms folded into families (Platform::fromLabels() read osx as mac, SteamOS as linux).

The stores' APIs

Steam's store, GOG's catalogue and itch.io's pages need no key. This script asks each for a game's record, with French texts and the French price where the store has them:

<?php // stores.php

require __DIR__.'/vendor/autoload.php';

use Omniplay\Gog\GogFactory;
use Omniplay\Itch\ItchFactory;
use Omniplay\Model\Game;
use Omniplay\Model\GameRef;
use Omniplay\Registry;
use Omniplay\Steam\SteamFactory;
use Symfony\Component\HttpClient\HttpClient;

$http = HttpClient::create(['timeout' => 30]);
$stores = new Registry([new SteamFactory($http), new GogFactory($http), new ItchFactory($http)], [
    'steam' => ['factory' => 'steam'],                          // the store needs no key
    'gog' => ['factory' => 'gog'],
    'itch' => ['factory' => 'itch'],
]);

$show = static function (Game $g): void {
    $price = null === $g->price ? '-' : sprintf('%.2f %s%s', $g->price->amount / 100, $g->price->currency, $g->price->discount ? ' (-'.$g->price->discount.'%)' : '');
    printf("%s [%s] %s, %s, %s\n  by %s; %s\n", $g->title, json_encode($g->identifiers->toArray()), $g->released?->format('Y-m-d') ?? '-', $price,
        implode('/', array_map(static fn ($p) => $p->value, $g->platforms)), implode(', ', $g->developers) ?: '-', $g->url);
};

foreach ([['steam', GameRef::steam(620)], ['gog', GameRef::gog(1207658924)], ['itch', GameRef::itch('leafo/x-moon')]] as [$name, $ref]) {
    try {
        $show($stores->catalogue($name)->game($ref, 'fr', 'FR'));
    } catch (Omniplay\Exception\OmniplayException $e) {
        echo get_class($e), ': ', $e->getMessage(), "\n";
    }
}
$ php stores.php
Omniplay\Exception\UnreachableException: Steam could not be reached for GET /api/appdetails: TLS connect error: error:0A000126:SSL routines::unexpected eof while reading for "https://store.steampowered.com/api/appdetails?appids=620&cc=FR&l=french".
The Witcher: Enhanced Edition [{"gog":"1207658924"}] 2011-05-09, 1.19 EUR (-85%), windows/mac
  by -; https://www.gog.com/game/the_witcher
X-Moon [{"itch":"leafo\/x-moon"}] -, 0.00 USD,
  by leafo; https://leafo.itch.io/x-moon

(the first line as it read then: since 2026-10-29 the message ends at the client's reason, without the URL - ... unexpected eof while reading.; run live on 2026-10-27, 06:18 UTC, in the harness: Steam's store could not be reached from the harness's network that morning - the TLS connection was cut, twice - which shows what a network failure becomes; GOG answered in two calls, itch.io in one)

Steam's record, read from the answer its package's tests recorded on 2026-10-24, is in Store records and prices. The keyed providers (IGDB, RAWG, Steam's players, itch.io's ownership) need an account: their manuals show the same calls with their options.

Building a provider calls nothing; a missing required option is reported by create() as an InvalidConfigException naming it (The "rawg" provider needs: api_key.).

In a framework

  • Symfony: no bundle yet; the registry is a service of the application's (Symfony).
  • Laravel: no provider in omnibridge/laravel yet; a service provider of the application's builds the registry once (Laravel).
  • Any other: build the Registry once, where the framework builds its services, as the scripts above do.

Online play, in plain PHP

composer require omnisense/omniplay:1.x-dev
composer require omniplay/docker:1.x-dev   # to start servers as Docker containers
composer require omniplay/edgegap:1.x-dev  # to deploy servers on Edgegap, near the players
composer require omniplay/steam:1.x-dev    # to verify players' Steam session tickets

PHP 8.2 or later with json and mbstring. The core requires symfony/http-client-contracts alone; the Docker provider requires symfony/http-client (and the curl extension to reach an engine on a unix socket).

A first script

The harness's bare script, docker/harness/bin/bare, in plain PHP: a registry of the fixed hosting provider (it starts nothing: each server is given the next port), a fleet in memory with one server registered by hand, two players seated - the first on that server, the second on one started for them since the first was then full - their tickets checked as a server checks them, a JSON call answered with no network.

$secrets = new FixedSecrets(['GAME_SECRET' => 'not-a-real-secret']);
$registry = new Registry([new FixedHostingFactory()], ['local' => ['factory' => 'fixed-hosting', 'options' => ['services' => ['game'], 'regions' => ['eu']]]]);
$service = new Service('game', secret: 'GAME_SECRET', ttl: 60, capacity: 2, engine: 'custom');
$fleet = new Fleet(new InMemoryStore());
$fleet->register(new Server('eu-1', 'game', '203.0.113.7:7777', 2, region: 'eu', engine: 'custom'));
$fleet->heartbeat('eu-1', 1);
$seating = new Seating($fleet, new Tickets($secrets), $registry->hosting('local'), $secrets);

$first = $seating->seat($service, new SeatRequest('player-1', 'eu'));
$second = $seating->seat($service, new SeatRequest('player-2', 'eu'));
TicketSigner::verify($second->ticket->token, 'not-a-real-secret', $second->server->id)['sub'];   // player-2
TicketSigner::verify($second->ticket->token, 'not-a-real-secret', 'eu-1');                       // null: another room

docker compose run --rm omniplay bare, on 2026-11-08 (the report, without the classes and files loaded):

{
  "capabilities": {"contracts": ["hosting"], "services": ["game"], "regions": ["eu"], "where": "nowhere: 127.0.0.1, in memory"},
  "seats": [["eu-1", "203.0.113.7:7777", false], ["game-1", "127.0.0.1:7001", true]],
  "checked": "player-2",
  "refused": null,
  "json": ["eu-1", 3, "omniplay-bare/1.0"],
  "json-404": null,
  "json-error": "Example answered 500 for GET /servers/3. Internal error"
}

With Docker

$hosts = new Registry([new DockerFactory()], ['local' => ['factory' => 'docker', 'options' => [
    'host' => 'unix:///var/run/docker.sock',
    'public_host' => 'play.example.org',
    'heartbeat_url' => 'https://www.example.org/omniplay/heartbeat',
    'services' => ['game' => ['image' => 'example/game-server:1.4', 'port' => '7777/udp', 'env' => ['MAP' => 'harbour']]],
]]]);

The image must be on the engine already (docker pull); see omniplay/docker's README for every option.

Proposer une modification docs/installation.md

Symfony

There is no Omniplay bundle, and omnibridge/symfony has no Omniplay wiring yet: a Symfony application declares the registry as a service of its own. Every factory takes the HTTP client as its one constructor argument (?Symfony\Contracts\HttpClient\HttpClientInterface), which autowiring fills with the application's http_client service.

The registry as a service

# config/services.yaml
services:
    _defaults:
        autowire: true

    Omniplay\Steam\SteamFactory: ~
    Omniplay\Gog\GogFactory: ~
    Omniplay\Igdb\IgdbFactory: ~

    Omniplay\Registry:
        arguments:
            $factories:
                - '@Omniplay\Steam\SteamFactory'
                - '@Omniplay\Gog\GogFactory'
                - '@Omniplay\Igdb\IgdbFactory'
            $config:
                steam:
                    factory: steam
                    options:
                        key: '%env(default::STEAM_WEB_API_KEY)%'   # only for players' libraries
                        language: fr
                        country: FR
                gog:
                    factory: gog
                    options: { locale: fr-FR, country: FR, currency: EUR }
                igdb:
                    factory: igdb
                    options:
                        client_id: '%env(TWITCH_CLIENT_ID)%'
                        client_secret: '%env(TWITCH_CLIENT_SECRET)%'
# .env.local, or bin/console secrets:set
STEAM_WEB_API_KEY=...
TWITCH_CLIENT_ID=...
TWITCH_CLIENT_SECRET=...

The order of $config is the order Registry::game() asks the providers in when no name is given. Steam's key is optional: empty, the provider reads the store and refuses players' libraries (an empty option is the same as none).

A tag gathers the factories instead of listing them:

services:
    _instanceof:
        Omniplay\ProviderFactoryInterface:
            tags: ['omniplay.factory']

    Omniplay\Registry:
        arguments:
            $factories: !tagged_iterator omniplay.factory
            $config: '%omniplay.providers%'

Using it

use Omniplay\Model\GameRef;
use Omniplay\Registry;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class StorePanel
{
    public function __construct(private readonly Registry $stores, private readonly CacheInterface $cache)
    {
    }

    /** @return ?array<string, mixed> */
    public function steam(int $app): ?array
    {
        return $this->cache->get('steam.'.$app.'.fr', function (ItemInterface $item) use ($app): ?array {
            $item->expiresAfter(6 * 3600);        // prices change with sales

            return $this->stores->catalogue('steam')->game(GameRef::steam($app), 'fr', 'FR')?->toArray();
        });
    }
}

Nothing is built when the container compiles: a provider is built, and its required options checked, the first time the registry is asked for it, then kept. Records are not cached by Omniplay: the stores' endpoints are not made for heavy traffic (Steam's and GOG's storefront endpoints are undocumented), and the application's cache keeps them.

There is no command, event, Twig function or route. Show each provider's capabilities()->attribution beside its records, as its terms ask: Game::$source names the provider a record came from.

This page was written from the classes' constructors; it was not run in a Symfony kernel.

Online play

The fleet, the tickets and the heartbeats are plain classes too, wired as services of the application's:

# Symfony: config/services.yaml
services:
    Omniplay\Secret\SecretsInterface: '@Omniplay\Secret\EnvironmentSecrets'
    Omniplay\Secret\EnvironmentSecrets: ~
    Omniplay\Storage\StoreInterface: '@App\Omniplay\DoctrineStore'   # the application's own
    Omniplay\Fleet: { arguments: ['@Omniplay\Storage\StoreInterface'] }
    Omniplay\Tickets: { arguments: ['@Omniplay\Secret\SecretsInterface'] }

Proposer une modification docs/symfony.md

Laravel

omnibridge/laravel, the bridge that hosts omnibase in Laravel, has no Omniplay provider yet. The registry is plain PHP: a service provider of the application's builds it once, from a config file, with a symfony/http-client client (every provider requires that package; Laravel's own HTTP client is Guzzle's, which the factories do not take).

// config/omniplay.php
return [
    'providers' => [
        'steam' => ['factory' => 'steam', 'options' => [
            'key' => env('STEAM_WEB_API_KEY'),       // null: the store only
            'language' => 'fr',
            'country' => 'FR',
        ]],
        'rawg' => ['factory' => 'rawg', 'options' => ['api_key' => env('RAWG_KEY')]],
    ],
];
// app/Providers/OmniplayServiceProvider.php
namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Omniplay\Rawg\RawgFactory;
use Omniplay\Registry;
use Omniplay\Steam\SteamFactory;
use Symfony\Component\HttpClient\HttpClient;

final class OmniplayServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Registry::class, function (): Registry {
            $http = HttpClient::create(['timeout' => 20]);

            return new Registry([
                new SteamFactory($http),
                new RawgFactory($http),
            ], config('omniplay.providers'));
        });
    }
}

Register the provider (bootstrap/providers.php on Laravel 11 and later), then inject the registry where it is needed:

use Illuminate\Support\Facades\Cache;
use Omniplay\Model\GameRef;
use Omniplay\Registry;

final class GameController
{
    public function show(Registry $stores, int $app)
    {
        $game = Cache::remember('steam.'.$app, now()->addHours(6), fn () => $stores->catalogue('steam')->game(GameRef::steam($app))?->toArray());

        return view('games.show', ['game' => $game]);
    }
}

List in the provider every factory the application uses: a hand-built registry knows only the factories it is given. Configuration caching (php artisan config:cache) keeps working: the options are plain arrays read through config(). This page was written from the registry's API; it was not run in a Laravel application.

Online play

The fleet and the heartbeats' route, in a service provider:

// Laravel: a service provider's register()
$this->app->singleton(Fleet::class, fn ($app) => new Fleet($app->make(StoreInterface::class)));
$this->app->singleton(Heartbeats::class, fn ($app) => new Heartbeats($app->make(Fleet::class), new EnvironmentSecrets(), [new Service('game', secret: 'GAME_TICKET_SECRET')]));
Route::post('/omniplay/heartbeat', function (Request $request, Heartbeats $heartbeats) {
    $result = $heartbeats->receive((string) $request->header('Authorization'), $request->json()->all());

    return response()->json($result->toArray(), $result->status);
});

The wiring will live in omnibridge (omnibridge/symfony, omnibridge/laravel) when a site needs it.

Proposer une modification docs/laravel.md

Models

Providers and contracts

Omniplay\ProviderInterface is one store or catalogue, configured. What it can be asked comes from the contracts it implements, one per capability:

Interface Method Returns
SearchInterface search(Query $query): array list<Game> whose title matches, best first, as short as the provider's search answers them
CatalogueInterface game(GameRef $ref, ?string $language = null, ?string $country = null): ?Game the game's whole record, null when unknown; NotSupportedException when the provider does not find a game by that identifier
LibraryInterface library(string $player): array list<OwnedGame>, as far as the store lets them be read (a private Steam profile reads as an empty library)
OwnershipInterface owns(string $player, GameRef $game): bool whether the player owns the game (bought it, was given a key), as the store answers it

ProviderInterface itself: getName(): string (the factory's name, also the source of every Game), getTitle(): string (Steam, itch.io), capabilities(): Capabilities.

$language is the language of the texts where the store translates (fr, fr-FR, Steam's french); $country the country whose price is answered (ISO 3166-1 alpha-2); null takes the provider's options. What a player is depends on the store: Capabilities::$player says it.

Capabilities

Omniplay\Capabilities (a readonly value):

Property Type Default
search bool false it implements SearchInterface
catalogue bool false it implements CatalogueInterface
library bool false it can list a player's games - with Steam and itch.io, only when the key option is set
ownership bool false it can say whether a player owns a game - likewise
lookupBy list<Scheme> [] the identifiers game() finds a game by
prices bool false its records carry a price
player ?string null what a player is to it, for library and ownership
attribution ?string null what its terms ask to be shown beside its records
terms list<string> [] URLs of its terms
keyless bool false its catalogue needs no account nor key
hosting bool false it starts and stops game servers (HostingInterface)
services list<string> [] the services it starts a server of; any, when empty
regions list<string> [] the regions its servers run in; any asked, when empty
where ?string null where it runs them or whom it asks, for a person to read
identity bool false it verifies players' proofs from a platform (IdentityInterface) - Steam's only with appid
Method
resolves(GameRef $ref): bool catalogue is true and the ref's scheme is in lookupBy
starts(string $service, ?string $region): bool hosting is true, the service and the region among those it runs (any, when empty)
contracts(): list<string> search, catalogue, library, ownership, hosting, identity, those it has
toArray(): array the same as plain values: contracts, lookupBy, prices, player, attribution, terms, keyless, services, regions, where
Provider lookupBy player attribution
steam steam a 64-bit Steam ID ("76561197960287930") Data from Steam; Valve, Steam and their logos are trademarks of Valve Corporation
gog gog - Data from GOG.com
itch itch an itch.io user id ("12345"), a verified e-mail address, or a download key; "me" for the library of the key's account Data from itch.io
igdb igdb, steam, gog - Data from IGDB.com
rawg rawg - Data from RAWG (rawg.io), with a link on every page that shows it

Registry::library() and ::ownership() give a provider only when its capabilities() says it can (library, ownership true), else a NotSupportedException: Steam's and itch.io's providers implement both interfaces, and without a key they are refused there.

Factory and Config

Omniplay\ProviderFactoryInterface builds a provider from options: getName(): string and create(array $options = []): ProviderInterface. Every provider's factory extends Omniplay\ProviderFactory: createConfig($options) fills a Config (an ArrayObject) with the options given, then the factory's defaults under them; the keys of omniplay.required_options are checked (one missing, '' or [] is an InvalidConfigException listing them all: The "igdb" provider needs: client_id, client_secret.); build() returns the provider. Reserved keys: omniplay.factory_name, omniplay.factory_title, omniplay.required_options. Config reads options with types: get(), string() (null when empty), int(), float(), bool() ("1", "true", "yes", "on").

Every factory takes an optional HttpClientInterface; given none, it makes its own with a 20-second timeout. Building calls nothing (IGDB's Twitch token is asked on the first question).

Registry

Omniplay\Registry holds the application's providers by name:

$stores = new Registry(
    [new SteamFactory($http), new IgdbFactory($http)],      // iterable of factories, keyed by getName()
    [
        'steam' => ['factory' => 'steam', 'options' => ['key' => '...']],
        'igdb' => ['factory' => 'igdb', 'options' => ['client_id' => '...', 'client_secret' => '...']],
    ],
);
Method
get(string $name): ProviderInterface the provider, built on first use, then kept
search($name), catalogue($name), library($name), ownership($name) the same provider as that contract, or NotSupportedException (The "i" provider (Omniplay\Itch\ItchProvider) does not search.)
game(GameRef $ref, ?string $language = null, ?string $country = null, string ...$names): ?Game the record from the first provider - in the configuration's order, or in the order of $names - that implements CatalogueInterface, resolves the ref and finds the game; null when none does
has($name), options($name), names(), factories() the configuration

game() skips the providers that cannot resolve the ref without calling them and goes on when one answers null; an exception of a provider ends it. A name not configured, or a factory not installed, is an InvalidConfigException (No "x" game provider; configured: steam.).

GameRef and Scheme

Omniplay\Model\GameRef (readonly, \Stringable): new GameRef(Scheme $scheme, string|int $id) (trimmed; empty: InvalidArgumentException), and GameRef::steam(620), GameRef::gog(1207658924), GameRef::itch('leafo/x-moon'), GameRef::igdb(72), GameRef::rawg(4200) (or a slug, GameRef::rawg('portal-2')). (string) $ref is steam:620. There is no GameRef::epic(): no provider resolves an Epic identifier.

Omniplay\Model\Scheme, a string-backed enum:

Case Value Identifier
Steam steam the app id: 620
Gog gog the product id: 1207658924
Itch itch the game's page, creator/game: leafo/x-moon for https://leafo.itch.io/x-moon (its number, 3, is what its API answers)
Epic epic the Epic Games Store product slug, as IGDB and RAWG give it
Igdb igdb a number
Rawg rawg a number, or its slug where RAWG takes it

Identifiers

Omniplay\Model\Identifiers (readonly): one game's identifiers keyed by scheme value - what lets a store page be found from a catalogue's record. Identifiers::of(['steam' => 620, 'igdb' => 72]) (values trimmed, empty ones dropped, keys sorted; a key that is not lower-case letters, digits, - or _ is an InvalidArgumentException), none(), get(), has(), with(), merge() (these, completed by the other's), isEmpty(), toArray().

Game

Omniplay\Model\Game (readonly) is a game as one store or catalogue knows it. What the provider does not give is empty or null, never guessed: a search answer is usually shorter than a record.

Property Type
title string
identifiers Identifiers its identifiers in every store the provider knows
source string the provider's name: steam, igdb...
url ?string the game's page at the store or catalogue
summary ?string plain text (HTML stripped where the store gives HTML)
released ?\DateTimeImmutable the day of release (UTC midnight)
comingSoon bool not released yet, as the store says (IGDB: a release date in the future)
developers, publishers list<string>
genres, tags list<string> the provider's words, in the language asked where it translates (Steam's categories and IGDB's themes are its tags)
platforms list<Platform>
price ?Price in the country asked, where the store has prices
images list<Image>
ratings list<Rating>
Method
ref(Scheme $scheme): ?GameRef the game named in another store, when the record knows its identifier there
image(ImageKind $kind = Cover): ?Image the first picture of that kind
runsOn(Platform $platform): bool
toArray(): array every field as plain values (released as Y-m-d)

Platform

Omniplay\Model\Platform: Windows, Mac, Linux, Android, Ios, Web, Switch, PlayStation, Xbox - families a store's finer names are folded into.

Platform::fromLabel(string $label): ?Platform reads a label case-insensitively, the first rule that matches winning:

Platform A label that
Windows contains windows, or is pc or win
Mac contains mac or osx, or is os x
Linux contains linux or steamos
Android contains android
Ios is ios, or contains iphone or ipad
Web contains web, html or browser
Switch contains switch
PlayStation contains playstation, or starts with ps and a digit
Xbox contains xbox

Anything else is null (left out). fromLabels(iterable $labels): list<Platform> gives each family once, in the order first met.

Price

Omniplay\Model\Price (readonly): amount (minor units: 969 EUR is 9.69 €), currency (ISO 4217; XXX for a free Steam game whose store names no currency), initial (the price before a discount, when there is one), discount (percent, 0 when none); isFree() is amount === 0.

Image, ImageKind

Omniplay\Model\Image (readonly): kind, url (where the store serves it, under the store's terms and the publisher's rights), width, height. Omniplay\Model\ImageKind: Cover (box art, portrait), Header (the store's wide banner: Steam's header, a capsule), Screenshot, Background, Logo, Icon.

Rating

Omniplay\Model\Rating (readonly): source (metacritic, igdb, rawg, gog), value, scale (100 by default; 5 for GOG and RAWG), votes.

OwnedGame

Omniplay\Model\OwnedGame (readonly): identifiers, title, playtime (minutes, ?int), lastPlayed (?\DateTimeImmutable); toArray() gives lastPlayed in ISO 8601.

Query

new Query(string $text, ?string $language = null, ?string $country = null, int $limit = 10): the words (trimmed; empty: InvalidArgumentException A search needs words.), the language of the texts answered, the country of the prices, at most limit games (1 to 50, A search answers 1 to 50 games. otherwise).

The HTTP layer

Omniplay\Http\JsonClient and Omniplay\Http\Data are the same as Omnifilm's: headers on every request, a 404 read as null, any other status of 400 or more a ProviderException naming the method and the path (the body's text after it, tags stripped, 300 characters at most), a body that is not JSON a ProviderException, a network error an UnreachableException; Data reads an answer by key or dotted path without trusting its shape (string(), int(), float(), bool(), date() - a day in UTC, or a format given -, data(), list(), items(), strings($key, $field), keys(), toArray()).

Online play: models and contracts

Contracts

Contract Implemented by What it answers
FleetInterface Fleet (over a StoreInterface) register(), heartbeat() (null when unknown), get(), servers() of a service, healthy() ones, available() ones (a seat left; healthy within the ttl or starting within the startup's wait), occupy() (one more player, null when full, draining or down), retireStale() (the removed servers), remove()
SeatingInterface Seating seat(Service, SeatRequest): ?Seat
TicketsInterface Tickets (over a SecretsInterface) mint(Service, room, claims): Ticket, verify(Service, token, room): ?Ticket
HostingInterface providers (omniplay/docker, omniplay/edgegap, Testing\FixedHosting) start(Service, secret, region, tags): Server (Starting, not registered), stop(Server), running(service)
IdentityInterface providers (omniplay/steam, Testing\FixedIdentity); optional platform(), verify(proof): Identity (or an IdentityRefusedException saying why): Player identity
ProviderInterface every provider getName(), getTitle(), capabilities()
Storage\StoreInterface InMemoryStore, the application's own save(), find(), all(), delete()
Secret\SecretsInterface EnvironmentSecrets, FixedSecrets, the application's own (a vault) secret(reference): string

Heartbeats is no contract: it is the application's end of a server's heartbeat (the heartbeat).

Capabilities

The same Capabilities as the stores' (above), its online-play fields: Capabilities(hosting, services, regions, where, identity): whether the provider starts servers, of which services (any when empty), in which regions (any when empty), where it runs them for a person to read, and whether it verifies players' proofs from a platform. starts($service, $region) is what Seating and Registry::hostingFor() ask; contracts() lists hosting and identity as they hold.

The registry

new Registry($factories, $config): $config maps a name to ['factory' => ..., 'options' => [...]]. get(), has(), hosting($name) (a NotSupportedException when the provider starts nothing), hostingFor($service, $region, ...$names) (the first that starts it there, or null), identity($name) (a NotSupportedException when the provider verifies no player), identityFor($platform, ...$names) (the first that verifies that platform's players, or null), options(), names(), factories().

Server

Field
id its name, unique in the fleet; the room its tickets name
service the Service's name
address where players reach it, as their client takes it (203.0.113.7:7777, wss://eu1.example.org/play)
capacity, players seats, and players held (free() the difference)
health Health::Starting, Healthy, Draining, Down (seats(): starting or healthy)
heartbeat when it was last heard from (silence($now) in seconds)
region, tags what a seat may ask of it (has(...$tags))
engine declared by the application, never read
provider, instance the provider that started it and its handle there (a container's id)

toArray() and fromArray() are what a store keeps; withHeartbeat() and withPlayers() give a new one.

Service

new Service($name, $url = null, $secret = 'OMNIPLAY_TICKET_SECRET', $ttl = 60, $capacity = 16, $engine = null); fromArray($name, $values) reads a configuration's entry. $secret is a reference, read through a SecretsInterface; $url is the public address where tickets are presented when the service has one of its own (a relay).

Seat, Ticket, SeatRequest, HeartbeatResult

  • SeatRequest($player, $region = null, $tags = [], $claims = []): $player a string, or an Identity a platform verified (then the player's id is Identity::player() and $identity keeps it).
  • Identity($platform, $id, $claims): player() is <platform>:<id>.
  • Seat($player, Server, Ticket, $started): toArray() is what the client is handed (address, ticket, expiry in Unix seconds, server, service, region, engine, started).
  • Ticket($token, $room, $expires, $claims): the claims without room and exp.
  • HeartbeatResult($status, $reason, ?Server): accepted(), toArray() the response's body.

The engine

Service::$engine and Server::$engine are free strings the application declares: unity-mirror, unity-netcode, godot, node, custom, or anything else. Omniplay stores them, returns them (in Seat::toArray(), a Docker server's OMNIPLAY_ENGINE), and never branches on them: it does not detect what a server runs.

Proposer une modification docs/models.md

Tickets

A ticket is a short-lived signed text a player presents to a server. The server checks it by itself with the secret it shares with the application; it never calls the application back. The format is the same in every language, so that a server written in anything can check what PHP signed.

ticket    = body "." signature
body      = base64url(payload)                      ; RFC 4648 section 5, no padding
signature = hex(hmac_sha256(key = secret, message = body))   ; 64 lower-case hex digits
payload   = a JSON object: "room" (a string), "exp" (Unix seconds), and the application's claims

The signature is over the body as it stands, the base64url text, not over the JSON: a verifier never encodes anything again, and two languages that write JSON differently still agree.

Signing

Omniplay\Ticket\TicketSigner::sign($payload, $secret) encodes the payload with PHP's json_encode() and its default flags (slashes as \/, every character past ASCII as \uXXXX, surrogate pairs for those past U+FFFF), then signs it. It refuses a payload without a non-empty string room or an integer exp, and an empty secret. signJson($json, $secret) signs a JSON text as given. Tickets::mint() builds the payload: the application's claims, then room, then exp (now plus the service's ttl); Seating adds the player as sub and uses the server's id as the room.

TicketSigner::sign(['uid' => 2, 'room' => 'lobby', 'exp' => 2000000000], 'secret');
// eyJ1aWQiOjIsInJvb20iOiJsb2JieSIsImV4cCI6MjAwMDAwMDAwMH0.313fb4df1b3be1ffc941ae513ec9a3fcad3827593abcfb3545d9424b7b2aedfb

Verifying

A verifier, given the ticket, the secret and the room it serves, answers the payload or nothing:

  1. An empty secret verifies nothing.
  2. The signature is what follows the last dot; no dot, nothing.
  3. hmac_sha256(secret, body) in lower-case hex equals the signature, compared in constant time (a signature in capitals does not hold).
  4. The body, base64url-decoded (padding restored), is JSON whose top level is an object.
  5. room is a string equal to the room served (a number 5 is not the room "5").
  6. exp is a JSON number; its integer part is not before now (an expiry equal to now still holds). A string, a boolean or nothing there: refused.
  7. The payload is answered whole: room, exp and every claim.

TicketSigner::verify($ticket, $secret, $room, $now = null) is that in PHP.

The vectors

Tests/vectors.json (written by bin/vectors.php): sign holds 5 payloads with their secret, their JSON as PHP writes it and the ticket expected; verify holds 22 tickets with the secret and room they are checked with, whether they hold, and the payload when they do. Valid tickets expire at 4102444800 (2100-01-01), expired ones at 1000000000 (2001-09-09): check them against the real clock. They cover a player's seat, slashes and characters past ASCII ("Zoë 🎮"), nested claims, a heartbeat token, an expiry with a fraction, and every refusal above (expired, another room, another secret, an empty secret, the payload changed, the signature changed or in capitals or empty, no dot, an extra dot, no expiry, a boolean expiry, a numeric room, no room, a list, a body that is not base64url JSON, a player's ticket presented as a heartbeat).

A verifier in another language passes when, for every verify vector, it answers the payload (deep-equal) or nothing as the vector says; a signer, when signJson(json, secret) gives each sign vector's ticket.

Checked against the vectors Language Verify Sign
Omniplay\Ticket\TicketSigner PHP 22 5 (from the payload and from the JSON)
omnibase's Base\Service\Ticket\TicketSigner (OmnibaseParityTest) PHP 22, the same answers 5
omniplay/node JavaScript 22 5 (from the payload, PHP's escaping, and from the JSON)
omniplay/dotnet (and through it omniplay/mirror and omniplay/netcode) C# 22 5 (from the payload, PHP's escaping, and from the JSON)
omniplay/godot GDScript 22 5 (from the payload and from the JSON) - not run: no Godot binary where it was written

The secret

One secret per Service, named by reference (Service::$secret, an environment variable's name by default) and read through a SecretsInterface when a ticket is minted or a server started. A hosting provider hands it to the server it starts (Docker: in the container's environment, OMNIPLAY_TICKET_SECRET unless configured otherwise). Whoever holds it can mint tickets: keep it off the client, out of images and out of logs. Rotating it means restarting the servers with the new one; tickets live a minute by default, so the old ones die with them.

omnibase

omnisense/omnibase signs tickets in this format too (Base\Service\Ticket\TicketSigner, for its collaboration relay). Omniplay does not require it: the two are kept equal by OmnibaseParityTest, which loads omnibase's class when it is installed beside omniplay or named in OMNIBASE_TICKET_SIGNER. They differ in one case no signer writes: an exp written as a string ("4102444800") holds in omnibase, which casts it, and not here.

Proposer une modification docs/tickets.md

SearchInterface::search(Query $query): array gives the games whose title matches, best first. Steam, GOG, IGDB and RAWG answer it; itch.io has no search API (Registry::search('itch') is a NotSupportedException).

use Omniplay\Model\Query;
use Omniplay\Model\Scheme;

foreach ($stores->search('steam')->search(new Query('portal', 'fr', 'FR', limit: 5)) as $game) {
    printf("%s (Steam %s) %s\n", $game->title, $game->identifiers->get(Scheme::Steam),
        null === $game->price ? '' : sprintf('%.2f %s', $game->price->amount / 100, $game->price->currency));
}

On the answer Steam's tests recorded on 2026-10-24, in the harness (2026-10-27), a search for portal in France with a limit of 3 gives:

  Portal 2 620 969 EUR
  Portal 400 969 EUR
  Portal Knights 374040 1999 EUR

(title, Steam number, price in minor units)

A search answer is a short record: ask the catalogue for the whole one (Store records and prices).

What each provider does

Call Language Country Most answers What a result carries
steam store.steampowered.com/api/storesearch/?term=...&l=...&cc=... sent (Steam's names: fr is french) sent: its prices what Steam answers, cut to limit app number, name, page, platforms, price (and its discount), a small header (231 x 87), Metacritic score; apps only (not bundles)
gog catalog.gog.com/v1/catalog?query=like:... sent as a locale (fr is fr-FR) sent; the currency is the currency option's limit, sent product number, title, page, release date, upcoming, developers, publishers, genres, tags, systems, price in currency, vertical and horizontal covers, GOG's rating out of 5
igdb POST /v4/games with search "..." (quotes escaped) ignored ignored limit, sent the whole record, as a lookup gives it
rawg GET /api/games?search=...&page_size=... ignored ignored limit, sent RAWG number, name, page, release date, platforms, scores, background; no stores (their identifiers come with a lookup)
fixed the titles' words - - limit the records given

No match is [] everywhere. The words are sent as typed: what matches is the store's search engine's business.

Proposer une modification docs/usage-search.md

Store records and prices

CatalogueInterface::game(GameRef $ref, ?string $language = null, ?string $country = null): ?Game gives a game's whole record by one of its identifiers. Every provider answers it, each for the schemes of its capabilities()->lookupBy; a ref of another scheme is a NotSupportedException before any call, an unknown game null.

use Omniplay\Model\GameRef;
use Omniplay\Model\ImageKind;

$portal = $stores->catalogue('steam')->game(GameRef::steam(620), 'fr', 'FR');

$portal->summary;                                    // plain text, in French
$portal->price?->amount;                             // minor units, in France's currency
$portal->image(ImageKind::Header)?->url;             // served from Steam's servers, under its terms

The record Steam's provider reads from the answer its tests recorded on 2026-10-24 (in French, for France), printed in the harness on 2026-10-27 - each field of the Game, then the call:

Portal 2 | 2011-04-18 | 969 EUR initial - -0%
  ids {"steam":"620"} platforms ["windows","linux"]
  dev ["Valve"] pub ["Valve"] genres ["Action","Aventure"] tags ["Solo","Multijoueur","Coopération"]
  summary L'« initiative de tests perpétuels » a été étendue pour vous permettre
  header https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/faffc0f560786e2f05104a8d2fac837c6969bf13/header.jpg?t=1790187113
  background https://store.akamai.steamstatic.com/images/storepagebackground/app/620?t=1790187113
  screenshot https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/620/ss_f3f6787d74739d3b2ec8a484b5c994b3d31ef325.1920x1080.jpg?t=1790187113
  rating metacritic 95/100
  https://store.steampowered.com/app/620/
  > GET https://store.steampowered.com/api/appdetails {"appids":"620","cc":"FR","l":"french"}

From a catalogue to the stores

IGDB and RAWG know a game's identifiers on the stores; Game::ref() turns one into the key to a store's record, and Registry::game() asks the providers in turn:

use Omniplay\Model\GameRef;
use Omniplay\Model\Scheme;

$entry = $stores->catalogue('igdb')->game(GameRef::igdb(72));          // Portal 2 at IGDB
$onSteam = null !== ($ref = $entry?->ref(Scheme::Steam)) ? $stores->game($ref, 'fr', 'FR', 'steam') : null;
$onGog = null !== ($ref = $entry?->ref(Scheme::Gog)) ? $stores->game($ref, 'fr', 'FR', 'gog') : null;

IGDB also finds a game by its Steam or GOG number (/v4/external_games), so $stores->game(GameRef::steam(620)) with IGDB configured before Steam answers IGDB's record.

Cross identifiers in a record
igdb steam, gog, epic, itch (IGDB's external game sources 1, 5, 26, 30)
rawg steam, itch, epic (read from the links of /games/{id}/stores)
steam, gog, itch their own only

Prices

Steam, GOG and itch.io give prices; IGDB and RAWG none.

Country Price
steam cc = the country asked, else country price_overview: final, initial, discount_percent; a free game is 0 in its currency, or XXX
gog /products/{id}/prices?countryCode=... (a second call) the country's own currency, finalPrice and basePrice, the discount computed
itch none: the page's price as shown price and original_price ($2.00, €4,99, $1,000.00, ¥500), the currency read from the symbol ($ USD, € EUR, £ GBP, ¥ JPY; USD when none), in minor units (none for JPY), thousands separators read as such

A price is the store's at the moment it is read: sales change it. Cache it for hours, not days, and show where it comes from.

What each provider reads

Calls Language Notable fields
steam appdetails?appids=...&cc=...&l=... Steam's names (fr -> french, 17 mapped) short description (HTML stripped), release date read in the store's languages (18 avr. 2011), coming soon, developers, publishers, genres, categories as tags, platforms, price, header, background, screenshots, Metacritic
gog /products/{id}?expand=description&locale=..., then its prices a locale (fr -> fr-FR) description's lead (else the full one), release date, in development, systems, logo, background, icon; no studios nor genres (the product API gives none)
itch https://{creator}.itch.io/{game}/data.json - title, authors as developers, tags, price, cover, screenshots
igdb Twitch token; external_games for a Steam or GOG number; /v4/games - summary, first release date, involved companies (developers, publishers), genres, themes as tags, platforms, cover and screenshots (images.igdb.com), total rating out of 100 with its count, the cross identifiers
rawg /api/games/{id}, then /api/games/{id}/stores - raw description, release date, tba as coming soon, developers, publishers, genres, 20 tags, platforms and parent platforms, background and screenshots, RAWG rating out of 5, Metacritic, the cross identifiers

Each provider's manual gives the fields in detail.

Proposer une modification docs/usage-catalogue.md

Players: libraries and ownership

LibraryInterface::library(string $player): array lists a player's games (OwnedGame: identifiers, title, playtime in minutes, last played); OwnershipInterface::owns(string $player, GameRef $game): bool says whether a player owns a game. Two providers answer them, each with a key:

Player Library Ownership
steam (key: a Steam Web API key) a 64-bit Steam ID, 17 digits (76561197960287930) IPlayerService/GetOwnedGames with app info and free games played: playtime and last played; a private profile reads as [] the library, searched for the app number (one call per check)
itch (key: a server-side API key) an itch.io user id, a verified e-mail address, or a download key; me for the library the key's own account only (/profile/owned-keys, pages of 500, 20 at most): identifiers and titles, no playtime /games/{id}/download_keys?user_id=... (or email=, download_key=) on one of your games; a page ref is turned into its number from the page's data.json first
use Omniplay\Model\GameRef;

$steam = $stores->library('steam');
foreach ($steam->library('76561197960287930') as $owned) {
    printf("%s: %d h\n", $owned->title, intdiv($owned->playtime ?? 0, 60));
}

if ($stores->ownership('itch')->owns((string) $itchUserId, GameRef::itch('my-studio/my-game'))) {
    // grant the role, the download, the forum access
}

On the answer Steam's tests use (written after the Web API's documentation), a library reads:

  {"identifiers":{"steam":"620"},"title":"Portal 2","playtime":1250,"lastPlayed":"2026-01-02T12:00:00+00:00"}

(in the harness, 2026-10-27; no Steam key was used)

Players' data

A player's library is that player's data. Valve's Web API terms allow it to be read only as that player asks, and ask for a privacy policy saying what is kept; on itch.io, an e-mail address must have been verified (by the application) before ownership is asked by it - anyone could type another's. Prefer the identifier the player signed in with (Steam's OpenID, itch.io's OAuth).

Without a key, with a wrong player

  • No key: capabilities()->library and ->ownership are false, but the provider still implements both interfaces (Models): library() throws InvalidConfigException (A player's Steam library needs the "key" option (a Steam Web API key).).
  • A Steam player that is not 17 digits: InvalidConfigException ("gabe" is not a 64-bit Steam ID.) before any call.
  • itch.io's library of anyone but me: InvalidConfigException (itch.io lets a key read the library of its own account only: ask for "me".).
  • itch.io's answer "no download key found" (or "invalid download key") is false, whatever its status; an unknown game page is false.
  • A game of another scheme: NotSupportedException (Steam does not find a game by its gog identifier.).

GOG, IGDB and RAWG have no players: GOG offers no way for a third party to read a player's games.

Proposer une modification docs/usage-players.md

The heartbeat

A server tells the application it is alive, how many players it holds and whether it takes more, every few seconds (10 by the providers' default, under the fleet's ttl of 30). The application's route hands the request to Omniplay\Heartbeats::receive() and answers with what it returns.

The request

POST <the application's heartbeat URL>          ; OMNIPLAY_HEARTBEAT_URL in a Docker server's environment
Authorization: Bearer <token>
Content-Type: application/json

{"server": "omniplay-game-3fa9c2d1", "service": "game", "players": 5, "health": "healthy"}
  • The token is a ticket (format) signed with the service's secret, its payload {"room": "heartbeat:<server id>", "exp": <now + 60>}. Heartbeats::room() and ::token() make them in PHP; the providers that run in the game server make the same bytes (the vector "a server's heartbeat token"). A player's ticket names the server's id as its room, never heartbeat:...: it cannot pass for a heartbeat.
  • The body: server and service (required), players (0 when absent), health (starting, healthy, draining, down; healthy when absent or unknown). A server the fleet does not know adds address (required then), and may add capacity (the service's otherwise), region, tags (a list) and engine (the service's otherwise): it is registered.

The answers

Status When Body's reason
200 heard: the fleet holds the players and health sent Heard.
201 registered: unknown, with an address Registered.
400 no server or service; a value no server can hold (capacity 0) the reason
401 the token does not hold for heartbeat:<server> with the service's secret (another secret, expired, another server's, a player's ticket) The heartbeat's token does not hold for this server.
404 the service is not one the application gave Heartbeats; or the server is unknown and gave no address the reason
409 the server is known under another service the reason

HeartbeatResult::toArray() is the body: {"status": 200, "reason": "Heard.", "server": {"id": "...", "players": 5, "health": "healthy"}}. Nothing secret.

The route, in plain PHP

$heartbeats = new Heartbeats($fleet, new EnvironmentSecrets(), [$game]);
$result = $heartbeats->receive($_SERVER['HTTP_AUTHORIZATION'] ?? '', json_decode(file_get_contents('php://input'), true) ?: []);
http_response_code($result->status);
header('Content-Type: application/json');
echo json_encode($result->toArray());

A server that says down is retired at the next Fleet::retireStale(); one that says draining keeps its players and takes no new one.

Proposer une modification docs/heartbeat.md

Configuration reference

The registry's configuration

new Registry($factories, $config) takes the providers by name, each with its factory and options:

[
    '<provider name>' => [
        'factory' => '<factory name>',     // required: a factory's getName()
        'options' => [/* ... */],          // optional, default []: handed to the factory's create()
    ],
]

The order matters to Registry::game() called without names. Options are checked when the provider is first asked for. Every factory takes an optional HTTP client in its constructor and makes its own (HttpClient::create(['timeout' => 20])) when given none. An empty string is the same as an option not given.

omniplay/steam

Factory Omniplay\Steam\SteamFactory, name steam.

Option Type Default Required
key string null no a Steam Web API key: only for players' libraries and ownership; sent in the query string (key=)
language string english no the store's language: Steam's name (french) or an ISO code (fr, pt-BR, zh-TW: 17 mapped)
country string US no the prices' country (cc)
user_agent string omniplay/1.0 (+https://github.com/glitchr-omnisense/omniplay) no sent with every request
store string https://store.steampowered.com no the storefront
api string https://api.steampowered.com no the Web API
appid int null for identity the game's Steam app id (an unsigned 32-bit number); without it, the provider verifies no player
publisher_key string STEAM_PUBLISHER_KEY for identity the publisher Web API key's reference, read through the factory's SecretsInterface (the environment by default) at each call
identity string null no the string the game passes to GetAuthTicketForWebApi; Steam then authenticates only the tickets made with it
refuse_banned bool false no a player banned by VAC or by the publisher is refused rather than answered with the ban in the claims
partner_api string https://partner.steam-api.com no the host AuthenticateUserTicket is asked of (https://api.steampowered.com accepted)

omniplay/gog

Factory Omniplay\Gog\GogFactory, name gog.

Option Type Default Required
locale string en-US no the texts' locale; a two-letter code is widened (en -> en-US, fr -> fr-FR)
country string US no the prices' country
currency string USD no the search's prices; a record's price is in the country's own currency
user_agent string omniplay/1.0 (+https://github.com/glitchr-omnisense/omniplay) no
api string https://api.gog.com no products and prices
catalog string https://catalog.gog.com no the search

omniplay/itch

Factory Omniplay\Itch\ItchFactory, name itch.

Option Type Default Required
key string null no a server-side API key (account settings, or OAuth): only for ownership and library; sent as Authorization: Bearer
user_agent string omniplay/1.0 (+https://github.com/glitchr-omnisense/omniplay) no
api string https://api.itch.io no the server-side API (pages are read from https://{creator}.itch.io)

omniplay/igdb

Factory Omniplay\Igdb\IgdbFactory, name igdb.

Option Type Default Required
client_id string - yes a Twitch application's client id, sent as Client-ID
client_secret string - yes its secret, for the client-credentials token
api string https://api.igdb.com/v4 no
token_url string https://id.twitch.tv/oauth2/token no
images string https://images.igdb.com/igdb/image/upload no covers and screenshots

omniplay/rawg

Factory Omniplay\Rawg\RawgFactory, name rawg.

Option Type Default Required
api_key string - yes sent in the query string (key=)
user_agent string omniplay/1.0 (+https://github.com/glitchr-omnisense/omniplay) no
api string https://api.rawg.io/api no

Per call

A call's language and country replace the provider's options for that call; the Query carries them for a search (Models).

The fixed provider (testing)

Omniplay\Testing\FixedProviderFactory, name fixed, takes its games and libraries in its constructor (new FixedProviderFactory($games, $libraries)) and no option.

Online play: the core classes, hosting and identity

The registry

new Registry([new DockerFactory($http), new FixedHostingFactory()], [
    'docker' => ['factory' => 'docker', 'options' => [/* the provider's */]],
    'test' => ['factory' => 'fixed-hosting', 'options' => ['services' => ['game'], 'regions' => ['eu']]],
]);

The core's classes

Class Arguments Defaults
Fleet store, ttl, startup, clock 30 s, 120 s, time()
Tickets secrets, clock time()
Seating fleet, tickets, hosting, secrets no hosting: no server started; secrets required with a hosting
Heartbeats fleet, secrets, services, clock time()
Service name, url, secret (reference), ttl, capacity, engine null, OMNIPLAY_TICKET_SECRET, 60 s, 16, null

A clock is a \Closure(): int giving Unix seconds.

fixed-hosting (Omniplay\Testing\FixedHostingFactory)

Option Default
services [] (any) the services it starts
regions ['local']
host, port 127.0.0.1, 7000 the n-th server is host:(port + n)

fixed-identity (Omniplay\Testing\FixedIdentityFactory)

Option Default
players [] the player's id by proof
platform test

docker (omniplay/docker)

See its README: services (required; by service name: image, port, env, command, address, network, labels, host_port, memory, cpus), host, api_version, public_host, region, secret_env, name_prefix, auto_remove, stop_timeout, tls, heartbeat_url, user_agent.

edgegap (omniplay/edgegap)

See its README: services (required; by service name: application, version, port, address, env, internal_port, cpu_units, memory_mib, require_cached_locations, tags), token (the API token's reference), regions, api, secret_env, name_prefix, heartbeat_url, wait, poll_interval, user_agent.

steam (omniplay/steam)

Its identity options - appid, publisher_key, identity, refuse_banned, partner_api - are in omniplay/steam above, beside the store's.

What a server is handed

A server started by a provider finds in its environment, whatever its engine:

Variable
OMNIPLAY_TICKET_SECRET (or the provider's secret_env) the secret its tickets are checked with
OMNIPLAY_SERVER_ID, OMNIPLAY_ROOM its id, the room its tickets name
OMNIPLAY_SERVICE, OMNIPLAY_CAPACITY, OMNIPLAY_PORT its service, seats, the port it listens on
OMNIPLAY_ENGINE the service's declared engine, when set
OMNIPLAY_HEARTBEAT_URL where to send its heartbeats, when configured

The providers that run in the game server read these by default.

Proposer une modification docs/configuration.md

Seating

$seating = new Seating($fleet, new Tickets($secrets), $hosting, $secrets);
$seat = $seating->seat($game, new SeatRequest('player-42', region: 'eu', tags: ['ranked'], claims: ['name' => 'Ada']));
  1. The candidates: FleetInterface::available($service, $region) - the servers of the service in the region asked (any, when null), with a seat left, healthy and heard from within the fleet's ttl (30 s), or starting and registered within its startup wait (120 s) - whose tags hold every tag asked.
  2. The order: ready servers before starting ones, then the fullest first, then by id. Matches fill before new servers are used, and a server started for the previous player is used by the next one rather than another started.
  3. The seat: occupy() counts one more player on the first candidate that still has room (the next heartbeat gives the true count). None has room: when a hosting is given and its capabilities say it starts the service in that region, a server is started, the service's secret handed to it, registered and occupied (Seat::$started, the client retries its address until the ticket expires). Otherwise the answer is null.
  4. The ticket: Tickets::mint(), room the server's id, claims sub (the player, which a claim of that name does not replace) then the request's claims, exp now plus the service's ttl.

$seat->toArray() is what the client is handed. The server checks the ticket with OMNIPLAY_ROOM (its id) as the room.

Retiring

foreach ($fleet->retireStale() as $server) {
    if (null !== $server->provider && $hosts->has($server->provider)) {
        $hosts->hosting($server->provider)->stop($server);
    }
}

Run it every half minute (a scheduler, a cron). A server is stale after 30 s without a heartbeat (120 s while starting) or when it said down. Server::$provider is the provider's own name (docker): configure it under the same name in the registry, or keep a map of your own.

After a restart

An InMemoryStore is gone with its process; the application's own store survives it. A provider's running() lists what it runs, to register again:

foreach ($hosts->hosting('docker')->running('game') as $server) {
    $fleet->get($server->id) ?? $fleet->register($server);
}

Proposer une modification docs/usage-seating.md

Player identity

A seat names its player by a string the application chooses. When the player comes from a platform that can vouch for them - Steam, a console's network, a store - the application can have that platform verify the player's proof first, and seat the player under the id the platform gave. That is the identity capability. It is optional: nothing in omniplay requires an identity provider, and a SeatRequest takes a plain string as before.

It runs on the platform, in the application, as hosting does: the game client gets a proof from the platform's SDK (a Steam session ticket), sends it to the application with its request to play, and the application asks the platform whether it holds. The game server is not involved: it reads the player's id from the omniplay ticket, as it always does.

The contract

interface IdentityInterface
{
    public function platform(): string;               // "steam"
    public function verify(string $proof): Identity;  // or IdentityRefusedException, saying why
}

verify() answers the player the platform vouches for, or throws:

Exception When
IdentityRefusedException the platform refused the proof (Steam refused the ticket: Invalid ticket (error 101).), or the proof is not one and the platform was not asked
ProviderException the platform answered with an error status, or with no verdict
UnreachableException the platform could not be reached

A refusal is never silent and never a null: a player whose proof does not hold is not seated under a guessed id. Whether to seat them anyway, under a guest id, is the application's choice.

Identity

new Identity($platform, $id, $claims = []): the platform (lower case: steam), the player's id there (a SteamID64), and what the platform said besides (owner, vac_banned... for Steam). player() - steam:76561197960287930 - is the player's id across platforms; toArray() gives player, platform, id, claims.

In the registry

A provider that verifies players says so in its capabilities (identity: true; contracts() lists identity). The registry finds it as it finds a hosting provider:

use Omniplay\Model\SeatRequest;
use Omniplay\Registry;
use Omniplay\Steam\SteamFactory;

$providers = new Registry([new SteamFactory(), new DockerFactory()], [
    'steam' => ['factory' => 'steam', 'options' => ['appid' => 480, 'publisher_key' => 'STEAM_PUBLISHER_KEY', 'identity' => 'omniplay']],
    'local' => ['factory' => 'docker', 'options' => [/* ... */]],
]);

$player = $providers->identityFor('steam')?->verify($request->get('ticket'));   // Identity, or a refusal
$seat = $seating->seat($game, new SeatRequest($player, 'eu'));
$seat?->toArray();   // the ticket's "sub" is "steam:76561197960287930"
  • identity($name): the provider configured under that name, or a NotSupportedException when it verifies no player.
  • identityFor($platform, ...$names): the first provider (in the configuration's order, or the order named) that verifies that platform's players; null when none does - the application then names its players itself.

In a seat

new SeatRequest($identity, $region, $tags, $claims): the player's id is $identity->player(), and $request->identity keeps the Identity. Seating puts the player's id in the ticket's sub, as for any player; the platform's claims stay with the application unless it passes some as the request's $claims. Tickets is unchanged.

Providers

Package Platform Proof
omniplay/steam steam a session ticket from GetAuthTicketForWebApi, hex-encoded, verified with ISteamUserAuth/AuthenticateUserTicket
Testing\FixedIdentity (factory fixed-identity) test, or the one given the proofs it was given

A provider of another platform implements ProviderInterface and IdentityInterface, says identity: true in its capabilities, reads its platform key by reference (SecretsInterface), and refuses aloud (Writing a provider).

Proposer une modification docs/identity.md

Errors

Every exception Omniplay throws implements Omniplay\Exception\OmniplayException (a \Throwable):

Exception Extends When
InvalidArgumentException \InvalidArgumentException a search without words, a limit outside 1-50, an empty identifier, a register name that is not one
InvalidConfigException \InvalidArgumentException a provider not configured, a factory not installed, a required option missing; Steam's or itch.io's players without a key; a Steam player that is not a 64-bit Steam ID; itch.io's library of anyone but me
NotSupportedException \LogicException a contract the provider lacks (Registry::search('itch')), an identifier it does not resolve. Thrown before any call
ProviderException \RuntimeException a status of 400 or more but 404, a body that is not JSON, Twitch giving no token
UnreachableException \RuntimeException a network error, a timeout

Not an exception: a game the store does not know - a 404 included - is null; a search with no match []; a player who does not own the game false.

The messages

Seen in the harness on 2026-10-27, the network ones in a container with no network (those three as they read since 2026-10-29: the address the HTTP client quotes left out, and IGDB's token asked with the credentials in the body):

steam library    UnreachableException: Steam could not be reached for GET /IPlayerService/GetOwnedGames/v1/: Could not resolve host: api.steampowered.com (Could not contact DNS servers).
rawg network     UnreachableException: RAWG could not be reached for GET /api/games/portal-2: Could not resolve host: api.rawg.io (Could not contact DNS servers).
igdb network     UnreachableException: IGDB could not be reached for POST /oauth2/token: Could not resolve host: id.twitch.tv (Could not contact DNS servers).
steam no key     InvalidConfigException: A player's Steam library needs the "key" option (a Steam Web API key).
steam player     InvalidConfigException: "gabe" is not a 64-bit Steam ID.
steam gog ref    NotSupportedException: Steam does not find a game by its gog identifier.
steam unknown    NULL
steam 429        ProviderException: Steam answered 429 for GET /api/appdetails. Too Many Requests
itch library     InvalidConfigException: itch.io lets a key read the library of its own account only: ask for "me".
itch search      NotSupportedException: The "i" provider (Omniplay\Itch\ItchProvider) does not search.
igdb token       ProviderException: IGDB answered 400 for POST /oauth2/token. {"status":400,"message":"invalid client secret"}
rawg no key      InvalidConfigException: The "rawg" provider needs: api_key.
rawg 401         ProviderException: RAWG answered 401 for GET /api/games. {"error":"The key parameter is not provided"}
query limit      InvalidArgumentException: A search answers 1 to 50 games.
ref empty        InvalidArgumentException: A game is named by a non-empty identifier.

A ProviderException names the provider, the status, the method and the path - never the query - then the answer's text (tags stripped, 300 characters at most); its code is 0.

An UnreachableException names the method and the path too, then the HTTP client's reason without the address it quotes (JsonClient::reason()): Steam's Web API key and RAWG's key, which travel in the query, stay out of it. Its previous exception is a copy of the HTTP client's (Http\Redaction::copy()): the same class and code, the message with the URL's key parameters masked (key=***), its own previous copied the same way. Neither keeps its trace's arguments, where the request's query, headers and body stood (IGDB's Twitch client_secret): a log that writes the whole chain, arguments included (zend.exception_ignore_args off), holds no key. Nor does an error answered (a refused key, nothing there, a server error, a body that is not JSON): every exception JsonClient::request() throws leaves its trace's arguments out.

What to catch where

Where Catch
a page showing a store's record OmniplayException: show what the application kept, log the message privately
a check before granting access (ownership) UnreachableException and ProviderException: do not grant on an error; ask again later
a batch refresh of prices a ProviderException for a 429 or 5xx: wait and retry, at a gentler pace
building the registry, a deploy InvalidConfigException: a configuration error, to fix
a feature a provider may lack check capabilities()->contracts() and resolves() first

Online play

Every exception implements Omniplay\Exception\OmniplayException.

Exception When Message, as seen
InvalidArgumentException a value nothing can hold: an empty id, a capacity of 0, a ticket without room or expiry, claims named room or exp, a payload that is not JSON A ticket names its room: "room", a non-empty string.
InvalidConfigException a provider not configured, a factory not installed, an option missing, a secret not found No "docker" factory for the "local" game provider; installed: fixed-hosting., The "docker" provider needs: services., No secret in the environment variable "GAME_TICKET_SECRET".
NotSupportedException a provider asked for what it does not do The "other" provider (...) does not start servers., The "local" provider (...) does not verify players., Docker starts no server of the "relay" service.
ProviderException the provider answered with an error, or not JSON; its code is the HTTP status Docker answered 500 for POST /v1.44/containers/<id>/start. {"message": "...port is already allocated"}
IdentityRefusedException a platform refused a player's proof, or the proof is not one Steam refused the ticket: Invalid ticket (error 101).
UnreachableException the provider could not be reached Docker could not be reached for GET /v1.44/containers/json: <the client's reason, without the address>.

A ticket or a heartbeat that does not hold is no exception: verify() answers null, Heartbeats::receive() a HeartbeatResult with its status.

The messages

As in omniplay, JsonClient names the method and the path only, never the query, and the exception chain keeps no trace arguments (Http\Redaction): a request's options - a Docker container's environment, which holds the server's secret - are in no trace.

Proposer une modification docs/errors.md

Testing

Testing an application that reads stores with Omniplay. Writing a provider and its tests: Writing a provider.

The fixed provider as a fake

Omniplay\Testing\FixedProvider answers all four contracts from games and libraries given in advance, with no network; FixedProviderFactory($games, $libraries) registers it under the name fixed. What it does:

  • capabilities: every contract, every scheme, prices, player: 'any name', keyless;
  • search: the games whose title, lower-cased, contains every word of the query, cut to the limit (language and country ignored);
  • game(): the first game whose identifier in the ref's scheme is the ref's; else null;
  • library(): the list given under that player's name; else [];
  • owns(): whether that library holds a game with the ref's identifier.

A provider on recorded answers

Omniplay\Testing\RecordedHttpClient (the same as Omnifilm's) answers a provider's calls with no network: RecordedHttpClient::routes([regex => [status, body]]) matched against "METHOD url" with the query, a body "@/path/file.json" read from that file, 404 for anything else; or new RecordedHttpClient(fn ($method, $url, $options) => [status, body]). $requests keeps every request, header($name, $request = -1) a header as sent. It does not stream - no Omniplay provider needs it to.

use Omniplay\Model\GameRef;
use Omniplay\Model\Identifiers;
use Omniplay\Model\OwnedGame;
use Omniplay\Registry;
use Omniplay\Steam\SteamFactory;
use Omniplay\Testing\FixedProviderFactory;
use Omniplay\Testing\RecordedHttpClient;
use PHPUnit\Framework\TestCase;

final class AccessTest extends TestCase
{
    public function testAPlayerWhoOwnsTheGameGetsIn(): void
    {
        $stores = new Registry([new FixedProviderFactory(libraries: [
            '76561197960287930' => [new OwnedGame(Identifiers::of(['steam' => 620]), 'Portal 2')],
        ])], ['steam' => ['factory' => 'fixed']]);

        self::assertTrue($stores->ownership('steam')->owns('76561197960287930', GameRef::steam(620)));
        self::assertFalse($stores->ownership('steam')->owns('76561197960287931', GameRef::steam(620)));
    }

    public function testWhatSteamIsAsked(): void
    {
        $http = RecordedHttpClient::routes([
            '~/IPlayerService/GetOwnedGames/v1/\?key=test-key&steamid=76561197960287930~' => [200, '{"response":{"games":[{"appid":620,"name":"Portal 2","playtime_forever":90}]}}'],
        ]);
        $steam = (new SteamFactory($http))->create(['key' => 'test-key']);

        self::assertSame(90, $steam->library('76561197960287930')[0]->playtime);
        self::assertSame('omniplay/1.0 (+https://github.com/glitchr-omnisense/omniplay)', $http->header('User-Agent'));
    }
}
$ vendor/bin/phpunit AccessTest.php
..                                                                  2 / 2 (100%)

OK (2 tests, 4 assertions)

(run on 2026-10-27 in the harness, PHPUnit 11.5, PHP 8.4)

A network error is tested with Symfony's MockHttpClient and a MockResponse whose error option is set: it becomes an UnreachableException.

The bare test case

Omniplay\Testing\BareTestCase is the check every package of the family runs in its BareTest: each of its classes loaded in a PHP process of its own, with nothing from outside but omnisense/omniplay and what the package requires. A package names its entryClass() and its requiredPackages() (symfony/http-client-contracts for every provider); allowedNamespaces() lists namespaces FOREIGN matches that its requirements may load (none is declared). FOREIGN: Symfony's classes but its contracts and polyfills, Illuminate, Laravel, Doctrine, Twig, League, Guzzle, Nyholm, Http, Omnibridge, Base, App. A second test checks that the report is not blind (a MockHttpClient loaded on purpose is seen). Installed in an application, the check is skipped.

In a framework's functional test

The registry is a service of the application's: in Symfony, define it with FixedProviderFactory in config/services_test.yaml, or replace http_client with a MockHttpClient in the test environment; in Laravel, bind another Registry singleton in the test's setUp().

Online play

  • Omniplay\Testing\FixedHosting (factory fixed-hosting): a hosting provider that starts nothing. The n-th server is <service>-n at host:(port + n), Starting; $started, $stopped and $secrets (the secret each was handed) are public for a test to read.
  • Omniplay\Testing\FixedIdentity (factory fixed-identity, options players, the player's id by proof, and platform): a platform that vouches for the players it was given and refuses any other proof; $asked keeps every proof.
  • Storage\InMemoryStore and Secret\FixedSecrets.
  • A clock: Fleet, Tickets and Heartbeats take a \Closure(): int.
  • Testing\RecordedHttpClient: an HTTP client answering by route, keeping every request ($requests, header()); a provider's calls checked with no network.
  • Testing\BareTestCase: each class of a package loaded in a PHP process of its own with nothing but omniplay and what the package requires (no framework, no omnibase).
  • Tests/vectors.json: the tickets every verifier is checked against (Tickets).
$now = 1800000000;
$fleet = new Fleet(new InMemoryStore(), 30, 120, function () use (&$now): int { return $now; });
$fleet->register(new Server('a', 'game', '127.0.0.1:7777', 8));
$fleet->heartbeat('a', 3);
$now += 31;
$fleet->healthy('game');   // []

Proposer une modification docs/testing.md

The Docker harness

docker/ runs the core with every provider in PHP 8.4 (Alpine), nothing installed on the host, in the image omnisense/omniplay-harness:

cd docker
echo 'OMNIPLAY_PLUGINS=../..' > .env      # the providers' checkouts beside the core
docker compose run --rm omniplay test     # every package's tests, recorded answers, no network
docker compose run --rm omniplay bare     # the bare script: what omniplay does and what PHP loaded
docker compose run --rm omniplay live     # the checks that need no key (Steam's store, GOG, itch.io pages)
docker compose run --rm omniplay phpstan  # PHPStan, highest level, each package
docker compose down -v                    # the harness's volume gone

What it installs

At each start, docker/harness/setup.php writes the harness's composer.json and phpunit.xml in the volume (/harness); the entrypoint runs composer install the first time and composer update when the composer.json changed:

  • the core from its checkout (/omniplay/core, a path repository, symlinked);
  • each provider of docker/harness/plugins.php (steam, gog, itch, igdb, rawg) from a checkout under OMNIPLAY_PLUGINS when there is one - docker/.env, git-ignored - else from GitHub (https://github.com/glitchr-omnisense/omniplay-<slug>.git, branch 1.x);
  • PHPUnit 11, PHPStan 2.1 and symfony/http-client for the tests; one suite per package.

The service uses Docker's default bridge network and one named volume, omniplay-harness_harness.

The commands

Command Runs
test (the default) vendor/bin/phpunit --exclude-group live
live vendor/bin/phpunit --group live: Steam's store (Portal 2's record and price, a search), GOG's catalogue (a product, a search), an itch.io page
bare docker/harness/bin/bare: a registry with the fixed provider (capabilities, a search, a game, platforms folded, a library, ownership) and the fixed hosting, two players seated (one on a server started for them) and their tickets checked, then JsonClient with no network (a record, a 404, a 500); the results and every class and file PHP loaded, as JSON
phpstan PHPStan on the core and each provider
composer ..., php ..., sh as they are, in /harness
$ docker compose run --rm omniplay test
OK (44 tests, 245 assertions)

(2026-10-27, every provider from its checkout; phpstan the same day: no error in the six packages). Some of what bare printed:

search ["Portal 2"]
platforms ["windows", "mac", "linux", "playstation"]
owns [true, false]
json-404 null
json-error "Example answered 500 for GET /games/3. Internal error"

Running a script of your own

docker compose run --rm -v "$PWD/scratch:/scratch" omniplay php /scratch/game.php

with require '/harness/vendor/autoload.php'; in the script. With no network at all:

docker run --rm --network none \
    -v omniplay-harness_harness:/harness:ro -v "$PWD/..:/omniplay/core:ro" -v "$PWD/../..:/omniplay/plugins:ro" \
    -v "$PWD/scratch:/scratch:ro" --entrypoint php omnisense/omniplay-harness /scratch/game.php

Online play in the harness

docker/: PHP 8.4 (alpine) with Composer, the core from its checkout and each provider of docker/harness/plugins.php from a checkout under OMNIPLAY_PLUGINS when there is one, from GitHub (branch 1.x) otherwise.

cd docker
printf 'OMNIPLAY_PLUGINS=../..\nOMNIBASE_CORE=../../../omnibase/core\n' > .env   # git-ignored
docker compose run --rm omniplay test      # every package's tests
docker compose run --rm omniplay bare      # the plain-PHP script and what it loaded
docker compose run --rm omniplay phpstan   # PHPStan max on each package
docker compose run --rm -v /var/run/docker.sock:/var/run/docker.sock omniplay live   # the Docker provider against a real engine
docker compose down -v

OMNIBASE_CORE, an omnibase checkout, is mounted read-only for OmnibaseParityTest; without it that test is skipped. The live run starts a container of an image the engine already has (php:8.4-cli, or OMNIPLAY_LIVE_IMAGE), checks it, and stops it.

Proposer une modification docs/harness.md

Writing a provider

A provider is a package omniplay/<name>, namespace Omniplay\<Name>\, requiring omnisense/omniplay and symfony/http-client, written as Omnifilm's are (Omnifilm's guide has a skeleton that runs; replace Work with Game, WorkRef with GameRef):

  1. The factory extends ProviderFactory (populate(): omniplay.factory_name, omniplay.factory_title, omniplay.required_options, the defaults; build(): the provider), its constructor taking ?HttpClientInterface $http = null and falling back on HttpClient::create().
  2. The provider implements ProviderInterface and the capability interfaces it has, with honest capabilities(): a library or ownership offered only when the key it needs is configured, player saying what a player is, lookupBy the schemes game() resolves, the attribution and the terms' URLs. game() throws NotSupportedException::ref() for a scheme it does not resolve and returns null for a game it does not know.
  3. Calls go through Http\JsonClient, answers are read with Http\Data. A key goes in a header (or a body) when the store accepts one: messages leave the query out (Errors), and so does the exception chain, but the HTTP client's own logs do not. A provider that calls its own library wraps its failure as JsonClient does: Redaction::withoutArguments(new UnreachableException(..., 0, Redaction::copy($e, Redaction::secrets($url, $options)))).
  4. The record: prices in minor units with their ISO 4217 currency; platforms through Platform::fromLabels(); HTML stripped from summaries; the identifiers of every other store the source lists; what it does not give left empty.
  5. Tests: recorded answers (captured when no key is needed, written after the documentation otherwise, and said so), a BareTest extending Testing\BareTestCase, a LiveTest in the group live for what needs no key. Then a line in docker/harness/plugins.php.
  6. Read only: no purchase, no key granted, nothing written.

The providers' terms, in short

Provider Attribution Limits and use
steam Valve's trademarks; no implied endorsement; links to Valve not "nofollow" Web API: 100,000 calls a day; Steam data about a player only as that player asks; a privacy policy for what is kept. The store's endpoints are not documented by Valve: a gentle pace, answers kept
gog - no published API terms: the store's own endpoints, a gentle pace, answers kept
itch - the server-side API's scopes; an e-mail must be verified before an ownership lookup by e-mail
igdb IGDB.com Twitch's developer agreement; 4 requests a second
rawg an active hyperlink to RAWG on every page that shows its data free up to 20,000 requests a month; commercial use free under 100,000 monthly active users

Writing a hosting or identity provider, or a server-side one

A provider on the platform starts and stops servers, or verifies players for a platform. It is a package omniplay/<name>, namespace Omniplay\<Name>\, requiring omnisense/omniplay (and symfony/http-client when it calls an API), written as omniplay's are:

  1. The factory extends ProviderFactory (populate(): omniplay.factory_name, omniplay.factory_title, omniplay.required_options, the defaults; build(): the provider), its constructor taking ?HttpClientInterface $http = null.
  2. The provider implements ProviderInterface and HostingInterface, with honest capabilities(): the services it has a configuration for, its regions. start() refuses (NotSupportedException::service()) what it does not start; it hands the server its secret and the variables of Configuration, and returns the server Starting, its provider and instance set, the secret in nothing it returns. stop() is quiet about a server already gone. running() rebuilds servers from what the platform keeps of them (Docker's labels).
  3. Calls go through Http\JsonClient; a failure of its own wrapped as JsonClient does.
  4. Tests: recorded answers written after the API's reference (and said so), a BareTest, a LiveTest in the group live when there is an account to run it with. Then a line in docker/harness/plugins.php.

A provider of player identity (omniplay/steam) implements ProviderInterface and IdentityInterface instead: capabilities() says identity: true, platform() names the platform, verify() answers an Identity or throws an IdentityRefusedException that quotes the platform's reason - never null, never a guessed id. The platform's key is an option holding its reference, read through a SecretsInterface at each call (the factory takes one, the environment by default), so that the key is in no option, no exception and nothing returned. See Player identity.

The providers that run inside the game server (omniplay/dotnet, /mirror, /netcode, /godot, /node) implement no PHP contract: they verify tickets as Tickets says, send heartbeats as the heartbeat says, and are checked against Tests/vectors.json.

Proposer une modification docs/providers.md

Verified, and not

After the merge (2026-11-09)

The omnigame family merged into omniplay. In the harness, every PHP package from its checkout, PHP 8.4.27, PHPUnit 11.5: 145 tests, 760 assertions, green (the live group left out). By package, live tests included: the core 75 (its 62 before and omnigame core's 16, the two bare tests now one), omniplay/steam 18 (the identity's 8 and the store's 10, one BareTest for both), omniplay/docker 11, omniplay/edgegap 13, omniplay/itch 14, omniplay/gog 7, omniplay/igdb 6, omniplay/rawg 6. PHPStan at its highest level, the baseline empty, on the core, steam, docker, edgegap, gog and rawg; igdb and itch keep one error each in their tests, there before the merge. OmnibaseParityTest ran against omnibase's TicketSigner, all 27 vectors the same. Inside the game server, unchanged by the merge: omniplay/node 35 tests green (Node 26.12), omniplay/dotnet 36 (.NET SDK 10.0.403); their vectors and godot's identical to Tests/vectors.json (cmp); omniplay/mirror and omniplay/netcode compiled against stubs on 2026-11-08, not again; omniplay/godot not run (no Godot binary).

The stores, as verified before the merge

Run on 2026-10-27 in the harness, every package from its checkout, PHP 8.4, PHPUnit 11.5: 44 tests, 245 assertions, green; PHPStan at its highest level on the six packages, no error.

  • The core: identifiers, references, the query's limits, a game's refs, pictures and platforms, Platform::fromLabels(), capabilities and resolves(), the registry (providers built once, a capability lacking, a missing option, game() asking the first provider that resolves), Data and JsonClient; a BareTest loading nothing but Omniplay and the HTTP client contracts.
  • Each provider: its calls and its reading of the answers, unknown as null, a refusal as an exception, and a BareTest.
Provider Answers its tests use
steam the store's answers recorded on 2026-10-24 (Portal 2 in French, an unknown app, a search); the library on an answer shaped after the Web API's documentation
gog recorded on 2026-10-24 (The Witcher in French with its French price, an unknown product, a search)
itch X-Moon's page recorded on 2026-10-24; ownership and library shaped after the server-side API's documentation
igdb shaped after IGDB's documentation, written by hand
rawg shaped after RAWG's documentation, written by hand

There is no conformance suite shared by the providers.

What was run live

  • docker compose run --rm omniplay live, 2026-10-24 (README): Steam's store (Portal 2's record and price, a search), GOG's catalogue (a product, its price, a search), an itch.io page.
  • For this manual, 2026-10-27, 06:17-06:24 UTC: GOG's product and price (The Witcher in France, two calls, twice) and itch.io's page (X-Moon, one call) answered; Steam's store could not be reached from the harness's network (the TLS connection cut, twice): its record in this manual is the recorded one.

What is not verified

  • Steam's library and ownership, itch.io's ownership and library, IGDB and RAWG: no key was used.
  • IGDB's move of external games from category to external_game_source: both are read, the lookup asks the latter - not seen live.
  • How itch.io answers "no download key found" (its status): read as "not owned" whatever the status.
  • The Symfony and Laravel pages were written from the classes' constructors, not run.

Known defects

None left: those the manuals found were fixed on 2026-10-29.

Responsibility

omnisense/omniplay and its omniplay/* providers are independent, unofficial clients: none is affiliated with, endorsed by or sponsored by the stores and services it reaches. Each keeps its own terms (accounts and keys, rate limits, commercial use, trademarks, players' data), named in the README and the manual of each provider. The MIT licence covers this code only and grants no right to any service or to its data; whoever runs these packages complies with those terms.

Online play, as verified before the merge (2026-11-08)

Before the merge, online play alone:

  • The harness (PHP 8.4.27): 94 tests green - the core 62, omniplay/docker 10, omniplay/edgegap 12, omniplay/steam 10 - and PHPStan at its highest level on each with an empty baseline. Among them: every vector of Tests/vectors.json (5 signed, 22 verified) signed and verified by TicketSigner, and answered the same by omnibase's Base\Service\Ticket\TicketSigner (OmnibaseParityTest, omnibase mounted read-only); BareTest (nothing loaded but omniplay and the HTTP client contracts), in each of the four packages.
  • The identity capability (IdentityTest): a seat under a verified Identity, its ticket's sub the platform's id; the registry's identity() and identityFor(); nothing requires an identity provider (every test before it unchanged and green).
  • omniplay/edgegap and omniplay/steam: their requests checked one by one on Symfony's MockHttpClient against answers written after Edgegap's OpenAPI specifications (v2 2026.08.05, v1) and the Steamworks Web API reference; neither called live.
  • Live, Docker (Docker Desktop, engine 29.8.2, through /var/run/docker.sock mounted in the harness): a php:8.4-cli container started by DockerHosting, listed by running(), its environment holding OMNIPLAY_TICKET_SECRET and OMNIPLAY_ROOM, stopped and removed by Docker (AutoRemove); 1 test, 6 assertions, 10.2 s.
  • The providers inside the game server, on the same Tests/vectors.json (copied, identical by cmp):
    • omniplay/node: node --test on Node 26.12, 35 tests green (5 sign vectors from the payload and from the JSON, 22 verify vectors, the heartbeat vector, heartbeats posted to an HTTP server of the test's process).
    • omniplay/dotnet: dotnet test on the .NET SDK 10.0.403, 36 tests green (the same vectors, the heartbeat client, TicketGate); the library built for netstandard2.1, warnings as errors.
    • omniplay/mirror and omniplay/netcode: compiled with omniplay/dotnet against Unity 6000.3's UnityEngine.CoreModule and stubs carrying the signatures of Mirror 93.0.0 and of com.unity.netcode.gameobjects 1.12.0 and 3.2.1: 0 errors, 0 warnings. Not imported in an editor.
    • omniplay/godot: not run (no Godot binary at hand); its tests run the same vectors.

Not verified:

  • Edgegap live (no account): a deployment, its status, its listing, its stop; whether Edgegap takes : and = in a tag (its specification gives 40 characters at most, no character set); current_status values other than the specification's example (the provider reads running, error and the ports instead).

  • Steam live (no publisher key, no game ticket): AuthenticateUserTicket's response shape (response.params, response.error) is the one Steam's clients read, not printed in Steam's reference.

  • A game server of any engine seated end to end (a client connecting with its ticket); a heartbeat sent by a provider inside the game server and answered by Heartbeats (the vectors prove the token is the one PHP makes).

  • A Docker Engine over TCP or TLS (the requests are checked against recorded answers only).

  • Concurrency: two Seating::seat() at once on one store may both occupy the last seat; the next heartbeat gives the true count. A store with atomic updates is the application's.

Proposer une modification docs/verified.md