roy_casual_kit

A Flutter SDK for casual/idle games built on GetX + Flame: local storage, i18n, audio, haptics, local reminders, theme tokens, an economy/progression layer, live-ops and remote-content tooling, privacy-aware analytics, and a candy-styled widget kit — one RoyCasualKit.initialize(...) call replaces hand-rolling Get.put calls for every service a casual game typically needs to build from scratch.

Quick path for non-technical reviewers

Want to see proof without reading code? Use this order:

  1. Open example/ on a device.
  2. Tap Game Demo.
  3. Tap the circle 10 times.
  4. Expected result: the badge reaches tap: 10/10, gems reach 20, a confetti burst appears, and the achievement icon turns into a star.
  5. Background and resume the app. Expected result: the pause panel appears while backgrounded and disappears after resume; no crash.

What this proves:

  • Flame gameplay events reach SDK services (EconomyWallet, AchievementService).
  • Achievement unlock triggers reward, haptics, banner, and confetti.
  • Render-heavy pieces are isolated behind RepaintBoundary so the game loop does not repaint the whole UI.
  • Background lifecycle can trim memory cache through RoyLifecycleCoordinator(trimMemoryOnBackground: true).

Device proof

Latest physical-device smoke proof:

  • Device: TECNO KJ7
  • Build: example/build/app/outputs/flutter-apk/app-release.apk
  • APK size: 61.6MB
  • Install: Success
  • Launch package: com.galaxyjoy.roycasualkit
  • Smoke result: app launched with a running pid and no FATAL EXCEPTION / AndroidRuntime crash in scanned logcat output.

Re-run the focused performance/memory proof:

cd example
flutter test integration_test/d4_perf_memory_test.dart

Re-run the release APK build:

cd example
flutter build apk --release

Screenshots

All from example/ — light and dark are the same NeonTheme tokens, no call-site changes.

Home screen, light theme Home screen, dark theme Widget kit: buttons, light theme Widget kit: buttons, dark theme

Widget kit: badges and interactive widgets Widget kit: tooltips, bottom sheet, dialogs Settings screen, dark theme Flame game demo with a world-tracked HUD label

What's in the package

