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
Gameback. - 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
- 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. - 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. - 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. - 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.
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
Servicewith 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
- Installation: requirements, a first script in plain PHP, the stores' APIs.
- Symfony and Laravel: wiring the registry in a framework (no bridge yet: a service definition of the application's).
- Models: providers, capabilities, factories, the registry, every model and enum.
- Usage: search, store records and prices, players: libraries and ownership, seating (how a server is chosen, started, and a ticket minted).
- Tickets: the format, field by field, with its vectors; the heartbeat: the protocol between a server and the application.
- Player identity: a player verified by a platform, seated under that id.
- Configuration reference: the registry's configuration and every provider's options.
- Errors: every exception and when it is thrown.
- Testing: the fixed providers, the recorded HTTP client, the bare test case.
- The Docker harness.
- Writing a provider, and the providers' terms.
- Verified, and not.
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/omniplayrequiressymfony/http-client-contracts(^3.0) and PHP'smbstringextension (ext-mbstring: the core shortens error bodies withmb_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
mbstringextension, 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 typedsearch(),catalogue(),library()andownership(), andgame(), which asks each provider in turn; - references: a
GameRefnames 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()readosxasmac,SteamOSaslinux).
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
Registryonce, 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 = []):$playera string, or anIdentitya platform verified (then the player's id isIdentity::player()and$identitykeeps 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 withoutroomandexp.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:
- An empty secret verifies nothing.
- The signature is what follows the last dot; no dot, nothing.
hmac_sha256(secret, body)in lower-case hex equals the signature, compared in constant time (a signature in capitals does not hold).- The body, base64url-decoded (padding restored), is JSON whose top level is an object.
roomis a string equal to the room served (a number5is not the room"5").expis 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.- The payload is answered whole:
room,expand 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
Search
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()->libraryand->ownershiparefalse, but the provider still implements both interfaces (Models):library()throwsInvalidConfigException(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 isfalse. - 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, neverheartbeat:...: it cannot pass for a heartbeat. - The body:
serverandservice(required),players(0 when absent),health(starting,healthy,draining,down;healthywhen absent or unknown). A server the fleet does not know addsaddress(required then), and may addcapacity(the service's otherwise),region,tags(a list) andengine(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']));
- 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. - 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.
- 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. - The ticket:
Tickets::mint(), room the server's id, claimssub(the player, which a claim of that name does not replace) then the request's claims,expnow 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 aNotSupportedExceptionwhen 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(factoryfixed-hosting): a hosting provider that starts nothing. The n-th server is<service>-nathost:(port + n), Starting;$started,$stoppedand$secrets(the secret each was handed) are public for a test to read.Omniplay\Testing\FixedIdentity(factoryfixed-identity, optionsplayers, the player's id by proof, andplatform): a platform that vouches for the players it was given and refuses any other proof;$askedkeeps every proof.Storage\InMemoryStoreandSecret\FixedSecrets.- A clock:
Fleet,TicketsandHeartbeatstake 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 underOMNIPLAY_PLUGINSwhen 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-clientfor 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):
- The factory extends
ProviderFactory(populate():omniplay.factory_name,omniplay.factory_title,omniplay.required_options, the defaults;build(): the provider), its constructor taking?HttpClientInterface $http = nulland falling back onHttpClient::create(). - The provider implements
ProviderInterfaceand the capability interfaces it has, with honestcapabilities(): a library or ownership offered only when the key it needs is configured,playersaying what a player is,lookupBythe schemesgame()resolves, the attribution and the terms' URLs.game()throwsNotSupportedException::ref()for a scheme it does not resolve and returnsnullfor a game it does not know. - Calls go through
Http\JsonClient, answers are read withHttp\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 asJsonClientdoes:Redaction::withoutArguments(new UnreachableException(..., 0, Redaction::copy($e, Redaction::secrets($url, $options)))). - 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. - Tests: recorded answers (captured when no key is needed, written after the documentation
otherwise, and said so), a
BareTestextendingTesting\BareTestCase, aLiveTestin the grouplivefor what needs no key. Then a line indocker/harness/plugins.php. - 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:
- 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. - The provider implements
ProviderInterfaceandHostingInterface, with honestcapabilities(): 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, itsproviderandinstanceset, 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). - Calls go through
Http\JsonClient; a failure of its own wrapped asJsonClientdoes. - Tests: recorded answers written after the API's reference (and said so), a
BareTest, aLiveTestin the grouplivewhen there is an account to run it with. Then a line indocker/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 andresolves(), the registry (providers built once, a capability lacking, a missing option,game()asking the first provider that resolves),DataandJsonClient; aBareTestloading 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 aBareTest.
| 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
categorytoexternal_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 byTicketSigner, and answered the same by omnibase'sBase\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 verifiedIdentity, its ticket'ssubthe platform's id; the registry'sidentity()andidentityFor(); 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
MockHttpClientagainst 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.sockmounted in the harness): aphp:8.4-clicontainer started byDockerHosting, listed byrunning(), its environment holdingOMNIPLAY_TICKET_SECRETandOMNIPLAY_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 bycmp):- omniplay/node:
node --teston 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 teston 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.CoreModuleand 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.
- omniplay/node:
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_statusvalues other than the specification's example (the provider readsrunning,errorand 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.