Skip to content

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:

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 of Awake() 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 THIS

Rationale: 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 GameObject
private 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 category
GameLog.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.Log
Debug.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 to GameLog.Log. Pick the best category or use "COMMON"
  • GameLog.LogError always outputs regardless of whether the category is enabled

How categories are toggled (scene setup):

  1. A parent GameObject named “GameLog” holds children
  2. Each child has a GameLogCategory component with a logCategory string field
  3. Enable the child GameObject → logs for that category appear
  4. 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 change
public 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 listening
bool hasHandler = OnRunIntroActivate != null;
// --- SUBSCRIBER (e.g. RunIntroManager) ---
// Subscribe in OnEnable, unsubscribe in OnDisable — ALWAYS, no exceptions
private void OnEnable() => GameRunManager.OnRunIntroActivate += StartIntro;
private void OnDisable() => GameRunManager.OnRunIntroActivate -= StartIntro;
private void StartIntro(LevelConfig config) { /* handle it */ }

Key rules:

  • Subscribe in OnEnable, unsubscribe in OnDisable. Never Awake/OnDestroy for this — OnEnable/OnDisable correctly handles object activation state
  • The publisher checks event != null to 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:

FromToNotes
GameRunScene systemsOverworldScene systemsDifferent scenes — direct refs will be null
CharacterCustomizationGameSave / ProfileManagerSave should react to customization changes, not be called directly
Perfection systemOther gameplay systemsMust stay isolated to remain testable
Goldilocks systemOther gameplay systemsMust stay isolated to remain testable
BossEncounterGameManager session logicBoss behavior should not call session methods directly
RunIntroGameRunManager / GameRunOrchestratorFixed: 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 FindFirstObjectByType
var manager = FindFirstObjectByType<RunIntroManager>();
manager.StartIntro(config);
// WRONG — direct singleton call across system boundaries
GameRunOrchestrator.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 forget
GameRunManager.InvokeOnRunIntroActivate(levelConfig);
// RunIntroManager subscribes; if absent, nothing happens and a fallback triggers

When 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., GameRunStateManager calling GameRunManager internals)
  • The reference is a data object, not a manager (passing LevelConfig around is fine)

State Transitions

Always use orchestrator methods for state changes:

// CORRECT
GameRunOrchestrator.Instance.TransitionToState(GameRunState.Gameplay);
// WRONG - bypassing orchestrator
currentState = 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 implementations

Keep 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

  1. God classes - Split large managers by responsibility
  2. Deep inheritance - Prefer composition
  3. Magic strings - Use constants or enums
  4. Premature abstraction - Write concrete code first
  5. Over-engineering - Simple solutions for simple problems