lib/core/ groups by what the service is for — grep lib/roy_casual_kit.dart or CLAUDE.md's Architecture section for the exhaustive, always-current list; this is the shape, not a full inventory:

  • Bootstrap — RoyCasualKit.initialize(config: ...) registers every requested core service idempotently and never throws; a failing module is reported in the result instead of crashing boot.
  • Storage & save data — StorageService (SharedPreferences wrapper with a write-behind buffer for hot-path counters), VersionedJsonStore + SaveMigrationRegistry (schema-versioned saves with multi-hop migration), SaveSlotManager, save_integrity.dart (HMAC tamper detection), DisasterRecoverySaveExport, CheckpointCoordinator.
  • Economy & progression — EconomyWallet, RewardTransactionPipeline, PlayerProgressionService, InventoryService, EnergyService, OfflineProgressionService (cheat-proof idle earnings, see below), DailyLoginService, DailyQuestService, AchievementService, LocalScoreboardService, PurchaseLedgerService + PurchaseSeam.
  • Live-ops & remote content — RemoteConfigService, RemoteContentPack (signed, versioned, asset-fallback-then-fetch), the Remote Schema Compiler (tool/remote_schema_compiler.dart, compiles a declarative schema into a typed, self-contained Dart model), RemoteKillSwitchController, SeasonEventService, ExperimentBucketingService.
  • Privacy, analytics & diagnostics — ConsentStateService + ConsentGatedAnalyticsProvider, PrivacyAwareAnalyticsSampler (consent-gated, deterministically-sampled, rate-limited), SdkEventSchemaRegistry (PII redaction before any event ships), SdkHealthReport, DiagnosticsExportBundle, CrashReporter seam.
  • Platform seams (bring your own adapter) — AnalyticsProvider, CrashReporter, CloudSaveProvider, PurchaseSeam, SecureStorageAdapter, RemoteConfigService, verified against your adapter with PluginAdapterConformanceSuite.
  • App/session infrastructure — AppVersionGateController, AppSessionTracker, RoyLifecycleCoordinator, GameSessionController, GameTimeController, ConnectivityCoordinator, DeepLinkCommandRouter, OnboardingCoordinatorService, maybeRequestReview (in-app review helper), ReminderService, WakeLockService, OfflineOutboxService, PersistentCooldownService, AssetPreloadCoordinator, PlatformCapabilityRegistry, MemoryWatchdog.
  • i18n, audio, haptics, theme — AppTranslations/LocaleService, AudioManager, fireHaptic + HapticChoreographer, NeonTheme design tokens (light-candy default, neon-dark and color-blind-safe variants).
  • Utilities (lib/core/utils/) — 20+ pure helpers the services above delegate to: clock/replay determinism (ClampedClock, TrustedClockService, SeededRandom), formatting (fmtDur, fmtNum, fitFontSizeForLongestWord), resilience (AsyncActionGuard, RetryPolicy, throttled), and the library code behind every tool/*_check.dart gate below.
  • Dev/CI tooling (tool/, headless dart run, no device needed) — accessibility audit, dependency SBOM/security gate, asset-license manifest check, API-compatibility gate, performance budget CI, deprecated-API removal-schedule gate, pseudo-locale QA harness, consumer-app starter generator.

lib/presentation/widgets/ — the neon widget kit (NeonButton, NeonDialog, NeonAppBar, NeonBg, NeonAuraLayer, AuroraBgLayer, NeonIcon, StrokeText, PressableScale — keyboard/gamepad-activatable, not just touch) plus lib/presentation/widgets/common/: a large set of generic, game-agnostic widgets spanning buttons/interactive, feedback/overlay, progress/reward, layout/cards, game-specific, and game-feel/juice — example/lib/screens/widget_showcase_screen.dart is the living usage reference, and the whole set is exported from one barrel, lib/presentation/widgets/common/common_widgets.dart.

Cookbook

Every core service in lib/core/ (grouped the same way as the overview above), with a real, compilable-shape usage snippet each — not just the class name. example/lib/screens/cookbook_screen.dart exercises one call from most sections below on a live screen; lib/presentation/widgets/ has its own living reference in example/lib/screens/widget_showcase_screen.dart instead of being repeated here. Most services are a GetxService — register once (bootstrap module, or Get.put(..., permanent: true)) and read back later via a X.maybe null-safe static accessor or Get.find<X>().

Storage & save data

StorageService

StorageService.to.setInt('coins', 100);
final coins = StorageService.to.getInt('coins', def: 0);
StorageService.to.setIntBuffered('tapCount', tapCount); // hot-path counter
await StorageService.to.flush();
final backup = StorageService.to.exportAll(); // whole store as JSON

Registered by the bootstrap storage module.

VersionedJsonStore<T>

final store = VersionedJsonStore<PlayerProfile>(
  storage: StorageService.to,
  key: 'player_profile',
  schemaVersion: 2,
  toJson: (p) => p.toJson(),
  fromJson: PlayerProfile.fromJson,
  migrate: (fromVersion, json) => migrationRegistry.migrate(fromVersion, json),
  migrationRegistry: migrationRegistry, // SaveMigrationRegistry, multi-hop chain
);
await store.save(profile);
final loaded = store.load();

SaveSlotManager

final slots = SaveSlotManager(maxSlots: 3);
if (slots.canCreateSlot) {
  final slot = slots.createSlot('My Save');
  await slots.setActiveSlot(slot.id);
  final key = slots.keyFor(slot.id, 'player_profile'); // pass to VersionedJsonStore's key
}

save_integrity.dart

final signed = signExport(StorageService.to.exportAll(), mySecret);
// ... later, before importAll:
final verified = verifyAndStrip(signed, mySecret); // throws FormatException if tampered
await StorageService.to.importAll(verified);

Plain top-level functions, not a class — HMAC tamper detection over exportAll()/importAll().

DisasterRecoverySaveExport

final recovery = DisasterRecoverySaveExport(storage: StorageService.to, slotManager: slots);
final export = recovery.buildExport(slotIds: slots.listSlots().map((s) => s.id).toList(), appVersion: kAppVersion);
final signed = recovery.sign(export.value!, mySecret);
// ... on restore:
final preview = recovery.previewRestore(signed, mySecret);
if (preview.isSuccess) await recovery.applyRestore(preview.value!);

CheckpointCoordinator

final checkpoints = CheckpointCoordinator(storage: StorageService.to);
Get.put(checkpoints, permanent: true);
checkpoints.registerParticipant('board', snapshot: () => board.toJson(), restore: (data) => board.load(data));
await checkpoints.requestCheckpoint(); // debounced 2s unless critical: true
if (checkpoints.wasDirtyOnLoad) checkpoints.restoreLatest();

Economy & progression

EconomyWallet

final wallet = EconomyWallet(storage: StorageService.to);
Get.put(wallet, permanent: true);
await wallet.earn(currency: 'coins', amount: 50, transactionId: 'quest_12');
final result = await wallet.trySpend(currency: 'coins', amount: 20, transactionId: 'shop_buy_3');
final coins = wallet.balanceOf('coins');

RewardTransactionPipeline

final rewards = RewardTransactionPipeline(wallet: wallet);
Get.put(rewards, permanent: true);
await rewards.grantFromDailyQuest(
  questId: 'daily_win_3',
  periodKey: '2026-09-21',
  lines: [RewardLine(currency: 'coins', amount: 100)],
);

Single audited entry point for every reward grant (daily login, daily quest, purchase, or a raw grant(...)) — every call is idempotent on its transactionId and survives a kill mid-grant via resumePending().

PlayerProgressionService

final progression = PlayerProgressionService(storage: StorageService.to, levelCurve: myLevelCurve, pipeline: rewards);
Get.put(progression, permanent: true);
final result = await progression.grantXp(amount: 250, transactionId: 'level_3_clear');
progression.snapshot; // Rx<PlayerProgressionSnapshot>

InventoryService

final inventory = InventoryService(storage: StorageService.to, itemCatalog: myItemCatalog, capacity: 40);
Get.put(inventory, permanent: true);
await inventory.grant(lines: [InventoryLine(itemId: 'sword_01', quantity: 1)], transactionId: 'shop_buy_sword');
await inventory.setEquipped(slotId: 0, equipped: true);

EnergyService — the cheat-proof idle/lives pattern

Idle/incremental games pay out or refill based on "how long was the player away" — most base kits compute that straight from DateTime.now(), so winding the device clock back and forth farms free energy/rewards indefinitely. EnergyService and OfflineProgressionService both read elapsed time through nowMsClamped() (lib/core/utils/clamped_clock.dart), a monotonic clock that never goes backward — a rewind attempt permanently burns the player's own future time instead of resetting the calculation.

final energy = EnergyService(maxEnergy: 5, refillInterval: const Duration(minutes: 30));
Get.put(energy, permanent: true);
if (energy.currentEnergy > 0) energy.consumeEnergy();
final wait = energy.timeUntilNextEnergy;

OfflineProgressionService

final offline = OfflineProgressionService(maxOfflineCap: const Duration(hours: 8));
Get.put(offline, permanent: true);

// e.g. on app resume, or whenever the player checks in:
final earned = await offline.claim(coinsPerSecond);
if (earned > 0) grantCoins(earned);

maxOfflineCap caps the payout (an 8h absence still only pays out 8 hours' worth), and a fresh install never hands out a free payout on its very first read — the baseline is seeded to "now", not epoch zero.

DailyLoginService

final login = DailyLoginService();
Get.put(login, permanent: true);
if (login.canClaimToday()) {
  final result = login.claimToday(); // day 1..7, wraps back to 1
}
login.currentStreakDay;

DailyQuestService

final quests = DailyQuestService();
Get.put(quests, permanent: true);
quests.register('win_3_matches', 3, period: QuestPeriod.daily);
quests.incrementProgress('win_3_matches', 1);
if (quests.isCompleted('win_3_matches') && !quests.isClaimed('win_3_matches')) quests.claim('win_3_matches');

AchievementService

final achievements = AchievementService();
Get.put(achievements, permanent: true);
achievements.register('first_win', 1);
achievements.onUnlock.listen((id) => showUnlockToast(id));
achievements.incrementProgress('first_win', 1);

LocalScoreboardService

final scoreboard = LocalScoreboardService(capacity: 50);
Get.put(scoreboard, permanent: true);
scoreboard.submitScore('Player1', 9800);
final top10 = scoreboard.topN(10);
final nearMe = scoreboard.entriesAround('Player1', radius: 2); // "you're #47" window

PurchaseLedgerService + PurchaseSeam

// 1. Consumer app implements the real store integration:
class MyPurchaseSeam implements PurchaseSeam {
  @override
  Future<bool> buy(String productId) async { /* verify with store/server */ return true; }
  @override
  Future<void> restorePurchases() async {}
  @override
  bool isOwned(String productId) => false;
}
Get.put<PurchaseSeam>(MyPurchaseSeam(), permanent: true);

