DEV Community

unity source code
unity source code

Posted on

Building a Relaxing Coloring Game in Unity: A Systems Breakdown of Mandala Fill Mechanics

Coloring games look simple from the outside. Tap a shape, it fills with a color, move to the next shape. But if you've ever tried to build one that feels good — smooth fills, no flood-fill lag, palettes that update instantly, and progress that survives an app restart — you know there's a surprising amount of systems design hiding behind that simplicity.

I recently went through the architecture of a mandala-style coloring template built in Unity, and wanted to break down the core systems the way I'd want them explained if I were reskinning or extending one myself. This isn't a marketing post — it's a walkthrough of the actual mechanics: how region detection works, how the palette and fill pipeline talk to each other, how zoom/pan is handled without killing frame rate on low-end Android devices, and how the save system persists a half-finished mandala across sessions.

If you want to see the finished product this breakdown is based on, there's a working template here: Mandala Coloring Unity Template. Everything below applies whether you're building from scratch or extending an existing base.

Why Coloring Games Are Harder Than They Look

The core loop of a coloring game — tap, fill, repeat — is trivial to describe and non-trivial to implement well. Three problems show up almost immediately:

  1. Region detection: how do you know which "cell" of the mandala the player just tapped?
  2. Fill rendering: how do you change that region's color without repainting the whole texture or triggering a garbage collection spike?
  3. State persistence: how do you remember which regions are colored, in what color, across app restarts, without bloating save file size?

Get any of these wrong and the game either lags on mid-range phones, looks visually broken (color bleeding past region borders), or loses player progress — all of which tank retention numbers fast in casual mobile games.

System 1: Region Detection (Sprite Segmentation vs. Mesh Segmentation)

There are two common approaches to segmenting a mandala into fillable regions:

