Skip to main content

unified_game_services 0.2.1 copy "unified_game_services: ^0.2.1" to clipboard
unified_game_services: ^0.2.1 copied to clipboard

Unified, multi-platform game services API for Dart (achievements, leaderboards, cloud save and more).

unified_game_services #

One Dart API for game services โ€” achievements, leaderboards, stats, cloud save, profiles and presence โ€” across many providers (Steam, Epic, GameJolt, and your own custom backends), with no dependency on Flutter.

Write your integration once and publish to several platforms at the same time. Pure Dart, so it runs in Flutter apps, CLIs, servers and game engines alike.

Features #

  • ๐ŸŽฎ Unified API: achievements, leaderboards, stats, cloud save, friends, presence.
  • ๐Ÿ”Œ Pluggable providers โ€” target several at once.
  • ๐Ÿงฉ Multi-provider fan-out: one submitScore writes to every capable provider.
  • ๐Ÿ› ๏ธ Bring your own backend (Supabase, Firebase, RESTโ€ฆ) as a custom provider.
  • ๐Ÿชถ Pure Dart โ€” no Flutter dependency.
  • ๐Ÿงช Capability-gated, with a typed error hierarchy and an event stream.

Install #

Upgrading from 0.1? See MIGRATION.md.

dependencies:
  unified_game_services: ^0.2.0
  # plus the provider(s) you need:
  unified_game_services_steam: ^0.2.0
  unified_game_services_gamejolt: ^0.2.0

This package re-exports the shared models, capabilities, events and exceptions, so a single import is enough.

Provider setup differs. Pure-REST providers like GameJolt are zero-config (just credentials). Native providers like Steam need a one-time native-library setup that pub.dev cannot deliver for you (Valve's SDK can't be redistributed) โ€” run dart run unified_game_services_steam:setup and see the Steam package README.

Usage #

import 'package:unified_game_services/unified_game_services.dart';
import 'package:unified_game_services_steam/unified_game_services_steam.dart';
import 'package:unified_game_services_gamejolt/unified_game_services_gamejolt.dart';

// Once, at startup: builds the global facade over your providers.
GameServices.initialize(providers: [
  SteamProvider(appId: 480),
  GameJoltProvider(
    gameId: 'โ€ฆ', privateKey: 'โ€ฆ', username: 'โ€ฆ', userToken: 'โ€ฆ',
  ),
]);

// Anywhere else, call the static API directly:
await GameServices.signIn(); // per-provider results; see "Multi-provider results"

// Writes fan out to every provider that supports the capability:
if (GameServices.supports(GameCapability.achievements)) {
  await GameServices.unlockAchievement('first_win'); // Steam + GameJolt
}
await GameServices.submitScore(leaderboardId: 'global', score: 1500);

// Reads come from the primary provider (the first one), or pick with `from:`:
final achievements = await GameServices.getAchievements();
final board = await GameServices.getLeaderboard('global');

// Listen to events from all providers:
GameServices.events.listen(print);

GameServices is a static class that delegates to the global UnifiedGameServices created by initialize (GameServices.instance; it throws StateError until initialize is called, maybeInstance is the nullable variant and reset() clears it for tests). At least one provider is required โ€” an empty list throws ArgumentError. For standalone facades or dependency injection, build UnifiedGameServices(providers: [...]) directly; it has the same methods and does not touch the global.

Capabilities #

Each provider advertises what it supports; gate calls with supports:

if (services.supports(GameCapability.cloudSave)) {
  await services.saveData('profile', bytes);
}

GameCapability covers achievements, leaderboards, stats, cloudSave, friends, presence, invitations, lobbies, matchmaking, overlays, and identityTokens. A fan-out to a capability no provider supports throws CapabilityNotSupportedException.

Discover what is configured:

services.capabilities;                          // union across providers
services.providersFor(GameCapability.friends);  // only providers advertising it

What each provider advertises today:

Provider achievements leaderboards stats cloudSave friends presence lobbies invitations overlays identityTokens
Steam โœ“ โœ“ โœ“ โœ“ โœ“ โœ“ โœ“ โœ“ โœ“
Epic โœ“ โœ“ โœ“ โœ“ โœ“ โœ“ โœ“
GameJolt โœ“ โœ“ โœ“ โœ“
PlayFab โœ“ โœ“ โœ“ โœ“ โœ“
Game Center โœ“ โœ“
Google Play (Android) โœ“ โœ“ โœ“
Google Play (REST) โœ“ โœ“

overlays (the native achievements and leaderboards screens) is only on Google Play Android today, and matchmaking has no provider yet; calling an unsupported one throws CapabilityNotSupportedException. Xbox PC is a placeholder and advertises nothing.

Provider IDs #

