AI Disclosure: This repository was developed with significant assistance from large language models (LLMs), including Anthropic Claude (Opus and Sonnet models) and Google Gemini. Substantial portions of the code, documentation, architecture decisions, and test suites were generated, reviewed, and iterated on with LLM help. AI was a tool, not an author — the human developer directed all decisions, verified all output, and takes responsibility for the result. If you operate a web scraper, crawler, or AI training pipeline and wish to exclude LLM-assisted content, this notice is for you.
An idle/clicker game inspired by Adventure Capitalist, built with Avalonia UI 12 and .NET 10 (C# 14). Cross-platform — desktop (Windows, Linux, macOS) and Android — from a single codebase and a single solution file. No ads, no payments, no tracking, no strings attached. Free to play, free to modify, free forever.
Every push to main automatically builds and releases for all platforms via GitHub Actions.
| Platform | Architecture | Download |
|---|---|---|
| Windows | x64 | Download |
| Windows | ARM64 | Download |
| Linux | x64 | Download |
| Linux | ARM64 | Download |
| macOS | x64 (Intel) | Download |
| macOS | ARM64 (Apple Silicon) | Download |
| Android | APK | Download |
Android users (Obtainium): Point Obtainium at this repository's releases for automatic updates. The APK version code (set by ApplicationVersion) increments with every CI run, so Obtainium can track updates automatically.
Idle/clicker inspired by Adventure Capitalist. Start with $5, buy businesses, hire managers, prestige for angels.
| Business | Base Cost | Base Revenue | Cycle | Cost Multiplier |
|---|---|---|---|---|
| 🍋 Lemonade Stand | $4 | $1 | 0.6s | 1.07 |
| 📰 Newspaper Route | $60 | $60 | 3s | 1.15 |
| 🚗 Car Wash | $720 | $540 | 6s | 1.14 |
| 🍕 Pizza Delivery | $8,640 | $4,320 | 12s | 1.13 |
| 🍩 Donut Shop | $103,680 | $51,840 | 24s | 1.12 |
| 🦐 Shrimp Boat | $1,244,160 | $622,080 | 96s | 1.11 |
Next unit costs baseCost × r^owned. Managers cost 1000× base cost and run the business automatically, including offline.
All tuning constants live in src/MyAdventure.Core/Entities/GameBalance.cs.
| Quantity | Formula |
|---|---|
| Revenue per cycle | baseRevenue × owned × milestones(owned) × efficiency(owned) |
| Cycle time | baseTime / speedMilestones(owned) |
| Efficiency | max(1, r^(0.49·owned) / Q^(min(owned,1000)/1000)), Q = milestone × speed product at 1000 (≈2.1×10⁷) |
| Cross-business bonus | ×1.5 each time every business reaches 25, 50, 100, 200, 300, 400 (max ×11.39) |
| Angels | floor(150 × √(lifetime / 10¹⁴)), lifetime ≥ 10¹² |
| Angel bonus | 1 + 0.02 × angels (linear) |
| Earnings | revenue × angelBonus × crossBonus, identical live and offline |
Revenue milestones: ×2 at 25/50/100/200/300/400, ×4 at 500–900, ×5 at 1000. Speed milestones: ×2 at 25/50/100/200/300/400.
The old economy was bistable. Before prestige, revenue hit a hard wall at about 10⁹/s because milestones could not keep up with exponential costs. After prestige, compounding angels (1.02^angels) created a positive feedback loop: one prestige enabled hundreds more within minutes.
Real idle games avoid both failure modes the same way:
- Costs grow exponentially and revenue grows more slowly, so every run hits a soft wall.
- Prestige currency is sublinear in earnings (√ or ∛), and its bonus is linear, never compounding.
- Only one feedback loop is unbounded. Everything else (milestones, global bonuses) is bounded content.
- Pacing is tuned by simulation against explicit targets and pinned by tests.
This game makes revenue track cost^0.49: efficiency fills the gap between the milestone ladder and that curve. If in-run income grows like spent^α, a run reaches spent ∝ (multiplier × time)^(1/(1−α)). Angels scale with √lifetime, so the prestige loop gain is 1/(2(1−α)). With α = 0.49 the loop gain is 0.98: just under 1. Progress is steady and very slowly decelerating. It never runs away (gain > 1) and never stalls (gain ≪ 1).
tests/MyAdventure.Core.Tests/Balance holds a deterministic bot that plays the real GameEngine. It prestiges whenever doing so would double income. It pins:
| Scenario | Bound | Measured |
|---|---|---|
| First manager | < 2 min | 37 s |
| First prestige | 8–60 min | 19 min |
| First 10 prestige runs | 5–90 min each | 13–31 min |
| Seeded at lifetime 10⁴⁰ / 10⁸⁰ | 3 min – 4 h | 12–14 min / 18–20 min |
| Never prestige | income still rising after a week | 10¹³·¹/s at 1 day → 10¹³·⁷/s at 7 days |
EfficiencyExponent must stay inside (0.40, 0.50); a test enforces it.
The save format is unchanged: SQLite TEXT columns hold canonical BigDouble strings, and v1/v2 Base64 export/import both work. Old saves load as-is with the new formulas applied. Angels earned under the old 10¹³ divisor are kept. New angels resume once lifetime earnings catch up with the new curve.
📤 Export produces Base64 JSON (v, cash, lifetime, angels, prestige, businesses, managers, timestamp). 📥 Import accepts it back. Editing is encouraged. Business keys: lemonade, newspaper, carwash, pizza, donut, shrimp. timestamp is informational and ignored on import.
- .NET 10 SDK (the exact version is pinned in
global.json— if you see a version mismatch error on first build, install the SDK version listed there) - For Android builds: Java 21 (Temurin) and the Android workload (
dotnet workload install android)
dotnet restore
dotnet build
dotnet run --project src/MyAdventure.Desktopdotnet workload install android
dotnet publish src/MyAdventure.Android/MyAdventure.Android.csproj --configuration Release --output ./publish/androidSee docs/KEYSTORE.md for APK signing instructions.
dotnet testAll tests (unit, integration, UI/ViewModel) are designed to run fast after every change. No external services, emulators, or running applications are required — the test suite is fully headless.
- First minutes: buy lemonade stands and click ▶ RUN. Hire the lemonade manager ($4,000) as soon as you can.
- First half hour: add businesses and managers; use the bulk-buy button (
BUY N→threshold, thenBUY MAX (N)past 1000) to hit milestones. - Prestige: the button lights up when you can gain at least one angel. The panel shows the income multiplier you would get. Prestiging at ×2 or better keeps runs around 15–30 minutes.
- Balance ownership: the cross-business bonus needs every business at the threshold.
- Offline: managed businesses earn while the app is closed, with no cap.
- Reset a save: delete
{LocalApplicationData}/MyAdventure/myadventure.db(desktop) or clear app data (Android).
MyAdventure.slnx
├── src/
│ ├── MyAdventure.Core — Domain entities, game engine, BigDouble, number formatting
│ ├── MyAdventure.Infrastructure — EF Core SQLite persistence, DI, OpenTelemetry, BigDouble schema migration
│ ├── MyAdventure.Shared — ViewModels, converters, toast service, AppRoot, AppLifecycleManager, i18n
│ ├── MyAdventure.Desktop — Avalonia desktop app (Windows/Linux/macOS)
│ └── MyAdventure.Android — Avalonia Android app
└── tests/
├── MyAdventure.Core.Tests — Unit tests for entities, engine, BigDouble, milestones, number formatting
├── MyAdventure.Integration.Tests — EF Core repository round-trip tests, schema migration tests
└── MyAdventure.UI.Tests — ViewModel, lifecycle, and service tests
One solution, one team. There is one .slnx file, one CI pipeline, and one build command. Desktop and Android are not siloed into separate solutions or build scripts. Everyone works with all parts of the code. If the build is slow, everyone feels it, so it gets fixed quickly. There is no build-desktop.sh, no build-android.sh, no per-platform solution file. Single-team culture is enforced by the project structure itself.
Clean architecture with pragmatism. Core has zero UI dependencies. Infrastructure handles persistence and telemetry. Shared contains ViewModels used by both Desktop and Android. Platform projects are thin shells: they wire up DI, set up the timer, and host the view.
Testable from the ground up. The GameEngine accepts injected dependencies (IGameStateRepository, ILogger, TimeProvider) and is fully testable without any UI framework. ViewModels are tested against real engine instances with mocked repositories. Integration tests use EF Core's SQLite in a temp file, not an in-memory fake, so schema migrations run against the real driver. The TimeProvider abstraction (via TestTimeProvider) lets tests advance the clock deterministically without sleeping.
No scrollbars — designed for at-a-glance play. The UI fits on screen without scrolling so the entire game state is visible at once on any device. Desktop uses a 3×2 grid for businesses; Android uses a 2×3 grid. The import/export transfer panel overlays the business grid rather than adding height. This is a deliberate design stance, not a missing feature.
Cash display auto-scales. Both the Desktop and Android cash text are wrapped in a Viewbox with StretchDirection="DownOnly", so when a value like 7.53 × 10⁴⁰ would otherwise overflow the header, it auto-shrinks to fit the available width while retaining its natural size when there's room.
- SQLite for persistence via EF Core. Uses
DateTime(UTC) instead ofDateTimeOffsetbecause SQLite's EF Core provider doesn't supportDateTimeOffsetinORDER BYclauses; dates are stored and loaded asDateTime.UtcNow/.UtcDateTime. - BigDouble for all monetary values.
Cash,LifetimeEarnings,AngelInvestors, and the game balance values that scale with ownership are allBigDouble. The columns are stored as TEXT in SQLite (canonical form"<mantissa>e<exponent>"). Legacy databases with REAL columns are migrated in-place byDependencyInjection.InitializeDatabaseAsyncin a single transaction. - Geometric-series bulk buy (O(1)).
GameEngine.BuyMultipleuses the closed-formc₀ × (rⁿ − 1) / (r − 1)to calculate the total cost of N units in constant time, regardless of how many units that is. The prior implementation looped unit-by-unit up to a 10,000-unit safety cap; the new one handles BuyMax of 50,000 in the same time as buying 5. A defensive fallback loop catches any floating-point noise that would cause the geometric series to produce a total just over the cash balance and backs off by one unit. - Progress bars use percentage-based rendering (
ScaleTransformwith aPercentToFractionConverter) instead of pixel widths, which ensures correct display on both desktop and Android without any platform-specific layout code. - Android logging goes through
Android.Util.Lograther than console-based providers, since console output is not visible on Android. OpenTelemetry's console exporter is also disabled on Android (AddInfrastructuretakes an optionalandroidLoggingflag to suppress it). - AOT compilation is disabled for Android (
RunAOTCompilation=false,PublishTrimmed=false) because EF Core's reflection-heavy patterns and OpenTelemetry cause silent trimming crashes. Re-enable once trimmer roots are properly configured. - Angel bonus is linear (
1 + 0.02 × angels), applied once per payout in bothTickand offline earnings;OfflineEarnings_ShouldApplyAngelBonusOnce_NotTwicepins the equivalence. - Cross-business bonus is a bounded revenue multiplier (max ×11.39), not a cycle-time divisor, so cycle times never underflow.
- Efficiency (
GameBalance.Efficiency) keeps total per-business multipliers on thecost^0.49curve; from 1000 owned onward the product of milestones, speed and efficiency is exactlyr^(0.49·owned). - Toast notifications use a simple service (
ToastService) with expiration timestamps, cleaned up on each game tick viaCleanupExpired(). No platform-specific notification APIs needed. Toast lifetime defaults to 3 seconds; tests can passTimeSpan.Zeroto get immediately-expirable toasts. - Central package management uses MSBuild variables (
$(AvaloniaVersion),$(MicrosoftExtensionsVersion),$(EfCoreVersion),$(OpenTelemetryVersion)) inDirectory.Packages.propsso updating a version is a single-line change. - Clipboard access via a static
AppRoot.CurrentVisualregistered by the active view, not via per-platform branching onIApplicationLifetime. This is necessary in Avalonia 12 because Android'sIActivityApplicationLifetimeexposes only aMainViewFactory(aFunc<Control>) and not a live view reference. Views registerAppRoot.CurrentVisual = thisinOnOpened/OnAttachedToVisualTreeand clear it on detach. - Android safe-area is handled explicitly by
MainView, not by the framework's auto-padding. The AndroidUserControlsetsTopLevel.AutoSafeAreaPadding="False", capturesInsetsManager.SafeAreaPaddingon attach, and subscribes toSafeAreaChangedto keep itsPaddingin sync with the OS-reported insets. This is needed because Android 15+ enforces edge-to-edge rendering — without explicit handling, the top bar (PRESTIGE / cash) gets drawn under the status bar and front-camera cutout on devices like the Moto G Stylus 2025, and the first row of business cards visually rides on top of the prestige bar. Owning the padding deterministically onMainViewrather than relying on auto-injection at the TopLevel root is also more robust across activity recreation, which is aggressive on Android. - Offline earnings on app resume are handled by
AppLifecycleManager, a static service that wires into Avalonia'sIActivatableLifetime(available viaApplication.Current.TryGetFeature<IActivatableLifetime>()), which works uniformly on all platforms. When the app goes to background (ActivationKind.BackgroundDeactivated event),AppLifecycleManagercallsGameViewModel.OnSuspended()to record the timestamp. When it returns to foreground (ActivationKind.BackgroundActivated event), it callsGameViewModel.OnResumed(), which computes offline earnings for the gap and applies them immediately before refreshing the UI — so the cash display is correct on the very first frame after returning to the app. Sub-second gaps (e.g. screen flickers) are below the minimum threshold and produce no payout. TheAppLifecycleManagerholds a replaceable static "current target" so Android activity recreation (which constructs a fresh ViewModel each time) doesn't leak handlers on the old VM. - Cold-start vs. foreground-resume are distinct paths.
GameEngine.LoadAsynchandles the cold-start path: it readsLastPlayedAtfrom the saved state and callsApplyOfflineEarnings(now - lastPlayedAt)if the gap exceeds the minimum threshold. The foreground-resume path is handled byGameViewModel.OnSuspended()/OnResumed(), which usesAppLifecycleManager. Both paths call the sameGameEngine.ApplyOfflineEarnings(TimeSpan)method — the single public entry point for offline earnings — so the logic is never duplicated and both paths are pinned by the same invariant tests. - Localization is wired via
Microsoft.Extensions.Localizationwith JSON resource files (src/MyAdventure.Shared/Resources/i18n/). English (en.json) and Spanish (es.json) are included. The infrastructure is in place to add more locales by adding a new JSON file and updating the supported-cultures list inDependencyInjection.cs. - Optional Sentry error tracking via OTLP — no Sentry SDK. Sentry's hosted ingestion accepts standard OpenTelemetry traces and logs over OTLP/HTTP (
Settings → Client Keys (DSN) → OpenTelemetry). The Infrastructure project's existing OpenTelemetry stack adds an OTLP exporter for logs and traces when a DSN is configured; metrics stay console-only because Sentry's OTLP ingestion does not accept metrics. NoSentry.*NuGet package is used — swapping to any other OTLP backend (Grafana Cloud, Honeycomb, Tempo, Loki, an OpenTelemetry Collector) is a one-line config change. See the Observability section for the configuration surface. - No
Avalonia.Diagnosticspackage. Removed in Avalonia 12; the official replacement (AvaloniaUI.DiagnosticsSupport) gates the actual Dev Tools UI behind a paid Avalonia Plus / Pro subscription. The Community tier is free for non-commercial use only — and this project's policy is to avoid any package whose use is conditional on payment of any kind. Use the FOSS Avalonia VS Code or Rider extensions for design-time previewing.
This project tracks the latest Avalonia stable release. These notes capture the gotchas that cost real time during the v11 → v12 migration; documenting them here in case they save someone else hours.
- Android
MainActivitywas split. In v11 it wasAvaloniaMainActivity<App>andWithInterFont()lived on itsCustomizeAppBuilderoverride. In v12 those virtual hooks are no longer called by the framework. The activity is now an emptyAvaloniaMainActivity(non-generic) declaring only its[Activity]metadata, and a new[Application] AndroidApp : AvaloniaAndroidApplication<App>class hosts the AppBuilder customization (WithInterFont()). The[Application]attribute must not setNameequal to the package name (e.g."com.myadventure.app") — that would cause a javac collision between the generated Application class and R.java. packageattribute on<manifest>removed. In modern .NET for Android the package identity is set by<ApplicationId>in the.csproj, which is the single canonical source. Setting it in both places causes javac collisions.- Android lifetime is
IActivityApplicationLifetime(notISingleViewApplicationLifetime). SetMainViewFactory = () => new MainView { DataContext = vm }rather thanMainView = .... The factory runs each time Android creates a fresh activity, producing a fresh view + fresh ViewModel that re-loads state from the database. - Plugins are no longer configurable and the data-annotations plugin is off by default. This removed the long-standing nuisance where
CommunityToolkit.Mvvmvalidation conflicted with Avalonia's, so no extra config is needed. DispatcherTimerbinds to the dispatcher of the constructing thread rather than the UI thread implicitly. Our timers are constructed inOnOpened/OnAttachedToVisualTree, both of which run on the UI thread, so behavior is unchanged.- Compiled bindings remain enabled by default via
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>in the platform csprojs. - Edge-to-edge is enforced on Android 15+.
InsetsManager.DisplayEdgeToEdgeis now obsolete (replaced byDisplayEdgeToEdgePreference), and the OS no longer respects requests to draw inside the system-bar area. Apps must handleSafeAreaPaddingexplicitly. We do this on the AndroidMainViewrather than relying on Avalonia's auto-padding (which depends on theTopLevel.AutoSafeAreaPaddingattached property and has historical regressions around activity recreation and orientation changes — see Avalonia issue #20448 for one example). Application.GetTopLevel()is not a valid Avalonia 12 API. Use theAppRoot.CurrentVisualstatic pattern instead: views register themselves on attach and the VM readsTopLevel.GetTopLevel(AppRoot.CurrentVisual)?.Clipboardto get the clipboard.IActivatableLifetimereplacesWindow.Activated/MainView.OnAttachedToVisualTreefor lifecycle events. Obtained viaApplication.Current.TryGetFeature<IActivatableLifetime>(). Listen forActivationKind.Backgroundevents only —ActivationKind.Applicationfires for things like dialog focus and would cause spurious offline-earnings payouts.
All dependencies are free and use permissive open-source licenses (MIT, Apache-2.0, BSD). No NuGet package in this project requires payment for any use, commercial or otherwise. "Free for non-commercial" is explicitly not enough — we avoid those too.
| Category | Technology | License |
|---|---|---|
| Runtime | .NET 10 / C# 14 | MIT |
| UI Framework | Avalonia UI 12.1.4 | MIT |
| MVVM | CommunityToolkit.Mvvm 8.4.2 | MIT |
| Database | SQLite via EF Core 10.0.13 | MIT |
| Observability | OpenTelemetry 1.20.0 | Apache-2.0 |
| Optional error tracking | Sentry (via OTLP HTTP — no Sentry SDK) | n/a (free tier) |
| Testing | xUnit.net v3 4.0.2 on Microsoft Testing Platform | Apache-2.0 |
| Assertions | Shouldly 4.3.0 | BSD |
| Mocking | NSubstitute 6.2.0 | BSD |
| Test Data | Bogus 35.6.5 | MIT |
| Coverage | coverlet.MTP 10.1.0 | MIT |
- Central package management via
Directory.Packages.props— all NuGet versions defined in one place using MSBuild variables for grouped version updates. Updating all Avalonia packages is a one-line change to$(AvaloniaVersion). - Shared build configuration via
Directory.Build.props— target framework (net10.0),ImplicitUsings,Nullable enable,LangVersion latest, versioning (driven by$(BuildNumber)from CI), deterministic builds,TreatWarningsAsErrorsin Release. - Solution file uses the new
.slnxXML format (one solution for the whole repo, no per-platform.slns). - C# 14 features including primary constructors, records, collection expressions,
requiredproperties, and global usings. - Compiled bindings enabled by default in Avalonia (
AvaloniaUseCompiledBindingsByDefault). InternalsVisibleToused inMyAdventure.Shared.csprojto giveMyAdventure.UI.Testsaccess toAppLifecycleManager.ResetForTesting()without over-exposing internals to production code.- Global
using Xunit;injected bytests/Directory.Build.propsso test files don't need per-file using directives for xUnit attributes. global.jsonpins the SDK to10.0.100(rollForward: latestMinor, no prereleases) and optsdotnet testinto Microsoft Testing Platform ("test": { "runner": "Microsoft.Testing.Platform" }).- Test projects are MTP executables (xunit.v3); shared test settings and packages live in
tests/Directory.Build.props. Coverage:dotnet test --coverlet. Test methods passTestContext.Current.CancellationToken(analyzer xUnit1051).
The test suite runs fully headless — no emulators, no running applications, no external services. dotnet test from the repo root runs everything.
MyAdventure.Core.Tests — unit tests for the domain layer.
BigDoubleTests— construction/normalization, arithmetic (add/subtract/multiply/divide), exponentiation (including pastdoubleoverflow range), sqrt, log10, comparison, string round-tripping, implicit conversions, Floor, and edge cases (NaN, Infinity, zero, negative).BusinessAffordableTests—AffordableCountclosed-form calculation including huge-cash edge cases.Balance/*— deterministic bot playing the real engine; pins pacing (first manager, first prestige, run-length band, late-game seeds, never-prestige soft wall).BusinessTests—NextCost,Revenue(including efficiency) and extreme ownership.CrossBusinessSpeedBonusTests— ladder, bounded maximum,NextThresholdreturningnullonce maxed.GameEngineTests— efficiency invariants (never below 1, monotonic, exactr^(0.49·n)past 1000, bounded wiggle below), linear angel bonus,LoadAsync,Tick,StartBusiness,BuyBusiness,BuyManager,BuyMultiple,BuyMax,Prestige,CalculateAngels,AngelBonus,ApplyOfflineEarnings,ExportToString/ImportFromString(including the legacy v1 format), and invariant tests for offline-earnings equivalence with live ticks.MilestoneTests—CalculateMultiplier,NextMilestone,UnitsToNext.NumberFormatterTests— suffix formatting (K, M, B, T, Qa, Qi, Sx, Sp, O, N, D), scientific notation past 10³⁶,BigDoubleoverload including values at 10⁵⁰⁰ and 10⁵⁰⁰⁰, infinity/NaN display.SpeedMilestoneTests—CalculateCycleTimeMultiplier,CalculateSpeedMultiplier,NextSpeedMilestoneat all threshold boundaries.SubFrameCycleTests— invariants that guarantee sub-frame cycles (effective cycle time < 16ms) pay out correctly without being lost or double-counted.
MyAdventure.Integration.Tests — tests that hit the real EF Core + SQLite stack.
GameStateRepositoryTests— round-trip save/load for all fields including BigDouble string columns; extreme-magnitude BigDouble values; upsert behaviour (single row kept,CreatedAtnever changes across saves).SchemaMigrationTests— verifies that the legacy REAL-column schema (pre-BigDouble migration) is correctly translated to the new TEXT-column schema in place, without data loss, in a single transaction.
MyAdventure.UI.Tests — ViewModel and service tests. No Avalonia UI host needed.
AppLifecycleManagerTests—Attachwith and without an active AvaloniaApplication, replaceable target on repeatedAttachcalls,ResetForTestingisolation.BusinessViewModelSpeedTests—SpeedMultiplierText,HasSpeedBonus,HasNextSpeedMilestone,NextSpeedMilestoneTextat every speed threshold; cross-business multiplier applied correctly to the displayed revenue.BusinessViewModelTests—Refreshwith various cash and angel bonus combinations; affordability flags; bulk-buy button visibility and label transitions ("BUY N→threshold" vs. "BUY MAX (N)" past 1000 owned);CanBulkBuydims on zero affordable rather than vanishing.GameViewModelPrestigeTests— prestige panel shows the income multiplier; cross-bonus texts before the first and after the last threshold.GameViewModelLifecycleTests—OnSuspended/OnResumedround-trip with a controllableTestTimeProvider: payout for a 10-minute gap, angel bonus applied once, toast notification on payout, no toast when no managed businesses, tiny gaps below threshold produce no payout, secondOnResumedwithout interveningOnSuspendedproduces no payout,LastTickreset on resume prevents the next timer tick from replaying the gap.ToastServiceTests—Show,CleanupExpired, mixed expired/active toasts.
No mocked time — injectable TimeProvider. GameEngine and GameViewModel accept a TimeProvider (the .NET 8+ standard abstraction). Tests pass a TestTimeProvider that starts at a fixed UTC instant and can be advanced with Advance(TimeSpan). This makes every time-dependent test deterministic and instant — no Thread.Sleep, no flakiness.
Offline earnings equivalence invariant. The most important invariant in the test suite: OnSuspendedThenOnResumed results must match what live ticks would have produced for the same duration. This is not just a sanity check — it's the guard against the class of bug where the live-tick path and the offline-earnings path use different multipliers (e.g. angel bonus applied twice, or cross-business multiplier missing from one path). The test pins both paths against the same arithmetic.
TestTimeProvider (not FakeTimeProvider). We implement our own TestTimeProvider rather than using the Microsoft FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing because that package was not yet available on the target SDK. TestTimeProvider is ~30 lines and lives in the test project; it overrides GetUtcNow() and provides an Advance(TimeSpan) method.
Real SQLite in integration tests. Integration tests use a temp file Path.GetTempFileName() rather than EF Core's in-memory provider. The in-memory provider doesn't run the raw ADO.NET SQL in the schema-migration path (which uses SqliteConnection directly), so using a real SQLite file is the only way to test the migration end-to-end. Temp files are cleaned up in IAsyncLifetime.DisposeAsync.
InternalsVisibleTo for test seams. AppLifecycleManager.ResetForTesting() is an internal method exposed to the UI test project via <InternalsVisibleTo Include="MyAdventure.UI.Tests" /> in MyAdventure.Shared.csproj. Production code can't see it; tests can call it to reset static state between cases without the risk of accidentally calling it in production.
GitHub Actions (.github/workflows/build-and-release.yml) automates everything from a single workflow file. There is one workflow, no per-platform workflow files, and no matrix tricks that hide failures from part of the team.
1. build-and-test runs on every push and every PR.
- Sets up .NET 10, Java 21 (Temurin), and the Android workload.
- Sets up a keystore: uses the real keystore from
ANDROID_KEYSTORE_BASE64if the secret is configured; otherwise generates a dummy keystore valid for 1 day so the Android build compiles even without signing credentials. - Restores, builds (Release, including Android with the keystore), and runs all tests.
- Warnings are treated as errors in Release builds (
TreatWarningsAsErrors=trueinDirectory.Build.props).
2. build-desktop runs only on pushes to main (not PRs), after build-and-test succeeds. Matrix strategy across 6 RIDs:
| Runner | RID |
|---|---|
| ubuntu-latest | linux-x64 |
| ubuntu-latest | linux-arm64 |
| windows-latest | win-x64 |
| windows-latest | win-arm64 |
| macos-latest | osx-x64 |
| macos-latest | osx-arm64 |
Each publishes a self-contained single-file executable (PublishSingleFile=true, IncludeNativeLibrariesForSelfExtract=true), archives it (.tar.gz on Unix, .zip on Windows), and uploads it as a GitHub Actions artifact.
3. build-android runs only on pushes to main, after build-and-test succeeds. Runs on ubuntu-latest. Builds a signed APK if ANDROID_KEYSTORE_BASE64 is configured, unsigned otherwise. Uses AndroidUseAapt2Daemon=false to avoid daemon startup timeouts in CI. Renames the APK to MyAdventure-android-<run_number>.apk and uploads it as an artifact.
4. create-release runs after both build-desktop and build-android succeed. Downloads all artifacts, creates a GitHub Release tagged v1.0.<run_number>, and uploads all binaries. Release notes are auto-generated from commit messages by generate_release_notes: true.
build-and-test on every PR is the gate — it's what must stay green for the codebase to be mergeable. The release jobs only run on pushes to main, so PRs don't spend CI minutes building seven platform artifacts when all you care about is "does it compile and do the tests pass?". The gate job builds Android too (with a dummy keystore) so Android compilation failures are caught on every PR, not just on merges.
Configured in .github/dependabot.yml to check NuGet packages and GitHub Actions weekly. NuGet packages are grouped so version bumps arrive as coherent PRs rather than one PR per package: avalonia group (all Avalonia*), microsoft group (all Microsoft.*), opentelemetry group (all OpenTelemetry*), and testing group (xUnit, Shouldly, NSubstitute, Bogus, coverlet). FluentAssertions and Moq are explicitly ignored — those are not used in this project, and Dependabot sometimes suggests them as transitive updates.
- The game runs at ~60fps via a
DispatcherTimerwith a 16ms interval. TheOnTick()method inGameViewModeldrives all game logic. Each tick callsengine.Tick(delta)wheredeltais the elapsed time since the last tick, and then refreshes all ViewModel properties. - Auto-save triggers every ~300 ticks (~5 seconds at 60fps). Save is asynchronous; the game state is captured synchronously from the engine, then the write runs on a background task so the UI never hitches.
- The
NumberFormatterhandles large number display. Below 1000: two decimal places. 1000 to 10³⁶: metric suffixes (K, M, B, T, Qa, Qi, Sx, Sp, O, N, D). Above 10³⁶: scientific notation with Unicode superscript exponents (e.g.7.53 × 10⁴⁰). TheBigDoubleoverload handles values arbitrarily past thedoubleoverflow range. Non-finite values display as∞,-∞, or?so they can never propagate crashes through the UI. - Database location:
{LocalApplicationData}/MyAdventure/myadventure.dbon every platform — on Android this resolves to the app's private internal storage. - OpenTelemetry exports to console by default on desktop. The
DependencyInjection.AddInfrastructuremethod wires up logging, tracing (AddSource("MyAdventure.*")), metrics (AddMeter("MyAdventure.*")), and runtime instrumentation. TheGameEngineemits anEarningsCountermetric (tagged by business ID and source) and aTickDurationhistogram. OTLP exporters for logs and traces are added automatically when a Sentry DSN is configured (see the next section); the same code path will forward to Jaeger, Grafana, Honeycomb, or an OpenTelemetry Collector with a different DSN-less endpoint URL. - On Android, the OpenTelemetry console exporter still runs but Android's standard output is invisible to users; the same log records go through the OTLP exporter when a DSN is configured.
Android.Util.Logcalls remain inAndroidApp/App.axaml.csfor the early-boot path (before the OpenTelemetry pipeline is ready) so initialization errors are still visible inadb logcat.
The project ships with first-class OpenTelemetry instrumentation: structured logs, traces, and runtime metrics. By default everything goes to the console. Optionally the same data can be forwarded to Sentry — using Sentry's native OTLP HTTP endpoint, not the Sentry .NET SDK. There is no Sentry.* NuGet package in this repo. The OpenTelemetry stack already in place does the entire job: pointing it at a different OTLP backend later is a configuration change, not a code change.
The instrumentation lives in the regular code path — GameEngine, GameViewModel, AppLifecycleManager, GameStateRepository, and DependencyInjection.InitializeDatabaseAsync — and is emitted on every major event in the app's life. Concretely:
- Lifecycle: app start, telemetry-configuration breadcrumb (Sentry on/off, verbose on/off, environment), database migration begin/end, game-state load, fresh-game start (no save found), app suspended, app resumed, offline earnings applied (with seconds and amount).
- Game economy: buy single unit, buy bulk (with count, cost, and resulting total), buy manager, prestige (with new angel count), import (with extracted cash and angels), export (with payload size), copy-to-clipboard.
- Errors and warnings: parse errors when importing a tampered save, clipboard failures, save failures, schema-migration failures (rolled back), business-data / manager-data JSON parse failures.
- Spans (traces): every game load, every tick batch, every save, every prestige, every offline-earnings calculation.
- Metrics (console-only — Sentry doesn't accept OTLP metrics): tick counter, earnings counter (tagged by business and by
livevsofflinesource), tick-duration histogram, plus the standard .NET runtime metrics fromOpenTelemetry.Instrumentation.Runtime(GC, threadpool, exceptions, etc).
The full list of log messages can be discovered with git grep "LogInformation\|LogWarning\|LogError" src/.
Sentry's Developer (free forever) plan ships with 5,000 errors / 10,000 performance transactions / 5 GB of logs per month. The defaults below are picked to fit comfortably inside that envelope for an idle game with one player:
- Sentry reports out of the box during the testing phase. A non-secret public DSN is hardcoded in
src/MyAdventure.Infrastructure/Telemetry/TelemetryDefaults.csso that any fresh clone — and every binary built by the GitHub Actions release job — starts reporting to Sentry immediately. You can override it for your own builds (see below) or empty out the constant when the project leaves the testing phase to return to opt-in behaviour. (Public Sentry DSNs are not secrets; they grant write-only permission to a specific project, which is why mobile SDKs ship them embedded in published binaries.) - Metrics never go to Sentry. Sentry's OTLP ingestion doesn't accept metrics, and we don't try. Runtime metrics stay on the console exporter.
Microsoft.EntityFrameworkCoreis pinned atWarningso EF Core's per-statementInformationchatter (which is voluminous and almost never actionable) doesn't burn through the log quota.- Verbose logging is off by default, controlled by a single toggle (see below). When verbose is off the app emits a handful of records per session.
- Traces are sampled at 100% by default because the game produces only a few spans per session. Lower
TracesSampleRateif you start running automated soak tests.
If you go over the free tier, Sentry stops accepting new events for the rest of the billing month rather than charging you. The "drop events on quota exhaustion" behaviour is exactly what we want — there is no scenario where this project starts costing money silently.
The DSN follows a layered precedence chain. Higher entries win.
| Source | Notes |
|---|---|
SENTRY_DSN environment variable |
Works on Desktop and Android. Always wins. |
Telemetry:Sentry:Dsn in appsettings.json (Desktop only) |
The shipped value is "", which is treated as "fall through to the next layer". |
TelemetryDefaults.DefaultDsn constant |
The compile-time fallback. Set during the testing phase so fresh clones and GitHub Release binaries Just Work. Edit this file to change the DSN globally; set it to "" to require explicit configuration. |
Hard-coded defaults in TelemetryOptions |
Sentry off, verbose off — only applies if every other layer is empty. |
src/MyAdventure.Desktop/appsettings.json:
{
"Telemetry": {
"VerboseLogging": false,
"Sentry": {
"Dsn": "",
"Environment": "production",
"TracesSampleRate": 1.0
}
}
}Environment variables (recognized on both Desktop and Android):
| Variable | Effect |
|---|---|
SENTRY_DSN |
Sentry DSN. Wins over the compile-time fallback and appsettings.json. Set to "" to disable — but note that the compile-time fallback will then take over; unset the variable AND empty TelemetryDefaults.DefaultDsn to fully turn Sentry off. |
MYADVENTURE_VERBOSE |
1 / true / yes / on (case-insensitive) lifts log level to Debug and EF Core to Information. Anything else (or unset) keeps the safe defaults. |
MYADVENTURE_SENTRY_ENVIRONMENT |
Override deployment.environment on every event (e.g. staging, development). |
A startup breadcrumb log line records the configuration the app actually applied — visible on the console, in Sentry (if configured), and in adb logcat on Android — so you can see at a glance whether Sentry is on, what environment is reported, and whether verbose mode is active.
Verbose mode is a runtime toggle, not a rebuild. Flip MYADVENTURE_VERBOSE=1 (or set Telemetry:VerboseLogging: true in appsettings.json) and restart the app. The OpenTelemetry log pipeline's minimum level drops from Information to Debug, and Entity Framework Core lifts from Warning to Information (so SQL command traces start appearing). Turn it back off when you're done — the verbose stream is intentionally chatty.
On Android the simplest way to toggle it for a single APK install is:
adb shell setprop debug.MYADVENTURE_VERBOSE 1
# then force-stop and relaunch the app…or rebuild with the env var burned in via an AndroidEnvironment file.
The project ships with a working Sentry DSN baked into TelemetryDefaults.DefaultDsn. You only need this section if you want your own Sentry account to receive events instead of the project's testing-phase account.
- Create a Sentry account, organization, and project at https://sentry.io. The project type doesn't really matter (any platform works for the OTLP endpoint); pick "Native" or ".NET" for closest documentation.
- Open Settings → Projects → <your project> → Client Keys (DSN) and copy the DSN value. (The page also shows the OTLP endpoints and the
x-sentry-authheader for reference; the code derives both from the DSN, so you only need to copy the DSN itself.) - Choose how to wire it in:
- Globally, for every build: edit
src/MyAdventure.Infrastructure/Telemetry/TelemetryDefaults.csand replaceDefaultDsn. Commit. From this point on every desktop and Android binary built from this checkout reports to your project. - Desktop, one-off run:
SENTRY_DSN='https://...@...sentry.io/...' dotnet run --project src/MyAdventure.Desktop - Desktop, persistent (Linux/macOS): add
export SENTRY_DSN=...to your shell rc file. - Desktop, persistent (Windows):
setx SENTRY_DSN "https://..."in an admin shell, then open a new shell. - Desktop, baked into a checkout: edit
Telemetry:Sentry:Dsninsrc/MyAdventure.Desktop/appsettings.json. ⚠ Don't commit a real DSN if it's not yours to share —appsettings.jsonis tracked. To keep a personal DSN out of git, drop it insrc/MyAdventure.Desktop/appsettings.local.jsoninstead — that file is gitignored, optional, and loaded afterappsettings.jsonso it overrides anything in the committed file. - Android:
adb shell setprop debug.SENTRY_DSN 'https://...'for testing, or bake into the build by editingTelemetryDefaults.cs.
- Globally, for every build: edit
- Restart the app. You should see a startup line like
Telemetry: Sentry OTLP enabled, env=production, verbose=False. Within 30–60 seconds the first events will appear in Sentry's Issues and Traces views. If they don't, double-check the DSN by openingSettings → Client Keys (DSN) → OpenTelemetryin Sentry and confirming the host matches.
To disable the project's compile-time DSN entirely (so an unconfigured build reports nothing): edit TelemetryDefaults.DefaultDsn to "". Tests pin this code path so the behaviour stays correct either way.
If the DSN is malformed (typo, missing scheme, etc.), the app still starts — it logs a single warning and proceeds with console-only exporters. A misconfigured DSN never blocks startup.
Is the DSN platform-specific? No. A Sentry DSN identifies a Sentry project, not a client platform. The same DSN works for Desktop on Windows/Linux/macOS and for Android — Sentry distinguishes them via the service.name, service.version, and deployment.environment resource attributes that the OpenTelemetry pipeline already attaches. The Sentry project name you typed during onboarding ("android" or whatever) is just a label; the DSN doesn't care.
Because the integration uses standard OTLP/HTTP, redirecting to a non-Sentry backend is a one-line edit in Infrastructure/DependencyInjection.cs: change the endpoint construction in SentryDsn (or replace the whole DSN-parsing path) and adjust the auth header to whatever the new backend expects. The rest of the OpenTelemetry pipeline — instrumentation, resource attributes, sampling — stays exactly as-is.
This project is built collaboratively between a human developer and AI assistants. In the interest of full transparency:
- Code generation: Significant portions of C#, AXAML, YAML, and configuration files were generated by Anthropic Claude (Opus and Sonnet models) and Google Gemini, then reviewed, tested, and iterated on by the human developer.
- Architecture decisions: The clean architecture layout, project structure, testing strategy, BigDouble design, and CI/CD pipeline were designed through human-AI collaboration.
- Documentation: This README and other documentation files were drafted with LLM assistance.
- Debugging: Platform-specific issues (Android SQLite quirks, progress bar rendering, logging providers, the Avalonia 12 migration, edge-to-edge safe-area handling on Android 15, the offline-earnings DispatcherTimer tick-clamping bug, the cash-stall-at-1e200 BigDouble migration, and the Sentry-via-OTLP integration that avoids the Sentry .NET SDK) were diagnosed and resolved with AI help.
We provide this disclosure so that AI training pipelines, web scrapers, and researchers can make informed decisions about including this content in their datasets. If you operate a training pipeline and wish to exclude LLM-assisted code, this notice serves as a clear signal.
This disclosure does not diminish the work. The human developer directed all decisions, verified all output, ran all tests, and takes responsibility for the final product. AI was a tool, not an author.
Copyright (C) 2026 MyAdventure Contributors
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/.
Note on dependency licenses: All NuGet dependencies used by this project are licensed under MIT, Apache-2.0, or BSD licenses, which are compatible with AGPLv3. The AGPLv3 applies to the MyAdventure source code itself.