Universal Runtime Scene Pattern in Unity
Intended Audience: Intermediate to Advanced Unity Developers
Estimated Reading Time: 7 minutes
Every Unity developer has felt the pain: you add a new level, so you create a new scene. Then you need to test it, but first you have to play through the menu. Your singleton managers start piling up in DontDestroyOnLoad. Before long, you're drowning in scene dependencies and prefab references.
Run Squish takes a different approach. Seven completely different themed courses — ghosts, vampires, eyeballs, cupids, leprechauns, rotten eggs, and chocolate santas — all run from a single "Universal Runtime" scene. The magic lies in a lightweight persistence layer and ScriptableObject configuration system that lets developers skip the menu entirely during development.
The Architecture
The system has four key components:
1. PlayerPrefs as the Source of Truth
Instead of passing data between scenes via singleton managers or static variables that persist across domain reloads, the last selected course lives in PlayerPrefs:
public static class LastValid
{
public const CourseIds DEFAULT_FALLBACK_COURSE = CourseIds.Ectoplasm;
public static CourseIds CourseId
{
get
{
CourseIds result = (CourseIds)PersistentStorage.Load<BoxedInt>(
PersistKeys.LAST_VALID_COURSEID,
fallbackValue: new BoxedInt((int)DEFAULT_FALLBACK_COURSE)
).Value;
// Validate - corrupted data gets the fallback
if (!VALID_COURSES_IN_PLAY_ORDER.Contains(result))
result = DEFAULT_FALLBACK_COURSE;
return result;
}
set => PersistentStorage.Save(
PersistKeys.LAST_VALID_COURSEID,
new BoxedInt((int)value)
);
}
}
The validation step is crucial. If a player's save data references a course that no longer exists (perhaps removed in an update), the system gracefully falls back rather than crashing.
2. Session State That Auto-Persists
A Current static class provides the in-memory session state. The clever part: the setter automatically writes through to PlayerPrefs:
public static class Current
{
static CourseIds _courseId = LastValid.CourseId;
public static CourseIds CourseId
{
get => _courseId;
set
{
_courseId = value;
LastValid.CourseId = value; // Persists immediately
}
}
public static CourseFullConfig Course
{
get
{
if (!m_fullConfigs.ContainsKey(CourseId))
{
var fullConfig = CourseFullConfig.Load(CourseId);
RegisterCourse(fullConfig);
}
return m_fullConfigs[CourseId];
}
}
}
The Course property lazily loads the full configuration - only when needed, and only one at a time. When the course changes, the cache clears, keeping memory tight on mobile devices.
3. ScriptableObject Configuration
Each course has a CourseFullConfig ScriptableObject in Resources/CourseConfigs/. This is where the theming lives:
- Background music
- Monster body materials and face sprites
- Terrain textures and goo splatters
- Skybox and fog settings
- Physics materials
- UI elements for sharing screenshots
Loading is straightforward:
public static CourseFullConfig Load(CourseIds courseId)
=> ResourceLoader.Load<CourseFullConfig>(
folderName: "CourseConfigs",
fileName: courseId.ToString()
);
The filename matches the enum value, so CourseIds.Vampires loads Resources/CourseConfigs/Vampires.asset.
4. The Universal Runtime Scene
This is where it all comes together. The single gameplay scene contains components that read from Current.Course during initialization:
// CoursePrefabSpawner.cs
void Awake()
{
GameObject prefab = Current.Course.PrefabAtOrigin;
if (prefab != null)
Instantiate(prefab, Vector3.zero, Quaternion.identity);
}
// CourseMusicPlayer.cs
void Start()
{
GetComponent<AudioSource>().clip = Current.Course.CourseBackgroundMusic;
GetComponent<AudioSource>().Play();
}
// TerrainRuntimeSkinner.cs
void OnEnable()
{
ApplyTerrainLayer(0, Current.Course.LandLayerTextureConfig);
ApplyTerrainLayer(1, Current.Course.GooLayerTextureConfig);
}
The scene itself contains no course-specific assets. It's a blank canvas that gets painted based on Current.CourseId.
The Developer Experience Win
Here's where this pattern really shines. An Odin Inspector editor window lets developers set the active course directly:
public class CourseIdChooser : OdinEditorWindow
{
[OnValueChanged("SelectedCourseChanged")]
public CourseIds CurrentCourse;
public void SelectedCourseChanged()
{
Current.CourseId = CurrentCourse;
EventBinder.Trigger(EventTypes.SELECTED_COURSE_CHANGED);
}
[MenuItem("Run Squish/CourseId Chooser")]
public static void OpenWindow() => GetWindow<CourseIdChooser>().Show();
}
The workflow:
- Open the CourseId Chooser window
- Select "Vampires" from the dropdown
- Enter Play Mode on the Universal Runtime scene
- You're immediately playing the vampire level
No menu scene. No navigation. No waiting. The course ID persists in PlayerPrefs, so Unity remembers your choice between play sessions.
Why Not Addressables?
Run Squish actually has Unity Addressables configured with asset groups per seasonal edition (Halloween, Spring, Winter). The infrastructure exists for downloading course content on demand - a mobile-friendly approach that keeps the initial download small.
But sometimes simpler wins. The Resources-based loading works, the game ships with all content included, and there's no CDN to maintain. The Addressables setup remains as future infrastructure if the content library grows large enough to warrant it.
The Pattern Summarized
- Persist the selection, not the data: PlayerPrefs holds the course ID, not the course assets
- Validate on load: Corrupted or stale data gets a graceful fallback
- Write-through session state: Setting the current course automatically persists it
- Lazy-load configurations: Only load what you need, when you need it
- One scene to rule them all: The runtime scene is content-agnostic
- Editor tooling for instant access: Skip the menu during development
The result is a system where adding a new themed course means creating one ScriptableObject asset and adding one enum value. No new scenes. No prefab wiring. No singleton coordination.
For a game with seven distinct visual themes running on mobile devices, that's a meaningful simplification.