Every provider exposes a stable providerId (steam, epic, game_center, google_play_rest, google_play_android, gamejolt, playfab). It never changes across versions and is what results use to say which provider did what. Each Google Play tier (REST / Android) has its own id. Data read through the facade (getAchievements, getFriends, getLeaderboard, getStat, loadData, getCurrentPlayer) is tagged with the source providerId, so you can show or filter by platform:

final steamOnly = (await services.getFriends()).where((p) => p.providerId == 'steam');
``` Custom providers must override
`providerId`.

### Multi-provider results

Write operations (`signIn`, `signOut`, `unlockAchievement`, `submitScore`,
`setStat`, `saveData`, `setPresence`, โ€ฆ) are broadcast to every capable provider
and return a `MultiProviderOperationResult`. A provider failing does **not**
throw โ€” its error is kept in the result while the others still complete (and
are not rolled back):

```dart
final result = await services.submitScore(leaderboardId: 'global', score: 4200);

for (final r in result.succeeded) {
  print('${r.providerId} succeeded');
}
for (final r in result.failed) {
  print('${r.providerId} failed: ${r.error}');
}

result.allSucceeded; // every attempted provider succeeded
result.anySucceeded; // at least one did
result.allFailed;    // all failed (and at least one was attempted)
result['steam'];     // one provider's ProviderOperationResult, or null
result.values;       // values returned by the providers that succeeded
result.throwIfAnyFailed(); // all-or-nothing: rethrow the first error

Each entry is a sealed ProviderOperationResult: an OperationSuccess (non-null value) or an OperationFailure (non-null error, stackTrace). Switch exhaustively, or use isSuccess, valueOrNull, errorOrNull, getOrThrow() and when(success:, failure:):

switch (result['steam']) {
  case OperationSuccess(:final value): ...
  case OperationFailure(:final error): print(error.message);
  case null: print('steam was not attempted');
}

Reads default to the primary provider (or from:). To see what every provider returns โ€” handy for debugging mappings or diffing stores โ€” use the *FromAll variants, which return one result per capable provider:

final boards = await services.getLeaderboardFromAll('global');
for (final r in boards.results) {
  print('${r.providerId}: ${r.when(
    success: (b) => '${b.entries.length} entries',
    failure: (e) => 'failed: ${e.message}',
  )}');
}
// Also: getAchievementsFromAll(), getStatsFromAll(), getStatFromAll(key),
// getPlayerScoreFromAll(id), getFriendsFromAll(), loadDataFromAll(slot), listSavesFromAll(),
// getCurrentPlayerFromAll().

Errors that are not GameServiceExceptions are wrapped in a PlatformOperationException whose cause is the original object. Exceptions are still thrown for "nothing could be attempted" (CapabilityNotSupportedException when no provider supports the capability). Reads (getAchievements, getLeaderboard, โ€ฆ) are not aggregated: they use the primary provider or from:.

Identity tokens #

getIdentityToken() returns something your backend can verify to know who the player really is. The format depends on the provider, so check providerId:

final token = await GameServices.getIdentityToken(from: steam);
// Steam: hex Web API ticket, valid about 2 minutes. Send it right away.
// Epic:  EOS Connect ID token (JWT). Verify it with Epic's JWKS.

Lobbies and parties #

A lobby belongs to one provider and is never fanned out: a Steam lobby holds Steam players, a PlayFab lobby holds PlayFab players. For lobbies shared across stores, use a backend provider (PlayFab, or your own custom provider).

// Goes to the first provider that advertises `lobbies`, or pick one with on:.
final lobby = await GameServices.createLobby(
  maxMembers: 4,
  visibility: LobbyVisibility.public,
  data: {'mode': 'ranked'},
);

final open = await GameServices.findLobbies(
  filters: [const LobbyFilter('mode', LobbyFilterOp.equals, 'ranked')],
);
final joined = await GameServices.joinLobby(open.first.joinToken);

await GameServices.setMemberData(joined, {'ready': 'true'});
await GameServices.leaveLobby(joined); // calls on a Lobby route by its providerId
  • Lobby is an immutable snapshot. Every operation and every event returns a fresh one.
  • createLobby(kind: LobbyKind.party) makes a persistent group (same operations, usually private).
  • Lobby.joinToken is what joinLobby accepts: put it in RichPresence.joinToken, an invitation or your own channel. Incoming invitations and "Join" clicks arrive as InvitationReceivedEvent and JoinRequestedEvent.
  • Changes arrive on GameServices.events as LobbyMemberJoinedEvent, LobbyMemberLeftEvent, LobbyDataChangedEvent, LobbyOwnerChangedEvent and LobbyClosedEvent.
  • Call GameServices.dispose() when the app closes so the player leaves.

