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:
- Open
example/on a device. - Tap Game Demo.
- Tap the circle 10 times.
- Expected result: the badge reaches
tap: 10/10, gems reach20, a confetti burst appears, and the achievement icon turns into a star. - 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
RepaintBoundaryso 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/AndroidRuntimecrash 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.
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,CrashReporterseam. - Platform seams (bring your own adapter) —
AnalyticsProvider,CrashReporter,CloudSaveProvider,PurchaseSeam,SecureStorageAdapter,RemoteConfigService, verified against your adapter withPluginAdapterConformanceSuite. - 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,NeonThemedesign 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 everytool/*_check.dartgate below. - Dev/CI tooling (
tool/, headlessdart 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, seeVersionedJsonStoreabove.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 intoexample/lib/screens/settings_screen.dart's locale toggle for a live QA pass.validateNeonThemeContrast()/contrastRatio(...)(theme_contrast_validator.dart) — WCAG-style contrast checks overNeonThemecolor 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 thetool/*_check.dartCLI gates below.regenEnergy(...)/offlineEarnings(...)(economy_math.dart) — the pure formulasEnergyService/OfflineProgressionServicedelegate to, also used bytool/economy_sim.dartso 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/consent_gated_analytics_provider
- core/consent_state_service
- core/consumer_contract_test_kit
- core/crash_reporter
- core/daily_login_service
- core/daily_quest_service
- core/debug_log
- core/deep_link_command_router
- 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/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 headlessdart runCI 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.dartruns 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 bundledLICENSEfile text, and cross-checks the result against an optional vulnerability-advisory list and a suppression baseline.tool/dependency_sbom_check.dartwires this into a genuinely headlessdart runCI 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.dartcan run as a genuine headlessdart runCLI (same constraint astool/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 ornullinstead 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/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_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/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/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/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_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.