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#
| Assembly | Folders | Rule |
|---|---|---|
NextAppLabs.IdleRpg.Core | every Logic (pure C#)/ folder | plain C#, no UnityEngine/UnityEditor: game rules, formulas, state, commands, the save codec |
NextAppLabs.IdleRpg.Game | Features/, Framework/, Services/ (outside Logic (pure C#) and Editor) | Unity runtime code: boot, views, presenters, service seams. Never names an SDK type |
NextAppLabs.IdleRpg.Editor | Editor/ and every Editor/ subfolder | editor only: the Hub, TemplateApi, Pre-flight, importers, validators |
NextAppLabs.IdleRpg.Editor.BuildErrors | Editor/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 insideLogic (pure C#)/(next section). A newEditor/folder underFeatures/orServices/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#
| Kind | What | Lives in | Changed by |
|---|---|---|---|
| Content | what exists: heroes, enemies, prices, curves | immutable *Def classes in one GameContent, built at start from the ContentDatabase assets | nobody at run time |
| State | what the player has | GameState: plain classes of ids and numbers, saved as JSON | only commands |
| Transient | enemy HP, timers, views | the battle simulation and the views | their 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 inCommandProcessor.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) andGrantAdReward(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
DeviceClockreads the device clock. Rules takeITimeProvider;TrustedClockfreezes 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.FileSaveStorewrites to a temp file, then renames, and keeps a backup.SaveServicedecides when: after purchases and claims at once, otherwise every 30 s when something changed, and on pause.- The save file is
save.jsoninApplication.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 stableCode(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.Activeis 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.mdlists 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
GameConfigor assets. Economy numbers aredouble; 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, biome | Hub > Content (Add content) |
| a currency | a CurrencyAsset (its id is the wallet key) in the database; its sources list feeds the "Not enough" sheet |
| a mission / achievement | a MissionAsset / AchievementAsset over a statistic id, added to the database |
| a statistic to count | a constant in StatisticIds, its row in that file's table, one line in StatisticsReactor |
| a stat | one line in Stat, one row in StatRules, one row in the config's stat table (base and clamp) |
| a bonus system | one StatBucket value, one IStatSource, registered in StatSheetBuilder.CreateDefault, a ceiling row |
| an economy action | one command file (command + handler) in a Logic (pure C#)/ folder, registered in CommandProcessor.CreateDefault, tests for success and refusal |
| a state section | a 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 migration | an ISaveMigration that edits the JSON tree, one line in SaveMigrations.All(), GameState.CurrentSaveVersion + 1 |
| a local reminder | one INotificationSource, registered in NotificationSources.CreateDefault(), with its ui.notify.* texts |
| a string | a key in the English table, read with the localizer |
| a Pre-flight check | a class implementing IPreflightCheck in any editor assembly (see Editor/Template/README.md) |
| a screen or tab of your own | Add your own tab |
| a service backend | Your own online backend |