Sprite-based segmentation treats each closed shape in the mandala as its own separate sprite object, layered with a shared outline sprite on top. Each sprite has its own SpriteRenderer and a PolygonCollider2D (or PolygonCollider2D auto-generated from the sprite's physics shape) so taps can be captured via standard OnMouseDown or a raycast against 2D colliders.

public class MandalaRegion : MonoBehaviour
{
    public int regionId;
    public SpriteRenderer regionRenderer;

    public void Fill(Color color)
    {
        regionRenderer.color = color;
        SaveSystem.Instance.RecordFill(regionId, color);
    }

    private void OnMouseDown()
    {
        Fill(PaletteManager.Instance.CurrentColor);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the approach most reskinnable coloring templates use, because it keeps each region as a discrete, inspectable GameObject — artists can drop in a new mandala design just by swapping sprites and re-generating colliders, without touching code.

Mesh/texture-based segmentation instead bakes the entire mandala into a single texture with a region ID encoded per pixel (often in an unused color channel or a separate lookup texture). A tap does a pixel read at the touch coordinate to determine the region ID, then a shader recolors just that region using a color-replace pass. This scales better for extremely dense mandalas (hundreds of tiny regions) since you avoid the overhead of hundreds of individual GameObjects and colliders, but it's harder for non-programmers to reskin since new art requires regenerating the ID lookup texture.

For most mobile coloring games — including mandala templates where region counts are usually in the 40–150 range — sprite-based segmentation is the more sustainable choice. It trades a small amount of raw performance for a huge gain in reskin speed, which matters more when the business model is "sell the template and let studios re-skin it fast."

System 2: The Palette-to-Fill Pipeline

The palette bar is deceptively important. Players expect:

  • Instant visual feedback when they select a color (no delay before the next tap fills correctly)
  • The currently selected color to be visually indicated (usually a border/highlight state)
  • Colors to persist correctly if the player switches palettes mid-design

A clean way to handle this is a singleton PaletteManager that other systems subscribe to via events rather than polling every frame:

public class PaletteManager : MonoBehaviour
{
    public static PaletteManager Instance;
    public Color CurrentColor { get; private set; }
    public event Action<Color> OnColorChanged;

    private void Awake() => Instance = this;

    public void SelectColor(Color color)
    {
        CurrentColor = color;
        OnColorChanged?.Invoke(color);
    }
}
Enter fullscreen mode Exit fullscreen mode

Region components then just read PaletteManager.Instance.CurrentColor at the moment of the tap, rather than caching a stale value. This avoids a subtle bug that shows up in a lot of hobby coloring-game code: the player selects a new color, taps a region, but it fills with the previous color because the region component cached the color reference on Start() instead of reading it live.

System 3: Zoom and Pan Without Killing Frame Rate

Mandalas have fine detail, so zoom is not optional — it's core to the game feel. The naive implementation (scaling a Canvas or parent Transform directly in response to pinch input) works but tends to produce jittery, low-frame-rate zooming on budget Android devices because every frame recalculates layout for potentially dozens of UI-driven region objects.

A more robust approach separates the camera from the canvas:

  • Keep the mandala on a World Space canvas (or plain sprites, not UI at all)
  • Drive zoom via Camera.main.orthographicSize, clamped to a min/max range
  • Drive pan via camera position, clamped to keep the mandala roughly centered in view
  • Use Cinemachine (or a lightweight custom camera rig) so zoom/pan momentum feels natural instead of snapping
public class MandalaCameraRig : MonoBehaviour
{
    public float minZoom = 2f;
    public float maxZoom = 8f;
    private Camera cam;

    private void Awake() => cam = Camera.main;

    public void Zoom(float delta)
    {
        cam.orthographicSize = Mathf.Clamp(
            cam.orthographicSize - delta,
            minZoom,
            maxZoom
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Driving zoom through the camera instead of scaling individual objects means the fill/tap logic never has to know or care about the current zoom level — a tap is still a tap, region colliders don't need re-scaling, and performance stays flat regardless of zoom depth. This is the same principle that shows up in a lot of casual mobile games: keep gameplay logic decoupled from camera/viewport state so one system's changes don't cascade into another.

System 4: Save System — Persisting Partial Progress

Coloring games are rarely finished in one sitting. A player might color 12 of 60 regions, close the app, and come back three days later expecting exactly where they left off. That means the save system needs to track, per mandala design:

  • Which regions have been filled
  • What color each filled region currently holds
  • Which mandala/level the player was last working on

A lightweight approach avoids saving a full serialized scene and instead stores a compact dictionary keyed by region ID:

[Serializable]
public class MandalaSaveData
{
    public string mandalaId;
    public List<int> filledRegionIds = new List<int>();
    public List<string> hexColors = new List<string>(); // parallel array
}

public class SaveSystem : MonoBehaviour
{
    public static SaveSystem Instance;
    private MandalaSaveData current;

    public void RecordFill(int regionId, Color color)
    {
        int idx = current.filledRegionIds.IndexOf(regionId);
        string hex = ColorUtility.ToHtmlStringRGB(color);

        if (idx >= 0) current.hexColors[idx] = hex;
        else
        {
            current.filledRegionIds.Add(regionId);
            current.hexColors.Add(hex);
        }

        PlayerPrefs.SetString(current.mandalaId, JsonUtility.ToJson(current));
    }
}
Enter fullscreen mode Exit fullscreen mode

PlayerPrefs is fine for a coloring game since save payloads are small (a mandala with 150 regions is still a tiny JSON blob), but if you're extending this into something with dozens of unlockable designs, cloud sync, or account-based progress, it's worth migrating to a proper local file store or a lightweight backend fairly early — retrofitting a save system after players already have progress on-device is a much more painful migration than building it in from day one.

System 5: Reskin Architecture — Why This Matters for the Business Model

A lot of coloring, puzzle, and casual mobile templates are sold specifically because they're fast to reskin — a studio buys the base mechanic once and reskins it repeatedly for different markets or ad campaigns. That only works if the codebase separates content from logic cleanly:

  • All mandala designs live as prefab variants referencing the same base MandalaController script
  • Palette color sets are ScriptableObject assets, not hardcoded arrays, so a new palette is a new asset, not a new build
  • UI theme (background, buttons, fonts) is decoupled from gameplay scripts entirely

This pattern isn't unique to coloring games — it's the same architectural discipline you'd want in any casual game meant to support multiple SKUs from one codebase. I covered a related angle of this — building systems that are meant to be extended and reskinned rather than rebuilt from scratch — in a previous breakdown of adding leaderboards and achievements to a Unity puzzle game, which walks through wiring meta-progression systems into an existing puzzle loop without touching core gameplay code. The same separation-of-concerns logic applies whether you're adding a leaderboard to a match-3 game or adding a new mandala pack to a coloring game — the content layer and the systems layer should never know too much about each other.

Extending the Core Loop

Once the fill, palette, zoom, and save systems are solid, most of the "make it feel like a full game" work is additive rather than architectural:

  • Daily challenges: a ScriptableObject-driven rotation that swaps which mandala is "featured" based on the system date
  • Gradient/texture fills: swap the flat SpriteRenderer.color assignment for a material property block driving a gradient shader
  • Ambient audio: a simple AudioSource crossfade system tied to scene load, decoupled from gameplay entirely
  • Monetization hooks: reward-based unlocks (watch an ad to unlock a premium palette or design) plumbed through the same PaletteManager/save system rather than as a bolted-on separate system

None of these require touching the region-fill or save-system core — which is exactly the point of building the foundation this way. A coloring game with a well-separated fill/palette/save architecture can absorb months of content updates (new mandala packs, new palettes, seasonal designs) without ever needing a rewrite of the underlying mechanics.

If you're building something adjacent — say, a physics-based puzzle rather than a fill-based one — a lot of the same principles (decoupled input handling, ScriptableObject-driven content, lightweight per-object save state) carry over directly. There's a similar mechanically-distinct but architecturally-related template worth looking at if nuts-and-bolts style puzzle mechanics are more your speed: Wood Nuts & Bolts Screw Unity Template, which deals with a different core loop (screw/unscrew physics puzzles) but faces a lot of the same reskin and save-state design questions.

Wrapping Up

Coloring games earn their "casual" label by hiding complexity, not by lacking it. The systems that make a mandala coloring game feel smooth — clean region segmentation, an event-driven palette pipeline, camera-decoupled zoom, compact save state, and a content/logic split that supports fast reskinning — are the same categories of systems you'll run into building almost any casual mobile game. Get those five things right and the rest (new designs, new palettes, new monetization hooks) is just content work layered on top of a foundation that doesn't need to change.

If you're prototyping something similar, start with the region-detection decision first — sprite-based vs. texture-based segmentation shapes almost every downstream decision about performance, reskin speed, and how your save system needs to be structured. Everything else in this breakdown follows from getting that one choice right for your specific mandala density and target device range.

Top comments (0)