// 2. After a verified purchase, PurchaseLedgerService just records the result:
final ledger = PurchaseLedgerService();
Get.put(ledger, permanent: true);
if (await PurchaseSeam.maybe!.buy('remove_ads')) ledger.grantPermanent('remove_ads');

No Noop* default for PurchaseSeam on purpose — silently no-op'ing a purchase would hide a real integration bug.

Live-ops & remote content

RemoteConfigService

final remoteConfig = RemoteConfigService(assetPath: 'assets/remote_config_defaults.json');
Get.put(remoteConfig, permanent: true);
await remoteConfig.init(); // asset first, then merges a fetchRemote() result — never throws
final rewardMultiplier = remoteConfig.getDouble('reward_multiplier', fallback: 1.0);

RemoteContentPack<T>

final eventPack = RemoteContentPack<EventDefinition>(
  assetPath: 'assets/season_event_defaults.json',
  schemaVersion: 1,
  fromJson: EventDefinition.fromJson,
  contentSecret: myContentSecret, // signature-verified before it's applied
);
final content = await eventPack.load(); // asset first, background fetch/verify/merge after
await eventPack.refreshed;

tool/remote_schema_compiler.dart compiles a declarative JSON schema into this typed fromJson model for you: dart run tool/remote_schema_compiler.dart --schema=event_schema.json --outDir=lib/generated.

RemoteKillSwitchController

final killSwitch = RemoteKillSwitchController(remoteConfig: remoteConfig);
Get.put(killSwitch, permanent: true);
killSwitch.runIfEnabled('new_shop_ui', () => showNewShop());

SeasonEventService

final seasonEvents = SeasonEventService();
Get.put(seasonEvents, permanent: true);
final window = seasonEvents.currentWindow('winter_2026', length: const Duration(days: 7), cooldown: const Duration(days: 21));
if (window.isActive) showSeasonBanner(window.end);

ExperimentBucketingService

final experiments = ExperimentBucketingService();
Get.put(experiments, permanent: true);
final variant = experiments.variantFor('shop_layout_v2', ['control', 'treatment']);

Stable per-device bucketing — same device always lands in the same variant, seeded via StorageKeys.experimentAnonId + fnv1aHash.

Privacy, analytics & diagnostics

ConsentStateService

final consent = ConsentStateService(policyVersion: 1);
Get.put(consent, permanent: true);
consent.grant(ConsentCategory.analytics);
if (consent.isGranted(ConsentCategory.analytics)) { /* ... */ }

Default-deny: an undecided or stale-policy-version category reads back unknown, never granted.

ConsentGatedAnalyticsProvider + PrivacyAwareAnalyticsSampler

