A dual-layer configuration system that pairs ScriptableObject defaults with runtime JSON overrides, so you can ship sensible numbers and still let players or designers tune them without a rebuild.
Summary
The Scylla configuration system is organized into two layers and four tiers in order to resolve configurations.
The two layers are:
- Unity
ScriptableObjectassets for build-time defaults (set in the Inspector). - JSON files for runtime overrides (editable on disk without rebuilding).
The four tiers is the priority-ordered resolution that the framework does at startup to resolve final values, in order highest to lowest:
- The config JSON file in the User Documents folder
- The config JSON file in the Application folder
- The game package-included Unity Resources config asset
- The compiled defaults (hard-coded defaults).
The User Documents tier wins, so end users can override an installed build, with fallthrough to the lower tiers when a key is missing. Two static flags on ScyllaCoreConfiguration (ConfigFilesEnabledInDebugBuild, ConfigFilesEnabledInProductionBuild) gate whether the JSON tiers are read at all per build type.
The asset side is ScyllaConfiguration, an abstract ScriptableObject base. Every configurable module declares a subclass that holds its fields as [SerializeField] private variables exposed by => getter properties, then overrides four virtual methods: Validate() returns a ConfigurationValidationResult[] describing any field problems; ResetToDefaults() restores compile-time defaults; ApplyConfigFileOverrides(ConfigFile) reads JSON values into the fields; ExportToConfigFile() emits the asset as a ConfigFile JSON. You wire the asset to the bootstrap (one of its four slots) or to a module ([SerializeField] on the module class), and the framework applies the JSON tier during ScyllaBootstrap.Awake before any module’s OnInitialize runs.
The JSON side is ConfigFile, an in-memory representation of one configuration file. It uses a Group > Property structure that maps directly onto JSON’s object > value shape: groups are top-level keys with object values, properties are second-level keys with primitive values. The supported primitive types are bool, int, float, double, string, Color (hex string), Group (nested object), and Array. ConfigFile exposes typed TryGet* / Set* methods for each type; the asset’s ApplyConfigFileOverrides reads through those methods. ConfigFileGroup is the nested-group type for hierarchical configuration; ConfigFileProperty is a readonly struct that carries a typed value plus a ConfigFilePropertyType tag.
Edit-time tuning (a slider in the Inspector) and runtime tuning (a QA tester adjusts a value, a player toggles an option, no rebuild) are both first-class, which is the whole point of the two layers. A project ships a sensible default asset, and QA or end users override any value at runtime by dropping a JSON file into a well-known location, no re-opening Unity required. The four-tier resolution runs automatically, so the asset’s exposed values are already the post-override final values by the time any code reads them. Modules don’t have to know the JSON layer exists, unless they want the override-applied count back from ApplyConfigFileOverrides.
Use the configuration system for:
- Project-level defaults: initialization delays, debug-draw caps, logging levels, time-scale limits, event-bus thresholds.
- Per-module tunable values: max health, regen rate, spawn weights, AI parameters, audio mix levels.
- QA and player-editable settings: a JSON file in Documents that overrides specific values without a rebuild.
- Build-tier-specific tuning: different values for debug vs production builds via the per-build enablement flags.
- Configuration that needs Inspector ergonomics (sliders, dropdowns, colour pickers) but also runtime mutability.
Don’t use the configuration system for values that change frequently at runtime (use the modifier or property system on ScyllaTime, ScyllaStats, etc.), for save data (use Serialization plus File IO), or for one-off scene-specific constants (those belong as [SerializeField] fields on a Unity component).
Features
- Two-layer design. Unity
ScriptableObjectassets for build-time defaults (set in the Inspector) and JSON files for runtime overrides (editable on disk without rebuilding). The same values are reachable from both layers. - Four-tier fallback resolution. User Documents JSON -> Application folder JSON -> Unity Resources asset -> compiled defaults. The framework resolves per-value, so a partial override file only overrides the keys it specifies.
- Per-build gates. Two flags on
ScyllaCoreConfiguration(ConfigFilesEnabledInDebugBuild,ConfigFilesEnabledInProductionBuild) toggle whether the JSON tiers are read at all per build type. You can ship a hardened production build with no on-disk overrides and allow them in QA. ScyllaConfigurationbase class. Inherit, add[SerializeField]fields with=>getter properties, override four virtuals:Validate,ResetToDefaults,ApplyConfigFileOverrides,ExportToConfigFile. The base class wires up everything else.- Inspector custom drawer. Validate, Reset, Apply Config File, and Export Config File appear as actions in the Inspector. Override results display inline; validation errors highlight the offending field.
ConfigFileJSON data shape.Group > Propertystructure mirroring JSON’sobject > valuelayout. Supportsbool,int,float,double,string,Color(hex), nestedGroup, andArray. See Config Files for the data model.- Typed
TryGet*/Set*accessors. Each primitive type has its own pair soApplyConfigFileOverridesreads with explicit semantics instead of(int)json["..."]casts. Missing keys returnfalsecleanly; unexpected types don’t crash. Validate()with three severity levels.Info,Warning,Errorresults returned fromValidate()flow through to the Inspector display and to theIsValid()shortcut. Per-field error messages support targeted UI highlighting.IConfigurationRegistry. Per-module registry resolves config assets by type.Get<T>,GetAll,ValidateAll. Modules look up their config inOnInitialize; nothing needs to know about the file paths.- Automatic JSON override application.
ConfigFileManager.Initializeruns duringScyllaBootstrap.Awakebefore any module’sOnInitialize. Modules read their config asset already with overrides applied; the JSON layer is invisible to module code. - Starter JSON generation.
CreateConfigFileIfNeededwrites a starter override file with the current asset values when none exists. The right primitive for “give the player a complete file to edit” workflows. - Round-trippable export.
ExportToConfigFile()returns aConfigFilewith every current value. Paired withConfigFile.ToJSON()it produces a human-readable JSON dump of the asset’s state, useful for QA snapshots and bug reports.
Concepts
A few terms appear repeatedly. Configuration asset is a ScriptableObject instance of a ScyllaConfiguration subclass that holds the build-time default values for one module. Configuration file is a JSON file on disk that holds runtime override values; the framework parses it into a ConfigFile in-memory representation. Tier is one of the four priority-ordered locations where the framework looks for a value: User Documents (highest priority), Application folder, Unity Resources asset, compiled defaults (lowest). Group is a top-level named container inside a ConfigFile; Property is one named value inside a group. Validation is the asset-side hook that returns ConfigurationValidationResult[] describing whether the current field values are coherent. You’ll encounter all of these in the recipes below.
| Concept | Type | Notes |
|---|---|---|
ScyllaConfiguration | abstract ScriptableObject | Base class for every configuration asset. Subclass it, add [SerializeField] fields with => getter properties, override Validate, ResetToDefaults, ApplyConfigFileOverrides, ExportToConfigFile. Inspector custom drawer exposes the Validate / Reset / Apply / Export actions. |
ConfigFile | sealed class | In-memory JSON. Group > Property structure. FromJSON(string) parses; ToJSON() serializes. TryGet* / Set* methods for bool, int, float, double, string, Color, plus group and array support. |
ConfigFileGroup | sealed class | One named group inside a ConfigFile. Same TryGet* / Set* shape as ConfigFile but at one nesting level deeper. Supports nested groups and array properties. |
ConfigFileProperty | readonly struct | One typed value inside a group. Carries Type (ConfigFilePropertyType) plus the actual value via type-specific accessors. |
ConfigFilePropertyType | enum | Seven values: Bool, Int, Float, Double, String, Group, Array. |
ConfigFileManager | static class | Owns the runtime overrides loop. Initialize(coreConfig) runs during bootstrap. ApplyOverrides(configuration) resolves the highest-priority tier and applies it to the asset. CreateConfigFileIfNeeded writes a starter JSON when none exists. |
IConfigurationRegistry | interface | Per-module registry of configuration assets. Implemented internally; modules access via the ConfigurationRegistry property on ScyllaModule. Get<T> resolves an asset by type; GetAll enumerates registered assets; ValidateAll runs every asset’s Validate. |
ConfigurationValidationResult | readonly struct | Single validation outcome. Severity (Info / Warning / Error), Message, optional FieldName. Static factories Info(msg), Warning(msg), Error(msg) for ergonomic construction. |
ValidationSeverity | enum | Three values: Info, Warning, Error. Affects the Console log level used by the Inspector validate action and by IsValid(). |
The four-tier resolution runs once per configuration asset at framework startup. The order is fixed: User Documents (~/Documents/{ProductName}/Config/{Name}.json) is the highest-priority tier; then Application folder ({ApplicationPath}/Config/{Name}.json); then the Unity Resources asset (the .asset file bound to the bootstrap or to a module via [SerializeField]); then the compiled defaults (the values returned by ResetToDefaults() if none of the higher tiers match). Tier resolution is per-value: if the User Documents JSON has MaxHealth = 200 but no RegenPerSecond, the framework reads MaxHealth = 200 from the JSON and falls through to the asset’s default for RegenPerSecond. That means you don’t have to supply every key in your override file – only the values you actually want to change.
User Documents (highest priority)
~/Documents/{ProductName}/Config/{Name}.json
|
| missing or key not present?
v
Application Folder
{ApplicationPath}/Config/{Name}.json
|
| missing or key not present?
v
Unity Resources Asset
Resources/Config/{Name}.asset (or bootstrap slot)
|
| missing field?
v
Compiled Defaults (lowest priority)
ResetToDefaults() return values
Recipes
These recipes cover every public surface of the configuration system, from defining your first asset to gating overrides per build. Each one is self-contained, so jump to the one that matches what you’re trying to do. The first recipe – “Define a configuration asset” – creates the class that the later recipes extend, so if you’re new to the API, that’s the place to start.
Define a configuration asset
Problem. You want to give a module tunable values – max health, regen rate, AI aggression – that designers can adjust in the Inspector and that QA or end users can override via a JSON file without rebuilding. You need a typed class that hooks into the framework’s four-tier resolution automatically.
Solution. Create a ScyllaConfiguration subclass. Hold the values as [SerializeField] private fields with => getter properties (the property is the public API; the field is the Inspector-visible storage). Override ResetToDefaults (required: abstract) and Validate (required: abstract). Decorate the class with [CreateAssetMenu] to enable the Project window’s Create menu entry.
using UnityEngine;
using Scylla.Core.Config;
using Scylla.Core.Util.File;
[CreateAssetMenu(
menuName = "Scylla/Config/Health Configuration",
fileName = "Health Configuration"
)]
public sealed class HealthConfiguration : ScyllaConfiguration
{
/* Group and property constants. Use string constants instead of inline
* literals so the JSON shape and the asset's TryGet* calls stay in sync. */
public const string GROUP_TUNING = "Tuning";
public const string GROUP_UI = "UI";
public const string KEY_MAX_HEALTH = "MaxHealth";
public const string KEY_REGEN_PER_SECOND = "RegenPerSecond";
public const string KEY_ENABLE_LOW_HP_SCREEN = "EnableLowHealthScreen";
/* Default constants: compile-time values used by ResetToDefaults. */
public const int DEFAULT_MAX_HEALTH = 100;
public const float DEFAULT_REGEN_PER_SECOND = 5f;
public const bool DEFAULT_ENABLE_LOW_HP_SCREEN = true;
/* Inspector-visible storage. Private + SerializeField is the canonical
* shape; the getter property is the public API. */
[SerializeField] private int _maxHealth = DEFAULT_MAX_HEALTH;
[SerializeField] private float _regenPerSecond = DEFAULT_REGEN_PER_SECOND;
[SerializeField] private bool _enableLowHealthScreen = DEFAULT_ENABLE_LOW_HP_SCREEN;
/* Public getter properties. Make every getter a single-expression body so
* the field-to-property relationship is obvious. */
public int MaxHealth => _maxHealth;
public float RegenPerSecond => _regenPerSecond;
public bool EnableLowHealthScreen => _enableLowHealthScreen;
/* Abstract override: restore compile-time defaults. Called by the Inspector
* Reset action and as the lowest tier of the four-tier fallback. */
public override void ResetToDefaults()
{
_maxHealth = DEFAULT_MAX_HEALTH;
_regenPerSecond = DEFAULT_REGEN_PER_SECOND;
_enableLowHealthScreen = DEFAULT_ENABLE_LOW_HP_SCREEN;
}
/* Abstract override: validate every field. Return an array of results;
* one entry per problem. An empty array means the asset is valid. */
public override ConfigurationValidationResult[] Validate()
{
var results = new List<ConfigurationValidationResult>();
if (_maxHealth <= 0)
{
results.Add(ConfigurationValidationResult.Error(
message: $"MaxHealth must be positive (was {_maxHealth}).",
fieldName: nameof(_maxHealth)
));
}
if (_regenPerSecond < 0f)
{
results.Add(ConfigurationValidationResult.Error(
message: $"RegenPerSecond must be non-negative (was {_regenPerSecond}).",
fieldName: nameof(_regenPerSecond)
));
}
if (_regenPerSecond > 100f)
{
results.Add(ConfigurationValidationResult.Warning(
message: $"RegenPerSecond is very high ({_regenPerSecond}); confirm intent.",
fieldName: nameof(_regenPerSecond)
));
}
return results.ToArray();
}
}
Read and write typed values with the ConfigFile API
Problem. You need to read JSON override values into your asset’s fields (in ApplyConfigFileOverrides) and emit the asset’s values back out as JSON (in ExportToConfigFile). The ConfigFile API gives you a typed accessor for each supported primitive – no raw JSON parsing, no casting, no string-keyed dictionaries.
Solution. ConfigFile exposes a TryGet* / Set* pair for each primitive type: bool, int, float, double, string, plus Color (stored as a #RRGGBBAA hex string). Every accessor takes a group name and a property name; FromJSON / ToJSON round-trips preserve the structure exactly. Use TryGet* in ApplyConfigFileOverrides to read JSON values into your fields; use Set* in ExportToConfigFile to emit your current values as a fresh ConfigFile.
using Scylla.Core.Util.File;
/* Construct a ConfigFile in memory. */
var file = new ConfigFile();
/* Set typed values. Each call takes group + property + value. */
file.SetBool ("UI", "EnableLowHealthScreen", true);
file.SetInt ("Tuning", "MaxHealth", 100);
file.SetFloat ("Tuning", "RegenPerSecond", 5.0f);
file.SetDouble ("Tuning", "PreciseRate", 5.123456789);
file.SetString ("Visual", "PlayerName", "Hero");
file.SetColor ("Visual", "PlayerColor", Color.cyan);
/* Read typed values via TryGet*. Returns true when the value exists and the
* type matches; the out parameter is set on success. False on missing or
* type mismatch; the out parameter is the default. */
if (file.TryGetInt("Tuning", "MaxHealth", out int max))
{
/* max = 100 */
}
bool hasName = file.TryGetString("Visual", "PlayerName", out string name);
bool hasColor = file.TryGetColor("Visual", "PlayerColor", out Color color);
/* Group presence and inspection. */
bool hasTuning = file.HasGroup("Tuning");
ConfigFileGroup tuning = file.GetGroup("Tuning"); /* null if missing */
ConfigFileGroup ensured = file.GetOrAddGroup("Tuning"); /* never null */
/* Property presence inside a group. */
bool hasMaxHealth = file.HasProperty("Tuning", "MaxHealth");
/* Enumerate every group name. */
foreach (string groupName in file.GetGroupNames())
{
Log.Info($"Group: {groupName}", LogCategory.Core);
}
/* Serialize to JSON and parse back. The round-trip is exact. */
string json = file.ToJSON();
ConfigFile parsed = ConfigFile.FromJSON(json);
/* Merge two ConfigFile instances. The higher-priority file wins on conflicts. */
var merged = ConfigFile.Merge(higher: userFile, lower: appFile);
Apply JSON overrides to an asset’s fields
Problem. The framework has parsed the highest-priority JSON tier and now needs to push the override values into your asset’s fields. You need to implement ApplyConfigFileOverrides so only the keys present in the JSON are applied – missing keys should leave the existing field value alone.
Solution. Override ApplyConfigFileOverrides(ConfigFile) and use TryGet* for each field. Return the count of values you actually applied so the framework can log a summary. A field with no matching JSON key stays at whatever value it currently holds – either the Inspector-set default or the value from ResetToDefaults.
public override int ApplyConfigFileOverrides(ConfigFile configFile)
{
var applied = 0;
/* TryGetInt returns false when the key is missing or the type does not
* match. The asset's field stays at its current value when the override
* is missing; this is how the four-tier fallback works. */
if (configFile.TryGetInt(GROUP_TUNING, KEY_MAX_HEALTH, out var maxHealth))
{
_maxHealth = maxHealth;
applied++;
}
if (configFile.TryGetFloat(GROUP_TUNING, KEY_REGEN_PER_SECOND, out var regen))
{
_regenPerSecond = regen;
applied++;
}
if (configFile.TryGetBool(GROUP_UI, KEY_ENABLE_LOW_HP_SCREEN, out var lowHP))
{
_enableLowHealthScreen = lowHP;
applied++;
}
return applied;
}
Export an asset as a starter JSON file
Problem. A designer or QA tester wants to start from the current asset values and tweak a few of them. Rather than hand-writing the JSON structure from scratch, they want a complete file they can open and edit – and you want to generate it with one click.
Solution. Override ExportToConfigFile(), which is the inverse of ApplyConfigFileOverrides. Emit your asset’s current field values into a fresh ConfigFile and return it. The Inspector’s “Export Config File” action calls this method and writes the result to disk. The default base implementation returns null; you must override it to enable the export action.
public override ConfigFile ExportToConfigFile()
{
var file = new ConfigFile();
/* Emit every field. The asset's current values become the JSON values. */
file.SetInt (GROUP_TUNING, KEY_MAX_HEALTH, _maxHealth);
file.SetFloat (GROUP_TUNING, KEY_REGEN_PER_SECOND, _regenPerSecond);
file.SetBool (GROUP_UI, KEY_ENABLE_LOW_HP_SCREEN, _enableLowHealthScreen);
return file;
}
The exported JSON looks like:
{
"Tuning": {
"MaxHealth": 100,
"RegenPerSecond": 5.0
},
"UI": {
"EnableLowHealthScreen": true
}
}
Validate field values and surface errors in the Inspector
Problem. Anyone who has shipped a project with a configuration system has eventually had a designer enter a bad value and broken something downstream. You want validation on the asset itself – catching bad values at edit time, not at runtime when the damage is already done.
Solution. Override Validate(), which returns a ConfigurationValidationResult[] describing field-level problems. The Inspector’s “Validate” action calls it and reports the results inline; the framework also calls it during the bootstrap pipeline. Three severity levels exist: Info (informational, never blocks), Warning (non-blocking but flagged), Error (blocks initialization when configured to). The IsValid() virtual is a convenience that returns true when Validate() produces no Error-severity results.
public override ConfigurationValidationResult[] Validate()
{
var results = new List<ConfigurationValidationResult>();
/* Error: blocks initialization. The Inspector flags the error; the
* framework logs it as Fatal. */
if (_maxHealth <= 0)
{
results.Add(ConfigurationValidationResult.Error(
message: $"MaxHealth must be positive (was {_maxHealth}).",
fieldName: nameof(_maxHealth)
));
}
/* Warning: surfaces in the Inspector but does not block. Use for "this
* value works but is suspicious" cases. */
if (_regenPerSecond > 100f)
{
results.Add(ConfigurationValidationResult.Warning(
message: $"RegenPerSecond is very high ({_regenPerSecond}).",
fieldName: nameof(_regenPerSecond)
));
}
/* Info: purely informational. Useful for documenting non-obvious dependencies
* or for surfacing a note when a default value was applied. */
if (_enableLowHealthScreen && _maxHealth < 25)
{
results.Add(ConfigurationValidationResult.Info(
message: "Low-health screen will display almost immediately given the low MaxHealth.",
fieldName: nameof(_enableLowHealthScreen)
));
}
return results.ToArray();
}
/* IsValid is true when no Error-severity result exists. Default base impl
* checks Validate() for any Error; override only if a custom rule applies. */
public override bool IsValid()
{
var results = Validate();
foreach (var result in results)
{
if (result.Severity == ValidationSeverity.Error)
{
return false;
}
}
return true;
}
Organize hierarchical configuration with nested groups
Problem. Your configuration data has natural groupings inside groupings – a “Combat” section with separate “Melee” and “Ranged” sub-sections, each holding their own properties. A flat group-per-module structure doesn’t capture that tree shape cleanly.
Solution. ConfigFileGroup is a named container inside a ConfigFile. Properties can themselves be groups, producing nested structure. SetGroup writes a sub-group; TryGetGroup reads one back. ConfigFileGroup exposes the same TryGet* / Set* accessors as ConfigFile at one nesting level deeper.
using Scylla.Core.Util.File;
/* Build a nested structure. */
var combat = new ConfigFileGroup("Combat");
var melee = new ConfigFileGroup("Melee");
melee.SetInt("BaseDamage", 25);
melee.SetFloat("Range", 1.5f);
var ranged = new ConfigFileGroup("Ranged");
ranged.SetInt("BaseDamage", 15);
ranged.SetFloat("Range", 20.0f);
ranged.SetInt("MaxAmmo", 30);
combat.SetGroup("Melee", melee);
combat.SetGroup("Ranged", ranged);
var file = new ConfigFile();
file.SetGroup("Combat", "Combat", combat); /* the group's parent + name */
/* Read the nested structure. TryGetGroup retrieves a sub-group; the returned
* ConfigFileGroup exposes the same TryGet* methods one level deeper. */
if (file.TryGetGroup("Combat", "Combat", out var combatGroup))
{
if (combatGroup.TryGetGroup("Melee", out var meleeGroup))
{
meleeGroup.TryGetInt ("BaseDamage", out int damage);
meleeGroup.TryGetFloat ("Range", out float range);
}
}
/* The corresponding JSON form:
* {
* "Combat": {
* "Combat": {
* "Melee": { "BaseDamage": 25, "Range": 1.5 },
* "Ranged": { "BaseDamage": 15, "Range": 20.0, "MaxAmmo": 30 }
* }
* }
* } */
/* Enumerate properties inside a group. */
foreach (string propertyName in combatGroup.GetPropertyNames())
{
if (combatGroup.TryGetProperty(propertyName, out ConfigFileProperty prop))
{
Log.Info($"{propertyName}: {prop.Type}", LogCategory.Core);
}
}
Store repeating data as arrays or typed lists
Problem. Your configuration has data that’s naturally a list rather than a scalar: spawn weights for a wave’s enemy pool, damage modifiers per equipment slot, the roster of abilities a class can equip. You need to round-trip those lists through a JSON override file cleanly.
Solution. ConfigFile supports array values via SetArray / TryGetArray (for arrays of ConfigFileProperty items) and SetList<T> / TryGetList<T> (for arrays of complex types via a serialization delegate). Use arrays for repeating-shape data: spawn weights, damage modifiers, ability rosters.
/* Simple property array: ints, floats, strings, etc. each wrapped in
* ConfigFileProperty. */
var spawnWeights = new List<ConfigFileProperty>
{
/* Each ConfigFileProperty carries one typed value. The constructors live
* on ConfigFileProperty (typed and shape-specific). */
new ConfigFileProperty(10),
new ConfigFileProperty(20),
new ConfigFileProperty(30)
};
file.SetArray("Spawning", "Weights", spawnWeights);
/* Read back. Returns the items as an IReadOnlyList<ConfigFileProperty>. */
if (file.TryGetArray("Spawning", "Weights", out var weights))
{
foreach (var prop in weights)
{
if (prop.Type == ConfigFilePropertyType.Int)
{
int weight = prop.AsInt();
/* ... */
}
}
}
/* Complex array: SetList<T> with a per-item serializer that emits a
* ConfigFileGroup. Each item becomes a group with named properties. */
public sealed class Ability
{
public string Name;
public int Cost;
}
var abilities = new List<Ability>
{
new Ability { Name = "Fireball", Cost = 10 },
new Ability { Name = "Heal", Cost = 15 },
new Ability { Name = "Shield", Cost = 5 }
};
file.SetList<Ability>(
groupName: "Combat",
propertyName: "Abilities",
items: abilities,
toGroup: (ability, index) =>
{
var group = new ConfigFileGroup($"Ability{index}");
group.SetString("Name", ability.Name);
group.SetInt ("Cost", ability.Cost);
return group;
}
);
/* Read back via TryGetList<T> with a per-item deserializer. */
if (file.TryGetList<Ability>(
groupName: "Combat",
propertyName: "Abilities",
items: out var loadedAbilities,
fromGroup: group =>
{
group.TryGetString("Name", out string name);
group.TryGetInt ("Cost", out int cost);
return new Ability { Name = name, Cost = cost };
}))
{
/* loadedAbilities is now a List<Ability> of the parsed entries. */
}
Drive the four-tier fallback with ConfigFileManager
Problem. You want to understand how the four-tier resolution actually runs at startup, how you can create a starter JSON file on disk, and how to inspect or reset the manager’s state in tests and editor tooling.
Solution. ConfigFileManager is the static class that orchestrates the override loop. The bootstrap calls Initialize(coreConfig) during Awake, which reads the gating flags from ScyllaCoreConfiguration and sets IsEnabled / CreateEnabled. Each configuration asset then has ApplyOverrides called against it; the manager looks for the highest-priority tier with a matching file and applies it. You rarely call these methods directly in production code – the framework calls them for you – but they’re useful for editor tooling and test setup.
using Scylla.Core.Config;
/* Bootstrap-side initialization. Called automatically by ScyllaBootstrap.
* Reads ConfigFilesEnabledInDebugBuild / ConfigFilesEnabledInProductionBuild
* to determine if JSON tiers are read at all. */
ConfigFileManager.Initialize(coreConfiguration);
/* Per-asset apply. The manager:
* 1. Builds the User Documents path (~/Documents/{ProductName}/Config/{Name}.json).
* If the file exists and is valid JSON, applies it.
* 2. Otherwise, builds the Application folder path. If the file exists and
* is valid JSON, applies it.
* 3. Otherwise, the asset's existing field values (from the Inspector or
* from ResetToDefaults) remain in effect.
*
* Returns the number of values applied across all matched tiers. */
int applied = ConfigFileManager.ApplyOverrides(healthConfiguration);
/* Create a starter JSON file on disk if none exists. Writes the asset's
* ExportToConfigFile() output to the User Documents path. Useful for
* seeding the override location with the canonical shape. */
ConfigFileManager.CreateConfigFileIfNeeded(healthConfiguration);
/* Same but for built-in configurations identified by type rather than instance.
* Useful during framework startup when the asset reference is not yet bound. */
ConfigFileManager.CreateDefaultConfigFileIfNeeded<ScyllaCoreConfiguration>();
/* Inspect the gating flags at runtime. The flags are set in Initialize from
* ScyllaCoreConfiguration; user code reads them to decide whether to log
* override-related diagnostics. */
bool overridesEnabled = ConfigFileManager.IsEnabled;
bool autoCreateEnabled = ConfigFileManager.CreateEnabled;
/* Reset the manager. Useful in tests that need to re-initialize with a
* different configuration. */
ConfigFileManager.Reset();
Lock down JSON overrides per build type
Problem. You want JSON overrides available during development and QA but disabled in the shipped build – or you need to permanently lock a specific asset (encryption keys, network endpoints) against on-disk tampering regardless of the global setting.
Solution. ScyllaCoreConfiguration exposes two flags that gate whether the JSON tiers are read at all: ConfigFilesEnabledInDebugBuild (default true) and ConfigFilesEnabledInProductionBuild (default false). The framework reads the appropriate flag at startup and propagates the result to ConfigFileManager.IsEnabled. Individual configurations can additionally gate themselves via IsConfigFileEnabledForCurrentBuild().
/* Per-asset gate. Override to disable JSON overrides for a specific
* configuration regardless of the global flag. Default returns true. */
public override bool IsConfigFileEnabledForCurrentBuild()
{
/* Example: disable JSON overrides for a security-critical config
* (encryption keys, network endpoints) regardless of build type. */
return false;
}
/* The framework checks both gates: ConfigFileManager.IsEnabled (global) and
* IsConfigFileEnabledForCurrentBuild() (per-asset). The JSON tier applies
* only when both are true. */
The two ScyllaCoreConfiguration properties default conservatively. Debug builds get JSON overrides by default because that’s the most useful setting during development. Production builds disable JSON overrides by default so player tinkering doesn’t unbalance gameplay; flip the flag to true if your game explicitly wants player-editable config files in the shipped build.
Look up configuration assets from inside a module
Problem. Your module needs to read its configuration values in OnInitialize. Most modules have one configuration asset, but some have several, and you want the same lookup pattern to cover both cases without wiring each asset to its own [SerializeField] slot.
Solution. IConfigurationRegistry is the per-module registry of configuration assets. Your module accesses it through the ConfigurationRegistry property on ScyllaModule. Get<T>() resolves an asset by type; GetAll() enumerates every registered asset; ValidateAll() runs every asset’s Validate() and aggregates the results. The registry is populated before your OnInitialize runs, so you can read from it safely in that hook. See the Module System page for the full lifecycle order.
public sealed class HealthModule : ScyllaModule
{
/* The module gets its registry through the inherited property. */
protected override void OnInitialize()
{
IConfigurationRegistry registry = ConfigurationRegistry;
/* Resolve an asset by type. The registry returns the asset that was
* registered for the requested type during the module's setup. */
HealthConfiguration config = registry.Get<HealthConfiguration>();
/* Equivalent to the type-parameterized form. */
ScyllaConfiguration any = registry.Get(typeof(HealthConfiguration));
/* Enumerate every registered asset. Useful for editor tooling that
* wants to show every configuration owned by a module. */
foreach (ScyllaConfiguration asset in registry.GetAll())
{
Log.Info($"Module has config: {asset.GetType().Name}", LogCategory.Module);
}
/* Enumerate the registered types. */
foreach (Type configType in registry.GetAllTypes())
{
/* ... */
}
/* Validate every asset. Returns the aggregated ConfigurationValidationResult
* array across every registered asset. */
ConfigurationValidationResult[] results = registry.ValidateAll();
foreach (var result in results)
{
if (result.Severity == ValidationSeverity.Error)
{
Log.Error(result.Message, LogCategory.Module);
}
}
}
}
Use the Inspector actions to validate, reset, and export
Problem. You want to validate the current values, reset to defaults, push values into a running module, or export the asset as a starter JSON file – all without writing any code.
Solution. The framework’s custom Inspector for every ScyllaConfiguration exposes four actions: Validate (runs Validate() and surfaces the results inline), Reset to Defaults (runs ResetToDefaults() after a confirmation prompt), Apply Now (calls Apply() to push the current values into any live module reference), and Export Config File (calls ExportToConfigFile() and writes the result to either User Documents or Application folder). The Inspector also displays a metadata section showing the asset’s resource path, the active configuration name, and the description. Validation results render inline: errors appear with a red icon and a per-field highlight; warnings with a yellow icon; info entries with a blue icon.
/* Editor actions are invoked from the Inspector, not from code. The buttons
* are added automatically by the framework's custom configuration editor.
* The underlying methods are virtual on ScyllaConfiguration:
*
* public abstract ConfigurationValidationResult[] Validate();
* public abstract void ResetToDefaults();
* public virtual void Apply();
* public virtual ConfigFile ExportToConfigFile();
*
* The Inspector confirms destructive actions (Reset) before running them.
* Export prompts for User Documents or Application folder; the target is
* the location the JSON file will be written to. */
API Sketch
The public surface for configuration spans the asset base class, the file types, the manager, the registry, and the validation types. Start with ScyllaConfiguration when you’re authoring a new config class; reach for ConfigFile and ConfigFileGroup when you’re implementing the ApplyConfigFileOverrides / ExportToConfigFile pair; use ConfigFileManager and IConfigurationRegistry for bootstrap integration and module-side lookup.
namespace Scylla.Core.Config
{
/* Asset base class. Inherit, add SerializeField fields, override
* Validate, ResetToDefaults, ApplyConfigFileOverrides, ExportToConfigFile. */
public abstract class ScyllaConfiguration : ScriptableObject
{
/* Build-tier gating. */
public bool ConfigFileEnabledInDebugBuild { get; }
public bool ConfigFileEnabledInProductionBuild { get; }
public bool CreateConfigFile { get; }
/* Metadata. */
public string ConfigurationName { get; }
public string Description { get; }
/* Required overrides. */
public abstract ConfigurationValidationResult[] Validate();
public abstract void ResetToDefaults();
/* Virtual overrides. */
public virtual bool IsConfigFileEnabledForCurrentBuild();
public virtual void Apply();
public virtual string GetConfigFileName();
public virtual string GetConfigFileBaseName();
public virtual bool IsValid();
public virtual int ApplyConfigFileOverrides(ConfigFile configFile);
public virtual ConfigFile ExportToConfigFile();
}
/* Validation outcome. */
public readonly struct ConfigurationValidationResult
{
public ValidationSeverity Severity { get; }
public string Message { get; }
public string FieldName { get; }
public ConfigurationValidationResult(ValidationSeverity severity, string message, string fieldName = null);
public static ConfigurationValidationResult Info (string message, string fieldName = null);
public static ConfigurationValidationResult Warning(string message, string fieldName = null);
public static ConfigurationValidationResult Error (string message, string fieldName = null);
}
public enum ValidationSeverity { Info, Warning, Error }
/* Runtime overrides loop. */
public static class ConfigFileManager
{
public static bool IsEnabled { get; }
public static bool CreateEnabled { get; }
public static void Initialize(ScyllaCoreConfiguration coreConfig);
public static int ApplyOverrides(ScyllaConfiguration configuration);
public static void CreateConfigFileIfNeeded(ScyllaConfiguration configuration);
public static void CreateDefaultConfigFileIfNeeded<T>() where T : ScyllaConfiguration;
public static void Reset();
}
/* Per-module asset registry. */
public interface IConfigurationRegistry
{
T Get<T>() where T : ScyllaConfiguration;
ScyllaConfiguration Get(Type configurationType);
IEnumerable<ScyllaConfiguration> GetAll();
IEnumerable<Type> GetAllTypes();
ConfigurationValidationResult[] ValidateAll();
}
}
namespace Scylla.Core.Util.File
{
/* In-memory JSON. */
public sealed class ConfigFile
{
public int GroupCount { get; }
public static ConfigFile FromJSON(string json);
public static ConfigFile Merge (ConfigFile higher, ConfigFile lower);
public string ToJSON();
/* Group management. */
public bool HasGroup (string groupName);
public ConfigFileGroup GetGroup (string groupName);
public ConfigFileGroup GetOrAddGroup (string groupName);
public ConfigFileGroup AddGroup (string groupName);
public bool RemoveGroup (string groupName);
public IReadOnlyCollection<string> GetGroupNames();
public bool HasProperty(string groupName, string propertyName);
/* Typed accessors at group + property level. */
public bool TryGetBool (string groupName, string propertyName, out bool value);
public bool TryGetInt (string groupName, string propertyName, out int value);
public bool TryGetFloat (string groupName, string propertyName, out float value);
public bool TryGetDouble (string groupName, string propertyName, out double value);
public bool TryGetString (string groupName, string propertyName, out string value);
public bool TryGetColor (string groupName, string propertyName, out Color value);
public void SetBool (string groupName, string propertyName, bool value);
public void SetInt (string groupName, string propertyName, int value);
public void SetFloat (string groupName, string propertyName, float value);
public void SetDouble (string groupName, string propertyName, double value);
public void SetString (string groupName, string propertyName, string value);
public void SetColor (string groupName, string propertyName, Color value);
/* Nested groups. */
public void SetGroup (string groupName, string propertyName, ConfigFileGroup subGroup);
public bool TryGetGroup (string groupName, string propertyName, out ConfigFileGroup subGroup);
/* Arrays. */
public void SetArray (string groupName, string propertyName, List<ConfigFileProperty> items);
public bool TryGetArray (string groupName, string propertyName, out IReadOnlyList<ConfigFileProperty> items);
public void SetList<T> (string groupName, string propertyName, IReadOnlyList<T> items, Func<T, int, ConfigFileGroup> toGroup);
public bool TryGetList<T>(string groupName, string propertyName, out List<T> items, Func<ConfigFileGroup, T> fromGroup);
}
/* Named container inside a ConfigFile. Exposes the same TryGet*/Set* shape
* one level deeper (no group name parameter). */
public sealed class ConfigFileGroup
{
public string Name { get; }
public int PropertyCount { get; }
public ConfigFileGroup(string name);
public bool HasProperty(string propertyName);
public bool TryGetProperty(string propertyName, out ConfigFileProperty property);
public ConfigFileProperty GetProperty(string propertyName);
public void SetProperty (string propertyName, ConfigFileProperty property);
public bool RemoveProperty(string propertyName);
public IReadOnlyCollection<string> GetPropertyNames();
public IEnumerable<KeyValuePair<string, ConfigFileProperty>> GetProperties();
/* Same typed accessors as ConfigFile, one level deeper. */
public bool TryGetBool (string propertyName, out bool value);
public bool TryGetInt (string propertyName, out int value);
public bool TryGetFloat (string propertyName, out float value);
public bool TryGetDouble (string propertyName, out double value);
public bool TryGetString (string propertyName, out string value);
public bool TryGetColor (string propertyName, out Color value);
public bool TryGetGroup (string propertyName, out ConfigFileGroup subGroup);
public bool TryGetArray (string propertyName, out IReadOnlyList<ConfigFileProperty> items);
public void SetBool (string propertyName, bool value);
public void SetInt (string propertyName, int value);
public void SetFloat (string propertyName, float value);
public void SetDouble (string propertyName, double value);
public void SetString (string propertyName, string value);
public void SetColor (string propertyName, Color value);
public void SetGroup (string propertyName, ConfigFileGroup subGroup);
public void SetArray (string propertyName, List<ConfigFileProperty> items);
}
/* Typed value carrier. */
public readonly struct ConfigFileProperty : IEquatable<ConfigFileProperty>
{
public ConfigFilePropertyType Type { get; }
public bool IsInitialized { get; }
/* Plus type-specific accessors. */
}
public enum ConfigFilePropertyType { Bool, Int, Float, Double, String, Group, Array }
}
See the API reference for full signatures, remarks, and exception contracts on every member.
Settings reference
The framework-level configuration knobs live on ScyllaCoreConfiguration. Three of them gate the configuration system itself; others tune the broader framework (see Bootstrap Lifecycle for the full surface).
| Property | Type | Default | Effect |
|---|---|---|---|
ConfigFilesEnabledInDebugBuild | bool | true | Whether JSON config-file overrides are loaded in development / debug builds. Set to false to ignore user JSON during testing. |
ConfigFilesEnabledInProductionBuild | bool | false | Same for release builds. Default disables JSON overrides in shipped builds so player overrides don’t unbalance gameplay. Flip to true for modding-friendly or sandbox games. |
CreateConfigFiles | bool | true | Whether the framework auto-creates the Application-tier config files at startup when they are missing. Disable if you want to control the JSON files entirely by hand. |
Per-asset gates live on individual ScyllaConfiguration instances. ConfigFileEnabledInDebugBuild and ConfigFileEnabledInProductionBuild mirror the global flags but apply only to that asset; the global flag is the broader cap. CreateConfigFile enables per-asset auto-creation independently of the global CreateConfigFiles flag.
Best Practices
- Hold values as
[SerializeField]private fields with=>getter properties. The field is the Inspector-visible storage; the getter is the public API. Avoid public mutable fields; mutability outside the configuration system breaks the four-tier resolution by letting code modify the asset directly. - Lift group and property names into
public const stringconstants. Inline string literals inApplyConfigFileOverridesare typo-prone; JSON keys must match the constants exactly to take effect. Constants surface the relationship and make mismatches visible at compile time. - Override
ResetToDefaultsfor every configuration. It’s the lowest tier of the four-tier fallback; without it, an asset whose Inspector values are unset behaves differently from one whose defaults were never set. - Override
Validatefor every configuration. Field-level validation surfaces Inspector errors before runtime. UseErrorfor “cannot ship”,Warningfor “should review”,Infofor diagnostic context. - Override
ExportToConfigFilefor every configuration that should be JSON-overridable. The Inspector’s Export action depends on it; without an override, the action produces nothing and QA has no starter file to edit. - Document the JSON shape with the asset. A doc comment on the configuration class listing the group / property names makes it easy for QA to write the override file correctly without guessing the structure.
- Use the per-asset gate (
IsConfigFileEnabledForCurrentBuild) for security-critical assets. Encryption keys, network endpoints, premium-currency caps: every one of these should resist JSON tampering regardless of the global gating flag. - Click Export Config File once, commit the result. The asset’s current values become the canonical JSON shape; check the JSON into version control so the documented shape stays in sync with the code.
- Use the registry for multi-asset modules. A module with two configurations should resolve both through
ConfigurationRegistry.Get<T>()rather than wiring two[SerializeField]slots; the registry pattern scales to any number of assets. - Validate during framework startup. Calling
ConfigurationRegistry.ValidateAll()inOnInitializecatches misconfigurations at the right time – after JSON overrides are applied – and produces actionable error logs. - Treat the JSON file as a fallback, not the canonical source. The asset is the canonical source of truth; the JSON is the runtime override. Storing project-canonical values only in JSON means losing them on the next clean build.
Pitfalls
Related
- Bootstrap Lifecycle
- Module System
- Serialization Utils
- File IO Utils
- Config File Utils
- API reference:
Scylla.Core.Config.ScyllaConfiguration - API reference:
Scylla.Core.Config.IConfigurationRegistry - API reference:
Scylla.Core.Config.ConfigFileManager - API reference:
Scylla.Core.Config.ConfigurationValidationResult - API reference:
Scylla.Core.Config.ValidationSeverity - API reference:
Scylla.Core.Util.File.ConfigFile - API reference:
Scylla.Core.Util.File.ConfigFileGroup - API reference:
Scylla.Core.Util.File.ConfigFileProperty - API reference:
Scylla.Core.Util.File.ConfigFilePropertyType