-
C — Basics
This post covers the C foundations — everything from compiling your first file to managing memory manually.
Compilation
C is a compiled language. You write source code (
.c), then convert it to an executable using a compiler like GCC.gcc main.c -o my_program # compile main.c into an executable called my_program ./my_program # run itCommon flags:
gcc -Wall -Wextra -Werror main.c -o my_programFlag Effect -Wallenable all standard warnings -Wextraenable extra warnings -Werrortreat warnings as errors Code must compile with all three. No warning tolerance.
Variables and Types
C is statically typed — every variable has a fixed type declared at creation.
int age = 25; float price = 9.99; double pi = 3.14159265; char letter = 'A'; char name[] = "Sam";Type Size Use char1 byte single character or small integer int4 bytes whole numbers float4 bytes decimal numbers (less precise) double8 bytes decimal numbers (more precise) Constants are declared with
#defineorconst:#define MAX 100 const int LIMIT = 50;Standard Output
C has no
printbuilt-in. You usewrite(low-level) orprintf(formatted):#include <unistd.h> write(1, "Hello\n", 6); // file descriptor 1 = stdout#include <stdio.h> printf("Hello, %s! You are %d years old.\n", name, age);Common format specifiers:
Specifier Type %dinteger %ffloat %cchar %sstring %ppointer address A common exercise is to rewrite
printfyourself asft_printf.Conditionals
if (age >= 18) { printf("Adult\n"); } else if (age >= 13) { printf("Teenager\n"); } else { printf("Child\n"); }Comparison and logical operators:
Operator Meaning ==equal !=not equal ><greater / less than >=<=greater or equal / less or equal &&logical AND ||logical OR !logical NOT Bitwise & Bit Shift Operators
Bitwise operators work directly on the binary representation of integers, bit by bit.
Operator Name Example Result &AND 5 & 31|OR 5 | 37^XOR 5 ^ 36~NOT ~5-6<<left shift 1 << 38>>right shift 8 >> 14How They Work
Each integer is a sequence of bits. The operators act on each pair of bits independently:
5 = 0101 3 = 0011 ---- & AND: 0001 = 1 | OR: 0111 = 7 ^ XOR: 0110 = 6~flips every bit (bitwise NOT). Because of two’s complement,~5gives-6.Bit Shifts
<<shifts bits to the left (multiplies by powers of 2).>>shifts bits to the right (divides by powers of 2).int x = 1; // 0001 x = x << 1; // 0010 = 2 x = x << 2; // 1000 = 8 x = x >> 1; // 0100 = 4Shifting left by
nis equivalent to multiplying by2^n, and is faster than actual multiplication:int n = 3; int result = n << 2; // 3 * 4 = 12Practical Uses
Check if a number is odd:
if (n & 1) printf("odd\n");Set a bit (turn on bit at position
i):flags = flags | (1 << i);Clear a bit (turn off bit at position
i):flags = flags & ~(1 << i);Toggle a bit:
flags = flags ^ (1 << i);Check if a specific bit is set:
if (flags & (1 << i)) printf("bit %d is set\n", i);This pattern is common in systems programming, graphics (color channels), game states, and flags.
Loops
while
int i = 0; while (i < 5) { printf("%d\n", i); i++; }for
for (int i = 0; i < 5; i++) printf("%d\n", i);The Norminette forbids
forloops — usewhileinstead.do…while
Executes at least once before checking the condition:
int i = 0; do { printf("%d\n", i); i++; } while (i < 5);The Norminette also forbids
do…whileloops.Functions
Functions in C must be declared before they are used (either defined above
main, or via a prototype):int add(int a, int b); // prototype int main(void) { printf("%d\n", add(3, 4)); return (0); } int add(int a, int b) { return (a + b); }A function that returns nothing uses
void:void greet(char *name) { printf("Hello, %s\n", name); }Pointers
A pointer stores the memory address of another variable, not its value.
int x = 42; int *p = &x; // p holds the address of x printf("%d\n", x); // 42 — the value printf("%p\n", p); // 0x... — the address printf("%d\n", *p); // 42 — dereference: value at addressSyntax Meaning &xaddress of x *pvalue at address stored in p int *pdeclare p as a pointer to int Pointers are how C passes variables by reference to functions:
void increment(int *n) { *n = *n + 1; } int main(void) { int x = 5; increment(&x); printf("%d\n", x); // 6 }Arrays
An array is a contiguous block of memory holding multiple values of the same type:
int scores[5] = {10, 20, 30, 40, 50}; printf("%d\n", scores[0]); // 10 printf("%d\n", scores[4]); // 50The name of an array is a pointer to its first element:
int *p = scores; // equivalent to &scores[0] printf("%d\n", *p); // 10 printf("%d\n", *(p + 2)); // 30Strings
In C, a string is an array of
charending with a null terminator'\0':char name[] = "Sam"; // equivalent to: {'S', 'a', 'm', '\0'}Common string operations (from
<string.h>):#include <string.h> strlen(str); // length (not counting '\0') strcpy(dest, src); // copy src into dest strcat(dest, src); // append src to dest strcmp(s1, s2); // compare: 0 if equalA common exercise is to rewrite all of these yourself as
ft_strlen,ft_strcpy, etc.Pointer Arithmetic on Strings
char *s = "Hello"; while (*s) { write(1, s, 1); s++; }s++advances the pointer one byte — the next character.Dynamic Memory
Stack memory (local variables) is freed automatically. Heap memory must be managed manually with
mallocandfree.#include <stdlib.h> int *arr = malloc(5 * sizeof(int)); // allocate 5 ints on the heap if (!arr) return (1); // always check for NULL arr[0] = 10; arr[1] = 20; free(arr); // release the memory arr = NULL; // avoid dangling pointerRules:
- Every
mallocmust have a matchingfree - Never use memory after freeing it
- Never free the same pointer twice
2D Arrays with malloc
char **grid = malloc(3 * sizeof(char *)); int i = 0; while (i < 3) { grid[i] = malloc(4 * sizeof(char)); i++; } // ... use grid[row][col] ... // free each row, then the array itself i = 0; while (i < 3) { free(grid[i]); i++; } free(grid);Norminette
The Norminette is a strict code style checker. Key rules:
- Functions must be 25 lines maximum
- No more than 5 function parameters
- No
forloops (onlywhile) - Variable declarations at the top of the scope only
- 4-space tabs, specific brace placement
norminette my_file.c # check style compliance
Quick Reference
Concept Meaning gcc -Wall -Wextra -Werrorcompile with full warnings int *p = &xpointer to variable x *pdereference — value at address mallocallocate heap memory freerelease heap memory '\0'null terminator ending a string norminettestyle checker Created on July 2026 - Every
-
Shell Tips — Commands & Bash Scripting
The terminal is one of the most powerful tools in a developer’s toolkit. Whether you’re on Linux, macOS, or WSL on Windows, mastering a handful of Shell commands and understanding how to write Bash scripts can dramatically speed up your workflow.
Essential Commands
ls — List Directory Contents
lsshows what’s in the current directory. The flags make it genuinely useful:ls # basic list ls -l # long format: permissions, owner, size, date ls -a # show hidden files (starting with .) ls -la # combine both: long format + hidden filesThe
-lacombo is the one you’ll use 95% of the time. It gives you the full picture of a directory including dotfiles like.zshrc,.gitignore, etc.cd — Change Directory
cd /path/to/dir # absolute path cd Documents # relative path cd .. # go up one level cd ~ # go to home directory cd - # go back to previous directorycd -is underrated — it toggles between the last two directories you visited, great when you’re jumping between two project folders.cp — Copy Files or Directories
cp file.txt backup.txt # copy a file cp -r folder/ backup_folder/ # copy a directory recursively cp *.png ~/Pictures/ # copy all .png files to PicturesAlways use
-r(recursive) when copying directories, otherwisecpwill refuse.mv — Move or Rename
mv old_name.txt new_name.txt # rename a file mv file.txt ~/Documents/ # move a file mv folder/ ../other_folder/ # move a directorymvdoes double duty: moving and renaming are the same operation. There’s no dedicated rename command in Shell.chmod — Change File Permissions
Permissions control who can read, write, or execute a file. They apply to three groups: owner, group, and others.
chmod +x script.sh # make a file executable chmod 755 script.sh # rwxr-xr-x (owner full, others read+execute) chmod 644 file.txt # rw-r--r-- (owner read+write, others read only)There is also a symbolic mode using
+,-, and=to add, remove, or set permissions, andu(user/owner),g(group),o(others),a(all) to target who:chmod +x script.sh # give execute to everyone chmod -x script.sh # remove execute from everyone chmod +r file.txt # give read to everyone chmod -w file.txt # remove write from everyone chmod u+x script.sh # give execute to owner only chmod g-w file.txt # remove write from group chmod o-r file.txt # remove read from others chmod a+r file.txt # give read to all (same as +r) chmod u+rwx,go+rx file # owner full, group and others read+execute chmod o= file.txt # remove all permissions from othersSymbol Meaning +add permission -remove permission =set exact permissions (removes others) uowner (user) ggroup oothers aall (u+g+o) The numeric mode (
755,644) is the most common in practice. Each digit is a sum of three base values:Value Permission 4 read (r) 2 write (w) 1 execute (x) Add them together to combine permissions:
Value Permissions 7 rwx (4+2+1) 6 rw- (4+2) 5 r-x (4+1) 4 r— (4) 0 --- alias — Create Command Shortcuts
aliaslets you define a shortcut for any command or chain of commands:alias ll="ls -la" alias gs="git status" alias ..="cd .." alias ...="cd ../.."The problem: aliases defined in the terminal only last for the current session.
Making Aliases Permanent with .zshrc
To persist aliases across sessions, add them to your shell config file. For Zsh (default on macOS and most modern Linux distros), that’s
~/.zshrc:# Open .zshrc with your editor nano ~/.zshrc # Add your aliases at the bottom alias ll="ls -la" alias gs="git status" alias gp="git push" alias ..="cd .." # Reload the config without restarting the terminal source ~/.zshrcFor Bash users the file is
~/.bashrcinstead.
Writing a Bash Script
A Bash script is a plain text file containing a sequence of Shell commands. The first line — the shebang — tells the OS which interpreter to use.
The Shebang Line
#!/bin/bashThis must be the very first line of the file. It points to the Bash binary, so the script runs with Bash regardless of what shell the user has active.
A Practical Example
Here’s a script that creates a timestamped backup of a directory:
#!/bin/bash SOURCE="$HOME/Projects" DEST="$HOME/Backups" TIMESTAMP=$(date +"%Y-%m-%d_%H-%M") BACKUP_NAME="backup_$TIMESTAMP" mkdir -p "$DEST/$BACKUP_NAME" cp -r "$SOURCE/" "$DEST/$BACKUP_NAME/" echo "Backup created: $DEST/$BACKUP_NAME"Save it as
backup.sh, then make it executable and run it:chmod +x backup.sh ./backup.shKey Script Concepts
Variables — no spaces around
=:NAME="Sam" echo "Hello, $NAME"Conditionals:
if [ -f "file.txt" ]; then echo "File exists" else echo "File not found" fiLoops:
for file in *.txt; do echo "Processing $file" doneFunctions:
greet() { echo "Hello, $1" } greet "Sam"
Quick Reference
Command Purpose lsList directory contents cdChange directory cpCopy files or directories mvMove or rename files chmodChange file permissions aliasCreate a command shortcut source ~/.zshrcReload shell config #!/bin/bashShebang — declare Bash interpreter Created on July 2026 -
MarbleMaze: Event-Driven Manager Architecture
MarbleMaze is coordinated by more than 20 singleton managers — audio, levels, currencies, lives, customisation, achievements, notifications, cloud save, scene transitions, and more. None of them hold a direct reference to another. Instead, they publish and subscribe to C#
Action<T>events, leaving each system completely replaceable in isolation. This post is an architectural walkthrough of how the pieces fit together.
The Singleton Backbone
Every manager follows the same lightweight pattern: a static
Instanceproperty, a null-check guard inAwakethat destroys duplicates, and noDontDestroyOnLoad:public static LevelManager Instance { get; private set; } private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; }DontDestroyOnLoadis intentionally absent. Each scene owns its own manager objects. Scene loading and unloading handles the lifetime — a manager is only alive while its scene is loaded.SceneControllermanages which scenes are loaded at any given moment, so managers never need to protect themselves from early destruction.
Bootstrap: CoreManager
The entry point of the whole game is
CoreManager.Start:void Start() { GoogleUpdateManager.Instance?.CheckForUpdate(); SavingManager.Instance.LoadSession(); SceneController.Instance .NewTransition() .Load(SceneDatabase.Slots.Menu, SceneDatabase.Scenes.StartMenu) .Perform(); }Three things happen in sequence: a Play Store update check, a full session restore from disk (which populates all managers with persisted state), and the first scene transition. The rest of the game unfolds from events and user input —
CoreManagernever touches anything again.
No Direct Manager References: The Event Contract
The defining constraint is that managers do not call methods on each other. Instead, each publishes what it knows and subscribes to what it needs.
A concrete example: when the player loses a life, three systems need to react — the in-game HUD needs to update its heart icons, the difficulty engine needs to know for the next level, and
PlayerMovementneeds to expand its ground-detection radius. None of those systems callLifeManagerdirectly.LifeManager.RemoveLifefires two events and both updates propagate outward:public void RemoveLife() { if (currentLife > 0) { currentLife--; CoinManager.Instance.ReduceCurrencyAmount(CoinType.HEART, 1); } LevelManager.Instance.IncreaseLivesLostToThisLevel(); OnLifeRemoved?.Invoke(); }CoinManager.ReduceCurrencyAmountthen firesOnCoinChanged:OnCoinChanged?.Invoke(type, coins[type], previousCoins[type]);CoinManager.OnCoinChanged→LifePannel.UpdateCurrencyValue(HUD)LevelManager.OnLifeLostToThisLevel→PlayerMovement(ground radius)LifeManager.OnLifeRemoved→LifePannel.OnLifeRemoved(shake/flash animation)A single player death fans out to three independent subsystems with no coupling between any of them.
The Event Map
Event Publisher Subscribers OnCoinChanged(CoinType, int, int)CoinManagerAll CurrencyPannelinstances,LifePannelOnCoinSet(CoinType, int)CoinManagerAll CurrencyPannelinstances (snap, no animation)OnHeartTimerTick(TimeSpan)CoinManagerHeartPannelManager(live countdown)OnCoinTimerTick(TimeSpan)CoinManagerRewarded video button cooldown UI OnLifeRemovedLifeManagerLifePannel, game-over checkOnLifeLostToThisLevel(int)LevelManagerPlayerMovement(adaptive radius)OnStarCountChanged(int)LevelManagerStarPannel(in-game star counter)OnPowerUpStateChanged(PowerUpState)PowerUpManagerPowerUpButtonManager, audioOnCloudSaveCompletedCloudSaveManagerUserInfoManager(toast notification)OnCloudLoadCompletedCloudSaveManagerSavingManager(session restore trigger)OnAuthenticationReadyLoginManagerCloudSaveManager,AchievementManagerAll events are plain C#
Action<T>orActiondelegates — no UnityEvent overhead, no message-passing framework, no ScriptableObject event channels. The subscribers register inOnEnableand deregister inOnDisable, so scene unloading automatically cleans up every subscription.
Fluent Builder: SceneController
Scene transitions follow a Fluent Builder pattern.
SceneTransitionPlanaccumulates the load/unload list and options through chained calls, then executes whenPerform()is called:SceneController.Instance .NewTransition() .Load(SceneDatabase.Slots.Content, SceneDatabase.Scenes.Game) .Unload(SceneDatabase.Scenes.GamesMenu) .WithOverlay() .Perform();The builder methods each return
this, enabling the chain:public SceneTransitionPlan Load(SceneDatabase.Slots slot, SceneDatabase.Scenes scene, bool setActive = true) { ScenesToLoad[slot.ToString()] = scene.ToString(); if (setActive) ActiveScene = scene.ToString(); return this; } public SceneTransitionPlan WithOverlay() { Overlay = true; return this; } public void Perform() { SceneController.Instance.StartCoroutine( SceneController.Instance.ExecutePlan(this)); }Performhands the plan toExecutePlan, which runs the full async pipeline: fade-in overlay → unload scenes → optionalResources.UnloadUnusedAssets()→ load scenes additively → set active scene → fade-out overlay. TheisBusyguard prevents overlapping transitions:private IEnumerator ExecutePlan(SceneTransitionPlan plan) { if (isBusy) yield break; isBusy = true; yield return StartCoroutine(ChangeSceneRoutine(plan)); }SceneDatabase.SlotsandSceneDatabase.Scenesare enums that make call sites type-safe — no raw strings in the codebase.
Pattern Inventory
Every major design pattern in the codebase serves a specific architectural goal:
Pattern Where Why Strategy IDataService/JsonDataServiceSwap storage backend without touching call sites State Machine PlayerMovement,PowerUpManager,FlapingDoorsAnimationControlled transitions, no illegal state combinations Composite OrderedCondition(tutorial)Sequence sub-conditions without nesting coroutines Fluent Builder SceneController.NewTransition()Readable multi-step scene transitions at call sites Observer All Action<T>eventsZero coupling between publishers and subscribers Factory GridFactory,PhysicalMazeGeneratorCentralize complex object construction Singleton All managers Single source of truth per system, discoverable via Instance
Dependency Flow: Session Startup
The startup sequence illustrates how the architecture handles a complex multi-system initialization without any direct wiring:
CoreManager.Start() └─ SavingManager.LoadSession() ├─ RestorePlayerDataFromFile() │ └─ CoinManager.SetCurrencyAmount(CoinType.HEART, n) │ └─ OnCoinSet → CurrencyPannel.SetCurrencyValue() [all subscribed panels] ├─ RestoreGameDataFromFile() │ └─ LevelManager.levelDataDictionnary populated └─ RestoreTutorialDataFromFile() └─ TutorialManager flags set └─ CloudSaveManager (async, via OnAuthenticationReady event) └─ ForceCloudLoad() if needed └─ ApplyCloudPayload() └─ SavingManager.ForceLocalOverwrite<T>() └─ OnCloudLoadCompleted → SavingManager.LoadSession() [second pass with cloud data]CoreManagertriggers the chain with one line. Cloud sync happens independently and asynchronously — if it completes before the first scene loads, managers get cloud data; if it completes after, a secondLoadSessioncall updates them in-place and events propagate the changes to any already-open UI.
What This Enables
The zero-coupling rule has two practical payoffs:
Isolated testing — any manager can be removed from a scene without breaking others. The null check (
CoinManager.Instance?.ReduceCurrencyAmount(...)) is the only concession to this; it prevents crashes in scenes that only load a subset of managers.Predictable event ordering — because no manager calls another’s methods directly, there are no hidden call chains to trace. The subscriber list is the entire blast radius of any event. When a bug appears in the HUD, the search space is every subscriber of
OnCoinChanged— a known, finite list — rather than “somewhere in the systems that write to CoinManager.”
Created on February 2026 -
MarbleMaze: Bézier Curve Currency Animations
When a player collects coins or stars at the end of a level, the icons don’t just vanish — they arc smoothly across the screen to their respective HUD counter. This post covers the full implementation: the math behind the curves, the staggered batch system, the custom easing function, and how the completion callbacks wire together to drive the counter update.
The Math: Quadratic Bézier Curves
A quadratic Bézier curve is defined by three control points and a parameter
t ∈ [0, 1]:P(t) = (1-t)² · A + 2(1-t)t · B + t² · C- A — the start point (where the icon spawns: the star or coin on-screen position)
- B — the control point (determines the shape of the arc)
- C — the end point (the HUD counter the icon flies toward)
The curve passes through A at t=0 and C at t=1, but is pulled toward B without touching it — exactly the right behaviour for a looping arc animation. The implementation evaluates this formula every frame using
Mathf.Pow:rect.position = Mathf.Pow(1 - t, 2) * A + 2 * (1 - t) * t * B + Mathf.Pow(t, 2) * C;
Data Model: CurrencyAnimationRequest
Each animation batch is described by a plain serializable class. The inspector wires up the sprite, start positions, and destination counter at design time:
[System.Serializable] public class CurrencyAnimationRequest { public Sprite sprite; public List<RectTransform> startPoints = new List<RectTransform>(); public RectTransform endPoint; public int amount; }startPointsis a list so multiple spawn origins can be used — for a level with three stars, each icon can originate from a different on-screen star position.amountis set at runtime fromLevelManagerdata just before the animation plays.
Batch Orchestration: PlayRoutine
UICurrencyAnimator.Playaccepts a list of requests (coins and stars simultaneously) and a completion callback. The orchestrating coroutine sums the total icon count up front, then fires all request sub-coroutines in parallel:public void Play(List<CurrencyAnimationRequest> requests, System.Action onComplete = null) { StartCoroutine(PlayRoutine(requests, onComplete)); } private IEnumerator PlayRoutine( List<CurrencyAnimationRequest> requests, System.Action onComplete) { int totalIcons = 0; foreach (var request in requests) { if (request.amount > maxCurrencySpawn) request.amount = maxCurrencySpawn; totalIcons += request.amount; } if (totalIcons == 0) { onComplete?.Invoke(); yield break; } int completedIcons = 0; foreach (var request in requests) { StartCoroutine(AnimateRequest(request, () => { completedIcons++; if (completedIcons == 1) CoinManager.Instance.UpdateCounts(); if (completedIcons >= totalIcons) onComplete?.Invoke(); })); } }A shared
completedIconscounter is captured in each lambda closure. The HUD counter updates as soon as the first icon lands — giving immediate feedback — andonCompletefires only once all icons from all requests have finished.maxCurrencySpawncaps the batch at 10 icons regardless of how many coins were earned, keeping the screen readable.
Staggered Spawning: AnimateRequest
Each request spawns its icons one at a time with a
delayBetweenyield between them. The control point B is calculated per-icon with a random offset on top of a fixedarcHeight, so no two icons follow exactly the same path:private IEnumerator AnimateRequest(CurrencyAnimationRequest request, System.Action onIconComplete) { for (int i = 0; i < request.amount; i++) { RectTransform start = request.startPoints.Count > 1 ? request.startPoints[i % request.startPoints.Count] : request.startPoints[0]; Image icon = Instantiate(iconPrefab, canvasRect); RectTransform rect = icon.rectTransform; icon.sprite = request.sprite; rect.position = start.position; Vector2 randomOffset = Random.insideUnitCircle * scatterRadius; Vector3 A = start.position; Vector3 C = request.endPoint.position; Vector3 B = A + (Vector3)randomOffset + Vector3.up * arcHeight; StartCoroutine(AnimateIcon(rect, icon, A, B, C, onIconComplete)); yield return new WaitForSeconds(delayBetween); } }Key details:
- Start point cycling —
i % startPoints.Countdistributes icons across multiple spawn origins evenly. - Control point construction — B sits above A by
arcHeightworld units, then scattered within a circle of radiusscatterRadiususingRandom.insideUnitCircle. The result is a fan of distinct arcs all converging on the same HUD destination. - Each icon is parented to
canvasRect(the root CanvasRectTransform) so its world-space position maps correctly onto the screen regardless of layout.
Per-Frame Curve Evaluation: AnimateIcon
The actual flight happens in a manual time-accumulation loop rather than a DOTween call, keeping the Bézier evaluation explicit and avoidance of allocation from tween objects:
private IEnumerator AnimateIcon(RectTransform rect, Image icon, Vector3 A, Vector3 B, Vector3 C, System.Action onComplete) { float time = 0f; while (time < duration) { float t = time / duration; t = EaseInOutCubic(t); rect.position = Mathf.Pow(1 - t, 2) * A + 2 * (1 - t) * t * B + Mathf.Pow(t, 2) * C; time += Time.deltaTime; yield return null; } Destroy(icon.gameObject); onComplete?.Invoke(); }tis normalized to[0,1]then passed throughEaseInOutCubicbefore being fed into the Bézier formula. The easedtaccelerates out of A and decelerates into C, while the raw curve geometry creates the arc — the two concerns are cleanly separated.
Custom Easing: EaseInOutCubic
Rather than relying on an animation library’s preset, the easing is implemented directly using the standard cubic formula:
private float EaseInOutCubic(float t) { return t < 0.5f ? 4f * t * t * t : 1f - Mathf.Pow(-2f * t + 2f, 3f) / 2f; }- For
t < 0.5:4t³— cubic ease-in, starts slow and accelerates. - For
t ≥ 0.5:1 - (-2t+2)³/2— cubic ease-out, decelerates symmetrically into the destination.
The result is an icon that launches gently, reaches peak speed at the midpoint of the arc, then glides smoothly into the HUD counter.
Wiring It Up: EndPannelManager
EndPannelManagerowns twoCurrencyAnimationRequestfields configured in the inspector (one for coins, one for stars). AtStart()it populates theiramountfields fromLevelManager:private void Start() { coinAnimationRequest.amount = levelManager.CurrencyEarnedThisLevel; starAnimationRequest.amount = levelManager.CurrentStarCount; levelText.text = $"Level {levelManager.CurrentLevelIndex}"; if (levelManager.CurrentLevelData.numberOfStars >= 3) { AudioManager.Instance?.PlayWinSound(); if (GoogleReviewManager.Instance != null) GoogleReviewManager.Instance.RequestReview(); } }The animation is triggered separately (not on Start) so the end panel can display first, then launch icons once the player taps Continue. On completion the callback drives the scene transition:
public void StartCurrencyAnimation() { PannelVisuals.SetActive(false); currencyAnimator.Play( new List<CurrencyAnimationRequest> { coinAnimationRequest, starAnimationRequest }, () => ReturnToGamesMenu() ); }Passing both requests in a single
Playcall means coin and star icons fly simultaneously — thecompletedIconscounter inPlayRoutineensuresReturnToGamesMenufires only after every icon from both batches has landed.
HUD Counter: CurrencyPannel
Each HUD counter subscribes to
CoinManagerevents for live updates. When the first icon lands,CoinManager.UpdateCounts()firesOnCoinChanged, whichCurrencyPannelreceives:protected virtual void OnEnable() { coinManagerRef = CoinManager.Instance; coinManagerRef.OnCoinSet += SetCurrencyValue; coinManagerRef.OnCoinChanged += UpdateCurrencyValue; } protected virtual void UpdateCurrencyValue(CoinType type, int value, int previousValue) { if (type != m_coinType) return; text.AnimateCurrency(previousValue, value, 1.0f); }AnimateCurrencyis an extension method onTMP_Textthat tweens the displayed number from the old value to the new value over 1 second — so as the last few icons are still in flight, the counter is already counting up. The visual effect of icons arriving to “fill” the counter is an emergent result of these two independent timings rather than explicit synchronization.
Results
The full pipeline — Bézier geometry, random control-point scatter, cubic easing, staggered spawning, and event-driven counter updates — is under 170 lines of code with no animation library dependency. The only runtime allocations are the
Imageprefab instantiations themselves, which are destroyed immediately on landing.
Created on February 2026 -
MarbleMaze: Currency & Life Regeneration
MarbleMaze tracks five currencies — coins, stars, hearts, rockets, and UFOs — through a single
Dictionary<CoinType, int>. Hearts regenerate over real-world time usingDateTimecomparison rather than running a game-time timer, which means they accumulate correctly while the app is closed. A parallel event system drives all HUD updates soCoinManagernever touches UI directly.
The Currency Store
CoinManagermaintains two parallel dictionaries — current amounts and previous amounts:private Dictionary<CoinType, int> coins = new Dictionary<CoinType, int>(); private Dictionary<CoinType, int> previousCoins = new Dictionary<CoinType, int>();previousCoinsexists solely for the HUD animation system:OnCoinChangedbroadcasts both values soCurrencyPannelcan tween the displayed number from the old value to the new one. Any time a currency is written,LevelPreviousCoinAmountlevels the two dictionaries back into sync:public void LevelPreviousCoinAmount(CoinType type) { previousCoins[type] = coins[type]; }Adding a new currency type requires only a new enum entry and a
coins.AddinStart— no other code changes.
Events: Decoupled UI Updates
CoinManagerexposes four events and no direct UI references:public event Action<CoinType, int, int> OnCoinChanged; // type, newValue, previousValue public event Action<CoinType, int> OnCoinSet; // type, value (hard set, no animation) public event Action<TimeSpan> OnHeartTimerTick; public event Action<TimeSpan> OnCoinTimerTick;OnCoinChangedcarries both the new and previous value so subscribers can decide whether to animate.OnCoinSetis used during session restore when the displayed value should snap immediately rather than tween. Every UI panel subscribes inOnEnableand unsubscribes inOnDisable— no persistent references:protected virtual void OnEnable() { coinManagerRef = CoinManager.Instance; coinManagerRef.OnCoinSet += SetCurrencyValue; coinManagerRef.OnCoinChanged += UpdateCurrencyValue; } protected virtual void OnDisable() { coinManagerRef.OnCoinSet -= SetCurrencyValue; coinManagerRef.OnCoinChanged -= UpdateCurrencyValue; }
Heart Regeneration: DateTime Arithmetic
Hearts regenerate at a fixed rate (
timeToRegainHeartInMinutes) using twoDateTimefields persisted across sessions. The core logic sits inRecalculateHearts, called every frame while the app is focused:private void RecalculateHearts() { if (coins[CoinType.HEART] >= maxHeartAmount) { coins[CoinType.HEART] = maxHeartAmount; currentMinutesUntilFullHearts = -1; return; } DateTime now = DateTime.UtcNow; TimeSpan elapsed = now - lastHeartRefillTime; int heartsToAdd = (int)(elapsed.TotalMinutes / timeToRegainHeartInMinutes); if (heartsToAdd <= 0) return; int totalHeartsAmount = Mathf.Min(coins[CoinType.HEART] + heartsToAdd, maxHeartAmount); SetCurrencyAmount(CoinType.HEART, totalHeartsAmount); // Advance the refill timestamp by exactly the time consumed lastHeartRefillTime = lastHeartRefillTime.AddMinutes( heartsToAdd * timeToRegainHeartInMinutes ); }The key is the last line:
lastHeartRefillTimeis advanced byheartsToAdd * interval, not set toDateTime.UtcNow. This preserves sub-interval remainder time — if the player was owed 1.7 hearts, the 0.7 partial interval carries forward into the next calculation rather than being discarded. The result is mathematically exact regeneration regardless of call frequency.
Refill Timer: When It Starts
The refill timer only starts when the heart count drops from the maximum:
public void ReduceCurrencyAmount(CoinType type, int amount) { coins[type] -= amount; if (type == CoinType.HEART) { if (coins[CoinType.HEART] == maxHeartAmount - 1) { // Start refill timer ONLY when dropping from max lastHeartRefillTime = DateTime.UtcNow; } RecalculateHearts(); if (coins[CoinType.HEART] == 0) FirebaseManager.Instance.LogEvent("no_hearts_left"); } OnCoinChanged?.Invoke(type, coins[type], previousCoins[type]); LevelPreviousCoinAmount(type); }If the player already has fewer than the maximum, the existing
lastHeartRefillTimeis preserved and continues to accumulate — losing a second heart doesn’t reset the timer for the first.
Live Countdown: OnHeartTimerTick
A coroutine fires every real second and emits both timers as
TimeSpanvalues. UI panels subscribe to format and display them without any polling:IEnumerator TimerTickRoutine() { var wait = new WaitForSecondsRealtime(1f); TimeSpan remainingHeartTime = TimeUntilNextHeart(); OnHeartTimerTick?.Invoke(remainingHeartTime); TimeSpan remainingCoinVideoTime = TimeUntilNextCoinVideo(); OnCoinTimerTick?.Invoke(remainingCoinVideoTime); yield return wait; } public TimeSpan TimeUntilNextHeart() { if (coins[CoinType.HEART] >= maxHeartAmount) return TimeSpan.Zero; DateTime nextHeartTime = lastHeartRefillTime.AddMinutes(timeToRegainHeartInMinutes); return nextHeartTime > DateTime.UtcNow ? nextHeartTime - DateTime.UtcNow : TimeSpan.Zero; }The coroutine is restarted each
Updateframe so it’s always in sync with the focused-app check — the timer only ticks whileApplication.isFocusedis true.
Rewarded Video Safety Window
Hearts and coins can be granted by rewarded video ads, but rapid re-claims must be prevented. Both
RewardHeartsandRewardCoinscheck a sharedrewardedVideoSafeTimecountdown:public void RewardHearts(int amount) { if (rewardedVideoSafeTime > 0) return; rewardedVideoSafeTime = 20.0f; coins[CoinType.HEART] += amount; RecalculateHearts(); OnCoinChanged?.Invoke(CoinType.HEART, coins[CoinType.HEART], previousCoins[CoinType.HEART]); LevelPreviousCoinAmount(CoinType.HEART); SavingManager.Instance.SavePlayer(); }rewardedVideoSafeTimeis a float decremented everyUpdatetick. The 20-second window gives enough time for any pending ad callbacks to fire without triggering a double-grant if the user taps the reward button twice in quick succession. Coins have an additionallastVideoRewardTimepersisted to disk, enforcing a 4-hour cooldown across sessions:public TimeSpan TimeUntilNextCoinVideo() { DateTime nextPossibleVideo = lastVideoRewardTime.AddHours(hoursBetweenRewardedCoins); return nextPossibleVideo > DateTime.UtcNow ? nextPossibleVideo - DateTime.UtcNow : TimeSpan.Zero; }
LifeManager: In-Game Heart Representation
CoinManagertracks the full persistent heart count (up to the configured maximum).LifeManagerwraps it with an in-game cap of 3, so the player can have 15 hearts stored but only 3 on any given level:public void ResetLife() { currentLife = Mathf.Clamp( CoinManager.Instance.GetCoinAmount(CoinType.HEART), 0, 3); }ResetLifeis called at the start of each level and at the end panel before returning to the menu, socurrentLifealways reflects the current heart balance up to the in-game maximum. Losing a life reduces bothcurrentLifeand the persistentCoinType.HEARTamount simultaneously:public void RemoveLife() { if (currentLife > 0) { currentLife--; CoinManager.Instance.ReduceCurrencyAmount(CoinType.HEART, 1); } LevelManager.Instance.IncreaseLivesLostToThisLevel(); OnLifeRemoved?.Invoke(); }OnLifeRemovedis whatPlayerMovementsubscribes to in order to increase its ground-detection sphere radius after each life lost — a mechanical accessibility assist that comes naturally from the existing event without any additional coupling.
Created on February 2026 -
MarbleMaze: Deep Dive — Power-Up System, Player & DOTween Choreography
The player marble and its two power-ups — Rocket and UFO — share the same space but are fundamentally different objects. Swapping between them cleanly, without physics glitches or camera pops, required building a choreographed transition system on top of DOTween and a tightly coupled visual FSM on the player side. This post covers both, plus the movement, input, and camera systems that make the marble feel physical.
Power-Up Architecture
The core problem: when the player activates a power-up, several things must happen in a specific order with no overlap. Physics must be frozen before the swap; the camera must follow the new object before the old one disappears; the grow animation must not start until the shrink is complete.
A
Sequencefrom DOTween is the right tool — it guarantees order, allows callbacks between tween steps, and can be killed cleanly if activation is interrupted.PowerUpData and State
Each power-up is described by a
PowerUpDatastruct carrying its scene reference, duration, height offset, and cached original scale. Both are registered in aDictionary<CoinType, PowerUpData>atAwake:// PowerUpManager.cs [Serializable] public class PowerUpData { public CoinType type; public GameObject objectRef; public float duration; public float heightOffset; public int buyingValue; [HideInInspector] public Vector3 originalScale; } private void Awake() { powerUps = new Dictionary<CoinType, PowerUpData> { { rocket.type, rocket }, { ufo.type, ufo } }; foreach (var pu in powerUps.Values) { pu.originalScale = pu.objectRef.transform.localScale; // cache before hiding pu.objectRef.SetActive(false); } }Caching
originalScaleatAwake— before the object is hidden — ensures the grow animation always targets the correct designer-set scale regardless of later modifications.A three-value
PowerUpStateenum drives the manager’s public contract:public enum PowerUpState { Using, Clear, Blocked } private void SetPowerUpState(PowerUpState state) { powerUpState = state; OnPowerUpStateChanged?.Invoke(state); // UI, buttons, and EndTrigger subscribe to this }UsePowerUpguards against double-activation and dying-while-using:public void UsePowerUp(CoinType type) { if (powerUpState == PowerUpState.Using || playerState != PlayerState.Alive) return; // … ActivatePowerUp(pu); SetPowerUpState(PowerUpState.Using); }
Activation — The DOTween Sequence
ActivatePowerUpkills any in-flight tweens from a previous call before building a newSequence:private void ActivatePowerUp(PowerUpData pu) { powerUpScaleTween?.Kill(); sequence?.Kill(); sequence = DOTween.Sequence().SetTarget(this); // tagged for targeted Kill on disable Rigidbody puRb = pu.objectRef.GetComponent<Rigidbody>(); // 1. Position power-up at player's XZ, at its design height Vector3 pos = player.transform.position; pos.y = pu.heightOffset; pu.objectRef.transform.position = pos; pu.objectRef.transform.localScale = HiddenScale; // start invisible // 2. Camera switches before the player disappears PlayerCamera.SetCameraFollow(pu.objectRef); // 3. Freeze player physics during the transition playerRigidbody.isKinematic = true; // 4. Tell the player to shrink; reveal the power-up object simultaneously sequence.AppendCallback(() => { playerVisualEffects.ShouldShrink(); pu.objectRef.SetActive(true); }); // 5. Power-up grows with squash-and-stretch powerUpScaleTween = pu.objectRef.transform .DOScale(pu.originalScale * squashStretch, scaleDuration) // overshoot .SetEase(easeOut) // EaseOutBack .OnComplete(() => pu.objectRef.transform.DOScale(pu.originalScale, 0.1f) // settle ); sequence.Append(powerUpScaleTween); // 6. Unfreeze power-up physics — it can now roll/fly sequence.AppendCallback(() => puRb.isKinematic = false); }The camera transfer at step 2 — before the player shrinks — is intentional. If the camera were switched after the shrink, there would be a frame where it follows nothing. Switching first means the camera smoothly transitions to the new target while the player is still visible.
easeOut = Ease.OutBackand the 15% overshoot (* squashStretch) give the power-up a confident, elastic arrival. The quick 0.1s settle inOnCompleteavoids the tween chain being a nestedSequence— it’s a one-shot inline correction.
Deactivation — Reversing the Sequence
Deactivation mirrors activation but with inverted easing —
Ease.InBackfor the shrink feels like the power-up is being sucked away:private void DeactivatePowerUp() { powerUpScaleTween?.Kill(); sequence?.Kill(); sequence = DOTween.Sequence().SetTarget(this); var pu = powerUps[currentPowerType]; Rigidbody puRb = pu.objectRef.GetComponent<Rigidbody>(); // 1. Freeze power-up physics puRb.isKinematic = true; // 2. Teleport player to power-up's current world position; hide it player.transform.position = pu.objectRef.transform.position; player.transform.localScale = HiddenScale; // 3. Camera returns to player before the power-up disappears PlayerCamera.SetCameraFollow(player); // 4. Shrink the power-up to zero powerUpScaleTween = pu.objectRef.transform .DOScale(HiddenScale, scaleDuration) .SetEase(easeIn) // EaseInBack .OnComplete(() => pu.objectRef.SetActive(false)); sequence.Append(powerUpScaleTween); // 5. Re-enable player: show it, grow it, unfreeze physics, reset rotation sequence.AppendCallback(() => { player.SetActive(true); playerVisualEffects.ShouldGrow(); playerRigidbody.isKinematic = false; pu.objectRef.transform.rotation = Quaternion.identity; // reset any tilt }); }Teleporting the player to the power-up’s position at step 2 means the marble reappears exactly where the Rocket or UFO was — no spatial discontinuity for the player.
DOTween Cleanup
All tweens use
SetTarget(this)so they are associated with the manager:private void OnDisable() { DOTween.Kill(this); // kills all tweens targeting this component powerUpScaleTween?.Kill(); // explicit safety for the detached scale tween }DOTween.Kill(this)is a targeted kill — it only affects tweens whoseSetTargetmatchesthis. Without it, a scene reload while a tween is mid-flight would leave orphaned tweens referencing destroyed objects.
Power-up DOTween sequence — both activation (left) and deactivation (right) laid out as numbered step stacks, with the camera-transfer-first rule annotated at the bottom. The
Ease.OutBacksquash-and-stretch and the invertedEase.InBackdeactivation are both called out. TheSetTarget(this)cleanup note sits in the footer.
PlayerVisualEffects — Reusable Bidirectional Tweens
The shrink and grow animations on the marble are not separate tweens — they are the same tween played in opposite directions.
In
OnEnable, a singlescaleTweenis created, paused immediately, and configured with callbacks for both ends:// PlayerVisualEffects.cs scaleTween = transform .DOScale(originalScale, shrinkDuration) .SetEase(Ease.InOutQuad) .SetAutoKill(false) // keep alive after completion so it can be replayed .Pause() .SetLink(gameObject) // auto-kill if the GameObject is destroyed .OnRewind(OnShrinkComplete) // fires when played backwards and reaches t=0 .OnComplete(OnGrowComplete); // fires when played forwards and reaches t=1A matching
trailScaleTweendrives the trail renderer’swidthMultiplierin sync:trailScaleTween = DOTween.To( () => trailRenderers[0].widthMultiplier, value => { foreach (var tr in trailRenderers) tr.widthMultiplier = value; }, originalTrailWidth, shrinkDuration) .SetEase(Ease.InOutQuad) .SetAutoKill(false) .Pause() .SetLink(gameObject);Both are driven by a five-state FSM that responds to external
Should*flags set byPowerUpManagerandDeadZone:private enum AnimationState { Idle, Shrink, Shrunk, Grow, Blink } private void EvaluateAnimationState() { switch (state) { case AnimationState.Idle: if (shouldShrink) EnterShrink(); if (shouldBlink) EnterBlink(); break; case AnimationState.Shrunk: if (shouldGrow) EnterGrow(); break; // Shrink, Grow, Blink → waiting for tween callback, no polling needed } } private void EnterShrink() { state = AnimationState.Shrink; scaleTween.PlayBackwards(); trailScaleTween.PlayBackwards(); } private void EnterGrow() { state = AnimationState.Grow; scaleTween.PlayForward(); trailScaleTween.PlayForward(); }OnRewindandOnCompleteadvance the FSM when each direction finishes — no polling, noWaitForSeconds, no frame delay:private void OnShrinkComplete() { state = AnimationState.Shrunk; shouldShrink = false; } private void OnGrowComplete() { state = AnimationState.Idle; shouldGrow = false; }The Blink Sequence
The blink effect (played on spike death) is a nested DOTween structure — an inner loop sequence wrapped by an outer delay sequence:
private void EnterBlink() { state = AnimationState.Blink; EnableTrail(false); // Inner loop: toggle renderers off/on N times Sequence blinkLoop = DOTween.Sequence() .AppendCallback(() => SetRenderers(false)) .AppendInterval(blinkDuration) .AppendCallback(() => SetRenderers(true)) .AppendInterval(blinkDuration) .SetLoops(blinkCount * 2); // blinkCount full on/off cycles // Outer: pre-delay so the spike impulse plays out first, then blink blinkTween = DOTween.Sequence() .AppendInterval(blinkDelay) .Append(blinkLoop) .SetLink(gameObject) .OnComplete(() => OnBlinkCompleted()); }SetLoops(blinkCount * 2)rather thanblinkCountbecause each full visible/invisible cycle is two half-cycles in the inner sequence.
Visual effects FSM — the single bidirectional
scaleTweenat the top, then the five-state loop (Idle → Shrink → Shrunk → Grow → back to Idle, plus the Blink branch from Idle). Arrows are labelled with the trigger flags (shouldShrink,shouldGrow) and the tween callbacks (OnRewind,OnComplete). The nested blink sequence structure is explained at the bottom.
PlayerMovement — Physics & Ground Detection
Spherecast Ground Check
A
SphereCastrather than aRaycastis used for ground detection — a thin ray misses edges the marble is clearly resting on, while a sphere of radius 0.75 catches them:// PlayerMovement.cs — CheckGrounded() Ray ray = new Ray(transform.position, Vector3.down); isGrounded = Physics.SphereCast(ray, groundDetectionRadius, out hit, groundCheckDistance, groundedLayerMask);lastSafePlatformis updated every grounded frame, excluding hazard tiles and the end trigger — so the marble always respawns on solid, safe ground:if (!hit.collider.CompareTag("Hazard") && !hit.collider.CompareTag("MovingPlatform") && !hit.collider.CompareTag("End")) lastSafePlatform = hit.collider.transform;Adaptive Ground Radius
After two lives are lost on the same level, the spherecast radius expands from 0.75 to 0.95 — subtly making the marble easier to land without any visible change:
private void SetGroundRadiusHelp(int numberOfLivesLost) { if (numberOfLivesLost >= 2) groundDetectionRadius = 0.95f; }This subscribes directly to
LevelManager.OnLifeLostToThisLevel, so it fires automatically with no polling.Rolling with Torque
Movement applies torque perpendicular to the input direction — physically correct for a rolling sphere and better feeling than direct force:
private void Roll() { Vector3 torque = Vector3.Cross(Vector3.up, movementInput.normalized); playerRigidbody.AddTorque(torque * torqueStrength, ForceMode.Acceleration); }ForceMode.Accelerationapplies the torque ignoring mass — the marble accelerates at the same rate regardless of scale changes during power-up transitions.Jump with Directional Help
The jump force includes a fraction of the current movement input to give the player directional control in the air. The fraction differs by surface type:
private void Jump() { jumpHelpValue = currentPlatform.CompareTag("Ice") ? iceHelp : groundHelp; // iceHelp = 0.35f (slippery → bigger directional influence) // groundHelp = 0.2f (normal → smaller) Vector3 directionHelper = movementInput * jumpHelpValue; playerRigidbody.AddForce((Vector3.up + directionHelper).normalized * jumpForce, ForceMode.Impulse); }Extra gravity is applied every
FixedUpdatewhen airborne — making falls feel snappy rather than floaty:private void ApplyExtraGravity() { if (!isGrounded) playerRigidbody.AddForce(Physics.gravity * gravityScale, ForceMode.Acceleration); }Moving Platform Counter-Torque
When the marble is standing still on a moving platform, angular velocity is damped to prevent it from spinning in place as the platform slides beneath it:
if (!allowRotation && currentPlatform.CompareTag("MovingPlatform")) playerRigidbody.angularVelocity = Vector3.Lerp(playerRigidbody.angularVelocity, Vector3.zero, 0.4f);
Movement & ground detection — left panel covers the spherecast (with a small visual showing the sphere catching a ledge a thin ray would miss) and the adaptive radius expansion after 2 lives lost. Right panel shows the torque cross-product formula, the
groundHelp/iceHelpdirectional jump blend, and extra gravity + platform angular damping.
PlayerController — Touch Input & Gesture Recognition
PlayerControlleruses Unity’sEnhancedTouchAPI, which provides per-finger tracking withFingerobjects andTouchPhasestates.Joystick Finger Tracking
The first finger down becomes the “joystick finger” — subsequent fingers trigger jumps immediately:
private void OnFingerDown(Finger finger) { if (!InputGate.Allowed.HasFlag(AllowedInput.Touch)) return; if (IsPointerOverUI(finger)) return; if (joystickFinger == null) { joystickFinger = finger; joystickStartPos = finger.screenPosition; joystickStartTime = Time.timeAsDouble; joystickFingerDragged = false; return; } PerformJump(); // second finger = jump }Tap vs Swipe Disambiguation
On finger up, the system decides whether the gesture was a tap (trigger jump) or a drag (already sent movement) by checking three conditions simultaneously:
private void OnFingerUp(Finger finger) { float duration = (float)(Time.timeAsDouble - joystickStartTime); float distance = Vector2.Distance(joystickStartPos, finger.screenPosition); bool isTapJump = InputGate.Allowed.HasFlag(AllowedInput.Tap) && !joystickFingerDragged && // never moved past the deadzone duration <= tapMaxDuration && // < 0.25s distance <= tapMaxDistance; // < 35px if (isTapJump) PerformJump(); ResetJoystick(); }The
joystickFingerDraggedflag is set inOnFingerMoveonly when the cumulative drag exceedsjoystickDeadZone(20px) — so brief accidental movements don’t suppress the jump on release.8-Cardinal Direction Snapping
Raw joystick input is snapped to the nearest of eight cardinal directions before being sent to
PlayerMovement. This makes diagonal movement feel intentional and consistent regardless of exact finger angle:// DirectionMapper.cs public static Vector2 MapTo8CardinalPoints(Vector2 analogInput, float deadzone = 0.1f) { float angle = Mathf.Atan2(analogInput.normalized.y, analogInput.normalized.x) * Mathf.Rad2Deg; if (angle < 0) angle += 360f; float snappedAngle = Mathf.Round(angle / 45f) * 45f % 360f; Vector2 snappedDirection = new Vector2( Mathf.Cos(snappedAngle * Mathf.Deg2Rad), Mathf.Sin(snappedAngle * Mathf.Deg2Rad) ); return snappedDirection * analogInput.magnitude; // preserve analog magnitude }Preserving the original
magnitudeis important — a light drag produces gentle rolling, a full drag produces maximum torque. Only the direction is snapped, not the intensity.Movement Gate Clamping
ClampMovementappliesMovementGateflags by zeroing the relevant axes — no conditional branches inPlayerMovement, just clean input before it reaches the physics system:private Vector2 ClampMovement(Vector2 input) { if (!MovementGate.Allowed.HasFlag(AllowedMovement.Forward)) result.y = Mathf.Min(result.y, 0f); if (!MovementGate.Allowed.HasFlag(AllowedMovement.Backward)) result.y = Mathf.Max(result.y, 0f); if (!MovementGate.Allowed.HasFlag(AllowedMovement.Right)) result.x = Mathf.Min(result.x, 0f); if (!MovementGate.Allowed.HasFlag(AllowedMovement.Left)) result.x = Mathf.Max(result.x, 0f); return result; }
Touch input pipeline — three stages left to right:
EnhancedTouchevents (finger tracking, tap vs drag decision with the three-condition gate), the 8-cardinal direction snapper (with a compass rose visual showing the eight snapped directions), andMovementGateaxis clamping (zeroing individual axes without any conditional logic in the physics layer). TheInputGateUI check sits below as a prerequisite guard.
PlayerCamera — Follow, Shake & Grid Limits
The camera uses Cinemachine with a custom extension for grid-aware X clamping.
SetCameraFollowis a static method — any system can redirect the camera without a direct reference to thePlayerCameracomponent:public static void SetCameraFollow(GameObject objectToFollow) { cinemachineCam.Follow = objectToFollow != null ? objectToFollow.transform : null; }Camera shake drives Cinemachine’s
CinemachineBasicMultiChannelPerlinamplitude directly — a timer counts down inUpdateand sets amplitude to zero when it expires:// Jump shake: short, moderate amplitude public static void Shake(string _) { currentShakeTimer = shakeTimer; shakeAmplitude = 0.75f; } // Custom shake (e.g. end sequence rumble): duration + amplitude public static void Shake(float duration, float amplitude) { currentShakeTimer = duration; shakeAmplitude = amplitude; }X-axis camera limits are computed from the grid width at
Start, scaled by configurable percentage offsets — so the camera can only pan within the maze bounds:cameraLimitsX.x = (width - 1) * cellSize * cameraGridLimitsPercentage.x; cameraLimitsX.y = (width - 1) * cellSize * cameraGridLimitsPercentage.y; camClamp.SetXLimits(cameraLimitsX);
Summary
Component Key technique PowerUpManager.ActivatePowerUpDOTween Sequence: callback → squash scale → settle → callbackPowerUpManager.DeactivatePowerUpReverse sequence: kinematic freeze → teleport → shrink → callback DOTween.Kill(this)Targeted kill via SetTarget(this)— no orphaned tweens on scene reloadPlayerVisualEffectsscaleTweenSetAutoKill(false)+PlayForward/PlayBackwards— one tween, two directionsPlayerVisualEffectsblinkTweenNested Sequence: innerSetLoopsblink + outer delay wrapperPlayerMovement.CheckGroundedSphereCastradius 0.75 — catches edges a raycast missesAdaptive ground radius groundDetectionRadiusexpands to 0.95 after 2 lives lostRolling torque Vector3.Cross(up, input)+ForceMode.Acceleration— mass-independentJump directional help groundHelp/iceHelpfractions of movement input blended into jump forceDirectionMapperSnap angle to nearest 45° while preserving analog magnitude Tap/swipe disambiguation Three-condition gate: !joystickFingerDragged && duration <= 0.25s && distance <= 35pxClampMovementMovementGateflags zeroing axes — clean input before physicsPlayerCamera.SetCameraFollowStatic — any system can redirect without a component reference Created on February 2026 -
MarbleMaze: Save System & Data Persistence
Every mobile game needs save data that survives restarts, device swaps, and account changes. MarbleMaze has six independent data structures (game progress, player currencies, shop state, settings, tutorial flags, achievements) persisted through a Strategy pattern —
IDataService/JsonDataService— so the storage backend can be swapped without touching a single call site. On top of that, a Unity Cloud Save layer handles cross-device sync using a versioned payload and aSemaphoreSlim-guarded conflict resolution pipeline.
The Strategy Interface: IDataService
The entire save stack rests on a three-method interface:
public interface IDataService { bool Save<T>(T data, string fileName, bool overwrite = false); T Load<T>(string fileName); void Delete(string fileName); void ClearAllData(); }SavingManagerholds a singleIDataServicefield and constructs the implementation inAwake:private IDataService dataService; private void Awake() { // ... dataService = new JsonDataService(); }Nothing downstream ever references
JsonDataServicedirectly. Swapping to a binary, encrypted, or cloud-direct backend is a one-line change here.
JSON Persistence: JsonDataService
JsonDataServiceuses Newtonsoft.Json (not Unity’s built-inJsonUtility) for full support ofDictionary,DateTime, and polymorphic types. Path resolution is platform-aware via conditional compilation:private string GetFolderPath(string folderName) { #if UNITY_EDITOR return Path.Combine(Application.dataPath, folderName); #else return Path.Combine(Application.persistentDataPath, folderName); #endif }In the editor, files land inside
Assets/Data/(visible in the Project window for inspection). On device, they go toApplication.persistentDataPath— the OS-protected sandbox path that survives app updates.The save path includes automatic folder creation:
public bool Save<T>(T data, string fileName, bool overwrite) { string path = GetFilePath(fileName); if (!Directory.Exists(GetFolderPath("Data"))) Directory.CreateDirectory(GetFolderPath("Data")); try { if (File.Exists(path) && !overwrite) return false; string json = JsonConvert.SerializeObject(data, Formatting.Indented); File.WriteAllText(path, json); return true; } catch (System.Exception e) { Debug.LogError($"Could not save data to {path}: {e.Message}"); return false; } }
Six Data Structures
SavingManagerholds one typed field per data category:Field Type Contents currentGameDataGameDataLevel dictionary, difficulty debt, global modifier currentPlayerDataPlayerDataAll 5 currency amounts, DateTimetimers, skin/color indexcurrentSkinShopDataSkinShopDataDictionary<string, bool>per-item lock state by stable string IDcurrentSettingsDataSettingsDataAudio on/off, music on/off, vibration on/off currentTutorialDataTutorialDataCompletion flags for 4 tutorials, gift claim flags currentAchievementDataAchievementData6 integer counters (levels, perfect, marbles, premium, rockets, UFOs) Each has a corresponding
SaveXDataInFileandRestoreXDataFromFilemethod. Granular saves (SaveGame,SavePlayer,SaveShop) allow only the changed category to be written to disk rather than flushing all six files on every action.
Generic Read API: SavingManager
Any manager that needs to read saved state calls a single generic method:
public T Get<T>() where T : SaveableData { if (typeof(T) == typeof(GameData)) return currentGameData as T; if (typeof(T) == typeof(PlayerData)) return currentPlayerData as T; if (typeof(T) == typeof(SkinShopData)) return currentSkinShopData as T; if (typeof(T) == typeof(TutorialData)) return currentTutorialData as T; if (typeof(T) == typeof(AchievementData)) return currentAchievementData as T; return null; }Call sites are one line with no casting required:
PlayerData p = SavingManager.Instance.Get<PlayerData>();
Bootstrap: LoadSession and SaveSession
On startup,
CoreManagercallsSavingManager.Instance.LoadSession(), which restores all six categories in sequence and pushes their values into the relevant managers:public void LoadSession() { RestoreGameDataFromFile(GameDataFileName); RestorePlayerDataFromFile(PlayerDataFileName); RestoreSkinShopDataFromFile(SkinDataFileName); RestoreSettingsDataFromFile(SettingsDataFileName); RestoreTutorialDataFromFile(TutorialDataFileName); RestoreAchievementDataFromFile(AchievementDataFileName); }Each
Restoremethod creates a default object when the file is missing — so first-time installs work without any special-case code. Missing files setisDataPresent = false, whichCloudSaveManagerreads to decide whether to force a cloud load on the next authentication.SaveSessionis the symmetric full flush, used when writing to the cloud:public void SaveSession() { SaveGameDataInFile(GameDataFileName); SavePlayerDataInFile(PlayerDataFileName); SaveCustomizationShopDataInFile(SkinDataFileName); SaveSettingsDataInFile(SettingsDataFileName); SaveTutorialDataInFile(TutorialDataFileName); SaveAchievementDataInFile(AchievementDataFileName); if (CloudSaveManager.Instance != null) CloudSaveManager.Instance.BuildPayload(); }At the end of
SaveSession,BuildPayload()bundles all six structures into a singleCloudSavePayloadstamped with an incrementing version number.
Cloud Save: Versioned Payload and Conflict Resolution
The cloud key is a single JSON document containing all five persisted structures plus a monotonically increasing
versionfield and the device identifier:[Serializable] public class CloudSavePayload : SaveableData { public string deviceId; public long version; public PlayerData player; public SkinShopData skinShop; public GameData game; public TutorialData tutorial; public AchievementData achievement; }Conflict resolution in
SaveWithConflictResolutionAsyncuses aSemaphoreSlim(1,1)to prevent concurrent uploads from racing each other:private readonly SemaphoreSlim saveSemaphore = new(1, 1); private async Task SaveWithConflictResolutionAsync() { await saveSemaphore.WaitAsync(); try { CloudSavePayload localPayload = currentPayload; CloudSavePayload cloudPayload = await LoadFromCloudAsync(); if (cloudPayload != null) { if (cloudPayload.version > localPayload.version) { // Cloud is ahead — apply it and abort the upload ApplyCloudPayload(cloudPayload); lastKnownCloudVersion = cloudPayload.version; isDirty = false; return; } if (localPayload.version == cloudPayload.version) return; // Nothing to do } await SaveToCloudAsync(localPayload); lastKnownCloudVersion = localPayload.version; OnCloudSaveCompleted?.Invoke(); } catch (Exception e) { OnCloudOperationFailed?.Invoke(e); } finally { saveSemaphore.Release(); } }The resolution rule is simple: higher version wins. If the cloud version is ahead (another device played since the last sync), the cloud payload is applied locally and the upload is skipped. If versions match, the data is already in sync so no upload is needed. Only when the local version is strictly newer does the upload proceed.
Automatic Sync Triggers
Cloud save runs automatically on two triggers:
Time-based:
CloudSaveManager.Updateaccumulates real play time usingTime.deltaTime. When total accumulated time (stored inPlayerPrefsacross sessions) exceedssaveIntervalHours,TrySaveAllToCloudfires and resets the counter.Event-based (dirty flag):
LevelManagercallsCloudSaveManager.Instance.MarkDirty()everylevelsPerCycle * 2level completions:if (index % (levelsPerCycle * 2) == 0) CloudSaveManager.Instance.MarkDirty();The
Updateloop checksisDirtyon every frame — when set, a cloud save runs on the next tick regardless of the time accumulator. This ensures milestone progress is never lost even in short sessions.
Account Change Detection
When the user authenticates,
CloudSaveManagercompares the current UnityPlayerIdagainst the last known ID stored inPlayerPrefs. A mismatch means a different account logged in on the same device:string currentPlayerId = AuthenticationService.Instance.PlayerId; string lastPlayerId = PlayerPrefs.GetString(LastPlayerIdKey, ""); bool isNewPlayer = currentPlayerId != lastPlayerId; if (isNewPlayer) { SavingManager.Instance.DeleteAllData(); lastKnownCloudVersion = -1; await ForceCloudLoad(); OnCloudLoadCompleted?.Invoke(); }Local data is wiped and replaced with the account’s cloud state.
OnCloudLoadCompletedtriggersSavingManager.LoadSession(), which pushes the fresh data into all managers before the first scene loads.
Created on February 2026 -
MarbleMaze: Deep Dive — Timed Hazard Systems & Environment
Every tile hazard in MarbleMaze has two jobs: look good and not be frustrating. Looking good means smooth, readable motion. Not being frustrating means the player can always tell when it’s safe to cross. This post covers the shared interface that makes both properties consistent across every hazard type, the AnimationCurve-driven state machines behind each one, and everything else the environment does to the marble.
The ITimedHazard Interface
All three active hazard types — flapping doors, moving platforms, and spikes — implement the same interface:
// ITimedHazard.cs public interface ITimedHazard { void SetState(bool isInverted); // initialise to a phase offset float CycleDuration { get; } // full open+close cycle in seconds bool IsSafe(); // is it safe to cross right now? }Three methods, three distinct purposes:
SetStateis called once at spawn time byPhysicalMazeGenerator. TheisInvertedflag offsets the hazard to the midpoint of its cycle — used to prevent every hazard on a level starting in the same phase simultaneously.CycleDurationexposes the total loop time so external systems (audio cues, AI) can reason about timing without inspecting internals.IsSafelets any code — the player, a power-up, an AI navigator — ask whether the hazard is currently passable, again without knowing its internal state.Checkerboard Phase Assignment
The phase offset is assigned by
PhysicalMazeGeneratorusing a bitwise parity check on the cell’s grid coordinates:// PhysicalMazeGenerator.cs — SpawnGround() if (ground.TryGetComponent<ITimedHazard>(out var hazard)) { bool isInverted = ((x + y) & 1) == 1; hazard.SetState(isInverted); }(x + y) & 1is the parity of the coordinate sum —0for even,1for odd. On a grid, adjacent cells always have opposite parities, so every neighbouring hazard starts in the opposite phase. The result: hazards naturally alternate without any explicit coordination between instances.
ITimedHazard + checkerboard — the interface sits at the top with its three methods, branching down to all three implementors (doors, platforms, spikes) with their key characteristics. Below, the checkerboard grid renders visually as alternating coral (Phase A) and blue (Phase B) cells, making the
(x+y)&1parity trick immediately obvious — adjacent cells are always the opposite colour.
Flapping Doors
The flapping door is the most complex hazard. It has four distinct states and two independent moving parts — a left and right door panel that mirror each other’s rotation.
The State Machine
// FlappingDoors.cs public enum DoorState { Opening, PausingOpen, Closing, PausingClosed } private DoorState state = DoorState.Opening; private float stateTimer;UpdateincrementsstateTimerevery frame and dispatches to state-specific logic:private void Update() { stateTimer += Time.deltaTime; switch (state) { case DoorState.Opening: if (!isOpening) { OnDoorOpening?.Invoke(); isOpening = true; } Animate(openCurve, openDuration, DoorState.PausingOpen); break; case DoorState.Closing: isOpening = false; Animate(closeCurve, closeDuration, DoorState.PausingClosed); break; case DoorState.PausingOpen: if (stateTimer >= openPauseDuration) NextState(); break; case DoorState.PausingClosed: if (stateTimer >= closePauseDuration) { OnPauseEnded?.Invoke(); NextState(); } break; } }NextStateuses a switch expression to advance only the two pause states — the animated states (Opening,Closing) advance themselves insideAnimatewhent >= 1:private void NextState() { stateTimer = 0f; state = state switch { DoorState.PausingOpen => DoorState.Closing, DoorState.PausingClosed => DoorState.Opening, _ => state }; }AnimationCurve-Driven Rotation
The actual motion is evaluated from an
AnimationCurveasset — not a hardcoded lerp. This means the designer can shape the easing entirely in the inspector: slow-in fast-out, bounce, anticipation — any curve Unity’s editor can express:private void Animate(AnimationCurve curve, float duration, DoorState next) { float t = Mathf.Clamp01(stateTimer / duration); float value = curve.Evaluate(t); leftDoor.localRotation = leftClosedRotation * Quaternion.Euler(0f, 0f, value * flapAngle); rightDoor.localRotation = rightClosedRotation * Quaternion.Euler(0f, 0f, -value * flapAngle); if (t >= 1f) { state = next; stateTimer = 0f; } }Both doors rotate symmetrically from their closed positions — left door by
+flapAngle, right door by-flapAngle. Pre-cachingleftClosedRotationandrightClosedRotationinStartmeans the rotation is always expressed relative to the designer-placed starting orientation, not accumulated over frames.IsSafereturnstrueonly when the doors are fully closed and stationary:public bool IsSafe() => state == DoorState.PausingClosed; public float CycleDuration => openDuration + openPauseDuration + closeDuration + closePauseDuration;Phase Initialisation
SetStateplaces the door at the midpoint of its cycle when inverted:public void SetState(bool isInverted) { if (isInverted) { state = DoorState.Closing; stateTimer = CycleDuration / 2; } else { state = DoorState.Opening; } }Starting at
CycleDuration / 2with the timer already advanced means the door effectively begins halfway through its second half — interleaving cleanly with its non-inverted neighbours.DoorAngularPush — Physics Interaction
When the player marble is caught between a closing door, a separate
DoorAngularPushcomponent applies a radial impulse force scaled by proximity to the door’s hinge:// DoorAngularPush.cs — OnCollisionStay if (doorsAnimation.State == FlapingDoorsAnimation.DoorState.Opening && !hasCollided && !otherDoor.hasCollided) { float distanceFromCenter = Mathf.Abs(playerPos.x - parentPos.x); float normalized = 1f - Mathf.Clamp01(distanceFromCenter / maxEffectiveDistance); float direction = Mathf.Sign(playerPos.x - parentPos.x); Vector3 force = new Vector3( direction * pushForce * normalized, pushForce * normalized, 0f ); collision.rigidbody.AddForce(force, ForceMode.Impulse); hasCollided = true; }normalizedis the inverse of normalised distance — 1.0 at the centre, 0.0 atmaxEffectiveDistance. A marble sitting directly under the hinge gets the full push; one at the edge gets almost none. ThehasCollidedflag ensures only one impulse fires per open cycle; it resets via theOnPauseEndedevent from the animation:private void OnEnable() => doorsAnimation.OnPauseEnded += EnableCollision; private void OnDisable() => doorsAnimation.OnPauseEnded -= EnableCollision; private void EnableCollision() => hasCollided = false;
Door state machine — the four states arranged in a clockwise loop (Opening → PausingOpen → Closing → PausingClosed), with transition labels on each arrow. The centre panel explains the
AnimationCurverotation math — pre-cached closed rotations, mirrored±flapAngle, relative not accumulated.IsSafe = trueis highlighted in green only onPausingClosed. TheDoorAngularPushformula sits at the bottom.
Moving Platforms
Platforms use the same
ITimedHazardinterface and the sameAnimationCurvepattern, but applied to position rather than rotation — and running inFixedUpdateto stay in sync with the physics engine.// PlatformMovement.cs [RequireComponent(typeof(Rigidbody))] public class PlatformMovement : MonoBehaviour { [SerializeField] private AnimationCurve movementCurve; // 0–1 normalised offset [SerializeField] private float cycleDuration = 4f; [SerializeField] private float movementAmplitude = 3f; [SerializeField] private float pauseDuration = 0.25f; public event Action<bool> OnPlatformActive; // true = moving, false = pausedAwakesets the Rigidbody toInterpolatemode — essential for smooth visual motion when the physics tick rate is lower than the render rate:private void Awake() { rb = GetComponent<Rigidbody>(); rb.isKinematic = true; rb.interpolation = RigidbodyInterpolation.Interpolate; }The platform’s starting position is offset by
movementAmplitudeso the animation curve travels the full2 × amplituderange symmetrically:private void Start() { startPosition = rb.position; if (isHorizonal) startPosition.x -= movementAmplitude; else startPosition.z -= movementAmplitude; rb.position = startPosition; }Curve Evaluation and Keyframe Pause Detection
Every
FixedUpdate, the timer drives a normalisedtintomovementCurve.Evaluate. After applying the position, a pass over the curve’s keyframes checks whethertis within 1ms of any keyframe time — if so, the platform pauses:private void FixedUpdate() { if (isPaused) { pauseTimer -= Time.fixedDeltaTime; if (pauseTimer <= 0f) { isPaused = false; OnPlatformActive?.Invoke(true); } else return; } timer += Time.fixedDeltaTime; float t = (timer % cycleDuration) / cycleDuration; float normalizedOffset = Mathf.Clamp01(movementCurve.Evaluate(t)); Vector3 targetPosition = startPosition; if (isHorizonal) targetPosition.x += normalizedOffset * movementAmplitude * 2; else targetPosition.z += normalizedOffset * movementAmplitude * 2; rb.MovePosition(targetPosition); foreach (Keyframe key in movementCurve.keys) { if (Mathf.Abs(t - key.time) < 0.001f) { isPaused = true; pauseTimer = pauseDuration; OnPlatformActive?.Invoke(false); break; } } }The 0.001f threshold translates to about 1ms of cycle time — tight enough to only fire at the keyframe, loose enough to not miss it at any physics tick rate. Keyframes at
t = 0andt = 1(start and end of travel) produce the pauses the designer placed in the inspector curve.rb.MovePositionis used instead of settingtransform.positiondirectly — this keeps the Rigidbody in the physics pipeline and allows the marble to be carried correctly when standing on the platform.
Platform movement — left side traces the five-step
FixedUpdatepipeline (timer →t→ curve evaluate →MovePosition→ keyframe pause check), right side shows an annotated S-curve chart with the0.001pause-detection threshold marked att=0andt=1. The three key Rigidbody settings (isKinematic,Interpolate,MovePosition) are called out at the bottom.
Spikes (Piques)
Spikes follow the same four-state pattern as doors, applied to vertical translation instead of rotation:
// PiquesTileAnimation.cs private enum PiquesState { Rising, PausedUp, Lowering, PausedDown } private void Animate(AnimationCurve curve, float duration, PiquesState next) { float t = Mathf.Clamp01(stateTimer / duration); float value = curve.Evaluate(t); piques.localPosition = piqueOriginalPosition + Vector3.up * piquesYOffset * value; if (t >= 1f) { state = next; stateTimer = 0f; } } public bool IsSafe() => state == PiquesState.PausedDown; public void SetState(bool isInverted) { if (isInverted) { state = PiquesState.Lowering; stateTimer = CycleDuration / 2; } else { state = PiquesState.Rising; } }piqueOriginalPositionis cached atStart, so the spike’s resting position can be freely placed in the inspector without affecting the animation math. The spikes are safe only while fully retracted (PausedDown), giving the player a clear visual window — when they’re down, cross; when they start rising, don’t.
Death Triggers
DeadZone — Fall Death
DeadZonehandles the standard fall-off-the-edge scenario usingOnCollisionEnter. It checksGameState.Playingfirst — so death can’t fire during a pause or cutscene — and guards against double-processing with theIsDyingstate check:// DeadZone.cs private void OnCollisionEnter(Collision collision) { if (GameStateManager.Instance.CurrentGameState != GameState.Playing) return; if (!collision.gameObject.CompareTag("Player")) return; if (playerMovement.State == PlayerState.IsDying) return; playerMovement.SetState(PlayerState.IsDying); LifeManager.Instance.RemoveLife(); if (LifeManager.Instance.CurrentLife > 0) playerMovement.ReplacePlayer(); // respawn else { GameStateManager.Instance.SetState(GameState.WaitingForContinue); continuePannel.SetActive(true); playerMovement.ReplacePlayer(); } }The state guard (
IsDying) prevents the same death from being processed twice if the marble clips multiple colliders in the same frame — a common problem with physics-based death zones.PiquesDeadZone — Spike Death with Impulse
Spikes use a different death zone because they should feel more violent than a simple fall. Instead of instantly respawning,
PiquesDeadZoneapplies a radial impulse that launches the marble away from the spike tile, then waits before respawning:// PiquesDeadZone.cs — OnTriggerEnter playerVisualEffects.ShouldBlink(); // start the blink effect Vector3 projectionDir = (collision.transform.position - transform.position).normalized; playerRigidbody.AddForce( new Vector3( projectionDir.x * radialForce, projectionForce, // upward component projectionDir.z * radialForce ), ForceMode.Impulse ); StartCoroutine(DeathSequenceAfterDelay(deathDelay));private IEnumerator DeathSequenceAfterDelay(float delay, bool isLastLife = false) { yield return new WaitForSeconds(delay); // let physics play out yield return new WaitForFixedUpdate(); // sync with physics step if (isLastLife) { GameStateManager.Instance.SetState(GameState.WaitingForContinue); continuePannel.SetActive(true); } playerMovement.ReplacePlayer(); }The
WaitForFixedUpdateafter the delay ensures the respawn position change happens cleanly in a physics frame, not mid-update — avoiding a one-frame jitter where the marble briefly appears at its old position.
Star Trigger
Stars are the simplest trigger in the game — three lines and they do everything they need:
// StarTrigger.cs private void OnTriggerEnter(Collider other) { if (!other.CompareTag("Player") && !other.CompareTag("Ufo") && !other.CompareTag("Rocket")) return; LevelManager.Instance?.IncreaseStarCount(); VibrationManager.Instance?.MultiPop(3); // 3-pulse haptic feedback AudioManager.Instance?.PlayStarSound(); gameObject.SetActive(false); // self-deactivate }Accepting all three player tags (
Player,Ufo,Rocket) means stars can be collected regardless of which power-up is active.SetActive(false)is used instead ofDestroy— cleaner for any future pooling system, and avoids the one-frame delayDestroyhas before the object actually disappears.
End Trigger — Level Completion Sequence
The level exit is a multi-phase coroutine that takes the marble through a cinematic sequence before transitioning to the end screen. Each phase has its own duration and easing function:
// EndTrigger.cs private IEnumerator EndSequence(Transform player, Rigidbody rb) { rb.isKinematic = true; // freeze physics for the sequence // Phase timings derived from a single animationDuration value float centerTime = animationDuration * 0.10f; float levitateTime = animationDuration * 0.60f; float burstTime = animationDuration * 0.30f; // ── 1. Snap to center of the exit tile ── yield return MoveOverTime(player, player.position, centerPos, centerTime, EaseInOut); // ── 2. Levitate: align upright, spin, rise ── StartCoroutine(RotateToUpright(player, alignTime)); // concurrent StartCoroutine(Spin(player, spinTime, 6f)); // concurrent StartRumble(spinTime); // camera shake + audio yield return MoveOverTime(player, levitateStart, levitateEnd, levitateTime, EaseOutSine); StopRumble(); // ── 3. Burst upward ── PlayerCamera.SetCameraFollow(null); // detach camera rb.isKinematic = false; rb.linearVelocity = Vector3.zero; rb.AddForce(Vector3.up * burstForce, ForceMode.Impulse); PlayCometSound(); yield return Spin(player, burstTime, 10f); // ── 4. Scene transition ── LevelManager.Instance.ProcessLevelData(); SavingManager.Instance.SaveSession(); SceneController.Instance.NewTransition() .Load(SceneDatabase.Slots.Content, SceneDatabase.Scenes.EndPannel) .Perform(); }The levitate phase runs three coroutines concurrently:
RotateToUpright(Slerp to zero roll/pitch),Spin(continuous Y-axis rotation), and the mainMoveOverTime(vertical rise).yield returnwaits only on theMoveOverTime; the others run in parallel until the rise completes naturally.Custom easing functions are inline delegates rather than an external library:
private float EaseInOut(float t) => t * t * (3f - 2f * t); // smoothstep private float EaseOutSine(float t) => Mathf.Sin(t * Mathf.PI * 0.5f);MoveOverTimetakes the easing as aFunc<float, float>parameter, so each phase gets its own feel without duplicating the interpolation loop:private IEnumerator MoveOverTime( Transform t, Vector3 from, Vector3 to, float duration, Func<float, float> ease) { float elapsed = 0f; while (elapsed < duration) { t.position = Vector3.Lerp(from, to, ease(elapsed / duration)); elapsed += Time.deltaTime; yield return null; } t.position = to; }
End trigger sequence — four sequential phases with timing percentages (10% / 60% / 30%), the concurrent coroutine structure in Phase 2 clearly showing three
StartCoroutinecalls running in parallel whileyield return MoveOverTimewaits on just the rise. TheFunc<float,float>easing injection pattern and both easing functions are noted at the bottom.
Summary
Component Technique Key detail ITimedHazardInterface IsSafe()+SetState()+CycleDurationCheckerboard phasing (x + y) & 1Adjacent hazards always start in opposite phases FlapingDoorsAnimation4-state machine + AnimationCurveIndependent open/close curves; Quaternion.Eulermirrored on both doorsDoorAngularPushDistance-normalized impulse One push per cycle; reset via OnPauseEndedeventPlatformMovementAnimationCurveposition +FixedUpdate0.001f keyframe pause detection; rb.MovePositionfor physics carryPiquesTileAnimation4-state machine + AnimationCurveY-offset translation; safe only during PausedDownDeadZoneOnCollisionEnterIsDyingstate guard prevents double-killPiquesDeadZoneRadial impulse + delayed respawn WaitForFixedUpdatefor clean physics-step respawnStarTriggerOnTriggerEnterMulti-tag, SetActive(false)notDestroyEndTriggerMulti-coroutine sequence Concurrent levitate phase; Func<float,float>easing injectionThe shared
ITimedHazardinterface is the architectural win here —PhysicalMazeGeneratorcallsSetStatethrough the interface at spawn time and never touches the hazards again. The checkerboard parity trick means all that phase distribution happens in one line with no runtime state to manage.Created on February 2026 -
MarbleMaze: Deep Dive — Pluggable Tutorial Condition System
Tutorials are the most fragile part of a mobile game. Write them with hardcoded input checks and you get a mess that breaks every time the UI shifts. Wire them to specific GameObjects and scene changes become a refactor. This post covers the system I built to make every tutorial step data-driven, input-agnostic, and fully configurable in the Unity inspector — without a single special-case in the core tutorial runner.
Architecture Overview
The system has four distinct layers that compose independently:
Condition Layer ITutorialCondition — single-method interface: IsSatisfied() IContextBoundCondition — extends with BindContext() for UI-aware conditions ITargetedTutorialCondition — extends with AnchorId for inspector wiring 10+ concrete implementations — tap, swipe, drag, gamepad, area, ordered … Tutorial Data Layer TutorialStep — conditions + mask + animations + input gate + events Tutorial — named sequence of steps TutorialManager — coroutine runner; resolves conditions each frame Scene Integration Layer TutorialContext — registers UI anchor RectTransforms by string ID TutorialTrigger — 3D physics trigger → fires manager callback TutorialSignalEmitter / Bus — decoupled scene-to-manager signal channel Input Control Layer InputGate / MovementGate — flags enums; player code checks before acting TutorialOverlayMask — four-panel scrim with a live spotlight cutout
The Condition Interface Hierarchy
The entire system rests on one method:
// ITutorialCondition.cs public interface ITutorialCondition { bool IsSatisfied(); }TutorialManagerpolls this every frame in aWaitUntil— it doesn’t know or care what kind of input the condition listens to. Everything else is an implementation detail.Two extension interfaces handle conditions that need to know where on screen they apply. Instead of injecting a
RectTransforminto the constructor (which would break Unity’s serialization), binding happens through an explicitBindContextcall at step start:public interface IContextBoundCondition { void BindContext(TutorialContext context, string anchorId, Canvas canvas); } public interface ITargetedTutorialCondition : IContextBoundCondition { string AnchorId { get; } // exposes the anchor ID so the manager can call BindContext }TutorialManagerchecks for both interfaces and binds them automatically — no scene-side wiring needed:// TutorialManager.cs — RunTutorial() foreach (var condition in step.completionConditions) { if (condition is IContextBoundCondition bound && condition is ITargetedTutorialCondition targeted && currentContext != null) { bound.BindContext(currentContext, targeted.AnchorId, tutorialCanvas); } }
The Condition Implementations
Simple Conditions — No Context Needed
TapAnywhereCondition— the simplest possible condition. Any press anywhere on screen:public class TapAnywhereCondition : ITutorialCondition { public bool IsSatisfied() => Pointer.current != null && Pointer.current.press.wasPressedThisFrame; }PressInputActionCondition— binds to anyInputActionasset, so any button in any Unity Input System action map can act as a tutorial gate:public class PressInputActionCondition : ITutorialCondition { [SerializeField] private InputActionReference action; public bool IsSatisfied() => action?.action?.WasPressedThisFrame() ?? false; }TapGamepadButtonCondition— enumerates every standard gamepad button. Oneswitchon a strongly typed enum, no magic strings:public class TapGamepadButtonCondition : ITutorialCondition { public enum GamepadButton { South, North, East, West, LeftShoulder, RightShoulder, LeftTrigger, RightTrigger, Start, Select } public GamepadButton button; public bool IsSatisfied() { var gamepad = Gamepad.current; if (gamepad == null) return false; return button switch { GamepadButton.South => gamepad.buttonSouth.wasPressedThisFrame, GamepadButton.Start => gamepad.startButton.wasPressedThisFrame, // … }; } }Area Conditions — Context-Bound
TapInAreaCondition— resolves the target UI element’s world corners to screen coordinates at bind time, then checks every press against that rect:public class TapInAreaCondition : ITutorialCondition, IContextBoundCondition, ITargetedTutorialCondition { [SerializeField] private string anchorId; public string AnchorId => anchorId; public float padding = 0f; private Rect screenRect; public void BindContext(TutorialContext context, string anchorId, Canvas canvas) { var rect = context?.Get(anchorId); if (rect == null) return; Vector3[] corners = new Vector3[4]; rect.GetWorldCorners(corners); Vector2 min = RectTransformUtility.WorldToScreenPoint(null, corners[0]); Vector2 max = RectTransformUtility.WorldToScreenPoint(null, corners[2]); screenRect = Rect.MinMaxRect( min.x - padding, min.y - padding, max.x + padding, max.y + padding); } public bool IsSatisfied() { var pointer = Pointer.current; if (pointer == null || !pointer.press.wasPressedThisFrame) return false; return screenRect.Contains(pointer.position.ReadValue()); } }Binding at step start rather than every frame means the world-to-screen conversion runs once per step, not once per frame — important when Unity’s layout system might otherwise recalculate corners on every dirty.
TapAndReleaseInAreaConditionadds a tap quality gate: tracks press position and time, then validates on release that the pointer didn’t move too far and the press didn’t last too long. This filters out accidental swipes that started inside the target rect:public bool IsSatisfied() { var pointer = Pointer.current; if (pointer == null) return false; if (pointer.press.wasPressedThisFrame) { pressPosition = pointer.position.ReadValue(); pressTime = Time.time; pressedInside = screenRect.Contains(pressPosition); return false; } if (pressedInside && pointer.press.wasReleasedThisFrame) { float duration = Time.time - pressTime; float distance = Vector2.Distance(pressPosition, pointer.position.ReadValue()); return screenRect.Contains(pointer.position.ReadValue()) && duration <= maxDuration // < 0.3s && distance <= maxMoveDistance; // < 40px } return false; }Swipe Conditions
SwipeConditionvalidates direction using a dot product threshold — the swipe vector must point within ~37° of the required direction (dot > 0.8) to count:public class SwipeCondition : ITutorialCondition { public Vector2 direction; public float minDistance = 100f; private Vector2 start; private bool isSwiping = false; public bool IsSatisfied() { var pointer = Pointer.current; if (pointer == null) return false; if (pointer.press.wasPressedThisFrame) { start = pointer.position.ReadValue(); isSwiping = true; } if (isSwiping && pointer.press.isPressed) { Vector2 delta = pointer.position.ReadValue() - start; if (delta.magnitude >= minDistance && Vector2.Dot(delta.normalized, direction.normalized) > 0.8f) { isSwiping = false; return true; } } if (pointer.press.wasReleasedThisFrame) isSwiping = false; return false; } }SwipeAndReleaseConditionis a tighter variant that only resolves on finger lift, preventing mid-swipe detection for drag-style interactions.Drag Conditions
DragFromToConditionandDragFromReleaseAtConditionteach drag gestures by validating that the touch originated within a start radius, traveled a minimum distance, and arrived within an end radius. Both radii and positions are resolved from aTutorialContextanchor at bind time:public void BindContext(TutorialContext context, string anchorId, Canvas canvas) { var uiAnchor = context.anchors.FirstOrDefault(a => a.id == anchorId); if (uiAnchor.rect == null) return; // start/end are local positions on the anchor RectTransform startScreen = RectTransformUtility.WorldToScreenPoint(null, uiAnchor.rect.TransformPoint(uiAnchor.startPosition)); endScreen = RectTransformUtility.WorldToScreenPoint(null, uiAnchor.rect.TransformPoint(uiAnchor.endPosition)); }The difference between the two:
DragFromToConditionresolves while the pointer is still held (teaching joystick-style drags), whileDragFromReleaseAtConditionresolves on release (teaching flick or swipe-to-confirm gestures).OrderedCondition — Composite Sequencing
OrderedConditionwraps a list of other conditions and advances through them in order. Only when the last one is satisfied does it returntrue:public class OrderedCondition : ITutorialCondition { private int currentIndex = 0; public List<ITutorialCondition> conditions; public bool IsSatisfied() { if (currentIndex >= conditions.Count) return true; if (conditions[currentIndex].IsSatisfied()) currentIndex++; return currentIndex >= conditions.Count; } }This makes it possible to build a multi-gesture tutorial step — “tap here, then swipe left” — as a single condition entry in a step, without needing extra steps in the
Tutorialdata structure.
Condition interface hierarchy — the three-level inheritance chain (
ITutorialCondition→IContextBoundCondition→ITargetedTutorialCondition) at the top, with the 10+ implementations branching below: blue boxes for simple conditions (no context needed: tap anywhere, gamepad buttons, swipe) and coral boxes for context-bound ones (area tap, drag from/to).OrderedConditionsits below as the composite. The footer note explains the automaticBindContextcall.
SerializeReference — Polymorphism in the Inspector
Conditions are stored in
TutorialStepusing[SerializeReference]:// TutorialManager.cs — TutorialStep [SerializeReference] public List<ITutorialCondition> completionConditions = new List<ITutorialCondition>();[SerializeReference]is Unity’s mechanism for serializing interface and abstract class references by managed type — unlike[SerializeField], which only works with concrete types Unity knows the size of at compile time. Each list entry stores the full concrete type name alongside its fields, so adding a new condition class requires no changes toTutorialSteporTutorialManager.
The Custom Property Drawer
Serializing polymorphic types solves the data problem; the UX problem is that the default inspector shows a raw
managedReferenceValuefield with no way to pick a type. The custom drawer solves this with reflection-based type discovery:// TutorialConditionDrawer.cs static TutorialConditionDrawer() { conditionTypes = AppDomain.CurrentDomain.GetAssemblies() .SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty<Type>(); } }) .Where(t => typeof(ITutorialCondition).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract) .ToList(); }This runs once at editor load. Every class that implements
ITutorialConditionis automatically discovered and added to a dropdown — including any new condition type added in the future. No registration step, no attribute required:int newIndex = EditorGUI.Popup(dropdownRect, label, currentIndex, displayNames.Select(n => new GUIContent(n)).ToArray()); if (newIndex != currentIndex) { property.managedReferenceValue = newIndex == 0 ? null : Activator.CreateInstance(conditionTypes[newIndex - 1]); }Selecting a type from the dropdown instantiates it via
Activator.CreateInstanceand assigns it to themanagedReferenceValue. The drawer then draws each child field of the concrete type usingSerializedPropertyiteration — soTapInAreaCondition.anchorIdandSwipeCondition.minDistanceboth appear automatically in the inspector with their correct types and labels.
SerializeReference + property drawer — left side explains why
[SerializeReference]enables polymorphic interface lists where[SerializeField]can’t, plus the “zero changes to existing code” benefit. Right side traces the five-step drawer pipeline: static reflection scan → filter by interface → dropdown →Activator.CreateInstance→ child field iteration.
Tutorial Data Structures
A
Tutorialis a named sequence ofTutorialStepobjects:public class Tutorial { public string tutorialName; public int levelNumber; public string tutorialType; public List<TutorialStep> steps = new List<TutorialStep>(); }Each
TutorialStepcarries everything the runner needs for that beat:public class TutorialStep { public TutorialUIElement[] tutorialUIElements; // hand-prompt animations public TutorialMaskData maskData; // overlay focus target public bool autoComplete; // time-based vs condition-based public float waitTime; public ConditionLogic conditionLogic; // Any or All [SerializeReference] public List<ITutorialCondition> completionConditions; public AllowedInput allowedInput; // what inputs are gated public AllowedMovement allowedMovement; public UnityEvent onStepStart; public UnityEvent onStepComplete; }ConditionLogic.Anymeans the step completes when at least one condition is satisfied — useful for steps that can be completed by either a tap or a button press.ConditionLogic.Allrequires every condition — useful for ordered or simultaneous multi-input steps.
The Tutorial Runner Coroutine
TutorialManager.RunTutorialis a single coroutine that loops through steps. Each iteration:- Applies the overlay mask — focuses the four-panel scrim around the step’s target UI element
- Starts UI animations — plays the hand-prompt or arrow sequences defined per step
- Sets the input gate — restricts player input to what the step allows
- Fires
onStepStart— any designer-wired UnityEvent - Waits for completion — either a timer or a
WaitUntilpolling conditions - Cleans up — resets gates, stops animations, fires
onStepComplete, advances index
// TutorialManager.cs private IEnumerator RunTutorial(Tutorial tutorial) { yield return FadeTo(tutorialCanvasGroup, 1f, bgFadeInOutTime); while (currentStepIndex < tutorial.steps.Count) { var step = tutorial.steps[currentStepIndex]; // 1. Mask if (step.maskData?.enableMask == true && currentContext != null) { RectTransform target = currentContext.Get(step.maskData.focusTargetId); overlayMask.FocusOnTarget(tutorialCanvas, step.maskData.padding, target); overlayMask.gameObject.SetActive(target != null); } // 2. Animations foreach (var ui in step.tutorialUIElements) ui.elementAnimation.PlaySequence(ui.animationSequence, ui.loop); // 3. Input gate InputGate.Allowed = step.allowedInput; MovementGate.Allowed = step.allowedMovement; // 4. Step start event step.onStepStart?.Invoke(); // 5. Wait if (step.autoComplete) yield return new WaitForSeconds(step.waitTime); else { foreach (var c in step.completionConditions) // bind context-aware conditions if (c is IContextBoundCondition b && c is ITargetedTutorialCondition t) b.BindContext(currentContext, t.AnchorId, tutorialCanvas); yield return new WaitUntil(() => forceStepComplete || AreConditionsSatisfied(step)); } // 6. Cleanup InputGate.Allowed = AllowedInput.All; MovementGate.Allowed = AllowedMovement.All; foreach (var ui in step.tutorialUIElements) ui.elementAnimation.gameObject.SetActive(false); step.onStepComplete?.Invoke(); currentStepIndex++; } overlayMask.gameObject.SetActive(false); yield return FadeTo(tutorialCanvasGroup, 0f, bgFadeInOutTime); }forceStepCompleteis a flag thatTutorialTriggercan set externally to advance the step regardless of conditions — useful for physics-gated steps where the player’s marble entering a volume is the completion event.
Runner coroutine flow — the six sequential steps inside the per-step loop (mask → animate → input gate →
onStepStart→ wait → cleanup), with theautoCompletebranch clearly split, and theforceStepCompletephysics override shown as a side input into the wait step.ConditionLogic.Anyvs.Allnoted at the bottom.
InputGate and MovementGate
The gate system uses C#‘s
[Flags]enum so individual inputs can be combined bitwise:[Flags] public enum AllowedInput { None = 0, Move = 1 << 0, Jump = 1 << 1, Touch = 1 << 2, Swipe = 1 << 3, Tap = 1 << 4, All = ~0 // all bits set } public static class InputGate { public static AllowedInput Allowed = AllowedInput.All; }Player input code checks the gate before acting:
// PlayerController.cs (example check) if (!InputGate.Allowed.HasFlag(AllowedInput.Move)) return;Setting
AllowedInput.Nonelocks all input;AllowedInput.Move | AllowedInput.Jumplets the player move and jump but disables touch/swipe — useful for a step that says “use the joystick to move here” without the player being able to tap-skip the instruction.MovementGateapplies the same pattern at the physics level, restricting directional movement independently of the input layer.
TutorialOverlayMask — Four-Panel Scrim
The overlay consists of four semi-transparent panels (Top, Bottom, Left, Right) that together cover everything on screen except the target element. Instead of moving the panels — which would require recalculating anchors — only their sizes change:
// TutorialOverlayMask.cs public void FocusOnTarget(Canvas canvas, float padding, RectTransform target) { RectTransform canvasRect = canvas.transform as RectTransform; Vector3[] canvasCorners = new Vector3[4]; canvasRect.GetWorldCorners(canvasCorners); Vector3[] t = new Vector3[4]; target.GetWorldCorners(t); // Apply padding to target corners Vector3 targetBL = t[0] - new Vector3(padding, padding, 0); Vector3 targetTR = t[2] + new Vector3(padding, padding, 0); ScaleTopBottom(Top, canvasCorners[1].y, t[1].y); // canvas top → target top ScaleTopBottom(Bottom, t[0].y, canvasCorners[0].y); // target bot → canvas bot ScaleLeftRight(Left, canvasCorners[0].x, t[0].x); // canvas left → target left ScaleLeftRight(Right, t[2].x, canvasCorners[2].x); // target right → canvas right } private void ScaleTopBottom(RectTransform rect, float canvasEdgeY, float targetEdgeY) { Vector2 size = rect.sizeDelta; size.y = Mathf.Abs(canvasEdgeY - targetEdgeY); rect.sizeDelta = size; }Each panel is anchored to its respective canvas edge and only resizes along one axis. The result is a pixel-precise rectangular spotlight that appears instantly without any animation overhead — the resize is a single
sizeDeltaassignment.
Scene Integration
TutorialContext — UI Anchor Registry
TutorialContextis aMonoBehaviourplaced in every scene that has tutorials. It registers itself withTutorialManageronAwakeand exposes a named array ofUIAnchorstructs:public class TutorialContext : MonoBehaviour { [Serializable] public struct UIAnchor { public string id; public RectTransform rect; public Vector2 startPosition; // local position on rect (for drag conditions) public Vector2 endPosition; public bool gizmos; } public UIAnchor[] anchors; private void Awake() => TutorialManager.Instance.RegisterContext(this); public RectTransform Get(string id) { foreach (var a in anchors) if (a.id == id) return a.rect; return null; } }The
gizmosflag toggles a Scene View visualisation drawn withHandles— green lines connectstartPositiontoendPositionon each anchor, making drag gesture targets visible during authoring without entering play mode.TutorialBus — Decoupled Scene Signals
Any scene element can emit a named signal without a direct reference to
TutorialManager:public static class TutorialBus { public static event Action<TutorialSignal> OnSignal; public static void Raise(string id, object payload = null) => OnSignal?.Invoke(new TutorialSignal(id, payload)); } public class TutorialSignalEmitter : MonoBehaviour { public void Emit(string signalId) => TutorialBus.Raise(signalId); }TutorialReceiver— a baseMonoBehaviour— subscribes toTutorialBus.OnSignaland filters by a designer-configured list of signal IDs:public abstract class TutorialReceiver : MonoBehaviour, ITutorialReceiver { [SerializeField] protected List<string> listenToSignalIds; protected virtual void OnEnable() => TutorialBus.OnSignal += HandleSignal; protected virtual void OnDisable() => TutorialBus.OnSignal -= HandleSignal; private void HandleSignal(TutorialSignal signal) { foreach (string id in listenToSignalIds) if (signal.id == id) OnTutorialSignal(signal); } public abstract void OnTutorialSignal(TutorialSignal signal); }TutorialTrigger — Physics-Driven Tutorial Activation
A
TutorialTriggeris a 3D collider placed in the game world. When the player’s marble (or a power-up) enters it, it fires a callback registered withGameTutorialManager:public class TutorialTrigger : MonoBehaviour { public int triggerIndex; public bool skipStep; public string tutorialId; public GameTutorialManager manager; private void Start() { if (skipStep) manager.RegisterTrigger(triggerIndex, () => TutorialManager.Instance.CompleteCurrentStep()); else manager.RegisterTrigger(triggerIndex, () => TutorialManager.Instance.StartTutorial(tutorialId)); } private void OnTriggerEnter(Collider other) { if (other.CompareTag("Player") || other.CompareTag("Ufo") || other.CompareTag("Rocket")) manager.TriggerActivated(triggerIndex); } }GameTutorialManagerstores callbacks in aDictionary<int, Action>and removes them after firing — so each trigger activates at most once, even if the marble passes through the collider again.
Scene integration — the four components arranged around
TutorialManagerat the centre:TutorialContext(UI anchor registry with gizmo preview) top-left,TutorialBus(static decoupled signal channel) top-right,TutorialTrigger(3D physics → single-fire callback) bottom-left, andTutorialOverlayMaskbottom-right rendered as an actual four-panel diagram with the spotlight cutout visible. The[Flags] InputGatenote sits between the manager and the mask.
Summary
Component Role ITutorialConditionSingle-method interface; polled every frame by the runner IContextBoundConditionOpt-in binding for conditions that need UI element positions ITargetedTutorialConditionExposes AnchorIdso the manager can callBindContextautomaticallyOrderedConditionComposite: sequences sub-conditions; multi-gesture steps without extra steps [SerializeReference]Polymorphic list serialization; no concrete type needed at the step level TutorialConditionDrawerReflection discovers all implementations; dropdown + field expansion TutorialStepPer-step data: conditions, mask, animations, gates, UnityEvents TutorialManager.RunTutorialCoroutine runner: mask → animate → gate → wait → cleanup InputGate / MovementGate[Flags]enums; per-bit input restriction checked by player codeTutorialOverlayMaskFour-panel scrim; only sizeDeltachanges — no repositioningTutorialContextPer-scene UI anchor registry; gizmo visualisation of drag paths TutorialBusStatic event bus; signals without direct references TutorialTrigger3D physics → callback; single-fire via Dictionary<int, Action>The key property of this architecture is that adding a new kind of tutorial step requires only a new
ITutorialConditionclass. The drawer discovers it automatically, the runner polls it through the interface, and the context binding happens without any new code inTutorialManager. A drag condition, a timer condition, a network-event condition — all follow the same path from inspector to runtime.Created on February 2026 -
MarbleMaze: Deep Dive — ScriptableObject-Driven Level Progression
One recurring challenge in procedural games is separating what the code does from what the designer decides. A game where tweaking difficulty requires recompiling is a game that’s painful to balance. This post covers how I used Unity’s ScriptableObject system to make every tunable parameter in MarbleMaze editable at design time — without a single code change.
The Architecture at a Glance
The system is built as three independent layers that compose at runtime:
Tile Layer TileDefinition_SO — floor tile (prefab + rules) HazardTileDefinition_SO — hazard tile (+ ratio, placement constraints) OverlayDefinition_SO — overlays: Start, End, Star (prefab + y offset) TileDatabase_SO — collects all of the above; builds lookup dictionaries Progression Layer LevelArchetypeData_SO — which hazards appear and at what base weight LevelCycleDefinition — a named group of archetypes for one cycle LevelCycleProgression_SO — ordered list of cycles + recovery archetype State & Config Layer GeneratorParameters_SO — base grid config (patched at runtime) GlobalDifficultyState_SO — cross-session difficulty debt LevelDatabase_SO — registry of hand-authored levelsNo layer knows about the internals of the others. The generator receives a
TileDatabase_SOand aGeneratorParameters_SO; the progression system modifies those before the generator runs.
Three-layer architecture — the full containment diagram showing all eleven SO types grouped into their three independent layers (tile, progression, state/config), with the rule at the bottom that no layer knows the internals of another.
The Tile Layer
TileDefinition_SO — The Base Tile
Every tile type starts as a
TileDefinition_SO. This is the minimal contract: whatGroundTypeit represents, which prefab to instantiate, and whether the tile counts as walkable:// TileDefinition_SO.cs public class TileDefinition_SO : ScriptableObject { public GroundType groundType; public GameObject tilePrefab; public Color tileEditorColor; // used in the editor grid preview public bool isWalkable; public bool awllowsOverlay; // can a Star/End overlay sit on this tile? }The floor tile is a single instance of this base class — it has no spawn probability because it is the default. Every carved cell becomes a floor unless a hazard overrides it.
HazardTileDefinition_SO — Hazard Rules
Hazards extend the base tile with the information the generator needs to decide whether and how densely to place them:
// HazardTileDefinition_SO.cs public class HazardTileDefinition_SO : TileDefinition_SO { public float ratio; // current weight — written at runtime by the difficulty system public float maxRatio; // designer cap — the runtime never exceeds this // Placement constraint flags — control which generator pass handles this tile public bool requireTwoSolidNeighbours; // Pass 2: needs context public bool requiredLineOfThree; // Pass 3: spawns as a 3-cell group public GroundType sideGroundType; // for line tiles: the end-cap type public bool isHorizontal; }The two fields
ratioandmaxRatioencode a contract between the designer and the runtime difficulty system: the designer setsmaxRatioonce in the inspector as the maximum density that has been playtested and feels acceptable. The adaptive difficulty system then modulatesratiofreely between0andmaxRatio— it can never exceed the designer’s ceiling, no matter how much stress the player accumulates.The placement constraint flags (
requireTwoSolidNeighbours,requiredLineOfThree) tell the generator which of its three decoration passes should handle this tile type. Adding a brand-new tile type requires only a newHazardTileDefinition_SOasset and the appropriate flag — no changes to the generator code.OverlayDefinition_SO — Overlays
Overlays (Start, End, Star) sit on top of ground tiles. Each has its own prefab and a
yOffsetthat lifts it above the floor to the correct visual height:// OverlayDefinition_SO.cs public class OverlayDefinition_SO : ScriptableObject { public OverlayType overlayType; public GameObject overlayPrefab; public float yOffset; public Color overlayEditorColor; }TileDatabase_SO — The Lookup Hub
TileDatabase_SOcollects every tile and overlay into a single asset and builds O(1) runtime lookup dictionaries on load. The inspector lists are the source of truth; the dictionaries are an internal cache:// TileDatabase_SO.cs public class TileDatabase_SO : ScriptableObject { [SerializeField] private TileDefinition_SO floorTile; [SerializeField] private List<HazardTileDefinition_SO> hazardTiles; [SerializeField] private List<OverlayDefinition_SO> overlays; // Runtime dictionaries — built on OnEnable and OnValidate private Dictionary<GroundType, TileDefinition_SO> tilesByType; private Dictionary<GroundType, HazardTileDefinition_SO> hazardTilesByType; private Dictionary<OverlayType, OverlayDefinition_SO> overlaysByType; public TileDefinition_SO GetTileByGroundType(GroundType type) => tilesByType[type]; public HazardTileDefinition_SO GetHazardByGroundType(GroundType type) => hazardTilesByType[type]; public OverlayDefinition_SO GetOverlayByType(OverlayType type) => overlaysByType[type]; }Building the dictionaries happens in two places:
void OnEnable() => BuildCache(); // called when the asset loads at runtime #if UNITY_EDITOR void OnValidate() => BuildCache(); // called every time the inspector changes #endifOnValidateis critical for the editor workflow: every time a designer adds or reorders tiles in the inspector, the dictionary is rebuilt instantly without any manual refresh step.The database also exposes three LINQ-filtered views of the hazard list — one for each generator pass — so the generator never needs to know about the classification flags itself:
public IEnumerable<HazardTileDefinition_SO> SimpleHazards => HazardTiles.Where(t => !t.requireTwoSolidNeighbours && !t.requiredLineOfThree); public IEnumerable<HazardTileDefinition_SO> TwoSolidHazards => HazardTiles.Where(t => t.requireTwoSolidNeighbours); public IEnumerable<HazardTileDefinition_SO> LineHazards => HazardTiles.Where(t => t.requiredLineOfThree);The generator just calls
db.SimpleHazards,db.TwoSolidHazards, anddb.LineHazards— it never inspects the flags directly.
Tile hierarchy & filtering — the
TileDefinition_SO→HazardTileDefinition_SOinheritance chain on the left, with themaxRatiodesigner-ceiling callout, and on the right theTileDatabase_SOsplitting its hazard list into three LINQ-filtered views (SimpleHazards,TwoSolidHazards,LineHazards) that map directly to the three generator decoration passes.
The Progression Layer
LevelArchetypeData_SO — Hazard Composition
An archetype is the designer’s answer to the question: “What kind of level is this?” It declares which hazard types should be present, and at what relative strength:
// LevelArchetypeData_SO.cs public class LevelArchetypeData_SO : ScriptableObject { public string archetypeName; // e.g. "Ice Precision", "Platform Rush" [Serializable] public struct ModifierWeight { public GroundType groundType; [Range(0f, 1f)] public float weight; // 0 = inactive, 1 = fully weighted } public ModifierWeight[] modifiers; public int maxActiveModifiers = 2; }Weights in an archetype are relative intentions, not final ratios. The runtime multiplies each weight by
cycleT(position within the cycle) andfinalMultiplier(the adaptive difficulty value) before writing it into theTileDatabase_SO:// RuntimeLevelParameters.cs — ApplyArchetypeData() foreach (var modifier in data.modifiers) { float scaled = Mathf.Clamp01(modifier.weight * cycleT * difficultyModifier); HazardTileDefinition_SO hazardTile = tileDatabase.GetHazardByGroundType(modifier.groundType); hazardTile.ratio = Mathf.Min(hazardTile.maxRatio, scaled * hazardTile.maxRatio); }This means a designer setting
weight = 1.0on an Ice modifier is saying “make ice as prominent as the difficulty system allows”, not “always fill the level with ice”. The adaptive system governs the ceiling.LevelCycleDefinition and LevelCycleProgression_SO
Archetypes are grouped into cycles — blocks of
levelsPerCyclelevels that share a curated set of archetypes:// LevelCycleDefinition.cs public class LevelCycleDefinition { public string cycleName; // shown in the inspector public List<LevelArchetypeData_SO> allowedArchetypes; } // LevelCycleProgression_SO.cs public class LevelCycleProgression_SO : ScriptableObject { public LevelArchetypeData_SO recoveryArchetype; // injected when player is struggling public List<LevelCycleDefinition> cycles; // ordered by cycle index }The archetype for a given level is selected deterministically by position within the cycle — no randomness, so designers can reason about what the player will see at each slot:
// RuntimeLevelParameters.cs int cycleIndex = levelIndex / levelsPerCycle; // which cycle block int cycleLevel = levelIndex % levelsPerCycle; // position within the block int archetypeIndex = cycleLevel % cycle.allowedArchetypes.Count; return cycle.allowedArchetypes[archetypeIndex];Once the last defined cycle is reached, the system clamps to it — so the final cycle effectively becomes the endgame loop without any extra code.
The
recoveryArchetypeis a separate asset shared across all cycles. When the difficulty engine determines the player needs relief, it substitutes this archetype regardless of what the cycle would normally assign.
Archetype → ratio pipeline — traces how a designer’s intent (weight = 0.9) flows through cycleT and finalMultiplier, gets clamped, then hits the maxRatio ceiling before being written into the tile asset. A worked numeric example (Ice tile, cycleT = 0.7, difficulty 0.85 → ratio 0.535) makes the formula concrete.
The Config and State Layer
GeneratorParameters_SO — Base Configuration
GeneratorParameters_SOholds all the parameters the generator needs that don’t come from the progression system:// GeneratorParameters_SO.cs public class GeneratorParameters_SO : ScriptableObject { public int gridWidth = 10; public int gridHeight = 10; public bool randomEnd = true; public Vector2Int fixedEnd; public int endMaxHeightPercent = 20; public int inputSeed = -1; // -1 = random per level public TileDatabase_SO tileDatabase_SO; [Range(0, 20)] public int starCount = 3; [Range(1, 10)] public int minStarDistance = 2; }At runtime,
LevelManagerpatches this asset’s fields with values computed byRuntimeLevelProgressionbefore passing it to the generator — treating the SO as a mutable parameter bag:// LevelManager.cs — GenerateRuntimeLevel() baseParameters.gridWidth = runtimeParams.width; baseParameters.gridHeight = runtimeParams.height; baseParameters.tileDatabase_SO = runtimeParams.tileDatabase_SO; baseParameters.minStarDistance = runtimeParams.minStarDistance; baseParameters.inputSeed = -1; // always random for runtime levelsThis means the SO’s inspector values act as design-time defaults — useful for prototyping and editor testing — while the runtime always overwrites them with the computed values before generation.
GlobalDifficultyState_SO — Persistent Cross-Session State
The difficulty debt that accumulates as the player struggles is stored in a dedicated ScriptableObject, not serialized per-scene:
// GlobalDifficultyState_SO.cs public class GlobalDifficultyState_SO : ScriptableObject { public float difficultyDebt; // 0 → no easing, 1 → max easing (20% reduction) public int remainingLevels; // countdown to debt expiry }Using a ScriptableObject here means the debt value survives scene transitions automatically without any explicit save/load logic. It also makes the current debt directly inspectable in the Unity editor during playtesting — invaluable when validating that the difficulty curve is behaving as expected.
LevelDatabase_SO — Pre-Authored Level Registry
For tutorial levels and early handcrafted content, the
LevelDatabase_SOholds an ordered list ofLevelData_SOrecords.LevelManagerchecks this first on every level load, falling through to the procedural generator only if no authored level exists:// LevelDatabase_SO.cs public class LevelDatabase_SO : ScriptableObject { public List<LevelData_SO> levels = new List<LevelData_SO>(); public LevelData_SO GetLevelDataAtIndex(int index) => levels.Find(level => level.index == index); public void SaveLevelData(LevelData_SO data) { int existingIndex = levels.FindIndex(l => l.index == data.index); if (existingIndex >= 0) levels[existingIndex] = data; // override else levels.Add(data); levels = levels.OrderBy(level => level.index).ToList(); // keep sorted } }The database is sorted by index on every save, so
GetLevelDataAtIndexusingList.Findis always searching an ordered list — a binary search could replace this for very large databases, but at tutorial-scale sizes it is negligible.
The Editor Window — PathGeneratorWindow
All of this ScriptableObject infrastructure comes together in a custom editor window that lets me generate, preview, hand-paint, and save levels without entering play mode.
WIndow Diagram
Editor window workflow — the four capabilities of PathGeneratorWindow arranged around the central window node: live generation (any setting change triggers instant regeneration), hazard sliders (write to SO assets with undo support), grid painting (ref writes with platform orientation cycling), and save/load (flattening to LevelDatabase_SO and reconstructing for scene preview). The OnValidate() cache-rebuild note sits at the top.
The window has four main capabilities:
1 — Live Generation with Instant Preview
Any change to grid settings triggers an immediate regeneration:
// PathGeneratorWindow.cs private void DrawGridSettings() { EditorGUI.BeginChangeCheck(); parameters.gridWidth = EditorGUILayout.IntField("Width", parameters.gridWidth); parameters.gridHeight = EditorGUILayout.IntField("Height", parameters.gridHeight); // … more fields … if (EditorGUI.EndChangeCheck()) Regenerate(); // generate + repaint immediately } private void Regenerate() { grid = PxP.PCG.Generator.GenerateMaze(levelIndex, parameters, out usedSeed); physicalGenerator?.Generate(grid); // also updates the 3D scene view Repaint(); }2 — Per-Hazard Ratio Sliders
Each hazard tile in the
TileDatabase_SOgets its own slider in the editor. Dragging a slider writes directly to the SO asset and triggers a regeneration, so the effect on level layout is visible immediately:// PathGeneratorWindow.cs — DrawPathSettings() foreach (var hazard in db.HazardTiles) { Undo.RecordObject(hazard, "Modify Hazard Ratio"); hazard.ratio = EditorGUILayout.Slider( ObjectNames.NicifyVariableName(hazard.name), hazard.ratio, 0f, 1f); } if (EditorGUI.EndChangeCheck()) { foreach (var hazard in db.HazardTiles) EditorUtility.SetDirty(hazard); // persist slider values to disk Regenerate(); }Undo.RecordObjectregisters each change with Unity’s undo stack, so Ctrl+Z works as expected during design sessions.3 — Manual Grid Painting
For hand-authored content, the editor renders a 2D pixel-art view of the current grid and supports left-click to paint and right-click to erase:
// PathGeneratorWindow.cs — HandleGridMouseInput() ref CellData cell = ref grid.GetCellRef(x, y); if (e.button == 0) // left click → paint { switch (paintMode) { case PaintMode.Ground: cell.isEmpty = false; cell.ground = selectedGround; break; case PaintMode.Overlay: if (!cell.isEmpty) cell.overlay = selectedOverlay; break; } } if (e.button == 1) // right click → erase (overlay first, then wall) { if (cell.overlay != OverlayType.None) cell.overlay = OverlayType.None; else cell.isEmpty = true; }Moving platforms get special treatment: clicking a platform tile cycles its orientation between horizontal and vertical, and automatically paints or clears the
PlatformSideend-cap cells on either side.4 — Save and Load
A finished level is saved into the
LevelDatabase_SOwith a confirmation dialog if an existing level at that index would be overwritten:// PathGeneratorWindow.cs — SaveCurrentLevel() data.gridData = new CellData[width * height]; for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) data.gridData[y * width + x] = grid.GetCellRef(x, y); levelDatabase.SaveLevelData(data); EditorUtility.SetDirty(levelDatabase); AssetDatabase.SaveAssets();Loading reconstructs the
Gridfrom the flattened array and feeds it straight into thePhysicalMazeGeneratorto visualise in the scene:// PathGeneratorWindow.cs — LoadLevel() grid = new Grid(data.gridWidth, data.gridHeight); for (int y = 0; y < data.gridHeight; y++) for (int x = 0; x < data.gridWidth; x++) grid.GetCellRef(x, y) = data.gridData[y * data.gridWidth + x]; physicalGenerator?.Generate(grid);
Design Principles
A few rules held throughout the design of this system:
The code interprets; the ScriptableObjects decide. No tile type, archetype name, hazard weight, or cycle structure is hardcoded. Adding a new hazard type — say, a conveyor belt — requires a new
HazardTileDefinition_SOasset and a prefab, not a code change.Runtime state that needs to survive scenes goes into a ScriptableObject. The
GlobalDifficultyState_SOis the clearest example — debt state needs to persist across the menu scene and the game scene, and storing it in a SO costs nothing compared to wiring a DontDestroyOnLoad object or writing a custom serializer.OnValidatekeeps the editor in sync. Every SO that builds a cache does so inOnValidateas well asOnEnable, so any inspector edit immediately propagates through the system without a play-mode cycle.maxRatio is the designer’s veto. The adaptive difficulty system can freely modulate hazard density between sessions — but it can never push a hazard above the ceiling the designer set during playtesting. The cap is baked into the asset, not into a conditional in the runtime code.
Summary
Asset Purpose TileDefinition_SOFloor tile: prefab + walkability rules HazardTileDefinition_SOHazard tile: adds ratio, maxRatio, placement constraints OverlayDefinition_SOOverlay: prefab + y-offset per type TileDatabase_SOLookup hub; runtime O(1) dictionaries built from inspector lists LevelArchetypeData_SODesigner-authored hazard composition for a level type LevelCycleDefinitionNamed group of archetypes for one progression cycle LevelCycleProgression_SOOrdered cycle sequence + recovery archetype GeneratorParameters_SOBase config, patched at runtime by the difficulty engine GlobalDifficultyState_SOCross-scene persistent difficulty debt LevelDatabase_SORegistry of pre-authored levels, checked before PCG runs PathGeneratorWindowCustom editor: generate, preview, paint, save/load Created on February 2026