unified_game_services 0.2.1
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
submitScorewrites 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:setupand 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
Lobbyis an immutable snapshot. Every operation and every event returns a fresh one.createLobby(kind: LobbyKind.party)makes a persistent group (same operations, usuallyprivate).Lobby.joinTokenis whatjoinLobbyaccepts: put it inRichPresence.joinToken, an invitation or your own channel. Incoming invitations and "Join" clicks arrive asInvitationReceivedEventandJoinRequestedEvent.- Changes arrive on
GameServices.eventsasLobbyMemberJoinedEvent,LobbyMemberLeftEvent,LobbyDataChangedEvent,LobbyOwnerChangedEventandLobbyClosedEvent. - 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:
- 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. const bool.fromEnvironmentguards โ one entry point, but the FFI providers are tree-shaken out of builds that don't opt in. Aconstflag makes the branch a compile-time constant; unset โ const-falseโ the compiler proves the provider unreachable and drops its wholedart:fficlosure:const kEnableSteam = bool.fromEnvironment('ENABLE_STEAM'); if (kEnableSteam) providers.add(SteamProvider(appId: 480)); // only referencedart compile exe โฆ --define=ENABLE_STEAM=trueincludes it; a plainflutter 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.- 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. - 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 breaksflutter build web/dart compile js; constructing it throwsUnsupportedError. - Mobile (Android/iOS) โ
dart:fficompiles, but the constructor throwsUnsupportedError(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 +
.xcconfigper store (mirrors the iOS flavor recipe), then bundle the matching native lib (libsteam_api.dylib/libEOSSDK-Mac-Shipping.dylib) into that scheme'sResourcesvia 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.dartentry point per store, built withflutter build windows --target=lib/main_steam.dart(same forlinux). 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.txtalongsideRunner.exe;EOSSDK-Win64-Shipping.dlllikewise) โ 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:
unified-game-services-game-integrationโ architecture, background sign-in, handling write results and retries, events, shutdown and testing.unified-game-services-cross-store-progressionโ achievements, stats and leaderboards that behave the same on every store, ID tables and dashboard parity.unified-game-services-provider-behaviorsโ what each provider really does behind a facade call: writes that add instead of replace, calls that throw or no-op, ignored scopes, sign-in semantics.unified-game-services-per-store-buildsโ per-store entry points, web vs native, tree-shaking, native runtimes, assets, credentials and the release CI matrix.
In a consumer project with this dependency resolved, install package skills with
dart run skills@ get.
Related packages #
| 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_restalone โ 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 AndroidActivityfor you (viajni_flutter) and falls back to REST off Android. The one Flutter-only package in the family โ pulls influtter+jni_flutter(needs the Flutter SDK onPATHformelos bootstrap, seeCLAUDE.md).unified_game_services_google_playdirectly โ same auto-selecting behavior (native on Android, REST elsewhere), but you supply theactivityResolver. Use this from any host, Flutter or not, that either (a) isn't Flutter and has its own way to obtain the AndroidActivityjobject, or (b) is Flutter but wants to avoid thejni_flutterdependency (and its Flutter-SDK-on-PATHbootstrap requirement) and resolve theActivitysome other way.unified_game_services_google_play_androiddirectly โ 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.txtmissing โ seeunified_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; seeunified_game_services_epic's README "Host responsibilities" section. - Google Play:
401/token refresh loops โ checkaccess_type=offline+prompt=consenton the OAuth strategy; seeunified_game_services_google_play_rest's README auth section. - Any provider: catch
GameServiceExceptionsubtypes (NotSignedIn,SignInFailed,CapabilityNotSupported,Network,PlatformOperation) rather than a barecatch (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.