final realProvider = MyAnalyticsAdapter(); // implements AnalyticsProvider
Get.put<AnalyticsProvider>(
  PrivacyAwareAnalyticsSampler(
    ConsentGatedAnalyticsProvider(realProvider),
    defaultSamplingRate: 0.2,
    maxEventsPerWindow: 20,
  ),
  permanent: true,
);
AnalyticsProvider.maybe?.logEvent('level_complete', {'level': 12});

Stack the decorators: consent gate → deterministic sampling → rate limit, one AnalyticsProvider.logEvent call site everywhere else in the app.

SdkEventSchemaRegistry

final schemas = SdkEventSchemaRegistry()
  ..register(EventSchema(
    name: 'level_complete',
    version: 1,
    params: {'level': const EventParamSchema(type: EventParamType.int, required: true)},
  ));
final result = schemas.validate('level_complete', {'level': 12}); // default-deny unregistered names

PII fields are always redacted before an event ships, regardless of the inner provider.

SdkHealthReport + DiagnosticsExportBundle

final health = SdkHealthReport()..registerAll(defaultHealthCollectors());
final report = await health.collect(); // {schemaVersion, generatedAtMs, sections}

final bundle = DiagnosticsExportBundle();
final diagnostics = await bundle.build(appVersion: kAppVersion, health: health);
final signed = bundle.sign(diagnostics, mySecret);

Each collector in health.collect() has its own timeout + try/catch, so one broken section never blocks the rest of the report — handy to attach to a support ticket.

CrashReporter (seam)

class MyCrashReporter implements CrashReporter {
  @override
  void recordError(Object error, StackTrace stack, {String? reason}) { /* ship to your vendor */ }
}
Get.put<CrashReporter>(MyCrashReporter(), permanent: true);

No Noop* default — dlog() is debug-only and tree-shaken from release builds, so a release build with no CrashReporter registered has nowhere for an error to go.

Platform seams

Bring your own adapter for each of these (no concrete vendor SDK baked into the package):

Get.put<AnalyticsProvider>(myAnalyticsAdapter, permanent: true);
Get.put<CrashReporter>(myCrashAdapter, permanent: true);
Get.put<CloudSaveProvider>(myCloudSaveAdapter, permanent: true); // passed to VersionedJsonStore.syncWith
Get.put<PurchaseSeam>(myPurchaseAdapter, permanent: true);
Get.put<SecureStorageAdapter>(mySecureStorageAdapter, permanent: true);

Before shipping an adapter, run it through PluginAdapterConformanceSuite — a no-throw / completes-within-timeout / round-trip checklist so a broken adapter fails a fast local check instead of a flaky device test:

final report = await PluginAdapterConformanceSuite.verifyAnalyticsProvider(myAnalyticsAdapter);
expect(report.passed, isTrue, reason: report.failures.join('; '));

App/session infrastructure

AppVersionGateController

final versionGate = AppVersionGateController(remoteConfig: remoteConfig);
Get.put(versionGate, permanent: true);
switch (versionGate.decisionFor(kAppVersion)) {
  case GateDecision.forceUpdate: showForceUpdateDialog();
  case GateDecision.softUpdate: if (versionGate.softPromptDue) showSoftUpdateBanner();
  case GateDecision.maintenance: showMaintenanceScreen();
  case GateDecision.ok: break;
}

AppSessionTracker

final sessionTracker = AppSessionTracker();
Get.put(sessionTracker, permanent: true);
sessionTracker.current.sessionId;
sessionTracker.foregroundDuration; // real accumulated foreground time

RoyLifecycleCoordinator

final lifecycle = RoyLifecycleCoordinator();
Get.put(lifecycle, permanent: true); // or the bootstrap `lifecycle` module
lifecycle.registerHook('pause_audio', (event) async {
  if (event == RoyLifecycleEvent.background) AudioManager.maybe?.pauseBgm();
});

Ordered, isolated dispatcher — one hook throwing/timing out never blocks the others.

GameSessionController + GameTimeController

final session = GameSessionController(lifecycle: lifecycle);
Get.put(session);
session.markReady();
session.start();
// on win/lose:
session.win();

final gameTime = GameTimeController(session: session);
Get.put(gameTime);
gameTime.tick(deltaSeconds); // Flame Component.update(dt)-shaped

ConnectivityCoordinator

final connectivity = ConnectivityCoordinator(signal: myConnectivitySignal, probe: () async => pingServer());
Get.put(connectivity, permanent: true);
connectivity.enqueue(QueuedTask(idempotencyKey: 'sync_save', priority: 1, run: () => syncSave()));
connectivity.stateStream.listen((state) => updateOfflineBanner(state));

DeepLinkCommandRouter

final deepLinks = DeepLinkCommandRouter(routes: [
  DeepLinkRoute(commandType: 'open_shop', scheme: 'myapp', pathSegments: ['shop']),
]);
Get.put(deepLinks, permanent: true);
deepLinks.registerHandler('open_shop', (command) async => Get.toNamed('/shop'));
deepLinks.markReady(); // drains any link that arrived before this call

OnboardingCoordinatorService

final onboarding = OnboardingCoordinatorService();
Get.put(onboarding, permanent: true);
onboarding.registerFlow('first_launch_tutorial', priority: 10);
onboarding.registerFlow('shop_spotlight', priority: 5);
final next = onboarding.nextEligibleFlow(); // highest-priority unseen, or null
if (next != null) { showFlow(next); onboarding.markFlowSeen(next); }

maybeRequestReview