Provider limits (Steam only searches public lobbies; PlayFab needs lobbySearchKeys and has no automatic owner migration) are in each provider's README. Design notes: docs/design/lobbies.md.

Provider-specific features #

Some providers expose extras beyond the unified API. Reach them with the typed accessor:

final gj = GameServices.provider<GameJoltProvider>();
await gj?.startSessionHeartbeat();

Custom providers #

Back the API with your own database/service by extending UnifiedGameServicesPlatform. See example/custom_provider.dart for a complete template (illustrated with Supabase).

Per-store builds (Steam-only, Epic-only, โ€ฆ) #

Want a Steam-only build and an Epic-only build โ€” and don't care about Google Play in the Steam one? You don't need Flutter flavors. This is a federated plugin: the facade only fans out to the providers you register at runtime, so "Steam-only" just means "register only SteamProvider". A provider you never construct never runs.

The one thing that genuinely differs per store is the native runtime lib. Steam/Epic/GameCenter reach the OS over FFI, so the host ships a platform lib (steam_api64.dll/libsteam_api.dylib + steam_appid.txt; EOSSDK-Win64-Shipping.dll/libEOSSDK-Mac-Shipping.dylib). These are large, license-gated, and not cross-redistributable โ€” that, not the Dart code, is the real reason to split builds. Google Play is REST (no native lib), so leaving it out of a Steam build costs nothing: just don't register it.

Strategies, cheapest first:

  1. Runtime switch โ€” one binary, pick the store via --define: dart run --define=STORE=epic example/per_store_builds.dart. Simplest; unused Dart deps still ship. Fine for dev.
  2. const bool.fromEnvironment guards โ€” one entry point, but the FFI providers are tree-shaken out of builds that don't opt in. A const flag makes the branch a compile-time constant; unset โ†’ const-false โ†’ the compiler proves the provider unreachable and drops its whole dart:ffi closure:
    const kEnableSteam = bool.fromEnvironment('ENABLE_STEAM');
    if (kEnableSteam) providers.add(SteamProvider(appId: 480)); // only reference
    
    dart compile exe โ€ฆ --define=ENABLE_STEAM=true includes it; a plain flutter build appbundle (flag unset) removes it. Reference each FFI provider only inside its guard โ€” a single reference outside re-anchors the symbol and defeats the shake. This is the closest thing to "automatic per- platform" (Dart has no OS-conditional import), verified: toggling the flags changed a compiled binary by ~260 KB of FFI closure.
  3. Entry points โ€” bin/main_steam.dart + bin/main_epic.dart, each importing only its provider. Dart tree-shaking drops the other's code. Recommended for pure-Dart/CLI targets; package the matching native lib per build.
  4. Flutter flavors / separate app packages โ€” real dependency + asset separation. Use when a Flutter app must bundle only one store's native lib + credentials per variant.

Examples: example/per_store_builds.dart (strategy 1, doubles as a strategy-3 entry point) and example/treeshake_per_store.dart (strategy 2, single entry point + tree-shaking).

Steam/Epic are desktop-only

