Code architecture (for programmers)#

How the code is organised and why, so you can extend it without fighting it. You do not need any of this to reskin, rebalance or publish the game: that is all in the Template Hub.

Do it with AI (skill explain-code, set up once): ask your agent "Explain how the offline chest works." Or read on.

Assemblies and layers#

AssemblyFoldersRule
NextAppLabs.IdleRpg.Coreevery Logic (pure C#)/ folderplain C#, no UnityEngine/UnityEditor: game rules, formulas, state, commands, the save codec
NextAppLabs.IdleRpg.GameFeatures/, Framework/, Services/ (outside Logic (pure C#) and Editor)Unity runtime code: boot, views, presenters, service seams. Never names an SDK type
NextAppLabs.IdleRpg.EditorEditor/ and every Editor/ subfoldereditor only: the Hub, TemplateApi, Pre-flight, importers, validators
NextAppLabs.IdleRpg.Editor.BuildErrorsEditor/Template/BuildErrors/the Build Error Explainer and the help links; references nothing else, so it works when the rest does not compile
NextAppLabs.IdleRpg.Adapters.*Services/Adapters/<SDK>/optional: each compiles only when its package is installed (Adapters and backend)

A layer only references the layers below it: Core knows nothing of Unity, Game knows Core, adapters know both. Every template assembly is auto-referenced, so your own scripts see all of them.

The folders are sorted by feature: Features/Shop/ holds the shop's views, presenters, its Logic (pure C#)/ rules and its README. Namespace = the feature (NextAppLabs.IdleRpg.Shop); subfolders do not add to it.

Where your scripts go#

  • Your own code: Assets/<Company>/<Game>/Scripts/ (created by Make it yours), any subfolder. It compiles into Unity's default assembly (Assembly-CSharp), which sees every template assembly. A template update never touches it.
  • Your editor tools: an Editor/ folder in there.
  • Changing the template's code: allowed (it is all readable source), but an update overwrites the files you changed. Prefer the hooks below, and keep a list of what you edited (Changelog and updating).
  • A script anywhere in Features/<X>/ compiles too, except inside Logic (pure C#)/ (next section). A new Editor/ folder under Features/ or Services/ is linked to the Editor assembly automatically (the Folder Guard).

The one real limit: template assemblies cannot reference Assembly-CSharp, so template code cannot call your scripts directly. Your scripts call the template, or listen to its events. If you edit template code to use a third-party asset that has no assembly definition, you get compile.missing-reference (Errors.md).

Logic (pure C#)#

Each Logic (pure C#)/ folder belongs to the Core assembly. Code there must not use UnityEngine: that is what makes the rules testable with plain dotnet test, runnable by a simulator and reusable on the game server. Core also avoids DateTime.Now, unseeded System.Random and static mutable state.

A script there that uses Unity gets a Console warning; select it and use **Tools > Idle RPG > Move Out Of Logic Folder**. The compile error for it is compile.core-uses-unity.

The three kinds of data#

KindWhatLives inChanged by
Contentwhat exists: heroes, enemies, prices, curvesimmutable *Def classes in one GameContent, built at start from the ContentDatabase assetsnobody at run time
Statewhat the player hasGameState: plain classes of ids and numbers, saved as JSONonly commands
Transientenemy HP, timers, viewsthe battle simulation and the viewstheir owner

State stores ids, never indexes or object references, so adding or reordering content never breaks a save. Numbers live in GameConfig or content assets, never in code.

Commands: the one door for every change#

GameSession.Execute(command) (through CommandProcessor) is the only code that changes GameState.

  • One file per command (the command and its handler), in its feature's Logic (pure C#)/, registered in CommandProcessor.CreateDefault. A handler checks everything first, then changes: a refused command leaves the save exactly as it was.
  • CommandResult: Ok, Error (an enum, never an exception for an expected refusal), Events (what happened), Granted (what was paid out, after multipliers), Missing (what the player lacked).
  • Prices have one shape (Cost, lines of currency / hero shards / item fragments) and one payer (CostPayer), which checks every line before taking any. Rewards have one shape (Reward) and one grant path (RewardGranter).
  • Battle outcomes are commands too: the processor computes gold and drops itself, so a client can never claim an amount.
  • Purchases and ads are outcomes, not systems: GrantPurchase(productId, transactionId, isRestore) (idempotent by transaction id) and GrantAdReward(placementId) (only when an ad finished). See Adapters and backend.
  • After a handler succeeds, an ordered list of reactors (statistics, missions, achievements, tutorial, telemetry) sees its events in the same step. What a reactor raises is never fed back in, so reactions cannot loop.

Stats: the one bonus mechanism#

Everything that makes the player stronger is a StatModifier { stat, value, scope } in a bucket (Stars, Heroes, Equipment, Talents, Village, RunModifiers, Boosts, Vip). Inside a bucket values add; buckets multiply: final = base × Π(1 + bucketSum). Each bucket has one IStatSource and a designed ceiling (bucketCeilings in GameConfig), checked by Validate. Adding a bonus system is adding one stat source. The sheet is rebuilt when a source changes, never per frame; ask it with sheet.Get(stat, unit).

The battle#

BattleSim (Core) advances in fixed steps and knows nothing about sprites. BattleController (Game) ticks it (x2 / x3 = more steps, same results), sends outcomes as commands and hands battle events to the views. Deleting every view leaves a working game. A wave is a pack of up to five enemies; kills are reported per slot and cannot pay twice. Anything with lasting value (drops, offers) uses the saved random stream, so restarting cannot reroll it.

Time and save#

  • Only DeviceClock reads the device clock. Rules take ITimeProvider; TrustedClock freezes timers when the clock goes backwards (and, online, follows the server's time: Server time).
  • SaveCodec (Core): GameState ⇄ versioned JSON with a checksum; old saves are upgraded by ordered migration steps. FileSaveStore writes to a temp file, then renames, and keeps a backup. SaveService decides when: after purchases and claims at once, otherwise every 30 s when something changed, and on pause.
  • The save file is save.json in Application.persistentDataPath (see Saves off the phone).

Runtime composition#

GameBootstrap (Framework/Boot/) is the one composition root: it builds GameContent, loads the save, creates the GameSession and the services, and hands them to scene objects through explicit Init(...) calls. No static singletons, no reliance on script order. The title screen drives the boot one step per frame.

Services (store, ads, cloud save, analytics...) are interfaces in Services/, each with a fake. The GameBootstrap component's Service Profile (editor) and Release Profile (player builds) pick the implementations (Which service profile runs). GameServices is the container the screens receive.

Hook into the game (GameEvents)#

Your scripts react to the game without editing ours. GameEvents (Framework/GameEvents.cs) raises PurchaseCompleted (product id), WaveEnded (wave, won, boss), RewardGranted, HeroUnlocked (hero id), Ascended, FeatureUnlocked (feature id), and Any for every other game event. Each is raised on the main thread after the change is saved. A handler that throws is logged and skipped: it never breaks the game.

using NextAppLabs.IdleRpg;
using UnityEngine;

public class BossCheer : MonoBehaviour
{
    void OnEnable() => GameEvents.WaveEnded += OnWave;
    void OnDisable() => GameEvents.WaveEnded -= OnWave;
    void OnWave(WaveResult wave) { if (wave.IsBoss && wave.Won) Debug.Log("Boss beaten on wave " + wave.Wave); }
}

To read the game (wallets, heroes, waves) find the shell once: FindAnyObjectByType<GameShell>(), then shell.Session.State (read only: change it with commands) and shell.Session.Content. See Add your own tab for a complete example.

TemplateApi#

Everything the Hub does is a public static method on TemplateApi (Editor/Template/Api/), namespace NextAppLabs.IdleRpg.Editor, so a menu item, your editor script or an AI assistant does exactly what the button does:

ApiResult result = TemplateApi.Features.SetEnabled("castle", false); // plus whatever needs it, one undo step
Debug.Log(result.ToJson());   // {"ok":true,"code":"hub.ok","message":"Turning off Castle.",...}
TemplateApi.Game.Validate();  // an empty Issues list means the content is valid
  • Each call returns an ApiResult: Ok, a stable Code (listed in Errors.md), one sentence, optional data. It never throws for a user mistake.
  • Writes go through TemplateApi.Edit("name"): refused in Play mode or while compiling, one undo group, auto-save.
  • TemplateApi.Game.Active is the game being edited (the first scene in Build Settings and what it references).
  • The areas: Game, Project, Features, Balance, Content, Texts, Look, Monetization, Services, Release, Help. AGENTS.md lists the methods for AI assistants.

The UI#

One canvas at a fixed pixel density (270x480 reference pixels, scaled by whole numbers), five tabs along the bottom, and screens built from a small set of patterns (tab screen, sheet, panel, modal, dialog, offer) and widgets. Screens are presenters: they read the session and the preview helpers, draw, send commands and redraw on events. No game rule lives in a view: a number computed in a MonoBehaviour belongs in Core. The scene's UI is generated by **Tools > Idle RPG > Dev > Build Game Scene** from code (Editor/SceneBuilder/), so edit the builder, not the scene.

Texts#

Every string the player reads is a key in the language tables (Content/Localization/), read with the localizer at the point of use (ILocalizer.Get / Format, GameShell.localization). Never a literal on screen, never an English fallback in code: a missing key shows as #key# and the text checks name it. See Localization.

Sound#

One table of sound ids (Settings/Audio.asset), one mapper from game moments to ids (AudioCues), one audio service. A new sound for an existing moment is a file; a new moment is one row in AudioCues.

Conventions#

  • Root namespace NextAppLabs.IdleRpg, menus under Tools > Idle RPG and Window > Idle RPG, asset menus under Create > Idle RPG.
  • One public type per file, file name = type name. Every public type has a short summary of what it does.
  • No numbers in code: tunables live in GameConfig or assets. Economy numbers are double; curves saturate instead of reaching infinity.
  • Expected failures are results (CommandError, ApiResult), not exceptions. Content problems are caught by validators in the editor, not at run time.

Recipes#

To add...Do this
a hero, enemy, item, biomeHub > Content (Add content)
a currencya CurrencyAsset (its id is the wallet key) in the database; its sources list feeds the "Not enough" sheet
a mission / achievementa MissionAsset / AchievementAsset over a statistic id, added to the database
a statistic to counta constant in StatisticIds, its row in that file's table, one line in StatisticsReactor
a statone line in Stat, one row in StatRules, one row in the config's stat table (base and clamp)
a bonus systemone StatBucket value, one IStatSource, registered in StatSheetBuilder.CreateDefault, a ceiling row
an economy actionone command file (command + handler) in a Logic (pure C#)/ folder, registered in CommandProcessor.CreateDefault, tests for success and refusal
a state sectiona class in the state folder, a field on GameState; new fields default safely, so bump the save version only when a field changes meaning
a save migrationan ISaveMigration that edits the JSON tree, one line in SaveMigrations.All(), GameState.CurrentSaveVersion + 1
a local reminderone INotificationSource, registered in NotificationSources.CreateDefault(), with its ui.notify.* texts
a stringa key in the English table, read with the localizer
a Pre-flight checka class implementing IPreflightCheck in any editor assembly (see Editor/Template/README.md)
a screen or tab of your ownAdd your own tab
a service backendYour own online backend