await maybeRequestReview(
  recentWinStreak: playerWinStreak,
  showReview: () => InAppReview.instance.requestReview(),
  minWinStreak: 3,
);

The classic casual-game pattern: prompt right after a happy moment, not too often — a single platform-neutral function, no store-review SDK baked in.

ReminderService

final reminders = ReminderService();
Get.put(reminders, permanent: true); // or the bootstrap `reminders` module
await reminders.scheduleNext(delay: const Duration(hours: 24), title: 'Come back!', body: 'Your energy is full.');

WakeLockService

final wakeLock = WakeLockService(); // or the bootstrap `wakeLock` module
Get.put(wakeLock, permanent: true);
await wakeLock.init(); // restores the saved on/off preference and applies it
await wakeLock.setEnabled(false); // e.g. a settings toggle

Keeps the screen from auto-locking while enabled (defaults to true — opt-out, matching the classic casual-game expectation) — persists the choice via StorageKeys.wakeLockEnabled so a consumer app's own settings screen can expose the toggle instead of the kit forcing the screen awake unconditionally.

OfflineOutboxService

final outbox = OfflineOutboxService(storage: StorageService.to, uploader: (payload, key) => myApi.sync(payload, key));
Get.put(outbox, permanent: true);
outbox.enqueue(idempotencyKey: 'score_sync_42', payload: {'score': 9800});
await outbox.drain(); // priority-ordered sync attempt, retries with backoff

PersistentCooldownService

final cooldowns = PersistentCooldownService();
Get.put(cooldowns, permanent: true);
cooldowns.start('free_chest', const Duration(hours: 4));
cooldowns.remainingOf('free_chest');

AssetPreloadCoordinator

final preloader = AssetPreloadCoordinator(loader: (item) => precacheImage(AssetImage(item.path), context));
Get.put(preloader, permanent: true);
await preloader.preload(myLevelManifest);
preloader.progress; // Rx<double> 0.0–1.0

PlatformCapabilityRegistry

final capabilities = PlatformCapabilityRegistry();
Get.put(capabilities, permanent: true);
capabilities.withFallback(
  supported: capabilities.snapshot.supportsHaptics,
  ifSupported: () => fireHaptic(HapticLevel.medium),
  fallback: () {},
);

MemoryWatchdog (debug builds only, no-op in release)

final id = MemoryWatchdog.track(WatchdogKind.subscription, owner: 'ShopController', label: 'priceStream');
// ... on dispose:
MemoryWatchdog.release(id);
MemoryWatchdog.orphans(minAge: const Duration(minutes: 5)); // leak candidates

i18n, audio, haptics, theme

AppTranslations + LocaleService

GetMaterialApp(translations: AppTranslations(), locale: LocaleService.maybe?.current.value, /* ... */);
final locale = LocaleService(StorageService.to);
Get.put(locale, permanent: true); // or the bootstrap `locale` module
await locale.change(const Locale('vi'));

New keys go into every locale map in AppTranslations — test/core/app_translations_test.dart enforces key parity.

AudioManager

final audio = AudioManager(); // or the bootstrap `audio` module
Get.put(audio, permanent: true);
await audio.init();
audio.startBgm();
await audio.playSfx('coin.mp3', volume: 0.8);
audio.toggleMute();

AudioManager.maybe is the null-safe accessor for call sites that may run before/without audio registered (e.g. widget tests).

fireHaptic + HapticChoreographer

fireHaptic(HapticLevel.medium); // gated on StorageKeys.hapticsEnabled/hapticSoftMode

final choreographer = HapticChoreographer();
choreographer.play(HapticPattern.combo); // ordered pulses with per-step delay

Every HapticFeedback.* call site in a consumer app should go through fireHaptic, not the platform API directly.

NeonTheme

NeonTheme.dark = true; // flip to the neon-dark palette, persist the flag yourself
NeonTheme.colorBlindSafe = true;
Container(decoration: BoxDecoration(color: NeonTheme.card, boxShadow: NeonTheme.glow(NeonTheme.gemColors.first)));

Same token getters (ink, card, glow(...), ...) resolve to different colors depending on the flags — no call site needs to change.

Utilities (lib/core/utils/)