The FFI providers (Steam, Epic) run only on desktop (Windows/macOS/Linux) โ€” never mobile or web. You don't have to guard them manually:

  • Web โ€” each FFI package exports an inert stub (if (dart.library.io)), so importing it never breaks flutter build web / dart compile js; constructing it throws UnsupportedError.
  • Mobile (Android/iOS) โ€” dart:ffi compiles, but the constructor throws UnsupportedError (the native runtime lib isn't there). The native libs are never auto-bundled โ€” nothing ships to a mobile/web build automatically.

The stub/runtime guards keep bad builds from crashing cryptically, but a runtime gate does not tree-shake โ€” the FFI Dart is still compiled into a mobile bundle as dead code. Dart has no OS-conditional import to drop it automatically; to exclude it, make the symbol unreachable at compile time with a const bool.fromEnvironment guard (strategy 2 above โ€” the closest to automatic) or a per-platform entry point that never imports Steam/Epic (strategy 3/4).

Respect third-party redistributables. The Steamworks / EOS runtime libs are license-gated and not cross-redistributable โ€” ship each only under your own Steam/Epic agreement, bundle it only in the matching desktop build, and never commit it to source control.

Flutter desktop flavors (Windows/macOS/Linux)

If your app is a Flutter desktop app wanting a Steam build and an Epic build from one codebase, know that Flutter's --flavor flag is not first-class on Windows/Linux the way it is on Android (Gradle product flavors) or iOS/macOS (Xcode schemes + .xcconfig). Recommended setup per platform:

  • macOS โ€” real flavor support: add an Xcode scheme + .xcconfig per store (mirrors the iOS flavor recipe), then bundle the matching native lib (libsteam_api.dylib / libEOSSDK-Mac-Shipping.dylib) into that scheme's Resources via an Xcode "Copy Files" build phase, gated per configuration.
  • Windows/Linux โ€” no native flavor mechanism. Use strategy 3 from above instead: one lib/main_steam.dart / lib/main_epic.dart entry point per store, built with flutter build windows --target=lib/main_steam.dart (same for linux). Copy the matching native lib next to the built executable as a post-build step in your CI/release script (steam_api64.dll + steam_appid.txt alongside Runner.exe; EOSSDK-Win64-Shipping.dll likewise) โ€” Flutter's build output doesn't know about these, so this step is always manual regardless of flavor tooling.
  • CI matrix โ€” the cleanest way to produce all store variants is a CI job matrix over {store} ร— {os}, each running the platform-appropriate build above and attaching the correct native lib as a release asset. Don't try to ship every store's native lib in a single "universal" build โ€” that's exactly the redistribution problem called out above.

Agent skills #

This package ships best-practice skills for building a game on top of it:

In a consumer project with this dependency resolved, install package skills with dart run skills@ get.

Package Role Platforms
unified_game_services This package โ€” the app-facing facade. all
unified_game_services_platform_interface Shared contract + models (for provider authors). all
unified_game_services_steam Steam provider (Steamworks via FFI). desktop
unified_game_services_epic Epic Online Services provider (EOS C SDK via FFI). desktop
unified_game_services_game_center Apple Game Center provider (GameKit via Objective-C FFI). macOS, iOS
unified_game_services_gamejolt GameJolt provider (REST). all
unified_game_services_playfab PlayFab provider (REST). all
unified_game_services_google_play_rest Google Play Games via REST โ€” cross-platform, achievements + leaderboards. all
unified_game_services_google_play_android Google Play Games via the on-device Play Games v2 Java SDK (package:jni) โ€” native write UI. Android
unified_game_services_google_play Auto-selects native on Android, REST elsewhere (incl. web). Pure Dart. all
unified_game_services_google_play_flutter Flutter adapter for the above โ€” auto-resolves the Android Activity, zero wiring. all (Flutter apps)
unified_game_services_xbox_pc Xbox on PC (GDK) โ€” research placeholder, not implemented yet, not published. โ€”

Picking a Google Play tier:

  • unified_game_services_google_play_rest alone โ€” you don't need native write UI at all (e.g. a CLI, server, or an engine that only reads/writes over REST). No native dependency, works everywhere.
  • unified_game_services_google_play_flutter โ€” you're a Flutter app and want zero wiring: it auto-resolves the Android Activity for you (via jni_flutter) and falls back to REST off Android. The one Flutter-only package in the family โ€” pulls in flutter + jni_flutter (needs the Flutter SDK on PATH for melos bootstrap, see CLAUDE.md).
  • unified_game_services_google_play directly โ€” same auto-selecting behavior (native on Android, REST elsewhere), but you supply the activityResolver. Use this from any host, Flutter or not, that either (a) isn't Flutter and has its own way to obtain the Android Activity jobject, or (b) is Flutter but wants to avoid the jni_flutter dependency (and its Flutter-SDK-on-PATH bootstrap requirement) and resolve the Activity some other way.
  • unified_game_services_google_play_android directly โ€” you want the native tier only, Android-exclusive, with no REST fallback at all (e.g. an Android-only game engine host).

Troubleshooting & further reading #

Each provider's own README covers its setup gotchas in depth โ€” check there first if a provider misbehaves:

  • Steam: native lib not found / steam_appid.txt missing โ†’ see unified_game_services_steam's README setup section; requires a running Steam client for most calls.
  • Epic: EOS_InvalidRequest (1012) on sign-in โ†’ missing Epic Account Services scopes in the Dev Portal; see unified_game_services_epic's README "Host responsibilities" section.
  • Google Play: 401/token refresh loops โ†’ check access_type=offline + prompt=consent on the OAuth strategy; see unified_game_services_google_play_rest's README auth section.
  • Any provider: catch GameServiceException subtypes (NotSignedIn, SignInFailed, CapabilityNotSupported, Network, PlatformOperation) rather than a bare catch (e) โ€” they tell you which layer failed.

License #

MIT โ€” see the LICENSE file.

This is an independent, unofficial library, not affiliated with or endorsed by any platform vendor. Third-party credits, trademark notices, and SDK redistribution terms are in the repository's NOTICE.md.

2
likes
160
points
133
downloads

Documentation

API reference

Publisher

verified publisheraprimo.dev

Weekly Downloads

Unified, multi-platform game services API for Dart (achievements, leaderboards, cloud save and more).

Homepage
Repository (GitHub)
View/report issues

Topics

#game-services #achievements #leaderboards #cloud-save #gamedev

License

MIT (license)

Dependencies

async, unified_game_services_platform_interface

More

Packages that depend on unified_game_services