Code Style Guide
Philosophy
- Prefer simple Unity classes over complex abstractions
- Reduce managers - not everything needs a dedicated manager class
- Avoid super-long files - split by responsibility, aim for <500 lines
- New systems go in
/Assets/_Game/Scripts/Systems/
Dependency Injection: Init(args) Pattern
This project uses the Sisus Init framework for dependency injection. Documentation here: https://docs.sisus.co/init-args/ Particularly important pages:
- https://docs.sisus.co/init-args/introduction/what-is-init-args/
- https://docs.sisus.co/init-args/introduction/comparison/
- https://docs.sisus.co/init-args/services/service-attribute/
- https://docs.sisus.co/init-args/clients/monobehaviour-t/
- https://docs.sisus.co/init-args/services/services-component/
Service Registration
// Persistent across scenes (loaded with BootstrapScene)[Service(LoadScene = "BootstrapScene")]public class SaveLoadManager : MonoBehaviour { ... }
// Scene-specific (found in current scene)[Service(FindFromScene = true)]public class GameManager : MonoBehaviour { ... }Receiving Dependencies
public class GameSceneInitializer : MonoBehaviour<ProfileManager, SaveLoadManager>{ private ProfileManager profileManager; private SaveLoadManager saveLoadManager;
protected override void Init(ProfileManager pm, SaveLoadManager slm) { this.profileManager = pm; this.saveLoadManager = slm; }
protected override void OnAwake() { // Runs after Init() - use instead of Awake() }}Key points:
- Inherit from
MonoBehaviour<T1, T2, ...>listing dependency types - Override
Init(T1, T2, ...)to receive and store dependencies - Use
OnAwake()instead ofAwake()for initialization logic - Services are auto-injected by the framework
Explicit Initialize() Methods
For managers that need controlled initialization order:
public class GameRunManager : MonoBehaviour{ private bool _isInitialized = false;
public void Initialize() { if (_isInitialized) return;
// Setup code here _isInitialized = true; }}GameSceneInitializer controls the order by calling Initialize() explicitly.
SerializeField Conventions
UI Elements - Always Use SerializeField
[Header("UI References")][SerializeField] private Button startButton;[SerializeField] private TextMeshProUGUI scoreLabel;[SerializeField] private RectTransform panelContainer;Component References - No Auto-Find Fallback
[SerializeField] private ScoringManager scoringManager;public ScoringManager ScoringManager => scoringManager;
// WRONG - Never auto-find if SerializeField is set:// if (scoringManager == null)// scoringManager = GetComponent<ScoringManager>(); // DON'T DO THISRationale: If a [SerializeField] is null at runtime, it’s a configuration error that should fail loudly, not be silently “fixed” with GetComponent.
Exception: Predictable Prefab Structure
Only use GetComponent when the structure is explicitly predictable:
// OK - component is always on same GameObjectprivate Rigidbody rb;void Awake() => rb = GetComponent<Rigidbody>();Prefab Items for Repetitive UI
For lists of similar UI elements, use prefab instantiation:
[SerializeField] private HelperCatCard helperCatCardPrefab;[SerializeField] private RectTransform cardContainer;
private void CreateCards(List<HelperCatData> cats){ foreach (var cat in cats) { var card = Instantiate(helperCatCardPrefab, cardContainer); card.Initialize(cat); }}Don’t create 10+ SerializeField references for repetitive items - use a prefab.
Odin Inspector Attributes
// Read-only display in inspector[ShowInInspector, ReadOnly, LabelText("Current State")]private GameRunState currentState;
// Grouped settings[Header("Debug Settings")][SerializeField] private bool enableDebugLogs = true;
// Foldout groups[FoldoutGroup("Advanced")][SerializeField] private float advancedSetting;Debug Logging — GameLog System
Always use GameLog instead of Debug.Log. The GameLog system provides category-based filtering controlled by GameObjects in the scene.
// CORRECT — use GameLog with a categoryGameLog.Log("CatOrderingTable", $"Spawning counter at position {i}");GameLog.LogWarning("CatOrderingTable", "Counter prefab is null");GameLog.LogError("CatOrderingTable", "Invalid position index"); // always logs
// WRONG — raw Debug.LogDebug.Log($"[CatSpawnManager] Spawning counter at position {i}");Rules:
- Use a category that matches the system/feature (e.g.,
"CatOrderingTable","Progression","BossEncounter") - If no specific category exists yet, use
"COMMON" - When working on code that has
Debug.Log, convert them toGameLog.Log. Pick the best category or use"COMMON" GameLog.LogErroralways outputs regardless of whether the category is enabled
How categories are toggled (scene setup):
- A parent GameObject named “GameLog” holds children
- Each child has a
GameLogCategorycomponent with alogCategorystring field - Enable the child GameObject → logs for that category appear
- Disable the child → logs are silenced
Files: Assets/_Game/Scripts/Data/GameLog.cs, Assets/_Game/Scripts/Utils/GameLogCategory.cs
Event Patterns (Inter-System Communication)
The Standard
Use event Action<T> for all communication between major systems. This is the required pattern going forward. The existing codebase has many violations of this — they are legacy technical debt and should be migrated when touched.
Why this matters: Direct references between systems (via .Instance, FindFirstObjectByType, or SerializeField cross-system wiring) create tight coupling that makes systems impossible to test or reuse in isolation and causes cascading breakage when systems change.
Correct Pattern
// --- PUBLISHER (e.g. GameRunManager) ---// Declare the event on the system that owns the state changepublic static event Action<LevelConfig> OnRunIntroActivate;
// Invoke via a dedicated method (keeps invocation internal to the class)internal static void InvokeOnRunIntroActivate(LevelConfig config){ OnRunIntroActivate?.Invoke(config);}
// Before transitioning, check if anyone is listeningbool hasHandler = OnRunIntroActivate != null;
// --- SUBSCRIBER (e.g. RunIntroManager) ---// Subscribe in OnEnable, unsubscribe in OnDisable — ALWAYS, no exceptionsprivate void OnEnable() => GameRunManager.OnRunIntroActivate += StartIntro;private void OnDisable() => GameRunManager.OnRunIntroActivate -= StartIntro;
private void StartIntro(LevelConfig config) { /* handle it */ }Key rules:
- Subscribe in
OnEnable, unsubscribe inOnDisable. NeverAwake/OnDestroyfor this —OnEnable/OnDisablecorrectly handles object activation state - The publisher checks
event != nullto know if anyone is listening (use as a “has handler” guard) - The publisher must never
FindFirstObjectByType, hold a reference, or call methods on the subscriber directly
Systems That Must Be Isolated Via Events
The following system boundaries are high priority — any communication crossing these lines must use events, not direct references:
| From | To | Notes |
|---|---|---|
GameRunScene systems | OverworldScene systems | Different scenes — direct refs will be null |
CharacterCustomization | GameSave / ProfileManager | Save should react to customization changes, not be called directly |
Perfection system | Other gameplay systems | Must stay isolated to remain testable |
Goldilocks system | Other gameplay systems | Must stay isolated to remain testable |
BossEncounter | GameManager session logic | Boss behavior should not call session methods directly |
RunIntro | GameRunManager / GameRunOrchestrator | Fixed: now uses OnRunIntroActivate event |
Legacy Anti-Patterns (Do Not Replicate)
These patterns exist throughout the codebase and were wrong. Do not copy them. Migrate them when a system is being touched:
// WRONG — tight coupling via FindFirstObjectByTypevar manager = FindFirstObjectByType<RunIntroManager>();manager.StartIntro(config);
// WRONG — direct singleton call across system boundariesGameRunOrchestrator.Instance.TransitionToState(GameRunState.RunIntro);// (acceptable within the same scene layer; wrong when crossing system domains)
// WRONG — caching a cross-system reference[SerializeField] private CharacterCustomizationManager customizationManager;customizationManager.DoSomething(); // CharacterCustomization shouldn't be wired into GameSave
// CORRECT — fire and forgetGameRunManager.InvokeOnRunIntroActivate(levelConfig);// RunIntroManager subscribes; if absent, nothing happens and a fallback triggersWhen Direct References Are Still OK
Not everything needs events. Direct references are fine when:
- The dependency is injected via Init(args) (explicit, testable, not hidden)
- The caller and callee are in the same system (e.g.,
GameRunStateManagercallingGameRunManagerinternals) - The reference is a data object, not a manager (passing
LevelConfigaround is fine)
State Transitions
Always use orchestrator methods for state changes:
// CORRECTGameRunOrchestrator.Instance.TransitionToState(GameRunState.Gameplay);
// WRONG - bypassing orchestratorcurrentState = GameRunState.Gameplay;File Organization for New Systems
New systems should follow this structure:
Systems/NewFeature/├── NewFeatureManager.cs # Main coordinator (if needed)├── NewFeatureData.cs # Data models├── NewFeatureUI.cs # UI handling (if needed)└── Behaviors/ # Specific behavior implementationsKeep related code together. Not everything needs a Manager class.
ScriptableObject Databases
Content is defined as ScriptableObjects:
// Database pattern[CreateAssetMenu(fileName = "NewDatabase", menuName = "Game/New Database")]public class NewDatabase : ScriptableObject{ [SerializeField] private List<NewData> items; public IReadOnlyList<NewData> Items => items;
public NewData GetById(string id) => items.Find(x => x.id == id);}What to Avoid
- God classes - Split large managers by responsibility
- Deep inheritance - Prefer composition
- Magic strings - Use constants or enums
- Premature abstraction - Write concrete code first
- Over-engineering - Simple solutions for simple problems