Pure, dependency-light helpers the services above delegate to — reach for these directly when a service is more than what a call site needs.

  • nowMsClamped() / todayEpochDayClamped() (clamped_clock.dart) — monotonic never-rewinds-back clock.
  • TrustedClockService (trusted_clock.dart) — stricter wall-clock-vs-monotonic drift check.
  • fmtDur(d) / durationToLocalMidnight(d) / fmtNum(n) (format.dart) — mm:ss, countdown, locale-aware thousands separator.
  • fitFontSizeForLongestWord(...) (label_fit.dart) — shrinks a label until every word fits, guards mid-word line breaks.
  • asIntOr(json['x'], 0) / asStringOr(...) / asDoubleOr(...) (safe_json.dart) — tolerant JSON field coercion.
  • throttled(onTap, window: const Duration(milliseconds: 500)) (throttle.dart) — drops rapid repeat calls (double-tap/spam guard).
  • weightedRandomPick(myLootTable) (weighted_random_pick.dart) — one weighted pick (loot tables, reward rarities).
  • fnv1aHash(input) (fnv1a.dart) — deterministic string hash, the seed source for experiment bucketing.
  • SeededRandom / CompiledWeightedTable<T> / SeededRandomService (seeded_random.dart) — snapshot/resume-capable RNG for deterministic replay.
  • AsyncActionGuard (async_action_guard.dart) — ignore a call while a previous one from the same guard is still in flight (fast-double-tap guard for async actions).
  • RetryPolicy / RetryExecutor (retry_policy.dart) — exponential-backoff-with-jitter retry loop.
  • SaveMigrationRegistry (save_migration_registry.dart) — validated multi-hop save-schema migration chain, see VersionedJsonStore above.
  • SdkResult<T> / SdkSuccess / SdkFailure (sdk_result.dart) — the typed success/failure convention most services above return.
  • ObjectPool<T> (object_pool.dart) — generic acquire/release pool for per-frame particle/effect allocation.
  • pseudoLocalize(...) / PseudoLocaleTranslations (pseudo_locale.dart) — accents+pads strings to surface hardcoded/untranslated text; wired into example/lib/screens/settings_screen.dart's locale toggle for a live QA pass.
  • validateNeonThemeContrast() / contrastRatio(...) (theme_contrast_validator.dart) — WCAG-style contrast checks over NeonTheme color pairs.
  • scanAccessibility(...) (accessibility_audit.dart), auditDependencies(...) (dependency_sbom.dart), validateAssetLicenses(...) (asset_license_manifest.dart), DeprecationRegistry (deprecation_registry.dart), checkPerformanceBudgets(...) (performance_budget.dart), generateModelSource(...) (remote_schema_compiler.dart) — the library code behind the tool/*_check.dart CLI gates below.
  • regenEnergy(...) / offlineEarnings(...) (economy_math.dart) — the pure formulas EnergyService/OfflineProgressionService delegate to, also used by tool/economy_sim.dart so the balancing simulator can never silently drift from the real math.

Dev/CI tooling (tool/, headless dart run, no device needed)

dart run tool/api_compatibility.dart check          # public-export diff gate
dart run tool/accessibility_audit_check.dart         # reduced-motion / tap-target / RTL scan
dart run tool/dependency_sbom_check.dart             # SBOM + license/advisory gate
dart run tool/asset_license_check.dart               # every runtime asset has a LICENSES.json entry
dart run tool/performance_budget_check.dart check    # frame/allocation budget vs committed baseline
dart run tool/deprecation_check.dart                 # fails once a @Deprecated API is past its removal version
dart run tool/economy_sim.dart --days=30             # headless economy/balancing simulator
dart run tool/object_pool_benchmark.dart             # pooled-vs-unpooled allocation benchmark
dart run tool/remote_schema_compiler.dart --schema=event_schema.json --outDir=lib/generated
dart run tool/create_consumer_app.dart --name=my_game --org=com.example

accessibility_audit_check, dependency_sbom_check, asset_license_check, performance_budget_check, and deprecation_check are wired into CI's quality-gate job (see .github/workflows/ci.yml), scoped to only run when lib/**/tool/** change. performance_budget_check's realDevice metric only gets a fresh number when run with --device=<id>; otherwise it falls back to the committed baseline — see the Performance section below for how that baseline gets refreshed automatically.

Performance

tool/performance_budget_check.dart tracks two kinds of metric: a hostHeadless one (re-measured fresh on every check run, reproducible on any machine) and a realDevice one (real wall-clock boot time, only re-measured when a device is attached — see --device= above). A weekly GitHub Actions job (.github/workflows/benchmark.yml, free — this repo is public and runs on ubuntu-latest) boots a KVM-accelerated Android emulator, re-measures both, and opens a PR refreshing the numbers below.

Metric Value Source Recorded
Example App Boot Wall Ms 32318 ms realDevice 2026-09-20
Example App Boot Wall Ms Emulator 338690 ms realDevice 2026-09-22
Object Pool Allocation Reduction Percent 97.5% hostHeadless 2026-09-22
Object Pool Pooled Elapsed Us 22327 us hostHeadless 2026-09-22

Integration guide

Game mới nên bắt đầu bằng hướng dẫn tích hợp từng bước: bootstrap, core gameplay loop, Flame events, save/offline, audio/haptics, platform adapters, và release checklist.

Install

Android prerequisites: The flutter_local_notifications v20+ dependency requires compileSdk 35 at minimum, Java 17 compatibility, and core library desugaring. You must configure this in your consumer app's android/app/build.gradle.kts (or .gradle), or the app will crash/fail to build:

android {
    compileSdk = 35
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
        isCoreLibraryDesugaringEnabled = true
    }
}
dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

The plugin automatically merges POST_NOTIFICATIONS and VIBRATE into the app's manifest. Android 13+ still requires the notification permission to be granted at runtime; ReminderService requests it during initialization. If the player denies permission, reminders will not appear.

Only when using scheduled reminders: add this receiver inside <application> in android/app/src/main/AndroidManifest.xml. Without it, the app can build and register an alarm, but the scheduled notification will not appear:

<receiver
    android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"
    android:exported="false" />

ReminderService uses AndroidScheduleMode.inexactAllowWhileIdle: delivery may be delayed by Android, and neither SCHEDULE_EXACT_ALARM nor USE_EXACT_ALARM is required. Rescheduling after a reboot or app update is optional; for that behavior, also configure RECEIVE_BOOT_COMPLETED and ScheduledNotificationBootReceiver with the boot/update intent filters described in the plugin's Android setup documentation.

flutter pub add roy_casual_kit

For working against an unreleased local change, depend on it directly instead:

dependencies:
  roy_casual_kit:
    path: ../roy_casual_kit # or: git: { url: ..., ref: main }

Usage

Bootstrap the services a game needs once at startup, instead of hand-rolling Get.put calls for each one:

await RoyCasualKit.initialize(
  config: RoyCasualKitConfig(
    modules: {
      RoyCasualKitModule.storage,
      RoyCasualKitModule.locale,
      RoyCasualKitModule.audio,
      RoyCasualKitModule.lifecycle,
    },
  ),
);

A module that fails to register is reported in the returned result instead of crashing boot — see RoyCasualKitResult/RoyCasualKitStatus.

import 'package:flutter/material.dart';
import 'package:roy_casual_kit/roy_casual_kit.dart';

class MyScreen extends StatelessWidget {
  const MyScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return CommonButton(
      label: 'Play',
      variant: CommonButtonVariant.primary,
      onTap: () => debugPrint('tapped'),
    );
  }
}

The same entrypoint exposes supported core services, Flame starter APIs and the complete widget kit. Existing deep imports under core/ and presentation/ remain available for advanced use, but new consumer code should migrate to the entrypoint so the supported API surface is explicit.

For consumer tests, RoyCasualKitTestFixture provides deterministic in-memory storage and RoyCasualKitContractTestKit.verifyBootstrap checks module registration, error-free initialization and idempotent repeated setup without network or vendor SDK dependencies.

For how to actually use each service once it's registered — real, compilable-shape snippets for all of lib/core/, grouped the same way as the overview above — see the Cookbook section.

See it live

example/ is a separate, full Flutter app (its own pubspec.yaml, android/, ios/) — deliberately not a trimmed-down toy demo. Its lib/main.dart wires up real usage of most modules covered above (bootstrap, lifecycle, deep links, audio, reminders, theming); reading it directly is the fastest way to see the actual integration pattern, not just a single-widget snippet. See example/README.md for a tour of which file covers what.

cd example && flutter run

Commands

# From the package root
flutter analyze
flutter test --exclude-tags slow

# From example/ — the demo app has its own test/analyze surface
cd example
flutter analyze
flutter test --exclude-tags slow

History

This repo used to be a full match-3 puzzle game, "Pop Star Blast" (see git history). It was first stripped down to a reusable app base — core services plus a small widget kit, with lib/logic/, lib/data/, and lib/game/ emptied out — documented in docs/superpowers/plans/2026-09-05-strip-to-base-game.md. It was then reshaped again from an app into this pub.flutter-io.cn package: the reusable code stayed at the repo root as lib/, and everything app-specific (entry point, demo screens, android/, ios/) moved into a separate example/ app that depends on the package via a path: dependency — documented in docs/superpowers/plans/2026-09-05-convert-to-pub-package.md.

License

MIT — see LICENSE.

Libraries

core/achievement_service
core/ad_reward_seam
core/analytics_provider
core/app_info
core/app_session_tracker
core/app_translations
core/app_version_gate
core/asset_preload_coordinator
core/audio_manager
core/battery_saver_coordinator
core/checkpoint_coordinator
core/cloud_save_provider
core/connectivity_coordinator
core/consumer_contract_test_kit
core/crash_reporter
core/daily_login_service
core/daily_quest_service
core/debug_log
core/diagnostics_export_bundle
core/disaster_recovery_save_export
core/economy_certificate
core/economy_wallet
core/energy_service
core/experiment_bucketing_service
core/game_accessibility_announcer
core/game_event_bus
core/game_session_controller
core/game_time_controller
core/haptic_choreographer
core/haptics
core/in_app_review_helper
core/inventory_service
core/invite_friend_coordinator
core/kit_bootstrap
core/leaderboard_sync_seam
core/lifecycle_coordinator
core/local_scoreboard_service
core/locale_service
core/memory_lifecycle_watchdog
core/neon_theme
core/offline_outbox_service
core/offline_progression_service
core/onboarding_coordinator_service
core/performance_tier_service
core/persistent_cooldown_service
core/platform_capability_registry
core/player_data_rights_service
core/player_progression_service
core/plugin_adapter_conformance_suite
core/prestige_service
core/privacy_aware_analytics_queue
core/privacy_aware_analytics_sampler
core/purchase_ledger_service
core/purchase_seam
core/reminder_service
core/remote_config_service
core/remote_content_pack
core/remote_kill_switch_controller
core/replay_recorder
core/reproduction_capsule
core/reward_transaction_pipeline
core/runtime_flags
core/save_integrity
core/save_slot_manager
core/sdk_event_schema_registry
core/sdk_health_report
core/season_event_service
core/secure_storage_adapter
core/seeded_challenge_service
core/shadow_activation_controller
core/share_helper
core/storage_service
core/utils/accessibility_audit
Pure, source-text-scanning accessibility audit — no Flutter dependency, so it (and tool/accessibility_audit_check.dart, which runs it against this package's own widgets) works as a genuinely headless dart run CI check, no device/engine needed.
core/utils/asset_license_manifest
Pure data + validation for tracking who owns/licenses each runtime asset (font, audio, shader, image) a game ships — no Flutter dependency, so a consumer app's own CI can run this against their own asset folder the same way tool/asset_license_check.dart runs it against this package's.
core/utils/async_action_guard
core/utils/clamped_clock
core/utils/dependency_sbom
Pure, no-Flutter-dependency dependency-security/SBOM (Software Bill of Materials) support — parses pubspec.lock, classifies each dependency's bundled LICENSE file text, and cross-checks the result against an optional vulnerability-advisory list and a suppression baseline. tool/dependency_sbom_check.dart wires this into a genuinely headless dart run CI check.
core/utils/deprecation_registry
core/utils/economy_math
Pure, Flutter-free economy math shared by EnergyService/ OfflineProgressionService and tool/economy_sim.dart (IDEA-36).
core/utils/fnv1a
core/utils/format
core/utils/label_fit
core/utils/object_pool
core/utils/performance_budget
Pure-Dart performance budget / regression-check framework (FEAT-80).
core/utils/pseudo_locale
core/utils/remote_schema_compiler
Remote Schema Compiler (FEAT-81) — pure-Dart, no Flutter dependency, so tool/remote_schema_compiler.dart can run as a genuine headless dart run CLI (same constraint as tool/accessibility_audit.dart, tool/dependency_sbom.dart).
core/utils/retry_policy
core/utils/safe_json
Defensive Map-value parsers for JSON read from a not-fully-trusted source (an old save file, cloud data from a different app version, a hand-edited local file). Never throw — always fall back on a type mismatch or null instead of crashing the whole load over one bad field.
core/utils/save_migration_registry
core/utils/sdk_result
core/utils/seeded_random
core/utils/smart_reminder_scheduling
core/utils/theme_contrast_validator
core/utils/throttle
core/utils/trusted_clock
core/utils/weighted_random_pick
core/versioned_json_store
core/wake_lock_service
presentation/game/pooled_component
presentation/game/roy_game
presentation/widgets/aurora_bg_layer
presentation/widgets/common/achievement_unlock_listener
presentation/widgets/common/adaptive_game_hud
presentation/widgets/common/app_version_gate_overlay
presentation/widgets/common/async_common_button
presentation/widgets/common/avatar_frame
presentation/widgets/common/backup_restore_panel
presentation/widgets/common/badge_dot
presentation/widgets/common/bottom_sheet_panel
presentation/widgets/common/candy_text_field
presentation/widgets/common/circular_progress_ring
presentation/widgets/common/coin_fly_overlay
presentation/widgets/common/combo_heat_background
presentation/widgets/common/common_button
presentation/widgets/common/common_widgets
Barrel export for the "common widgets" kit — a set of generic, game-agnostic candy-styled widgets any project built on this base can import in one line:
presentation/widgets/common/confetti_overlay
presentation/widgets/common/confirm_dialog
presentation/widgets/common/cooldown_countdown_chip
presentation/widgets/common/countdown_chip
presentation/widgets/common/currency_counter
presentation/widgets/common/daily_login_calendar
presentation/widgets/common/empty_state_placeholder
presentation/widgets/common/energy_bar
presentation/widgets/common/floating_combo_text
presentation/widgets/common/game_over_card_template
presentation/widgets/common/hold_to_confirm_button
presentation/widgets/common/icon_badge_button
presentation/widgets/common/inventory_grid
presentation/widgets/common/leaderboard_list
presentation/widgets/common/level_select_grid
presentation/widgets/common/level_up_overlay
presentation/widgets/common/list_tile_row
presentation/widgets/common/loading_overlay
presentation/widgets/common/network_status_banner
presentation/widgets/common/notification_permission_primer
presentation/widgets/common/paginated_dots_indicator
presentation/widgets/common/panel_card
presentation/widgets/common/pause_overlay
presentation/widgets/common/progress_bar_stars
presentation/widgets/common/quest_board_panel
presentation/widgets/common/retry_error_state
presentation/widgets/common/review_prompt_trigger
presentation/widgets/common/reward_choice_panel
presentation/widgets/common/reward_popup
presentation/widgets/common/ribbon_badge
presentation/widgets/common/save_health_card
presentation/widgets/common/scene_transition_overlay
presentation/widgets/common/screen_shake
presentation/widgets/common/section_header
presentation/widgets/common/segmented_tab_bar
presentation/widgets/common/shimmer_placeholder
presentation/widgets/common/shop_item_card
presentation/widgets/common/smart_review_funnel
presentation/widgets/common/sound_toggle_fab
presentation/widgets/common/spotlight_overlay
presentation/widgets/common/squash_stretch
presentation/widgets/common/star_rating
presentation/widgets/common/streak_counter
presentation/widgets/common/toast_banner
presentation/widgets/common/toggle_switch
presentation/widgets/common/tooltip_bubble
presentation/widgets/common/tutorial_sequence
presentation/widgets/common/victory_card_template
presentation/widgets/common/wheel_spinner
presentation/widgets/debug_qa_overlay
presentation/widgets/flame_tracked_overlay
presentation/widgets/focus_trap_scope
presentation/widgets/neon_app_bar
presentation/widgets/neon_aura_layer
presentation/widgets/neon_bg
presentation/widgets/neon_button
presentation/widgets/neon_dialog
presentation/widgets/neon_icon
presentation/widgets/pressable_scale
presentation/widgets/shader_ticker_layer
presentation/widgets/stroke_text
roy_casual_kit
Stable public entrypoint for the Roy Casual Kit package.