Blog
Posts
  • 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 it

    Common flags:

    gcc -Wall -Wextra -Werror main.c -o my_program
    FlagEffect
    -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";
    TypeSizeUse
    char1 bytesingle character or small integer
    int4 byteswhole numbers
    float4 bytesdecimal numbers (less precise)
    double8 bytesdecimal numbers (more precise)

    Constants are declared with #define or const:

    #define MAX 100
    const int LIMIT = 50;

    Standard Output

    C has no print built-in. You use write (low-level) or printf (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:

    SpecifierType
    %dinteger
    %ffloat
    %cchar
    %sstring
    %ppointer address

    A common exercise is to rewrite printf yourself as ft_printf.

    Conditionals

    if (age >= 18)
    {
        printf("Adult\n");
    }
    else if (age >= 13)
    {
        printf("Teenager\n");
    }
    else
    {
        printf("Child\n");
    }

    Comparison and logical operators:

    OperatorMeaning
    ==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.

    OperatorNameExampleResult
    &AND5 & 31
    |OR5 | 37
    ^XOR5 ^ 36
    ~NOT~5-6
    <<left shift1 << 38
    >>right shift8 >> 14

    How 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, ~5 gives -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 = 4

    Shifting left by n is equivalent to multiplying by 2^n, and is faster than actual multiplication:

    int n = 3;
    int result = n << 2;   // 3 * 4 = 12

    Practical 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 for loops — use while instead.

    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…while loops.

    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 address
    SyntaxMeaning
    &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]);   // 50

    The 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));   // 30

    Strings

    In C, a string is an array of char ending 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 equal

    A 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 malloc and free.

    #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 pointer

    Rules:

    • Every malloc must have a matching free
    • 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 for loops (only while)
    • Variable declarations at the top of the scope only
    • 4-space tabs, specific brace placement
    norminette my_file.c   # check style compliance

    Quick Reference

    ConceptMeaning
    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
  • 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

    ls shows 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 files

    The -la combo 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 directory

    cd - 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 Pictures

    Always use -r (recursive) when copying directories, otherwise cp will 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 directory

    mv does 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, and u (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 others
    SymbolMeaning
    +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:

    ValuePermission
    4read (r)
    2write (w)
    1execute (x)

    Add them together to combine permissions:

    ValuePermissions
    7rwx (4+2+1)
    6rw- (4+2)
    5r-x (4+1)
    4r— (4)
    0---

    alias — Create Command Shortcuts

    alias lets 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 ~/.zshrc

    For Bash users the file is ~/.bashrc instead.


    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/bash

    This 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.sh

    Key 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"
    fi

    Loops:

    for file in *.txt; do
      echo "Processing $file"
    done

    Functions:

    greet() {
      echo "Hello, $1"
    }
    greet "Sam"

    Quick Reference

    CommandPurpose
    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 Instance property, a null-check guard in Awake that destroys duplicates, and no DontDestroyOnLoad:

    public static LevelManager Instance { get; private set; }
    
    private void Awake()
    {
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;
    }

    DontDestroyOnLoad is 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. SceneController manages 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 — CoreManager never 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 PlayerMovement needs to expand its ground-detection radius. None of those systems call LifeManager directly. LifeManager.RemoveLife fires 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.ReduceCurrencyAmount then fires OnCoinChanged:

    OnCoinChanged?.Invoke(type, coins[type], previousCoins[type]);

    CoinManager.OnCoinChangedLifePannel.UpdateCurrencyValue (HUD) LevelManager.OnLifeLostToThisLevelPlayerMovement (ground radius) LifeManager.OnLifeRemovedLifePannel.OnLifeRemoved (shake/flash animation)

    A single player death fans out to three independent subsystems with no coupling between any of them.


    The Event Map

    EventPublisherSubscribers
    OnCoinChanged(CoinType, int, int)CoinManagerAll CurrencyPannel instances, LifePannel
    OnCoinSet(CoinType, int)CoinManagerAll CurrencyPannel instances (snap, no animation)
    OnHeartTimerTick(TimeSpan)CoinManagerHeartPannelManager (live countdown)
    OnCoinTimerTick(TimeSpan)CoinManagerRewarded video button cooldown UI
    OnLifeRemovedLifeManagerLifePannel, game-over check
    OnLifeLostToThisLevel(int)LevelManagerPlayerMovement (adaptive radius)
    OnStarCountChanged(int)LevelManagerStarPannel (in-game star counter)
    OnPowerUpStateChanged(PowerUpState)PowerUpManagerPowerUpButtonManager, audio
    OnCloudSaveCompletedCloudSaveManagerUserInfoManager (toast notification)
    OnCloudLoadCompletedCloudSaveManagerSavingManager (session restore trigger)
    OnAuthenticationReadyLoginManagerCloudSaveManager, AchievementManager

    All events are plain C# Action<T> or Action delegates — no UnityEvent overhead, no message-passing framework, no ScriptableObject event channels. The subscribers register in OnEnable and deregister in OnDisable, so scene unloading automatically cleans up every subscription.


    Fluent Builder: SceneController

    Scene transitions follow a Fluent Builder pattern. SceneTransitionPlan accumulates the load/unload list and options through chained calls, then executes when Perform() 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));
    }

    Perform hands the plan to ExecutePlan, which runs the full async pipeline: fade-in overlay → unload scenes → optional Resources.UnloadUnusedAssets() → load scenes additively → set active scene → fade-out overlay. The isBusy guard prevents overlapping transitions:

    private IEnumerator ExecutePlan(SceneTransitionPlan plan)
    {
        if (isBusy) yield break;
        isBusy = true;
        yield return StartCoroutine(ChangeSceneRoutine(plan));
    }

    SceneDatabase.Slots and SceneDatabase.Scenes are 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:

    PatternWhereWhy
    StrategyIDataService / JsonDataServiceSwap storage backend without touching call sites
    State MachinePlayerMovement, PowerUpManager, FlapingDoorsAnimationControlled transitions, no illegal state combinations
    CompositeOrderedCondition (tutorial)Sequence sub-conditions without nesting coroutines
    Fluent BuilderSceneController.NewTransition()Readable multi-step scene transitions at call sites
    ObserverAll Action<T> eventsZero coupling between publishers and subscribers
    FactoryGridFactory, PhysicalMazeGeneratorCentralize complex object construction
    SingletonAll managersSingle 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]

    CoreManager triggers 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 second LoadSession call 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.”

    ← Back to Project Overview

    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;
    }

    startPoints is 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. amount is set at runtime from LevelManager data just before the animation plays.


    Batch Orchestration: PlayRoutine

    UICurrencyAnimator.Play accepts 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 completedIcons counter is captured in each lambda closure. The HUD counter updates as soon as the first icon lands — giving immediate feedback — and onComplete fires only once all icons from all requests have finished. maxCurrencySpawn caps 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 delayBetween yield between them. The control point B is calculated per-icon with a random offset on top of a fixed arcHeight, 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 cyclingi % startPoints.Count distributes icons across multiple spawn origins evenly.
    • Control point construction — B sits above A by arcHeight world units, then scattered within a circle of radius scatterRadius using Random.insideUnitCircle. The result is a fan of distinct arcs all converging on the same HUD destination.
    • Each icon is parented to canvasRect (the root Canvas RectTransform) 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();
    }

    t is normalized to [0,1] then passed through EaseInOutCubic before being fed into the Bézier formula. The eased t accelerates 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

    EndPannelManager owns two CurrencyAnimationRequest fields configured in the inspector (one for coins, one for stars). At Start() it populates their amount fields from LevelManager:

    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 Play call means coin and star icons fly simultaneously — the completedIcons counter in PlayRoutine ensures ReturnToGamesMenu fires only after every icon from both batches has landed.


    HUD Counter: CurrencyPannel

    Each HUD counter subscribes to CoinManager events for live updates. When the first icon lands, CoinManager.UpdateCounts() fires OnCoinChanged, which CurrencyPannel receives:

    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);
    }

    AnimateCurrency is an extension method on TMP_Text that 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 Image prefab 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 using DateTime comparison 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 so CoinManager never touches UI directly.


    The Currency Store

    CoinManager maintains 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>();

    previousCoins exists solely for the HUD animation system: OnCoinChanged broadcasts both values so CurrencyPannel can tween the displayed number from the old value to the new one. Any time a currency is written, LevelPreviousCoinAmount levels 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.Add in Start — no other code changes.


    Events: Decoupled UI Updates

    CoinManager exposes 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;

    OnCoinChanged carries both the new and previous value so subscribers can decide whether to animate. OnCoinSet is used during session restore when the displayed value should snap immediately rather than tween. Every UI panel subscribes in OnEnable and unsubscribes in OnDisable — 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 two DateTime fields persisted across sessions. The core logic sits in RecalculateHearts, 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: lastHeartRefillTime is advanced by heartsToAdd * interval, not set to DateTime.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 lastHeartRefillTime is 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 TimeSpan values. 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 Update frame so it’s always in sync with the focused-app check — the timer only ticks while Application.isFocused is true.


    Rewarded Video Safety Window

    Hearts and coins can be granted by rewarded video ads, but rapid re-claims must be prevented. Both RewardHearts and RewardCoins check a shared rewardedVideoSafeTime countdown:

    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();
    }

    rewardedVideoSafeTime is a float decremented every Update tick. 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 additional lastVideoRewardTime persisted 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

    CoinManager tracks the full persistent heart count (up to the configured maximum). LifeManager wraps 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);
    }

    ResetLife is called at the start of each level and at the end panel before returning to the menu, so currentLife always reflects the current heart balance up to the in-game maximum. Losing a life reduces both currentLife and the persistent CoinType.HEART amount simultaneously:

    public void RemoveLife()
    {
        if (currentLife > 0)
        {
            currentLife--;
            CoinManager.Instance.ReduceCurrencyAmount(CoinType.HEART, 1);
        }
    
        LevelManager.Instance.IncreaseLivesLostToThisLevel();
        OnLifeRemoved?.Invoke();
    }

    OnLifeRemoved is what PlayerMovement subscribes 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 Sequence from 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 PowerUpData struct carrying its scene reference, duration, height offset, and cached original scale. Both are registered in a Dictionary<CoinType, PowerUpData> at Awake:

    // 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 originalScale at Awake — before the object is hidden — ensures the grow animation always targets the correct designer-set scale regardless of later modifications.

    A three-value PowerUpState enum 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
    }

    UsePowerUp guards 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

    ActivatePowerUp kills any in-flight tweens from a previous call before building a new Sequence:

    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.OutBack and the 15% overshoot (* squashStretch) give the power-up a confident, elastic arrival. The quick 0.1s settle in OnComplete avoids the tween chain being a nested Sequence — it’s a one-shot inline correction.


    Deactivation — Reversing the Sequence

    Deactivation mirrors activation but with inverted easing — Ease.InBack for 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 whose SetTarget matches this. 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.OutBack squash-and-stretch and the inverted Ease.InBack deactivation are both called out. The SetTarget(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 single scaleTween is 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=1

    A matching trailScaleTween drives the trail renderer’s widthMultiplier in 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 by PowerUpManager and DeadZone:

    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();   }

    OnRewind and OnComplete advance the FSM when each direction finishes — no polling, no WaitForSeconds, no frame delay:

    private void OnShrinkComplete() { state = AnimationState.Shrunk; shouldShrink = false; }
    private void OnGrowComplete()   { state = AnimationState.Idle;   shouldGrow   = false; }

    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 than blinkCount because each full visible/invisible cycle is two half-cycles in the inner sequence.

    Visual effects FSM — the single bidirectional scaleTween at 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 SphereCast rather than a Raycast is 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);

    lastSafePlatform is 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.Acceleration applies 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 FixedUpdate when 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/iceHelp directional jump blend, and extra gravity + platform angular damping.


    PlayerController — Touch Input & Gesture Recognition

    PlayerController uses Unity’s EnhancedTouch API, which provides per-finger tracking with Finger objects and TouchPhase states.

    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 joystickFingerDragged flag is set in OnFingerMove only when the cumulative drag exceeds joystickDeadZone (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 magnitude is important — a light drag produces gentle rolling, a full drag produces maximum torque. Only the direction is snapped, not the intensity.

    Movement Gate Clamping

    ClampMovement applies MovementGate flags by zeroing the relevant axes — no conditional branches in PlayerMovement, 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: EnhancedTouch events (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), and MovementGate axis clamping (zeroing individual axes without any conditional logic in the physics layer). The InputGate UI 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. SetCameraFollow is a static method — any system can redirect the camera without a direct reference to the PlayerCamera component:

    public static void SetCameraFollow(GameObject objectToFollow)
    {
        cinemachineCam.Follow = objectToFollow != null ? objectToFollow.transform : null;
    }

    Camera shake drives Cinemachine’s CinemachineBasicMultiChannelPerlin amplitude directly — a timer counts down in Update and 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

    ComponentKey technique
    PowerUpManager.ActivatePowerUpDOTween Sequence: callback → squash scale → settle → callback
    PowerUpManager.DeactivatePowerUpReverse sequence: kinematic freeze → teleport → shrink → callback
    DOTween.Kill(this)Targeted kill via SetTarget(this) — no orphaned tweens on scene reload
    PlayerVisualEffects scaleTweenSetAutoKill(false) + PlayForward/PlayBackwards — one tween, two directions
    PlayerVisualEffects blinkTweenNested Sequence: inner SetLoops blink + outer delay wrapper
    PlayerMovement.CheckGroundedSphereCast radius 0.75 — catches edges a raycast misses
    Adaptive ground radiusgroundDetectionRadius expands to 0.95 after 2 lives lost
    Rolling torqueVector3.Cross(up, input) + ForceMode.Acceleration — mass-independent
    Jump directional helpgroundHelp/iceHelp fractions of movement input blended into jump force
    DirectionMapperSnap angle to nearest 45° while preserving analog magnitude
    Tap/swipe disambiguationThree-condition gate: !joystickFingerDragged && duration <= 0.25s && distance <= 35px
    ClampMovementMovementGate flags zeroing axes — clean input before physics
    PlayerCamera.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 patternIDataService / 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 a SemaphoreSlim-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();
    }

    SavingManager holds a single IDataService field and constructs the implementation in Awake:

    private IDataService dataService;
    
    private void Awake()
    {
        // ...
        dataService = new JsonDataService();
    }

    Nothing downstream ever references JsonDataService directly. Swapping to a binary, encrypted, or cloud-direct backend is a one-line change here.


    JSON Persistence: JsonDataService

    JsonDataService uses Newtonsoft.Json (not Unity’s built-in JsonUtility) for full support of Dictionary, 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 to Application.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

    SavingManager holds one typed field per data category:

    FieldTypeContents
    currentGameDataGameDataLevel dictionary, difficulty debt, global modifier
    currentPlayerDataPlayerDataAll 5 currency amounts, DateTime timers, skin/color index
    currentSkinShopDataSkinShopDataDictionary<string, bool> per-item lock state by stable string ID
    currentSettingsDataSettingsDataAudio 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 SaveXDataInFile and RestoreXDataFromFile method. 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, CoreManager calls SavingManager.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 Restore method creates a default object when the file is missing — so first-time installs work without any special-case code. Missing files set isDataPresent = false, which CloudSaveManager reads to decide whether to force a cloud load on the next authentication.

    SaveSession is 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 single CloudSavePayload stamped 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 version field 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 SaveWithConflictResolutionAsync uses a SemaphoreSlim(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.Update accumulates real play time using Time.deltaTime. When total accumulated time (stored in PlayerPrefs across sessions) exceeds saveIntervalHours, TrySaveAllToCloud fires and resets the counter.

    Event-based (dirty flag): LevelManager calls CloudSaveManager.Instance.MarkDirty() every levelsPerCycle * 2 level completions:

    if (index % (levelsPerCycle * 2) == 0)
        CloudSaveManager.Instance.MarkDirty();

    The Update loop checks isDirty on 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, CloudSaveManager compares the current Unity PlayerId against the last known ID stored in PlayerPrefs. 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. OnCloudLoadCompleted triggers SavingManager.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:

    SetState is called once at spawn time by PhysicalMazeGenerator. The isInverted flag offsets the hazard to the midpoint of its cycle — used to prevent every hazard on a level starting in the same phase simultaneously.

    CycleDuration exposes the total loop time so external systems (audio cues, AI) can reason about timing without inspecting internals.

    IsSafe lets 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 PhysicalMazeGenerator using 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) & 1 is the parity of the coordinate sum — 0 for even, 1 for 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)&1 parity 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;

    Update increments stateTimer every 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;
        }
    }

    NextState uses a switch expression to advance only the two pause states — the animated states (Opening, Closing) advance themselves inside Animate when t >= 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 AnimationCurve asset — 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-caching leftClosedRotation and rightClosedRotation in Start means the rotation is always expressed relative to the designer-placed starting orientation, not accumulated over frames.

    IsSafe returns true only when the doors are fully closed and stationary:

    public bool IsSafe() => state == DoorState.PausingClosed;
    
    public float CycleDuration =>
        openDuration + openPauseDuration + closeDuration + closePauseDuration;

    Phase Initialisation

    SetState places 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 / 2 with 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 DoorAngularPush component 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;
    }

    normalized is the inverse of normalised distance — 1.0 at the centre, 0.0 at maxEffectiveDistance. A marble sitting directly under the hinge gets the full push; one at the edge gets almost none. The hasCollided flag ensures only one impulse fires per open cycle; it resets via the OnPauseEnded event 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 AnimationCurve rotation math — pre-cached closed rotations, mirrored ±flapAngle, relative not accumulated. IsSafe = true is highlighted in green only on PausingClosed. The DoorAngularPush formula sits at the bottom.


    Moving Platforms

    Platforms use the same ITimedHazard interface and the same AnimationCurve pattern, but applied to position rather than rotation — and running in FixedUpdate to 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 = paused

    Awake sets the Rigidbody to Interpolate mode — 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 movementAmplitude so the animation curve travels the full 2 × amplitude range 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 normalised t into movementCurve.Evaluate. After applying the position, a pass over the curve’s keyframes checks whether t is 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 = 0 and t = 1 (start and end of travel) produce the pauses the designer placed in the inspector curve.

    rb.MovePosition is used instead of setting transform.position directly — 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 FixedUpdate pipeline (timer → t → curve evaluate → MovePosition → keyframe pause check), right side shows an annotated S-curve chart with the 0.001 pause-detection threshold marked at t=0 and t=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; }
    }

    piqueOriginalPosition is cached at Start, 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

    DeadZone handles the standard fall-off-the-edge scenario using OnCollisionEnter. It checks GameState.Playing first — so death can’t fire during a pause or cutscene — and guards against double-processing with the IsDying state 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, PiquesDeadZone applies 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 WaitForFixedUpdate after 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 of Destroy — cleaner for any future pooling system, and avoids the one-frame delay Destroy has 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 main MoveOverTime (vertical rise). yield return waits only on the MoveOverTime; 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);

    MoveOverTime takes the easing as a Func<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 StartCoroutine calls running in parallel while yield return MoveOverTime waits on just the rise. The Func<float,float> easing injection pattern and both easing functions are noted at the bottom.


    Summary

    ComponentTechniqueKey detail
    ITimedHazardInterfaceIsSafe() + SetState() + CycleDuration
    Checkerboard phasing(x + y) & 1Adjacent hazards always start in opposite phases
    FlapingDoorsAnimation4-state machine + AnimationCurveIndependent open/close curves; Quaternion.Euler mirrored on both doors
    DoorAngularPushDistance-normalized impulseOne push per cycle; reset via OnPauseEnded event
    PlatformMovementAnimationCurve position + FixedUpdate0.001f keyframe pause detection; rb.MovePosition for physics carry
    PiquesTileAnimation4-state machine + AnimationCurveY-offset translation; safe only during PausedDown
    DeadZoneOnCollisionEnterIsDying state guard prevents double-kill
    PiquesDeadZoneRadial impulse + delayed respawnWaitForFixedUpdate for clean physics-step respawn
    StarTriggerOnTriggerEnterMulti-tag, SetActive(false) not Destroy
    EndTriggerMulti-coroutine sequenceConcurrent levitate phase; Func<float,float> easing injection

    The shared ITimedHazard interface is the architectural win here — PhysicalMazeGenerator calls SetState through 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();
    }

    TutorialManager polls this every frame in a WaitUntil — 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 RectTransform into the constructor (which would break Unity’s serialization), binding happens through an explicit BindContext call 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
    }

    TutorialManager checks 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 any InputAction asset, 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. One switch on 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.

    TapAndReleaseInAreaCondition adds 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

    SwipeCondition validates 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;
        }
    }

    SwipeAndReleaseCondition is a tighter variant that only resolves on finger lift, preventing mid-swipe detection for drag-style interactions.

    Drag Conditions

    DragFromToCondition and DragFromReleaseAtCondition teach 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 a TutorialContext anchor 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: DragFromToCondition resolves while the pointer is still held (teaching joystick-style drags), while DragFromReleaseAtCondition resolves on release (teaching flick or swipe-to-confirm gestures).

    OrderedCondition — Composite Sequencing

    OrderedCondition wraps a list of other conditions and advances through them in order. Only when the last one is satisfied does it return true:

    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 Tutorial data structure.

    Condition interface hierarchy — the three-level inheritance chain (ITutorialConditionIContextBoundConditionITargetedTutorialCondition) 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). OrderedCondition sits below as the composite. The footer note explains the automatic BindContext call.


    SerializeReference — Polymorphism in the Inspector

    Conditions are stored in TutorialStep using [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 to TutorialStep or TutorialManager.


    The Custom Property Drawer

    Serializing polymorphic types solves the data problem; the UX problem is that the default inspector shows a raw managedReferenceValue field 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 ITutorialCondition is 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.CreateInstance and assigns it to the managedReferenceValue. The drawer then draws each child field of the concrete type using SerializedProperty iteration — so TapInAreaCondition.anchorId and SwipeCondition.minDistance both 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 Tutorial is a named sequence of TutorialStep objects:

    public class Tutorial
    {
        public string tutorialName;
        public int    levelNumber;
        public string tutorialType;
        public List<TutorialStep> steps = new List<TutorialStep>();
    }

    Each TutorialStep carries 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.Any means 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.All requires every condition — useful for ordered or simultaneous multi-input steps.


    The Tutorial Runner Coroutine

    TutorialManager.RunTutorial is a single coroutine that loops through steps. Each iteration:

    1. Applies the overlay mask — focuses the four-panel scrim around the step’s target UI element
    2. Starts UI animations — plays the hand-prompt or arrow sequences defined per step
    3. Sets the input gate — restricts player input to what the step allows
    4. Fires onStepStart — any designer-wired UnityEvent
    5. Waits for completion — either a timer or a WaitUntil polling conditions
    6. 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);
    }

    forceStepComplete is a flag that TutorialTrigger can 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 the autoComplete branch clearly split, and the forceStepComplete physics override shown as a side input into the wait step. ConditionLogic.Any vs .All noted 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.None locks all input; AllowedInput.Move | AllowedInput.Jump lets 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.

    MovementGate applies 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 sizeDelta assignment.


    Scene Integration

    TutorialContext — UI Anchor Registry

    TutorialContext is a MonoBehaviour placed in every scene that has tutorials. It registers itself with TutorialManager on Awake and exposes a named array of UIAnchor structs:

    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 gizmos flag toggles a Scene View visualisation drawn with Handles — green lines connect startPosition to endPosition on 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 base MonoBehaviour — subscribes to TutorialBus.OnSignal and 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 TutorialTrigger is a 3D collider placed in the game world. When the player’s marble (or a power-up) enters it, it fires a callback registered with GameTutorialManager:

    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);
        }
    }

    GameTutorialManager stores callbacks in a Dictionary<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 TutorialManager at 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, and TutorialOverlayMask bottom-right rendered as an actual four-panel diagram with the spotlight cutout visible. The [Flags] InputGate note sits between the manager and the mask.


    Summary

    ComponentRole
    ITutorialConditionSingle-method interface; polled every frame by the runner
    IContextBoundConditionOpt-in binding for conditions that need UI element positions
    ITargetedTutorialConditionExposes AnchorId so the manager can call BindContext automatically
    OrderedConditionComposite: 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 code
    TutorialOverlayMaskFour-panel scrim; only sizeDelta changes — no repositioning
    TutorialContextPer-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 ITutorialCondition class. The drawer discovers it automatically, the runner polls it through the interface, and the context binding happens without any new code in TutorialManager. 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 levels

    No layer knows about the internals of the others. The generator receives a TileDatabase_SO and a GeneratorParameters_SO; the progression system modifies those before the generator runs.

    Three-layer architecture

    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: what GroundType it 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 ratio and maxRatio encode a contract between the designer and the runtime difficulty system: the designer sets maxRatio once in the inspector as the maximum density that has been playtested and feels acceptable. The adaptive difficulty system then modulates ratio freely between 0 and maxRatio — 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 new HazardTileDefinition_SO asset 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 yOffset that 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_SO collects 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
    #endif

    OnValidate is 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, and db.LineHazards — it never inspects the flags directly.

    TileDatabase_SO hierarchy

    Tile hierarchy & filtering — the TileDefinition_SOHazardTileDefinition_SO inheritance chain on the left, with the maxRatio designer-ceiling callout, and on the right the TileDatabase_SO splitting 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) and finalMultiplier (the adaptive difficulty value) before writing it into the TileDatabase_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.0 on 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 levelsPerCycle levels 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 recoveryArchetype is 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.

    Progression ratio

    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_SO holds 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, LevelManager patches this asset’s fields with values computed by RuntimeLevelProgression before 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 levels

    This 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_SO holds an ordered list of LevelData_SO records. LevelManager checks 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 GetLevelDataAtIndex using List.Find is 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.

    WIndowDiagram
    PathGeneratorWindow showing the live grid preview and hazard sliders
    Diagram of the editor window

    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_SO gets 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.RecordObject registers 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 PlatformSide end-cap cells on either side.

    4 — Save and Load

    A finished level is saved into the LevelDatabase_SO with 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 Grid from the flattened array and feeds it straight into the PhysicalMazeGenerator to 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_SO asset and a prefab, not a code change.

    Runtime state that needs to survive scenes goes into a ScriptableObject. The GlobalDifficultyState_SO is 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.

    OnValidate keeps the editor in sync. Every SO that builds a cache does so in OnValidate as well as OnEnable, 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

    AssetPurpose
    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
© 2026 Samuel Styles