# Conflicts: # Packages/manifest.json
Color Book — Architecture Guide
A toddler-targeted (ages 2–6) coloring book game built on the same Strict Modular Monolith pattern as the Bus Game. Powered by VContainer for DI, UniTask for async, Addressables for shipped content, and a Canvas-only UI render strategy.
This document is the canonical reference for the Color Book game's structure. The Bus Game's Darkmatter Architecture Guide is the parent contract; this doc only adds game-specific structure.
1. Game Flow
App launch
└─ Boot scene (RootLifetimeScope)
└─ MainMenu scene (Spine mascot, Play button)
└─ Press "Play" → ColorBook scene
├─ Drawing catalog (grid of templates)
├─ Select drawing
├─ Shape Builder panel (drag pieces → snap to slots)
├─ ↓ on assembly complete
├─ Color panel (tap color → tap region)
├─ Undo / Redo any time
├─ "Save" → screenshot via CaptureCamera → native gallery plugin
│ saves PNG to phone's Photos album. Toast confirmation.
└─ "Next" → auto-save + load next drawing
The user views their captured drawings inside the phone's native Photos app — there is no in-app gallery viewer. Capture and gallery-save are two independent services: ICaptureService produces PNG bytes; IGalleryService is a thin shim over a native plugin that writes those bytes into the device's photo library.
2. Philosophy
Identical to Bus Game:
- Vertical slices — code grouped by Feature, not by type.
- Strict layering — dependencies flow downward only.
- Composition over inheritance — wired by DI.
- Code vs Content —
Code/for logic,Contents/for assets.
Game-specific additions:
- Toddler-first UX — large hitboxes, forgiving snap radii, no fail states, no timers.
- Stateless replay — every action (paint, snap) is an
ICommandso undo/redo and auto-save are trivial. - Capture-as-truth — the gallery is a folder of PNGs on disk, not a serialized scene graph. What the child sees is what gets saved.
3. Dependency Graph
App ──────────┐
▼
Features ──► Core ◄── Services
▲
└── Libs
Forbidden actions
- Services cannot reference Features.
- Features cannot reference Service implementations — only Core contracts via DI.
- Features cannot reference other Features — use
IEventBussignals or Core contracts. - Core may not contain logic. Interfaces, enums, DTOs, signal records only.
- Core may reference
UniTaskfor async contract signatures. Nothing else.
4. Folder Structure
This section reflects the actual project on disk today. Empty folders that have been reserved for upcoming work are marked (planned); everything else has at least one file in it. Aspirational additions for the rest of the game are listed in §4c at the bottom.
4a. Actual layout on disk
Assets/Darkmatter/
├── Scenes/
│ └── Boot.unity ← only scene wired so far
│
├── Content/ ← singular ("Content", not "Contents")
│ └── Gameplay/
│ └── PaperRig/ ← stale folder — paper rig dropped; safe to delete
│
├── Data/
│ ├── Inputs/ (Input System .inputactions)
│ └── Settings/
│ ├── Persistance/Resources/ (ProtectedPlayerPrefs settings)
│ ├── Rendering/ (URP renderer + asset)
│ └── Scenes/URP2DSceneTemplate.unity
│
└── Code/
├── App/
│ └── LifetimeScopes/
│ └── RootLifetimeScope.cs ← scope loads serialized IServiceModule list
│ Darkmatter.App.asmdef
│
├── Core/ (asmdef name: `Core`, namespace root `Darkmatter.Core.*`)
│ ├── Compatibility/
│ │ └── IsExternalInit.cs (C#9 init shim for older runtimes)
│ ├── Contracts/
│ │ ├── Paper/ ← stale empty folder — delete (Paper is no longer a feature)
│ │ └── Services/
│ │ ├── Assets/IAssetProviderService.cs
│ │ ├── Audio/IAudioService.cs, ISfxPlayer.cs
│ │ ├── Camera/ICameraService.cs
│ │ ├── Capture/ ← (planned — ICaptureService)
│ │ ├── Inputs/IInputReader.cs
│ │ └── Scenes/ISceneService.cs
│ ├── Data/
│ │ ├── Dynamic/Services/Audio/ (AudioHandle, AudioRequest)
│ │ └── Static/Services/Audio/ (SfxCatalogSO)
│ └── Enums/
│ └── Services/
│ ├── Audio/ (AudioChannel, AudioPlayMode, SfxId)
│ ├── Camera/CameraType.cs (MainCamera, UICamera — ArtCamera not added yet)
│ └── Scenes/GameScene.cs
│
├── Features/ ← (planned — empty folder today)
│
├── Libs/
│ ├── FSM/ (IState, State, StateMachine + Docs)
│ │ Libs.FSM.asmdef
│ ├── Installers/ (IServiceModule — Register(IContainerBuilder))
│ │ Libs.Installers.asmdef
│ ├── Observer/ (IEventBus, EventBus — note: not named "EventBus")
│ │ Libs.Observer.asmdef
│ ├── PlayerPrefs/ (ProtectedPlayerPrefs — used in place of a Persistence service)
│ │ ├── Editor/ Libs.PlayerPrefs.Editor.asmdef
│ │ └── Runtime/ Libs.PlayerPrefs.asmdef
│ └── UI/ (ToggleButton, ToggleButtonGroup)
│ Libs.UI.asmdef
│
└── Services/
├── Analytics/
│ ├── Installers/AnalyticsServiceModule.cs
│ └── Systems/FirebaseAnalyticsSystem.cs
│ Services.Analytics.asmdef
├── Assets/
│ ├── AddressableAssetProviderService.cs
│ └── AddressableLoadHandleTracker.cs
│ Services.Assets.asmdef
├── Audio/
│ ├── AudioService.cs
│ └── SfxPlayer.cs
│ Services.Audio.asmdef
├── Camera/
│ ├── Installers/CameraServiceModule.cs
│ └── Service/CameraService.cs
│ Services.Camera.asmdef
├── Inputs/
│ ├── Generated/GameInputs.cs (Input System codegen)
│ ├── Installers/InputServiceModule.cs
│ └── Readers/InputReaderSO.cs
│ Services.Inputs.asmdef
└── Scenes/
└── SceneService.cs
Services.Scenes.asmdef
4b. Conventions visible in current code
- Asmdef per Service / Lib / App / Core. No Feature asmdefs yet (folder is empty).
- Core sub-tree shape:
Core/{Compatibility, Contracts, Data, Enums}— deeply nested by Service rather than by topic. Game-specific Core types (Drawing, Coloring, Paper, Gallery, History, Progression, Signals) will be added under eitherCore/Contracts/<Area>/or new top-levelCore/<Area>/— pick one convention before adding the first one. - Service sub-shape varies — some are flat (
Services/Audio/*.cs), some split (Services/Camera/Installers/,Services/Camera/Service/). The "official" per-feature shape (§4d) hasn't been validated against real code yet. - Installer pattern:
MonoBehaviour, IServiceModulewithRegister(IContainerBuilder builder). NotIInstaller.Install(...)as older sections of this doc imply. New installers must followIServiceModule. - No
AppBootentry point exists yet.RootLifetimeScopeonly iterates a serializedMonoBehaviour[] serviceModulesand callsRegisteron eachIServiceModule. Boot sequence in §29 is aspirational. - No Persistence service.
Libs/PlayerPrefs(theProtectedPlayerPrefslibrary) is in place for small-key state. JSON persistence for gallery sidecars will need a separate service when Gallery lands. - Camera service is a registry, not a fitter.
ICameraService.Register/Getonly — no aspect-fit logic.
4c. Planned additions (not on disk yet)
All new game code follows the same nesting pattern as existing Services — Contracts/Features/<Name>/, Data/{Dynamic,Static}/Features/<Name>/, Features/<Name>/ — and asmdefs drop the Darkmatter. prefix to match Core, Services.Audio, Libs.Observer.
Rough landing order for ColorBook scene to be playable:
| Path | Role |
|---|---|
Core/Contracts/Services/Capture/ICaptureService.cs |
Capture service contract — returns PNG bytes |
Core/Contracts/Services/Gallery/IGalleryService.cs |
Native-gallery save shim |
Core/Contracts/Features/Drawing/IDrawingTemplate.cs, IDrawingTemplateCatalog.cs |
Drawing template contracts |
Core/Contracts/Features/Coloring/IColorPalette.cs |
Palette contract |
Core/Contracts/Features/Progression/IProgressionService.cs |
Progression contract |
Core/Data/Static/Features/Drawing/DrawingTemplateSO.cs |
Authored drawing data |
Core/Data/Static/Features/Drawing/ShapeSO.cs |
Authored shape (sprite + snap params, reusable) |
Core/Data/Static/Features/Coloring/ColorPaletteSO.cs |
Authored palette data |
Core/Data/Dynamic/Features/Drawing/ColorRegionDTO.cs |
Runtime region struct |
Core/Data/Dynamic/Features/Coloring/PaintCommandDTO.cs |
Runtime coloring struct |
Core/Data/Dynamic/Features/Signals/*.cs (DrawingSelectedSignal, ShapeAssembledSignal, ColorAppliedSignal, PaperSavedSignal) |
Cross-feature signal structs |
Core/Enums/Services/Camera/CameraType.cs |
Add CaptureCamera enum value to existing file |
Services/Capture/ (+ Services.Capture.asmdef) |
RenderTextureCaptureService drives the disabled CaptureCamera |
Services/Gallery/ (+ Services.Gallery.asmdef) |
NativeGallerySaveService — wraps the native gallery plugin |
Features/MainMenu/ (+ Features.MainMenu.asmdef) |
Spine mascot + Play button |
Features/{DrawingCatalog,ShapeBuilder,Coloring,Capture,Progression,ColorBookFlow}/ (+ asmdefs each) |
Game features |
App/LifetimeScopes/{MainMenu,ColorBook}LifetimeScope.cs |
Per-scene scopes |
App/Boot/AppBoot.cs |
Bootstrap entry point |
Assets/Darkmatter/Scenes/{MainMenu,ColorBook}.unity |
Scenes (no ArtBook — captures go to phone Photos) |
Content/Gameplay/Drawings/<theme>/<id>/{Template.asset, Drawing.prefab, Regions/, PaperBackground.png} |
Authored drawings — Drawing.prefab holds SlotMarkers at slot poses with ShapeSO refs |
Content/Gameplay/Shapes/*.asset |
Reusable ShapeSOs (one per shape; shared across drawings) |
Content/Gameplay/Prefabs/ShapePiece.prefab |
The single piece prefab (ShapePieceUI MB on root) |
Content/Gameplay/Palettes/*.asset |
Color palettes |
Content/Audio/{UI,Coloring}/ |
SFX banks |
4d. Per-feature folder layout (matches existing Services pattern)
Look at Services/Camera/ (Installers/ + Service/) and Services/Analytics/ (Installers/ + Systems/) — that's the convention. Features adopt the same shape, adding UI/ or Views/ only when there's something to put in them.
Features/[Name]/
├── Installers/ IServiceModule — VContainer registration
├── Systems/ (or Service/) Controllers, repositories, factories (pure C#)
├── UI/ (only if the feature has Canvas UI)
│ ├── *Presenter.cs Pure C#, listens to model, drives view
│ └── *View.cs MonoBehaviour, setters only
├── Views/ (only if the feature has world-space MonoBehaviours)
└── Features.<Name>.asmdef
Rules of thumb pulled from current Services:
- Use
Service/(singular) when the feature has exactly one main implementation class (likeServices/Camera/Service/CameraService.cs). - Use
Systems/(plural) when there are multiple pure-C# coordinators (likeServices/Analytics/Systems/). - Skip nesting entirely when the feature has only 1–2 files at root (like
Services/Audio/AudioService.cs+SfxPlayer.csflat). Docs/is per-folder in current code — drop aDocs/inside any sub-folder that needs notes, don't make a global feature-level Docs.
5. Namespaces & Asmdef naming
C# namespace pattern is Darkmatter.<Layer>.<Module>[.<SubArea>] — the Darkmatter. prefix stays on namespaces. Examples already in code:
Darkmatter.Core.Contracts.Services.Camera(ICameraService.cs)Darkmatter.Services.Camera(CameraService.cs)Darkmatter.Services.Camera.Installers(CameraServiceModule.cs)Darkmatter.Libs.Installers(IServiceModule.cs)
Asmdef names drop the Darkmatter. prefix. Existing pattern:
| Namespace | Asmdef |
|---|---|
Darkmatter.Core.* |
Core |
Darkmatter.App |
Darkmatter.App (one exception — keep as-is, don't churn) |
Darkmatter.Libs.Observer |
Libs.Observer |
Darkmatter.Libs.FSM |
Libs.FSM |
Darkmatter.Libs.Installers |
Libs.Installers |
Darkmatter.Libs.PlayerPrefs |
Libs.PlayerPrefs (+ Libs.PlayerPrefs.Editor) |
Darkmatter.Libs.UI |
Libs.UI |
Darkmatter.Services.Audio |
Services.Audio |
Darkmatter.Services.Assets |
Services.Assets |
Darkmatter.Services.Camera |
Services.Camera |
Darkmatter.Services.Inputs |
Services.Inputs |
Darkmatter.Services.Scenes |
Services.Scenes |
Darkmatter.Services.Analytics |
Services.Analytics |
New asmdefs follow the same convention: Services.Capture, Services.Gallery, Libs.CommandStack, Features.Paper, Features.Coloring, etc.
6. Scenes & Lifetime Scopes
| Scene | Scope | Status | Contents |
|---|---|---|---|
Boot.unity |
RootLifetimeScope |
✅ exists | All Services + Libs. Persists forever. |
MainMenu.unity |
MainMenuLifetimeScope |
⚠️ planned | Spine mascot, Play button. |
ColorBook.unity |
ColorBookLifetimeScope |
⚠️ planned | DrawingCatalog, ShapeBuilder, Coloring, History, Capture, ColorBookFlow. |
Only Boot.unity exists today; the two scene scope classes haven't been written yet either (only RootLifetimeScope exists in App/LifetimeScopes/).
Scopes nest: Root → (MainMenu | ColorBook). Services resolved from the root parent. Scene scopes only register their own features. There is no in-app gallery — captured drawings go to the phone's native Photos app via the gallery service.
Boot chain (planned)
No AppBoot class exists yet — today RootLifetimeScope only registers services and stops there. When AppBoot is added (as an IAsyncStartable registered via builder.RegisterEntryPoint<AppBoot>()), it should run once, in order:
- Initialize
IAssetProviderService(Addressables init). - Preload essential bundles (palettes, UI sounds).
- Load
IProgressionServicefrom disk. - Load
MainMenuscene.
Failures show a child-friendly retry screen; never crash.
7. Rendering Strategy
Full Canvas UI. No SpriteRenderer, no Physics2D, no offscreen RenderTexture for the live view. The paper, slots, pieces, and color regions are all Image components on a Screen-Space-Camera canvas. Standard Unity UI eventing (IPointerDownHandler, IDragHandler) handles all input.
┌──────────────────────────────────────────────────────────┐
│ PaperCanvas (Screen Space - Camera, UICamera) │
│ layer: PaperUI │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ PaperPanel (RectTransform, 2048×2048 ref units) │ │
│ │ ├─ BackgroundImage │ │
│ │ ├─ SlotsPanel (slot Image outlines) │ │
│ │ ├─ PiecesPanel (draggable piece Images) │ │
│ │ └─ RegionsPanel (colorable region Images) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ HUDCanvas (Screen Space - Overlay, OR separate camera) │
│ layer: HUDUI │
│ ├─ Palette panel │
│ ├─ Undo / Redo buttons │
│ ├─ Capture / Next buttons │
│ └─ Tray panel (during build phase) │
└──────────────────────────────────────────────────────────┘
CaptureCamera (disabled by default, one-shot Render() on capture)
orthographic, projection cloned from UICamera
cullingMask = PaperUI only
targetTexture = temp RT allocated per capture (2048×2048)
Cameras
| Camera | Render mode | Culling Mask | Render Target | Purpose |
|---|---|---|---|---|
UICamera |
Screen-Space - Camera (orthographic) | PaperUI, HUDUI |
Screen | Normal display each frame. |
CaptureCamera |
Orthographic, disabled | PaperUI only |
temp RenderTexture |
One-shot Render() invoked by ICaptureService.CaptureAsync(). |
CaptureCamera shares UICamera's position, orthographic size, and clip planes so the captured frame matches what the player sees — minus the HUD because of the culling mask.
Layers
| Layer | Used by |
|---|---|
PaperUI |
PaperCanvas and all of its children (background, slots, pieces, regions, completion FX). Visible in capture. |
HUDUI |
HUDCanvas and tray panel (palette, undo/redo, capture button, drawing catalog grid, etc.). Excluded from capture. |
EventSystem |
Unity's input layer — managed automatically. |
Why full UI
| Need | Choice | Why |
|---|---|---|
| Tap-to-paint region | Image + Image.alphaHitTestMinimumThreshold + IPointerClickHandler |
Tight alpha-based hit shape per region. No mesh / collider authoring. Tap events route through EventSystem natively. |
| Drag/drop shape pieces | Image + IBeginDragHandler / IDragHandler / IEndDragHandler |
Standard Unity UI drag. Pointer events come in canvas-local coords already. No screen→world math anywhere. |
| Visual transition during drag → snap | DOAnchorPos, DOSizeDelta, DOLocalRotate |
All pose is in RectTransform units. The "transition" is a tween over canvas-local values — no swap of render context. |
| Capture to PNG | Dedicated CaptureCamera with cullingMask = PaperUI |
One Render() call into a temp RT. HUD physically can't appear. |
| Multi-resolution support | CanvasScaler on PaperCanvas (Scale With Screen Size) |
Reference resolution 2048 × 2048, Match = 1 (height). All anchoredPosition units are constant across devices. |
| HUD layout independent of paper | HUDCanvas (separate Canvas) |
HUD scales/anchors per its own rules without affecting the paper layout. |
| Drawing catalog grid, palette, etc. | Standard UI (GridLayoutGroup, ScrollRect, Button) |
Anchors handle aspect ratios. Async thumbnail loader. |
Multi-resolution rule
The paper content is canvas-unit-stable. Author every drawing against a fixed 2048 × 2048 reference resolution. Slot positions, piece sizes, region rects, hit shapes — all expressed in anchoredPosition / sizeDelta units. CanvasScaler on PaperCanvas does the screen mapping.
PaperPanel is anchored center, fixed 2048×2048 (or whatever you pick for the reference). On a wider screen, CanvasScaler pillarboxes the panel; on a narrower screen, it letterboxes. The panel's contents never resize relative to each other.
If you want a backdrop (wood/cloth behind the paper area), it's a sibling Image of PaperPanel (still on PaperUI layer) sized to fill the canvas. The backdrop is captured into the PNG by default — set its layer to HUDUI if you want it excluded.
Tradeoff vs the old RT-paper-rig design
| Concern | RT-paper-rig (old) | Canvas-only (current) |
|---|---|---|
| Paper contracts | 2 contracts + ~80 lines of math | gone (paper is just RectTransforms in scene) |
| Input pipeline | IInputReader → bridge → Physics2D.OverlapPoint |
native EventSystem (IPointerDownHandler etc.) |
| Coloring hit shape | PolygonCollider2D from Sprite.Editor physics shape |
Image.alphaHitTestMinimumThreshold = 0.5f on the region sprite |
| Per-frame render passes | 2 (ArtCamera into RT + UICamera draws RawImage) | 1 (UICamera draws everything) |
| Capture | read persistent RT | one-shot CaptureCamera.Render() |
| Coordinate gotchas | mismatches between screen / RT / world | none — everything is canvas-local |
If you ever need world-space effects (particle sparkles that physically explode outside the paper, free-draw brush stroke, pinch zoom on the artwork), revisit the RT approach. For the v1 tap-to-fill + drag-to-snap design, Canvas-only is correct.
8. Core Contracts
All Core types are pure data or interfaces.
Drawing
Contracts live in
Darkmatter.Core.Contracts.Features.Drawing; DTOs inDarkmatter.Core.Data.Dynamic.Features.Drawing.
namespace Darkmatter.Core.Contracts.Features.Drawing;
public interface IDrawingTemplate {
string Id { get; }
string DisplayName { get; }
Sprite DefaultThumbnail { get; } // authored fallback (used when user has no captures for this template)
Sprite PaperBackground { get; }
IReadOnlyList<ShapeSO> Pieces { get; } // shapes that get spawned in the tray for this drawing
IReadOnlyList<ColorRegionDTO> Regions { get; }
}
public readonly struct ColorRegionDTO {
public string RegionId { get; }
public Sprite Sprite { get; } // assigned to Image.sprite
public Vector2 AnchoredPosition { get; } // canvas units, relative to RegionsParent
public Vector2 SizeDelta { get; } // canvas units
public Color InitialColor { get; } // usually white
// Hit shape comes from the sprite alpha — set Image.alphaHitTestMinimumThreshold = 0.5.
// No polygon path needed; sprite import settings ("Read/Write Enabled") provide it.
}
Shape authoring (ShapeSO + one prefab)
Shapes are authored as ScriptableObject assets via the Project Create menu (Assets > Create > Darkmatter > Drawing > Shape). One asset per shape — reusable across many drawings.
namespace Darkmatter.Core.Data.Static.Features.Drawing;
[CreateAssetMenu(menuName = "Darkmatter/Drawing/Shape", fileName = "Shape_")]
public sealed class ShapeSO : ScriptableObject
{
[field: SerializeField] public string Id { get; private set; }
[field: SerializeField] public Sprite Sprite { get; private set; }
[field: SerializeField] public Vector2 DefaultSizeDelta { get; private set; } = new(256, 256);
[field: SerializeField] public float SnapRadius { get; private set; } = 100f;
[field: SerializeField] public float PreviewRadius { get; private set; } = 200f;
}
How the runtime uses it:
- One piece prefab. A
ShapePiecePrefabinContent/Gameplay/Prefabs/carriesImage+ShapePieceUI. TheShapePieceUIMonoBehaviour has a[SerializeField] ShapeSO _shapefield — empty on the raw prefab, filled in by the controller at spawn time (or pre-assigned in inspector for testing scenes). SlotMarkerlives in the drawing's per-drawing scene/prefab at the slot's authored pose. Its[SerializeField] ShapeSO _shapefield tells which shape fits this slot. The slot'sRectTransform(anchoredPosition,sizeDelta,localRotation) is the target snap pose.- Matching is by
ShapeSOreference equality. Piece P matches slot S iffP._shape == S._shape. No string-id lookups at runtime. - Identity follows the asset. Whenever
_shapechanges (inspector edit or runtime assign),ShapePieceUIre-applies_shape.Spriteto itsImage, setsRectTransform.sizeDelta = _shape.DefaultSizeDelta, and exposesPieceId => _shape.Id. Done viaOnValidate(editor) +Awake(runtime) + an explicitAssign(ShapeSO)method (controller-driven).
Optional future editor tool: a wizard window for bulk-creating
ShapeSOs from a folder of sprites — setsIdfrom filename, assigns sprite, applies sensible default radii. For v1, the plain Create-Asset-Menu is enough.
Coloring
Contracts in
Darkmatter.Core.Contracts.Features.Coloring; DTOs inDarkmatter.Core.Data.Dynamic.Features.Coloring.
namespace Darkmatter.Core.Contracts.Features.Coloring;
public interface IColorPalette {
string Id { get; }
IReadOnlyList<Color> Colors { get; }
}
public readonly struct PaintCommandDTO {
public string RegionId { get; }
public Color FromColor { get; }
public Color ToColor { get; }
}
Scene composition (no Paper feature)
The paper area is just a PaperPanel GameObject in the ColorBook.unity scene with child RectTransforms for slots, pieces, and regions. There is no IPaperSurface contract and no Paper feature. Features that need to parent UI under one of these roots read them from a small ColorBookSceneRefs : MonoBehaviour that the scene scope registers as a singleton:
public sealed class ColorBookSceneRefs : MonoBehaviour {
[SerializeField] public RectTransform PaperRoot;
[SerializeField] public RectTransform SlotsParent;
[SerializeField] public RectTransform PiecesParent;
[SerializeField] public RectTransform RegionsParent;
[SerializeField] public Camera CaptureCamera; // disabled, used by ICaptureService
[SerializeField] public RectTransform HudRoot;
[SerializeField] public RectTransform TrayPanel;
}
Registered once via ColorBookLifetimeScope:
builder.RegisterInstance(_sceneRefs);
Features inject ColorBookSceneRefs directly and read the rects they need. This keeps the scene refs visible in the inspector while avoiding a one-feature wrapper that adds nothing.
History
Contracts in
Darkmatter.Core.Contracts.Features.History.
namespace Darkmatter.Core.Contracts.Features.History;
public interface ICommand {
void Execute();
void Undo();
}
public interface IUndoStack {
bool CanUndo { get; }
bool CanRedo { get; }
void Push(ICommand cmd); // executes + appends
void Undo();
void Redo();
void Clear();
}
Capture & Gallery
Two separate, independent services. Capture produces PNG bytes. Gallery writes those bytes into the device's native photo library. Neither service knows about the other; the orchestration lives in the Capture feature.
namespace Darkmatter.Core.Contracts.Services.Capture;
public interface ICaptureService {
// Renders the disabled CaptureCamera into a temp RT, ReadPixels into a Texture2D,
// encodes PNG, releases the RT. Returns the encoded bytes.
UniTask<byte[]> CaptureAsync();
}
namespace Darkmatter.Core.Contracts.Services.Gallery;
public interface IGalleryService {
// Saves the given PNG bytes into the device's native photo library
// under the given album. Native plugin handles platform permissions.
UniTask SaveToDeviceAsync(byte[] png, string albumName = "Color Book");
}
ICaptureServiceowns theCaptureCamerareference (a disabledCamerain the ColorBook scene). The camera'scullingMaskis set toPaperUIso the HUD physically cannot appear in the PNG. Paper background is just anImageonPaperUI— no compositing pass.IGalleryServiceis a thin shim over a native gallery plugin (NativeGallery, NativeShare, or a custom plugin). It does not save thumbnails, does not maintain a file list, does not delete entries. Everything in the device library is owned by the OS.- There is no
SavedArtworkDTO, no sidecar JSON, no in-app gallery file system. The user views their drawings in the phone's Photos app.
Signals
Signal structs live in
Darkmatter.Core.Data.Dynamic.Features.Signals(runtime data, cross-feature).
namespace Darkmatter.Core.Data.Dynamic.Features.Signals;
public readonly struct DrawingSelectedSignal {
public string TemplateId { get; }
}
public readonly struct ShapeAssembledSignal {
public string TemplateId { get; }
}
public readonly struct ColorAppliedSignal {
public string RegionId { get; }
public Color Color { get; }
}
public readonly struct PaperCapturedSignal {
public string TemplateId { get; } // captured, before native-gallery save
}
public readonly struct PaperSavedSignal {
public string TemplateId { get; } // PNG written to phone library
}
9. Feature Responsibilities
MainMenu
- Lives in
MainMenu.unity. Single primary action: Play (→ColorBookscene). There is no in-app gallery viewer — captured drawings live in the phone's Photos app. - Hosts a Spine character mascot (via
SkeletonGraphicfor Canvas). Authored animations include idle loop, wave, react-to-button, victory dance. MenuMascotPresenter(pure C#) drives the mascot from code: subscribes to Play-button hover / click events and the model's idle timer, callsIMenuMascotView.Play(animName, loop).- View is setter-only. Spine-Unity's
SkeletonGraphic.AnimationState.SetAnimation(track, name, loop)is encapsulated behindIMenuMascotView. - Mascot's skeleton + atlas ship via Addressables (see §10).
DrawingCatalog
- Loads the catalog manifest (list of available template IDs).
- Presents a scrollable grid of thumbnails (Canvas), one per template.
- Each cell shows
IDrawingTemplate.DefaultThumbnail(the authored fallback sprite). The user's captured drawings live in the phone's Photos app, not in the catalog grid. - On select → fires
DrawingSelectedSignal(templateId)and unloads the catalog UI.
ShapeBuilder
- Listens to
DrawingSelectedSignal. - Loads template, instantiates the single piece prefab once per
ShapeSOin the template, parents under the HUD tray panel (ColorBookSceneRefs.TrayPanel). Each instance isAssign(shape)ed to itsShapeSO. SlotMarkers in the drawing's per-drawing prefab (underColorBookSceneRefs.SlotsParent) provide target poses + matchingShapeSOrefs.- Each piece has
IBeginDragHandler/IDragHandler/IEndDragHandlerplus a per-pieceShapePieceFsm. Drag updatesRectTransform.anchoredPositiondirectly fromPointerEventData, converted to canvas-local viaRectTransformUtility.ScreenPointToLocalPointInRectangle. - On entering preview radius of the matching slot: reactive
LerpofanchoredPosition/sizeDelta/localRotationtowardSlotMarker'sRectTransform. Drives off pointer distance, not time. - On
OnEndDraginside snap radius: piece reparents toColorBookSceneRefs.PiecesParent, DOTween ease-out to exact slot pose, disable input. Otherwise DOTween back to tray slot. - Fires
ShapeAssembledSignalwhen all pieces locked.
Coloring
- Listens to
ShapeAssembledSignal. - Spawns one UI
ImageperColorRegionDTOunderColorBookSceneRefs.RegionsParent. Each region'sImage.alphaHitTestMinimumThreshold = 0.5fso taps on transparent pixels pass through to the next region. - Each region has
IPointerClickHandler. On click →ColoringController.PaintRegion(view). - Listens to palette selection (current color held in
ColoringStateRepository). - Controller builds
PaintRegionCommand(regionId, oldColor, newColor)and pushes toIUndoStack. - Command sets
Image.coloron undo/redo. - Fires
ColorAppliedSignalfor SFX / sparkle effects.
History
- Owns the scoped
IUndoStackfor the current ColorBook session. - Cleared on
DrawingSelectedSignal(new drawing = fresh history). - Capped at 20 entries (memory + cognitive simplicity).
- UI: two big arrow buttons; disabled state when
CanUndo / CanRedois false.
Capture
- Bound to the "Save" button (and triggered silently by "Next").
CaptureController.SaveAsync(templateId):_capture.CaptureAsync()→ PNG bytes (one-shotCaptureCamera.Render()into a temp RT)- Publish
PaperCapturedSignal(templateId) _gallery.SaveToDeviceAsync(bytes, "Color Book")→ native plugin writes into phone's Photos- Publish
PaperSavedSignal(templateId)
- HUD shows a brief "Saved to Photos" toast on
PaperSavedSignal. CaptureControlleris the only place that orchestrates capture-then-save. Other features never callIGalleryServicedirectly.
Progression
- Tracks completed template IDs and the in-progress draft.
- On "Next" button: silently runs
CaptureController.SaveAsync, marks current as completed, callsIDrawingTemplateCatalog.NextUnseen(). - Persists JSON via
Libs.PlayerPrefs(ProtectedPlayerPrefs).
ColorBookFlow
- The only orchestrator inside ColorBook scope.
- Subscribes to flow-relevant signals and toggles UI panels (catalog → builder → coloring).
- Coordinates "Next" sequence:
CaptureController.SaveAsync→IProgressionService.MarkCompleted→IDrawingTemplateCatalog.Release(currentId)→ load next. - Built as a small FSM (
Catalog → Building → Coloring → Done).
10. Addressables Strategy
Mirror the Bus Game pattern via IAssetProviderService.
What ships through Addressables
| Asset | Why |
|---|---|
DrawingTemplate ScriptableObject (per drawing) |
Many; load on demand. |
ShapeSO assets |
Reused across drawings; load once per drawing batch. |
| Region sprites | Heavy; loaded per drawing. |
| Paper backgrounds | Per template, sometimes shared. |
| Color palette SOs | Swap per theme. |
| Audio clips (tap, snap, complete, sparkle) | Shared SFX bank. |
Spine mascot (SkeletonDataAsset + atlas) |
Heavy textures; load with MainMenu scene, release on scene exit. |
What does NOT use Addressables
- HUD prefabs (palette button, undo icon) — always loaded with scene.
- Core UI canvases.
- Boot scene assets.
- User-saved gallery PNGs — those live in
Application.persistentDataPath.
Group layout
Drawings_Animals (label: drawing, animals)
Drawings_Vehicles (label: drawing, vehicles)
Drawings_Shapes (label: drawing, shapes)
Shapes_Library (label: shape) — reusable ShapeSO assets
Palettes (label: palette)
Audio_UI (label: sfx, ui)
Audio_Coloring (label: sfx, coloring)
Spine_MainMenu (label: spine, menu) — mascot skeleton + atlas
Lifecycle
- Catalog loads thumbnail handles only (cheap).
- On select → full template loads (pieces + regions + paper).
- On "Next" or scene exit → previous template
Released before next loads. - This bound matters on toddler tablets with limited RAM.
Remote groups (future)
Drawing packs ship as remote bundles. New theme packs (Christmas, Dinosaurs) update without an app store release.
11. Persistence
Only one in-app persistent store — small settings + progression. Captured drawings go to the device's native photo library (managed by the OS, not by the app).
Settings + progression via Libs.PlayerPrefs
ProtectedPlayerPrefs (in Libs/PlayerPrefs/) is a lightweight encrypted-string wrapper around Unity's PlayerPrefs. Used for:
- Completed template IDs (JSON-encoded list).
- Last opened drawing.
- Audio volume, simple toggles.
A higher-level IProgressionService reads/writes these keys; consumers never touch PlayerPrefs directly.
Native photo library (gallery)
Captured PNGs go to the phone's Photos app via IGalleryService.SaveToDeviceAsync(bytes, albumName). The app does not:
- Write
.pngfiles topersistentDataPath. - Generate or store thumbnails locally.
- Maintain any sidecar JSON / index.
- Provide list / load / delete operations.
The user opens the phone's Photos app to view, share, or delete their drawings. iOS / Android handle permissions and album organization.
12. Capture Pipeline
A dedicated CaptureCamera lives in the ColorBook scene, disabled by default. It renders only the PaperUI layer into a temp RenderTexture when capture fires. The PNG bytes are then handed to the native gallery plugin — no local file IO.
[Save button or Next button]
│
▼
CaptureController.SaveAsync(templateId)
│
▼
ICaptureService.CaptureAsync()
│
├─ rt = RenderTexture.GetTemporary(2048, 2048, 0, ARGB32)
├─ _captureCam.targetTexture = rt
├─ _captureCam.Render() (one-shot; cullingMask = PaperUI only)
├─ _captureCam.targetTexture = null
├─ prev = RenderTexture.active
├─ RenderTexture.active = rt
├─ tex = new Texture2D(2048, 2048, RGBA32, false)
├─ tex.ReadPixels(full rect, 0, 0); tex.Apply()
├─ RenderTexture.active = prev
├─ RenderTexture.ReleaseTemporary(rt)
├─ bytes = tex.EncodeToPNG() (on worker via UniTask.RunOnThreadPool)
├─ Object.Destroy(tex)
└─ return bytes
▼
EventBus.Publish(new PaperCapturedSignal(templateId))
│
▼
IGalleryService.SaveToDeviceAsync(bytes, "Color Book")
│
├─ native plugin handles platform permissions
├─ writes PNG into the device's Photos album
└─ (no return — fire and forget; throws on failure)
▼
EventBus.Publish(new PaperSavedSignal(templateId))
Notes:
- HUD never appears in capture because
CaptureCamera.cullingMaskexcludesHUDUI. Layer mask, not coincidence — even if you accidentally parent a HUD element underPaperPanel, putting it on the wrong layer keeps it out. - Paper background is just an
ImageonPaperUI. Already in the right layer; no special compositing. - Saved PNGs are 2048×2048 on every device.
CaptureCamerahas fixedorthographicSizeand aspect, independent of screen size. CaptureAsyncis safe to call repeatedly. The CaptureCamera's transform / projection are set once at scene start and never modified.- The temp RT is allocated via
RenderTexture.GetTemporaryso successive captures don't leak GPU memory. IGalleryServiceandICaptureServiceare independent —IGalleryServiceknows nothing about the camera;ICaptureServiceknows nothing about the native plugin. The chain is theCaptureController's sole responsibility.
13. Communication Rules
| Use case | Mechanism |
|---|---|
| Load template, return result | Direct DI call (IDrawingTemplateCatalog.LoadAsync). |
| Capture → save chain | Direct DI calls, sequenced. |
| Notify HUD that a region was painted | IEventBus signal. |
| Notify Progression that a drawing was completed | IEventBus signal. |
| Tell ColorBookFlow that pieces are assembled | IEventBus signal. |
| Tell Coloring which color is currently selected | Direct DI on ColoringStateRepository. |
Never use signals for request/response. If you need a return value or guaranteed single handler, define a Core interface.
14. UI (MVP — Passive View)
Identical to Bus Game.
- Model — controller / repository, fires C# events.
- View —
MonoBehaviour, only setters (SetColors(IReadOnlyList<Color>)). - Presenter — pure C#, subscribes to model events, calls view setters.
Inspector bridge
For palette icons, undo buttons, region prefabs:
[SerializeField, RequireInterface(typeof(IColorButtonView))]
private MonoBehaviour[] _colorButtons;
15. Toddler UX Constraints
These shape several design decisions and are non-negotiable:
- No fail states. Drawings cannot be "wrong".
- No timers. Nothing decays or runs out.
- No tiny hitboxes. Drag tolerance ≥ 40 canvas units; snap radius ≥ 80 canvas units for shape pieces. (Canvas reference resolution is 2048×2048 — these are anchored-position deltas, not screen px.)
- Auto-snap on near-miss. If a piece is dropped within
1.5 × SnapRadius, snap anyway and play a happy sound. - No text-heavy UI. Icons everywhere. Single-word labels max.
- Loud, immediate feedback. Every tap plays a sound; every fill bursts a small particle effect.
- Undo cap = 20. Toddlers will mash undo. Bound the memory.
- Long-press = quick menu off. Avoid surprise modals.
16. Testing
| Layer | Test type | Location |
|---|---|---|
Libs/CommandStack |
EditMode unit tests | Libs/CommandStack/Tests/ |
Core DTOs |
EditMode | rarely needed; mostly type-only checks. |
Services/Gallery |
EditMode w/ temp directory | mocks Application.persistentDataPath. |
Services/Capture |
PlayMode | requires a Camera in the test scene. |
Features/*/Systems |
EditMode w/ DI test container | inject fakes for IUndoStack, signals captured by a fake IEventBus. |
| Full flow | PlayMode smoke test | one drawing → assemble → color → capture → assert gallery has 1 file. |
17. "Where do I put this?" Checklist
- Is it a cross-assembly interface / enum / DTO? →
Core/ - Is it a generic, sellable utility? →
Libs/ - Is it infrastructure (input, audio, file IO, addressables, capture)? →
Services/ - Is it gameplay logic specific to coloring books? →
Features/ - Is it composition / scene wiring? →
App/
When in doubt, ask: would deleting this feature break Core? If yes, the dependency is wrong.
18. Open Questions / Future Work
- Pencil/brush mode — currently the design is tap-to-fill regions. A free-draw brush mode would need a
BrushStrokeCommandand a dynamic texture per region; out of scope for v1. - Multi-child profiles — single-profile for v1; multi-profile would slot in behind
IProgressionServiceandIGalleryServicekeyed byprofileId. - Cloud sync — gallery sync would happen behind
IGalleryService(decorator pattern); local-first stays the source of truth. - Sticker / decoration layer — additive sprite layer above coloring, also
ICommand-driven so it integrates with undo/redo cleanly.
19. Quick Reference — Feature ↔ Signal Map
| Feature | Subscribes to | Publishes |
|---|---|---|
MainMenu |
— | — (Play tap loads a scene directly) |
DrawingCatalog |
— | DrawingSelectedSignal |
ShapeBuilder |
DrawingSelectedSignal |
ShapeAssembledSignal |
Coloring |
ShapeAssembledSignal |
ColorAppliedSignal |
History |
DrawingSelectedSignal (to clear) |
— |
Capture |
— (button-driven) | PaperCapturedSignal, PaperSavedSignal |
Progression |
PaperSavedSignal |
— |
ColorBookFlow |
ShapeAssembledSignal, PaperSavedSignal |
— |
Maintained alongside the Darkmatter Architecture Guide. Do not break the dependency arrows.
20. Assembly Definition Map
Every Lib / Service / Feature is its own .asmdef. The Darkmatter. prefix is only on the App asmdef; everything else uses bare <Layer>.<Module> names. References follow the layer rules.
On disk today
| Asmdef | Path | References |
|---|---|---|
Darkmatter.App |
App/ |
All Services, Libs, Core (Features when they land) |
Core |
Core/ |
(none — UniTask allowed in async signatures) |
Libs.FSM |
Libs/FSM/ |
Core |
Libs.Installers |
Libs/Installers/ |
(VContainer only) |
Libs.Observer |
Libs/Observer/ |
Core |
Libs.PlayerPrefs |
Libs/PlayerPrefs/Runtime/ |
(standalone) |
Libs.PlayerPrefs.Editor |
Libs/PlayerPrefs/Editor/ |
Libs.PlayerPrefs |
Libs.UI |
Libs/UI/ |
Core |
Services.Analytics |
Services/Analytics/ |
Core, Libs.Installers |
Services.Assets |
Services/Assets/ |
Core, Libs.Installers |
Services.Audio |
Services/Audio/ |
Core, Libs.Installers |
Services.Camera |
Services/Camera/ |
Core, Libs.Installers |
Services.Inputs |
Services/Inputs/ |
Core, Libs.Installers |
Services.Scenes |
Services/Scenes/ |
Core, Libs.Installers |
Planned (not on disk yet)
| Asmdef | Path | References |
|---|---|---|
Services.Capture |
Services/Capture/ |
Core, Libs.Installers |
Services.Gallery |
Services/Gallery/ |
Core, Libs.Installers |
Features.MainMenu |
Features/MainMenu/ |
Core, Libs.Installers |
Features.DrawingCatalog |
Features/DrawingCatalog/ |
Core, Libs.Installers |
Features.ShapeBuilder |
Features/ShapeBuilder/ |
Core, Libs.Installers |
Features.Coloring |
Features/Coloring/ |
Core, Libs.Installers |
Features.Capture |
Features/Capture/ |
Core, Libs.Installers |
Features.Progression |
Features/Progression/ |
Core, Libs.Installers, Libs.PlayerPrefs |
Features.ColorBookFlow |
Features/ColorBookFlow/ |
Core, Libs.Installers, Libs.FSM |
Libs.CommandStack,Features.ArtBook, andFeatures.Paperwere previously planned but cut. Undo lives insideFeatures.History(already on disk). Art-book is removed because captures save to phone Photos. Paper is just RectTransforms in the scene — no contract needed.ICommand/IUndoStacklive inCore, soFeatures.Coloringreaches the undo stack without referencingFeatures.History.
Hard rules:
- No Service asmdef references any Feature asmdef.
- No Feature asmdef references another Feature asmdef.
- All Services and Features depend on
Libs.Installersso they can implementIServiceModule. - The compiler enforces this — if a
usingwon't resolve, the dependency is wrong.
21. LifetimeScope Concrete Sample
All scopes use the same pattern: a serialized MonoBehaviour[] list of IServiceModule installers. Each installer is a MonoBehaviour on a child GameObject of the scope. Scope iterates and calls Register. No hardcoded registrations in the scope itself. This is exactly what RootLifetimeScope.cs already does today.
RootLifetimeScope (Boot scene, persists forever) — actual code
using Darkmatter.Libs.Installers;
using UnityEngine;
using VContainer;
using VContainer.Unity;
public class RootLifetimeScope : LifetimeScope {
[SerializeField] private MonoBehaviour[] serviceModules;
protected override void Configure(IContainerBuilder builder) {
foreach (var module in serviceModules) {
if (module is IServiceModule serviceModule)
serviceModule.Register(builder);
}
}
}
The inspector lists the installer MonoBehaviours in serviceModules[]. Drag the children of the Boot scope GameObject (e.g. AudioServiceModule, CameraServiceModule, InputServiceModule, AssetProviderServiceModule, AnalyticsServiceModule, SceneServiceModule) into that slot. Each is a MonoBehaviour, IServiceModule.
ColorBookLifetimeScope (per-scene, child of Root) — same pattern
namespace Darkmatter.App.LifetimeScopes;
public sealed class ColorBookLifetimeScope : LifetimeScope {
[SerializeField] private MonoBehaviour[] sceneModules;
protected override void Configure(IContainerBuilder builder) {
foreach (var module in sceneModules) {
if (module is IServiceModule serviceModule)
serviceModule.Register(builder);
}
}
}
Drag the scene's installer MonoBehaviours into sceneModules[]:
DrawingCatalogModuleShapeBuilderModuleColoringModuleHistoryModuleCaptureModuleProgressionModuleColorBookFlowModule
Each registers its own classes via IServiceModule.Register(IContainerBuilder).
If a scope needs a non-installer reference (e.g. a
ColorBookSceneRefsMB holding camera + roots), expose it as a separate[SerializeField]andbuilder.RegisterInstance(...)it inside the scope'sConfigure. Don't put scene refs inside an installer — keep installers stateless across scenes.
22. Installer Pattern — Concrete Coloring Sample
Mirrors the existing CameraServiceModule.cs and InputServiceModule.cs — a MonoBehaviour implementing IServiceModule.Register.
namespace Darkmatter.Features.Coloring.Installers;
public sealed class ColoringModule : MonoBehaviour, IServiceModule {
[SerializeField] private ColoringConfig _config;
public void Register(IContainerBuilder builder) {
builder.RegisterInstance(_config);
builder.Register<ColoringStateRepository>(Lifetime.Scoped).AsSelf();
builder.Register<ColoringController>(Lifetime.Scoped)
.As<IColoringController>()
.AsSelf();
builder.Register<ColorPalettePresenter>(Lifetime.Scoped).AsSelf();
builder.RegisterEntryPoint<ColoringInputBinder>();
}
}
Convention:
- One
IServiceModuleper feature, named<Feature>Module(matchesCameraServiceModule,InputServiceModule,AnalyticsServiceModulealready in the project). MonoBehaviourlives on a GameObject under the scope's hierarchy; dragged into the scope'sserviceModules[]/sceneModules[]inspector list.- Method name is
Register, notInstall. There is noIInstallerin this project — usesIServiceModulefrom Libs.Installers. - Registers only its own types. Never touches another feature's types.
- If the installer needs to wire scene-bound MonoBehaviours into DI, expose them as
[SerializeField]fields on the installer itself andbuilder.RegisterInstance<IFoo>(_foo)them.ColorBookSceneRefs(§32.13) is registered this way directly from the scope's serialized field.
23. Command Pattern — PaintRegionCommand
namespace Darkmatter.Features.Coloring.Commands;
internal sealed class PaintRegionCommand : ICommand {
private readonly ColorRegionView _view;
private readonly Color _fromColor;
private readonly Color _toColor;
private readonly IEventBus _bus;
public PaintRegionCommand(ColorRegionView view, Color from, Color to, IEventBus bus) {
_view = view;
_fromColor = from;
_toColor = to;
_bus = bus;
}
public void Execute() {
_view.SetColor(_toColor);
_bus.Publish(new ColorAppliedSignal(_view.RegionId, _toColor));
}
public void Undo() {
_view.SetColor(_fromColor);
_bus.Publish(new ColorAppliedSignal(_view.RegionId, _fromColor));
}
}
Usage in controller:
public void PaintRegion(ColorRegionView view) {
var current = _state.CurrentColor;
if (view.Color == current) return; // no-op
var cmd = new PaintRegionCommand(view, view.Color, current, _bus);
_undoStack.Push(cmd); // Push executes + records
}
Same pattern applies to SnapPieceCommand if shape-builder steps should be undoable (optional for v1).
24. CommandStack — Libs/CommandStack
namespace Darkmatter.Lib.CommandStack;
public sealed class BoundedUndoStack : IUndoStack {
private readonly Deque<ICommand> _undo = new();
private readonly Stack<ICommand> _redo = new();
private readonly int _capacity;
public BoundedUndoStack(int capacity = 20) => _capacity = capacity;
public bool CanUndo => _undo.Count > 0;
public bool CanRedo => _redo.Count > 0;
public void Push(ICommand cmd) {
cmd.Execute();
_undo.AddLast(cmd);
if (_undo.Count > _capacity) _undo.RemoveFirst();
_redo.Clear();
}
public void Undo() {
if (!CanUndo) return;
var cmd = _undo.Last;
_undo.RemoveLast();
cmd.Undo();
_redo.Push(cmd);
}
public void Redo() {
if (!CanRedo) return;
var cmd = _redo.Pop();
cmd.Execute();
_undo.AddLast(cmd);
}
public void Clear() {
_undo.Clear();
_redo.Clear();
}
}
Deque<T> keeps the oldest entry cheap to evict when the cap fires.
25. View / Presenter Pair — Color Palette
View (MonoBehaviour, setters only)
namespace Darkmatter.Features.Coloring.UI;
public sealed class ColorPaletteView : MonoBehaviour, IColorPaletteView {
[SerializeField, RequireInterface(typeof(IColorButtonView))]
private MonoBehaviour[] _buttonsRaw;
private IColorButtonView[] _buttons;
public event Action<int> OnColorButtonClicked;
private void Awake() {
_buttons = _buttonsRaw.Cast<IColorButtonView>().ToArray();
for (var i = 0; i < _buttons.Length; i++) {
var idx = i;
_buttons[i].OnClicked += () => OnColorButtonClicked?.Invoke(idx);
}
}
public void SetColors(IReadOnlyList<Color> colors) {
for (var i = 0; i < _buttons.Length; i++)
_buttons[i].SetVisible(i < colors.Count);
for (var i = 0; i < colors.Count; i++)
_buttons[i].SetColor(colors[i]);
}
public void SetSelected(int index) {
for (var i = 0; i < _buttons.Length; i++)
_buttons[i].SetSelected(i == index);
}
}
Presenter (pure C#)
namespace Darkmatter.Features.Coloring.UI;
public sealed class ColorPalettePresenter : IStartable, IDisposable {
private readonly IColorPaletteView _view;
private readonly ColoringStateRepository _state;
public ColorPalettePresenter(IColorPaletteView view, ColoringStateRepository state) {
_view = view;
_state = state;
}
public void Start() {
_view.SetColors(_state.Palette.Colors);
_view.SetSelected(_state.SelectedIndex);
_view.OnColorButtonClicked += OnClicked;
_state.SelectedIndexChanged += OnIndexChanged;
}
private void OnClicked(int index) => _state.SelectColor(index);
private void OnIndexChanged(int index) => _view.SetSelected(index);
public void Dispose() {
_view.OnColorButtonClicked -= OnClicked;
_state.SelectedIndexChanged -= OnIndexChanged;
}
}
Same shape repeats for every feature's UI.
26. ShapeBuilder — Snap Algorithm
All math is in canvas-local space — anchoredPosition, sizeDelta, localRotation. No world coords.
// In ShapePieceFsm.OnDragEnd (state: Dragging or Preview):
public void OnDragEnd() {
var pieceRT = _ui.RectTransform;
var slotRT = _targetSlot.RectTransform;
var d = Vector2.Distance(pieceRT.anchoredPosition, slotRT.anchoredPosition);
if (d <= _cfg.SnapRadius) {
SnapToSlot();
} else if (d <= _cfg.SnapRadius * 1.5f) {
// Toddler grace zone — snap anyway, play happy sound
SnapToSlot();
_audio.PlayOneShot(SfxId.NiceTry);
} else {
ReturnToTrayAnimated();
}
}
private void SnapToSlot() {
_ui.RectTransform.SetParent(_paper.PiecesParent, worldPositionStays: false);
var slot = _targetSlot.RectTransform;
_ui.RectTransform.DOAnchorPos(slot.anchoredPosition, 0.25f).SetEase(Ease.OutBack);
_ui.RectTransform.DOSizeDelta(slot.sizeDelta, 0.25f).SetEase(Ease.OutBack);
_ui.RectTransform.DOLocalRotateQuaternion(slot.localRotation, 0.25f).SetEase(Ease.OutBack);
_audio.PlayOneShot(SfxId.Snap);
_bus.Publish(new PieceSnappedSignal(_ui.PieceId));
}
Three things to note:
- Reparent the piece from
TrayPanel(HUD canvas) toColorBookSceneRefs.PiecesParent(PaperCanvas) so it'll be included in capture.worldPositionStays: falsebecause we want the newanchoredPositionto be relative to the new parent, not the world. - Three simultaneous tweens — position, size, rotation. Use
DOAnchorPos,DOSizeDelta,DOLocalRotateQuaternion. They start together so the piece visually snaps as one motion. SnapRadiusis in canvas units (fromShapeBuilderConfig, e.g. 80–120), not world units. SameCanvasScalerreference resolution across devices = same hit feel.
Controller listens for PieceSnappedSignal, counts against expected piece count, fires ShapeAssembledSignal when complete.
27. Rendering Order & Sorting
Canvas-only — order is sibling index inside PaperPanel (front-most is last in hierarchy). No URP 2D sorting layers.
PaperPanel children (bottom → top):
PaperPanel
├─ BackgroundImage (paper texture)
├─ RegionsPanel (colorable region Images)
├─ SlotsPanel (slot outline Images — under pieces so snapped pieces hide them)
├─ PiecesPanel (draggable / snapped piece Images)
└─ EffectsPanel (sparkle / particle UI for completion FX, optional)
HUDCanvas is a separate Canvas at a higher sorting order (or Screen Space - Overlay). It never sorts against PaperCanvas because they're different canvases.
CaptureCamera renders only the PaperUI layer. The HUD physically cannot leak into the saved PNG because of the culling mask, regardless of sibling order.
If you ever need particles outside the canvas (e.g. confetti falling across the full screen on completion), use a separate Canvas above the HUD with its own sub-tree of UI particles. Don't add
ParticleSystems under PaperPanel — they don't render in UI canvases withoutUIParticleSystemor similar packages.
28. Native Gallery Integration
IGalleryService.SaveToDeviceAsync(byte[] png, string albumName) is the only operation. Implementations wrap a native plugin — recommended packages:
| Platform | Library |
|---|---|
| Cross-platform | Native Gallery for Android & iOS |
| iOS-only fallback | PHPhotoLibrary direct bindings |
| Android-only fallback | MediaStore direct bindings via AndroidJavaClass |
Permission handling:
- iOS —
NSPhotoLibraryAddUsageDescriptioninInfo.plist. iOS prompts on first save. - Android 13+ — no permission required for writes that target a public collection via the plugin.
- Android 11–12 —
WRITE_EXTERNAL_STORAGEdeclared but not requested at runtime; plugin uses scoped storage. - Android ≤ 10 —
WRITE_EXTERNAL_STORAGEruntime permission requested by the plugin.
NativeGallerySaveService (the planned concrete) catches plugin permission denials and either silently no-ops (toddler app) or surfaces a child-friendly retry prompt via the HUD.
No app-side data is persisted about saved drawings. Once SaveToDeviceAsync returns, the PNG is the OS's responsibility.
29. Boot & Error Handling
Status: not implemented. No
AppBootclass exists. Today, RootLifetimeScope.cs only iterates installer MonoBehaviours and registers them — nothing runs after that. The block below is the target sequence whenAppBootis added as anIAsyncStartableentry point underApp/Boot/.
AppBoot.StartAsync() (planned — Features/Boot/AppBoot.cs, registered via builder.RegisterEntryPoint<AppBoot>())
├─ try Addressables.InitializeAsync()
│ fail → show "Tap to retry" splash
├─ try preload palette + UI sounds (Addressables labels)
│ fail → log + continue (non-fatal)
├─ try _persistence.LoadAsync()
│ fail → start with empty progression (don't crash)
├─ _scenes.LoadAsync("MainMenu")
└─ done
Toddler-mode error UI:
- One large smiling icon.
- One big "tap" button.
- No text, no error codes.
- A small upper-right gear opens a parent-only diagnostic screen (long-press 3 s to unlock).
30. Setup Checklist (new dev, day one)
- Open
Colorbook.slnat the repo root. - Verify required Unity packages are installed (check
Packages/manifest.json): VContainer, UniTask, Addressables, Input System, URP, Spine-Unity runtime (com.esotericsoftware.spine.spine-unity) for the main-menu mascot, DOTween (for snap/return tweens). - Open
Assets/Darkmatter/Scenes/Boot.unity(currently the only scene wired). - Inspect the
RootLifetimeScopeGameObject — confirm itsserviceModules[]list references the child installer MonoBehaviours (AudioServiceModule,CameraServiceModule,InputServiceModule, etc.). - Hit Play from
Boot.unity. Other scenes (MainMenu,ColorBook) don't exist yet — they're listed in §6 / §4c as planned work. - When new scene scopes land, the same rule applies: never start a scene mid-flow, always enter from
Boot.unityso the root scope exists. - When drawings are authored: duplicate the template folder under
Content/Gameplay/Drawings/<theme>/<id>/, editTemplate.asset(pieces + regions), add to the appropriate Addressables group. - Run
Tests > EditModeandTests > PlayModebefore pushing (test infra not set up yet — see §16).
31. Quick Reference — Class ↔ Layer ↔ Asmdef
| Class | Layer | Asmdef |
|---|---|---|
IDrawingTemplate, ColorRegionDTO |
Core | Core |
ShapeSO (ScriptableObject) |
Core | Core |
ICommand, IUndoStack |
Core | Core |
UndoStack |
Features | Features.History |
AddressableAssetProviderService |
Services | Services.Assets |
NativeGallerySaveService |
Services | Services.Gallery |
RenderTextureCaptureService |
Services | Services.Capture |
ColoringController, PaintRegionCommand |
Features | Features.Coloring |
ShapeBuilderController, ShapePieceUI |
Features | Features.ShapeBuilder |
HistoryController |
Features | Features.History |
ColorBookFlowController |
Features | Features.ColorBookFlow |
MenuMascotView, MenuMascotPresenter |
Features | Features.MainMenu |
ColorBookSceneRefs, ColorBookLifetimeScope, AppBoot |
App | Darkmatter.App |
If a class's natural home doesn't match its asmdef, the architecture is bent — fix the placement, don't add a reference.
31b. Scripts inventory by domain
Comprehensive index — every script (existing or planned) grouped by its module. Use this as the single-page mental map. Status column: ✅ exists on disk, ⚠️ planned.
Core
| Module (path) | Scripts | Status |
|---|---|---|
Core/Compatibility/ |
IsExternalInit |
✅ |
Core/Contracts/Services/Assets/ |
IAssetProviderService |
✅ |
Core/Contracts/Services/Audio/ |
IAudioService, ISfxPlayer |
✅ |
Core/Contracts/Services/Camera/ |
ICameraService |
✅ |
Core/Contracts/Services/Inputs/ |
IInputReader |
✅ |
Core/Contracts/Services/Scenes/ |
ISceneService |
✅ |
Core/Contracts/Services/Capture/ |
ICaptureService |
⚠️ |
Core/Contracts/Services/Gallery/ |
IGalleryService |
⚠️ |
Core/Contracts/Features/Drawing/ |
IDrawingTemplate, IDrawingTemplateCatalog |
⚠️ |
Core/Contracts/Features/Coloring/ |
IColorPalette |
⚠️ |
Core/Contracts/Features/History/ |
ICommand, IUndoStack |
✅ |
Core/Contracts/Features/Progression/ |
IProgressionService |
⚠️ |
Core/Data/Dynamic/Services/Audio/ |
AudioHandle, AudioRequest |
✅ |
Core/Data/Static/Services/Audio/ |
SfxCatalogSO |
✅ |
Core/Data/Static/Features/Drawing/ |
DrawingTemplateSO, ShapeSO |
⚠️ |
Core/Data/Static/Features/Coloring/ |
ColorPaletteSO |
⚠️ |
Core/Data/Dynamic/Features/Drawing/ |
ColorRegionDTO |
⚠️ |
Core/Data/Dynamic/Features/Coloring/ |
PaintCommandDTO |
⚠️ |
Core/Data/Dynamic/Features/Signals/ |
DrawingSelectedSignal, ShapeAssembledSignal, ColorAppliedSignal, PieceSnappedSignal, PaperCapturedSignal, PaperSavedSignal |
⚠️ |
Core/Enums/Services/Audio/ |
AudioChannel, AudioPlayMode, SfxId |
✅ |
Core/Enums/Services/Camera/ |
CameraType (add CaptureCamera value) |
✅ |
Core/Enums/Services/Scenes/ |
GameScene |
✅ |
Libs
| Module (path) | Scripts | Status |
|---|---|---|
Libs/FSM/ |
IState, State, StateMachine |
✅ |
Libs/Installers/ |
IServiceModule |
✅ |
Libs/Observer/ |
IEventBus, EventBus |
✅ |
Libs/PlayerPrefs/Runtime/ |
ProtectedPlayerPrefs, ProtectedPlayerPrefsSettings, PlayerPrefsKeys, PlayerPrefsKeyRegistry, LocalWriteTracker, PendingWriteResync |
✅ |
Libs/PlayerPrefs/Editor/ |
PlayerPrefsEditorWindow, ProtectedPlayerPrefsGettingStartedWindow, ProtectedPlayerPrefsSettingsUtility, ProtectedPlayerPrefsSetupBootstrap |
✅ |
Libs/UI/ |
ToggleButton, ToggleButtonGroup |
✅ |
Services
| Module (path) | Scripts | Status |
|---|---|---|
Services/Analytics/Installers/ |
AnalyticsServiceModule |
✅ |
Services/Analytics/Systems/ |
FirebaseAnalyticsSystem |
✅ |
Services/Assets/ |
AddressableAssetProviderService, AddressableLoadHandleTracker |
✅ |
Services/Audio/ |
AudioService, SfxPlayer |
✅ |
Services/Camera/Service/ |
CameraService |
✅ |
Services/Camera/Installers/ |
CameraServiceModule |
✅ |
Services/Inputs/Generated/ |
GameInputs (Input System codegen) |
✅ |
Services/Inputs/Readers/ |
InputReaderSO |
✅ |
Services/Inputs/Installers/ |
InputServiceModule |
✅ |
Services/Scenes/ |
SceneService |
✅ |
Services/Capture/ |
RenderTextureCaptureService, CaptureServiceModule |
⚠️ |
Services/Gallery/ |
NativeGallerySaveService, GalleryServiceModule |
⚠️ |
Features
| Module (path) | Scripts | Status |
|---|---|---|
Features/History/Stack/ |
UndoStack |
✅ |
Features/History/Installers/ |
HistoryServiceModule |
✅ |
Features/History/UI/ |
HistoryButtonsView, HistoryPresenter, HistoryController |
⚠️ |
Features/MainMenu/Installers/ |
MainMenuModule |
⚠️ |
Features/MainMenu/Systems/ |
MainMenuModel, MenuMascotPresenter |
⚠️ |
Features/MainMenu/UI/ |
MenuMascotView, IMenuMascotView |
⚠️ |
Features/DrawingCatalog/Systems/ |
DrawingCatalogController |
⚠️ |
Features/DrawingCatalog/UI/ |
DrawingCatalogPresenter, DrawingCatalogView, IDrawingCatalogView, CatalogItemVM |
⚠️ |
Features/DrawingCatalog/Installers/ |
DrawingCatalogModule |
⚠️ |
Features/ShapeBuilder/Systems/ |
ShapeBuilderController, ShapePieceFsm, ShapePieceFactory, TrayLayout |
⚠️ |
Features/ShapeBuilder/UI/ |
ShapePieceUI, SlotMarker, TrayPanel |
⚠️ |
Features/ShapeBuilder/Installers/ |
ShapeBuilderModule |
⚠️ |
Features/Coloring/Systems/ |
ColoringController, ColoringStateRepository, ColorRegionFactory |
⚠️ |
Features/Coloring/UI/ |
ColorRegionView, ColorPaletteView, ColorPalettePresenter |
⚠️ |
Features/Coloring/Commands/ |
PaintRegionCommand |
⚠️ |
Features/Coloring/Installers/ |
ColoringModule |
⚠️ |
Features/Capture/Systems/ |
CaptureController |
⚠️ |
Features/Capture/UI/ |
CaptureButtonPresenter, SaveToastView |
⚠️ |
Features/Capture/Installers/ |
CaptureFeatureModule |
⚠️ |
Features/Progression/Systems/ |
ProgressionService, ProgressionRepository |
⚠️ |
Features/Progression/Installers/ |
ProgressionModule |
⚠️ |
Features/ColorBookFlow/Systems/ |
ColorBookFlowController |
⚠️ |
Features/ColorBookFlow/Installers/ |
ColorBookFlowModule |
⚠️ |
App
| Module (path) | Scripts | Status |
|---|---|---|
App/LifetimeScopes/ |
RootLifetimeScope |
✅ |
App/LifetimeScopes/ |
MainMenuLifetimeScope, ColorBookLifetimeScope |
⚠️ |
App/Boot/ |
AppBoot |
⚠️ |
App/SceneRefs/ |
ColorBookSceneRefs |
⚠️ |
32. Class Reference (Detailed)
Status: target spec, mostly unimplemented. Existing on disk:
AddressableAssetProviderService,AudioService/SfxPlayer,CameraService,SceneService,InputReaderSO,FirebaseAnalyticsSystem, plus the History feature (UndoStack,HistoryServiceModule,ICommand,IUndoStack). Everything else (MainMenu, DrawingCatalog, ShapeBuilder, Coloring, Capture, Gallery, Progression, ColorBookFlow, AppBoot, scene scopes) is the target shape for when those classes are written. Treat this section as a contract for new code, not documentation of current state.
Canonical breakdown of every concrete class and interface. For each: purpose, public surface (signatures), injected dependencies, and collaborators (signals or interfaces it talks to).
Convention used below
// fields:= constructor-injected dependencies// pub:= events / signals fired// sub:= events / signals consumed- All async returns are
UniTaskunless noted.- Folder labels follow the actual nesting pattern:
Core/Contracts/Features/<Name>/,Core/Contracts/Services/<Name>/,Core/Data/Dynamic/Features/<Name>/,Features/<Name>/<Sub>/,Services/<Name>/<Sub>/.
32.1 Core Contracts
Pure interfaces and DTOs. Zero logic.
IDrawingTemplate (Core/Contracts/Features/Drawing — planned)
Immutable view of a single drawing's authored data.
public interface IDrawingTemplate {
string Id { get; } // e.g. "animals/elephant"
string DisplayName { get; } // user-facing
Sprite DefaultThumbnail { get; } // 256×256 authored fallback for the catalog grid
Sprite PaperBackground { get; } // image under all paper content
IReadOnlyList<ShapeSO> Pieces { get; } // shapes spawned in tray (reusable across drawings)
IReadOnlyList<ColorRegionDTO> Regions { get; } // for Coloring
}
Implemented by DrawingTemplateSO (ScriptableObject) loaded via Addressables. The per-drawing slot positions live in the drawing's authored scene/prefab as SlotMarker MonoBehaviours, not in the template SO.
The catalog grid always uses
DefaultThumbnail. The user's captured drawings live in the phone's Photos app, not the catalog cell.
IDrawingTemplateCatalog (Core/Contracts/Features/Drawing — planned)
Authority on which drawings exist, completion state, and "next" selection.
public interface IDrawingTemplateCatalog {
UniTask InitializeAsync(); // pulls manifest from Addressables
IReadOnlyList<string> AllTemplateIds { get; }
UniTask<Sprite> GetThumbnailAsync(string id); // cheap; for grid
UniTask<IDrawingTemplate> LoadAsync(string id); // expensive; full template
void Release(string id); // Addressables ref count down
string NextUnseen(string currentId); // progression hint
}
IColorPalette (Core/Contracts/Features/Coloring — planned)
Set of colors offered to the child. Authored as ColorPaletteSO.
public interface IColorPalette {
string Id { get; }
IReadOnlyList<Color> Colors { get; } // 6–10 entries typical
}
ICommand & IUndoStack (Core/Contracts/Features/History — planned)
Already shown in section 8. Each undoable user action is one ICommand; the stack is bounded.
IGalleryService (Core/Contracts/Services/Gallery — planned)
Thin shim over a native gallery plugin. Saves PNG bytes into the phone's Photos app. Does not track files, thumbnails, or sidecar metadata — the OS owns the file once it's saved.
public interface IGalleryService {
UniTask SaveToDeviceAsync(byte[] png, string albumName = "Color Book");
}
- No list / load / delete operations. The user uses the phone's Photos app for those.
- Implementation (
NativeGallerySaveService) wraps a third-party native plugin and handles platform permission prompts.
ICaptureService (Core/Contracts/Services/Capture — planned)
Snapshots the paper area to a PNG blob. No arguments — implementation owns the disabled CaptureCamera reference.
public interface ICaptureService {
UniTask<byte[]> CaptureAsync();
}
- Independent of
IGalleryService. Returns raw PNG bytes; what happens next is the caller's call (save, share, discard).
Removed contracts
IPaperRig,IArtInputBridge,IPaperSurface— paper is just RectTransforms in the scene now, exposed viaColorBookSceneRefs. No contract.SavedArtworkDTO,IGalleryService.ListAsync/LoadFullAsync/LoadThumbnailAsync/DeleteAsync/GetLatestThumbnailAsync— no app-side gallery store.ArtworkCapturedSignal,ArtworkSavedSignal— replaced byPaperCapturedSignal/PaperSavedSignal(templateId only, no DTO).
IProgressionService (Core/Contracts/Features/Progression — planned)
Tracks which templates the child has completed and what they last opened.
public interface IProgressionService {
UniTask LoadAsync();
UniTask SaveAsync();
IReadOnlyCollection<string> CompletedTemplateIds { get; }
string LastOpenedTemplateId { get; }
void MarkCompleted(string templateId);
void SetLastOpened(string templateId);
}
IAssetProviderService (Core/Contracts/Services/Assets — ✅ exists)
Addressables wrapper. Hides handle bookkeeping from features.
public interface IAssetProviderService {
UniTask InitializeAsync();
UniTask<T> LoadAsync<T>(string address) where T : UnityEngine.Object;
UniTask<IReadOnlyList<T>> LoadByLabelAsync<T>(string label) where T : UnityEngine.Object;
void Release(string address);
void ReleaseAll();
}
IEventBus (Libs/Observer — ✅ exists; note the folder is Observer, not EventBus)
public interface IEventBus {
void Publish<T>(T signal) where T : struct;
IDisposable Subscribe<T>(Action<T> handler) where T : struct;
}
Signals are structs to avoid GC. Disposable subscription so presenters can unsubscribe in Dispose().
32.2 Services Layer
Concrete infrastructure. One implementation each. All singletons in RootLifetimeScope, registered via per-service MonoBehaviour, IServiceModule installers.
AddressableAssetProviderService (Services/Assets — ✅ exists)
Implements IAssetProviderService.
- Responsibility: Wrap
Addressables.LoadAssetAsync<T>and ref-count handles by address. - State:
Dictionary<string, AsyncOperationHandle>keyed by address. - Notes:
Release(address)decrements;ReleaseAll()for scene teardown. Initialization must complete before any other service may load.
NativeGallerySaveService (Services/Gallery — planned)
Implements IGalleryService as a thin wrapper around a native gallery plugin.
// fields:
// INativeGalleryPlugin _plugin (third-party native bridge)
// IEventBus _bus (optional — for failure surfacing)
public sealed class NativeGallerySaveService : IGalleryService {
public async UniTask SaveToDeviceAsync(byte[] png, string albumName = "Color Book") {
var permission = await _plugin.RequestWritePermissionAsync();
if (permission != Permission.Granted) {
// toddler app — silently skip; or publish a failure signal for HUD retry prompt
return;
}
await _plugin.SaveImageToAlbumAsync(png, albumName, fileName: $"colorbook_{DateTime.UtcNow:yyyyMMdd_HHmmss}.png");
}
}
- No file IO, no thumbnails, no sidecars. The native plugin owns everything past the call.
- Permission flow runs once per session; the plugin caches the grant.
- Failure handling: a toddler app shouldn't crash or block on denial — silently skip and let the user keep playing.
RenderTextureCaptureService (Services/Capture — planned)
Implements ICaptureService. Drives the scene's disabled CaptureCamera once per capture.
// fields:
// Camera _captureCam (scene-bound, registered via CaptureServiceModule)
// int _surfaceSize = 2048
// IPathProvider _paths (only if you want to expose paths — usually not needed here)
- Steps:
RenderTexture.GetTemporary(size, size, 0, ARGB32)→ set_captureCam.targetTexture = rt→_captureCam.Render()→ReadPixelsinto aTexture2D→ null out the target texture →RenderTexture.ReleaseTemporary(rt)→EncodeToPNG→ return bytes. - Threading: PNG encode happens on
UniTask.RunOnThreadPoolto avoid hitching the main thread on tablets. - Camera setup:
_captureCamhascullingMask = PaperUI,clearFlags = SolidColor(white or paper color),orthographicSizeandaspectcloned fromUICameraonce at scene start. Stays disabled —Render()is the only call site. - Sizing: default 2048², overridable. Capped at device max texture size.
Persistence (no dedicated service)
There is no IPersistenceService / JSON file writer. Libs/PlayerPrefs (ProtectedPlayerPrefs) is the only persistent storage in the app. IProgressionService consumes it directly.
- Backing store:
PlayerPrefsvia the encrypted wrapper. - Keys: namespaced strings registered in
PlayerPrefsKeyRegistry. - Format per value: JSON-encoded primitives (
ScriptableObject.CreateInstancenot needed at this layer). - Atomicity:
PlayerPrefsis already atomic per key on iOS/Android.
SceneService (Services/Scenes — ✅ exists)
Implements ISceneService. Wraps SceneManager.LoadSceneAsync with UniTask plus a fade-curtain.
public interface ISceneService {
UniTask LoadAsync(string sceneName, LoadSceneMode mode = LoadSceneMode.Single);
UniTask UnloadAsync(string sceneName);
}
AudioService (Services/Audio — ✅ exists; see also SfxPlayer)
Implements IAudioService. Plays SFX clips loaded by address, mixes via Unity AudioMixer groups.
public interface IAudioService {
UniTask PreloadAsync(string label); // e.g. "sfx,ui"
void PlayOneShot(string clipId, float volume = 1f);
void SetCategoryVolume(AudioCategory cat, float v01);
}
Holds an internal Dictionary<string, AudioClip> populated at preload.
InputReaderSO (Services/Inputs/Readers — ✅ exists)
ScriptableObject wrapping the new Input System; exposes events.
public interface IInputReader {
event Action<Vector2> PointerDown; // world pos
event Action<Vector2> PointerDrag;
event Action<Vector2> PointerUp;
}
- Why an SO: assignable in inspector and survives scene loads, but still resolvable via DI (
builder.RegisterInstance(_inputReader).As<IInputReader>()).
32.3 Libs
Generic, project-agnostic utilities.
BoundedUndoStack (Libs/CommandStack — planned)
Implements IUndoStack. Source already in section 24.
- Capacity: default 20.
- Invariant:
_redocleared on any newPush. - Edge cases:
Undo/Redoon empty stack is a no-op (never throws).
EventBus (Libs/Observer — ✅ exists)
Implements IEventBus with a Dictionary<Type, Delegate> of Action<T> per signal type.
- Subscribe returns an
IDisposablethat removes the handler onDispose. - Publish snapshots the invocation list before iterating (so handlers may safely unsubscribe during dispatch).
StateMachine / IState / State (Libs/FSM — ✅ exists)
Generic state machine. Current shape on disk uses IState / State / StateMachine (see Libs/FSM/). ColorBookFlowController (planned) will use this. The generic sketch below is the target shape if you decide to make it strongly-typed via an enum — verify against actual API before consuming.
public sealed class Fsm<TState> where TState : struct, Enum {
public TState Current { get; }
public event Action<TState, TState> Transitioned;
public void Bind(TState state, IFsmState handler);
public void Go(TState next); // calls Exit on old, Enter on new
}
public interface IFsmState { void Enter(); void Exit(); }
32.3b Feature — MainMenu (planned)
Lives in MainMenu.unity. Hosts the Play / Art Book entry buttons plus the Spine character mascot.
IMenuMascotView (UI contract)
Setter-only view interface. Hides Spine-Unity's API behind a tiny surface.
public interface IMenuMascotView {
event Action<string> AnimationComplete; // fires when a non-looping anim ends
void Play(string animName, bool loop);
void SetSkin(string skinName); // optional — character variants
}
MenuMascotView : MonoBehaviour, IMenuMascotView (UI)
Concrete view. Wraps a SkeletonGraphic component (Spine-Unity's Canvas-compatible renderer).
public sealed class MenuMascotView : MonoBehaviour, IMenuMascotView {
[SerializeField] private SkeletonGraphic _skeleton; // Spine UI component
public event Action<string> AnimationComplete;
public void Play(string animName, bool loop) {
var track = _skeleton.AnimationState.SetAnimation(0, animName, loop);
track.Complete += _ => AnimationComplete?.Invoke(animName);
}
public void SetSkin(string skinName) {
_skeleton.Skeleton.SetSkin(skinName);
_skeleton.Skeleton.SetSlotsToSetupPose();
_skeleton.AnimationState.Apply(_skeleton.Skeleton);
}
}
SkeletonGraphiclives on a child ofMainMenuCanvas. It's aGraphic, so it interacts withCanvasRendererjust like anImage.- The Spine asset (
SkeletonDataAsset) is loaded via Addressables, assigned at scene setup, and released on scene exit.
MenuMascotPresenter (UI) — IStartable, IDisposable
Pure C#. Subscribes to button events + idle timer, drives the view.
// fields: IMenuMascotView _view, MainMenuModel _model, IInputReader _input
public sealed class MenuMascotPresenter : IStartable, IDisposable {
public void Start() {
_view.Play("idle", loop: true);
_model.PlayButtonHovered += () => _view.Play("hover_play", loop: false);
_model.PlayButtonPressed += () => _view.Play("wave", loop: false);
_view.AnimationComplete += OnAnimationComplete;
}
private void OnAnimationComplete(string anim) {
if (anim != "idle") _view.Play("idle", loop: true); // always return to idle
}
}
- Mascot reactions are pure presenter logic — the view never decides what to play.
- If you want randomized idle variants, add an idle timer in the model + a list of clip names.
MainMenuModel (Repository)
Holds menu state — current selected skin, fires hover/click events from button presenters.
MainMenuModule : MonoBehaviour, IServiceModule (Installers)
Registers the view (RegisterInstance<IMenuMascotView>(_view)), the presenter as a startable entry point, and the model.
Package dependency: Spine-Unity runtime (
com.esotericsoftware.spine.spine-unity). Add toPackages/manifest.json. TheSkeletonGraphiccomponent lives inSpine.Unitynamespace.
32.4 Feature — DrawingCatalog (planned)
DrawingCatalogController (Systems)
Headless logic. Owns the list of template IDs visible in the grid.
// fields: IDrawingTemplateCatalog _catalog, IEventBus _bus
public sealed class DrawingCatalogController : IAsyncStartable {
public IReadOnlyList<string> VisibleIds { get; }
public event Action ListChanged;
public UniTask StartAsync(CancellationToken ct); // pulls catalog, refreshes list
public void OnTemplateSelected(string id); // bus.Publish(new DrawingSelectedSignal(id))
}
// pub: DrawingSelectedSignal
DrawingCatalogPresenter (UI)
Bridges controller ↔ view.
// fields: IDrawingCatalogView _view, DrawingCatalogController _ctrl, IDrawingTemplateCatalog _catalog
public sealed class DrawingCatalogPresenter : IStartable, IDisposable {
public void Start(); // wires view.OnItemClicked → _ctrl.OnTemplateSelected
public void Dispose();
}
- Thumbnail load:
_catalog.GetThumbnailAsync(id)per visible cell, with placeholder while loading.
DrawingCatalogView : MonoBehaviour (UI)
Implements IDrawingCatalogView. Pure setters + click event.
public interface IDrawingCatalogView {
event Action<string> OnItemClicked;
void SetItems(IReadOnlyList<CatalogItemVM> items); // vm = id + Sprite thumbnail
}
32.5 Feature — ShapeBuilder (planned)
ShapeBuilderController (Systems)
Spawns shape pieces for the selected template, tracks snap progress, fires ShapeAssembledSignal when complete.
// fields: IDrawingTemplateCatalog _catalog, ShapePieceFactory _factory,
// ColorBookSceneRefs _refs, TrayPanel _tray, IEventBus _bus, ShapeBuilderConfig _cfg
public sealed class ShapeBuilderController : IDisposable {
public IReadOnlyList<ShapePieceUI> Active { get; }
public UniTask BuildAsync(string templateId); // load template, spawn pieces in tray
public void Reset(); // clear, unsubscribe
}
// sub: DrawingSelectedSignal
// pub: ShapeAssembledSignal
- Internal: counts
PieceSnappedSignalagainst expected piece count. - Slot discovery: after a drawing's per-drawing prefab is instantiated under
ColorBookSceneRefs.PaperRoot, the controller queriesGetComponentsInChildren<SlotMarker>()to discover all slots in the loaded drawing. Each slot's_shapefield tells whichShapeSOit expects; matching pieces are spawned in the tray.
ShapePieceUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler (UI)
The UI Image that the toddler drags. One prefab; the assigned ShapeSO determines visual identity and snap params.
public sealed class ShapePieceUI : MonoBehaviour,
IBeginDragHandler, IDragHandler, IEndDragHandler
{
[SerializeField] private ShapeSO _shape; // set by controller at spawn (or in inspector for testing)
[SerializeField] private Image _image;
public string PieceId => _shape != null ? _shape.Id : null;
public ShapeSO Shape => _shape;
public RectTransform RectTransform => (RectTransform)transform;
public bool IsLocked { get; private set; }
public event Action<ShapeSO> Snapped;
// Controller calls this at spawn time
public void Assign(ShapeSO shape) {
_shape = shape;
ApplyShape();
}
private void OnValidate() => ApplyShape(); // editor inspector edits
private void Awake() => ApplyShape(); // runtime safety
private void ApplyShape() {
if (_shape == null || _image == null) return;
_image.sprite = _shape.Sprite;
RectTransform.sizeDelta = _shape.DefaultSizeDelta;
}
}
- Handlers forward to
ShapePieceFsm(OnDragBegin / OnDrag(localPos) / OnDragEnd). OnDragconvertsPointerEventData.positionto canvas-local viaRectTransformUtility.ScreenPointToLocalPointInRectangleagainst the piece's parent rect.- No collider, no Physics2D anywhere.
- Identity follows the SO — change
_shapein inspector and the visual + ID update on the nextOnValidate. At runtime,Assign(...)is the only mutation path.
ShapePieceFsm (Systems)
Per-piece state machine using Libs.FSM. States: InTray → Dragging → Preview → (Snapped | Returning).
// fields: ShapePieceUI _ui, SlotMarker _targetSlot, ShapeBuilderConfig _cfg,
// IAudioService _audio, IEventBus _bus
public sealed class ShapePieceFsm {
public void OnDragBegin();
public void OnDrag(Vector2 canvasLocalPos);
public void OnDragEnd();
public bool IsLocked { get; }
}
- Preview-state update: reactive lerp of
anchoredPosition / sizeDelta / localRotationtoward_targetSlot's pose, driven by1 - dist/PreviewRadius. No DOTween while previewing — it's per-frame. - Snapped enter: DOTween ease-out to exact slot pose (~0.2s), disable drag, fire
PieceSnappedSignal. - Returning enter: DOTween back to tray slot (
anchoredPositionfromTrayLayout).
SlotMarker : MonoBehaviour (UI)
The outline Image under ColorBookSceneRefs.SlotsParent showing where a piece should snap. Authored per drawing — designer places one in the per-drawing prefab at each slot location, with its RectTransform set to the target pose and _shape field assigned to the matching ShapeSO.
public sealed class SlotMarker : MonoBehaviour {
[SerializeField] private ShapeSO _shape; // which shape fits here
[SerializeField] private Image _outline; // optional faint outline UI
public ShapeSO Shape => _shape;
public string SlotId => _shape != null ? _shape.Id : null;
public RectTransform RectTransform => (RectTransform)transform;
}
- Pose lives on this MB's
RectTransform—anchoredPosition,sizeDelta,localRotationdirectly. No pose data on the SO. - Matching:
ShapePieceFsmcompares_piece.Shape == _slot.Shape(Unity Object reference equality). No string lookups.
TrayPanel : MonoBehaviour (UI)
HUD-side panel (on HUDCanvas) where pieces start out. Has a HorizontalLayoutGroup + ContentSizeFitter. Provides spawn anchors via RectTransform Slot(int index) for the controller.
ShapePieceFactory (Systems)
Pool of ShapePieceUI GameObjects (one prefab) + their associated FSMs. Reused across template loads.
public sealed class ShapePieceFactory {
// Instantiates the single piece prefab under `parent`, calls Assign(shape) on it,
// and wires up its FSM with the matching SlotMarker.
public ShapePieceUI Spawn(ShapeSO shape, SlotMarker targetSlot, RectTransform parent);
public void Despawn(ShapePieceUI piece);
}
- One prefab in
Content/Gameplay/Prefabs/ShapePiece.prefabis instantiated repeatedly. Visual identity comes from theShapeSOpassed toAssign.
ShapeBuilderInputBinder (Systems)
With UI handlers on the piece itself, an explicit input binder isn't strictly needed — drag events route via the EventSystem. Keep this class only if you need to listen for "any tap outside any piece" (e.g. to dismiss a preview). Otherwise skip.
32.6 Feature — Coloring (planned)
ColoringStateRepository (Repository)
In-memory model. Owns "currently selected color" and the palette in use.
public sealed class ColoringStateRepository {
public IColorPalette Palette { get; private set; }
public int SelectedIndex { get; private set; }
public Color CurrentColor => Palette.Colors[SelectedIndex];
public event Action<int> SelectedIndexChanged;
public void SetPalette(IColorPalette palette); // resets SelectedIndex to 0
public void SelectColor(int index);
}
- Why a repository: presenter and controller both need to read/write current color; an event-emitting POCO is simpler than wiring two signals.
ColoringController (Systems) — implements IColoringController
Builds and pushes PaintRegionCommand instances; spawns ColorRegionView per region.
// fields: IUndoStack _undo, ColoringStateRepository _state, ColorRegionFactory _factory,
// ColorBookSceneRefs _refs, IEventBus _bus
public interface IColoringController {
UniTask SpawnRegionsAsync(IDrawingTemplate template);
void PaintRegion(ColorRegionView view); // builds command, pushes to undo stack
void Clear();
}
// sub: ShapeAssembledSignal (via flow controller, not direct)
// pub: ColorAppliedSignal (via PaintRegionCommand)
Spawns each region as a UI Image under _paper.RegionsParent. No Physics2D.
ColorRegionView : MonoBehaviour, IPointerClickHandler (UI)
UI Image with alpha-based hit detection. Tap routes through Unity's EventSystem directly to OnPointerClick.
public sealed class ColorRegionView : MonoBehaviour, IPointerClickHandler {
public string RegionId { get; }
public Color Color => _image.color;
public void Initialize(ColorRegionDTO dto, IColoringController controller);
public void SetColor(Color c); // setter only; no logic
public void OnPointerClick(PointerEventData e) => _controller.PaintRegion(this);
}
- Required sprite setup: sprite import inspector → Read/Write Enabled = on, Generate Physics Shape = off (not needed).
Image.alphaHitTestMinimumThreshold = 0.5fon Initialize so taps on transparent pixels pass through to the next region below. - Sibling order matters for stacked regions — top sibling gets first crack at the click; with alpha hit-test, transparent areas defer correctly.
No ColoringInputBinder class needed. Unity's EventSystem fires OnPointerClick on the topmost UI element under the pointer that consumes it — exactly what we want.
PaintRegionCommand (Commands)
Source in section 23. Holds view, fromColor, toColor, bus. Symmetrical execute/undo.
ColorPaletteView, ColorPalettePresenter (UI)
Sources in section 25. Presenter binds ColoringStateRepository.SelectedIndexChanged ↔ IColorPaletteView.
ColorRegionFactory (Systems)
Mirror of ShapePieceFactory for regions. Pool-friendly.
32.7 Feature — History (planned)
HistoryController (Systems) — IStartable, IDisposable
Owns the per-session IUndoStack (registered scoped, so a new ColorBook scene = new stack).
// fields: IUndoStack _stack, IEventBus _bus
public sealed class HistoryController : IStartable, IDisposable {
public bool CanUndo => _stack.CanUndo;
public bool CanRedo => _stack.CanRedo;
public event Action StateChanged;
public void Undo(); // _stack.Undo() + StateChanged
public void Redo();
// sub: DrawingSelectedSignal → _stack.Clear()
}
HistoryButtonsView : MonoBehaviour (UI)
Two big arrow buttons. Setters only.
public interface IHistoryButtonsView {
event Action UndoClicked;
event Action RedoClicked;
void SetUndoEnabled(bool enabled);
void SetRedoEnabled(bool enabled);
}
HistoryPresenter (UI)
Wires controller StateChanged ↔ view enable/disable; view click events → controller.
32.8 Feature — Capture (planned)
CaptureController (Systems)
The orchestrator behind the "Save" button. Owns the capture-then-save chain. Stateless other than guarding against concurrent captures.
// fields: ICaptureService _capture, IGalleryService _gallery, IEventBus _bus
public sealed class CaptureController {
public bool IsBusy { get; }
public UniTask SaveAsync(string templateId);
}
// pub: PaperCapturedSignal (mid-flow), PaperSavedSignal (after native save)
- Flow:
_capture.CaptureAsync()→ publishPaperCapturedSignal→_gallery.SaveToDeviceAsync(bytes)→ publishPaperSavedSignal. - Concurrency: sets
IsBusy = trueon entry; UI binds button enabled to!IsBusyto prevent double-tap. - No camera args —
ICaptureServiceowns theCaptureCamerareference. - No file-IO awareness —
IGalleryServicehandles the native plugin handoff.
CaptureButtonPresenter (UI)
Wires button click → CaptureController.SaveAsync(currentTemplateId). Disables button while IsBusy. Shows a "Saved to Photos" toast on PaperSavedSignal.
32.9 Feature — Progression (planned)
ProgressionService (Systems) — implements IProgressionService
The only place that knows what "completed" means.
- Persistence: delegates to
ProtectedPlayerPrefs(Libs.PlayerPrefs) under key"progression". - Load order:
AppBootcallsLoadAsync()early. - Save trigger: after
MarkCompleted, debounced 500 ms to coalesce a burst of "Next" presses.
ProgressionRepository (Repository)
Pure in-memory holder used by the service. Separated so tests can inspect state without going through file IO.
32.10 Feature — ColorBookFlow (planned)
ColorBookFlowController (Systems) — IStartable, IDisposable
The only orchestrator inside the ColorBook scene. Drives the panel FSM: Catalog → Building → Coloring → Done.
// fields:
// IEventBus _bus
// IDrawingTemplateCatalog _catalog
// ShapeBuilderController _builder
// IColoringController _coloring
// CaptureController _capture
// IProgressionService _progression
// ColorBookSceneRefs _refs (panel roots to enable/disable)
// Fsm<ColorBookState> _fsm
-
State table:
State On enter Triggers exit CatalogShow catalog panel DrawingSelectedSignalBuilding_builder.BuildAsync(id)ShapeAssembledSignalColoring_coloring.SpawnRegionsAsync(template)"Next" or "Capture" pressed DoneRun autosave capture, mark completed, Go(Catalog)for nextalways advances -
"Next" sequence:
_capture.CaptureCurrentAsync→_progression.MarkCompleted→_catalog.Release(current)→_catalog.LoadAsync(_catalog.NextUnseen(current))→ re-enterBuilding.
32.12 App Layer
AppBoot (App/Boot — planned; folder doesn't exist yet) — IAsyncStartable
Single entry point. Steps in section 29.
// fields: IAssetProviderService _assets, IProgressionService _progress,
// IAudioService _audio, ISceneService _scenes, BootConfig _cfg
public sealed class AppBoot : IAsyncStartable {
public UniTask StartAsync(CancellationToken ct);
}
LifetimeScopes
RootLifetimeScope— ✅ exists (source). Iterates a serializedMonoBehaviour[] serviceModulesand callsRegisteron eachIServiceModule. Persists for app lifetime.MainMenuLifetimeScope— planned. Same pattern as Root (serialized installer list, no hardcoded registrations).ColorBookLifetimeScope— planned. Same pattern; installer list includes feature installers + the flow controller installer. Also has a[SerializeField] ColorBookSceneRefs _sceneRefs;and registers it viabuilder.RegisterInstance(_sceneRefs).
All scope classes are thin: a serialized installer-MonoBehaviour list (+ optional scene refs as separate fields) and a Configure(IContainerBuilder) that iterates and calls Register.
32.13 Cross-cutting types
ColorBookSceneRefs : MonoBehaviour (App — planned)
The single source of scene-bound Unity references for the ColorBook scene. Registered in ColorBookLifetimeScope via builder.RegisterInstance(_sceneRefs) so features don't Find things.
public sealed class ColorBookSceneRefs : MonoBehaviour {
public RectTransform PaperRoot;
public RectTransform SlotsParent;
public RectTransform PiecesParent;
public RectTransform RegionsParent;
public RectTransform HudRoot;
public RectTransform TrayPanel;
public Camera CaptureCamera; // disabled — used by ICaptureService
public ColorPaletteView PaletteView; // optional inline ref
public HistoryButtonsView HistoryButtons;
}
Replaces the dropped IPaperSurface contract — features that need a paper-area RectTransform read it off this MB.
IServiceModule (Libs/Installers — ✅ exists)
public interface IServiceModule {
void Register(IContainerBuilder builder);
}
Implemented as MonoBehaviour per feature/service so scopes can drag them in the inspector (CameraServiceModule.cs shows the pattern). The method is Register, not Install — there is no IInstaller in this project.
32.14 Class summary table
| Class | Layer | Role | Key dependencies |
|---|---|---|---|
AppBoot |
App | Startup sequencer | assets, progression, audio, scenes |
RootLifetimeScope |
App | Root DI | service modules |
ColorBookLifetimeScope |
App | Scene DI | scene refs, feature modules |
MainMenuLifetimeScope |
App | Menu scene DI | feature modules |
ColorBookSceneRefs |
App | Scene-bound RectTransform / Camera holder | — |
MenuMascotView |
Feature | Spine mascot UI (SkeletonGraphic wrapper) | — |
MenuMascotPresenter |
Feature | Drives mascot animations from model events | view, model |
DrawingCatalogController |
Feature | Grid logic | catalog, bus |
DrawingCatalogPresenter |
Feature | UI bridge | view, controller, catalog |
ShapeSO |
Core asset | Authored shape (sprite + snap params, reusable) | — |
ShapeBuilderController |
Feature | Piece spawn + snap tracking | catalog, factory, refs, tray, bus, cfg |
ShapePieceUI |
Feature | Draggable UI piece prefab; holds [SerializeField] ShapeSO _shape |
fsm |
ShapePieceFsm |
Feature | Per-piece state machine (Tray/Drag/Preview/Snapped/Returning) | ui, slot, cfg, audio, bus |
SlotMarker |
Feature | Slot outline UI Image at target pose; holds _shape |
— |
TrayPanel |
Feature | HUD-side tray with LayoutGroup | — |
ColoringStateRepository |
Feature | Current color model | — |
ColoringController |
Feature | Region spawn + paint cmd | undo, state, factory, refs, bus |
ColorRegionView |
Feature | Region UI Image + IPointerClickHandler | controller |
PaintRegionCommand |
Feature | Undoable paint (sets Image.color) | view, bus |
HistoryController |
Feature | Undo/redo facade | undo stack, bus |
UndoStack |
Feature | Bounded undo store | — |
CaptureController |
Feature | Capture-then-save orchestration | capture svc, gallery svc, bus |
ColorBookFlowController |
Feature | Scene FSM (Catalog → Building → Coloring → Done) | bus, catalog, builder, coloring, capture, progression |
ProgressionService |
Feature | Completion tracking | PlayerPrefs lib |
EventBus |
Lib | Pub/sub | — |
StateMachine |
Lib | Generic FSM | — |
IServiceModule |
Lib | DI installer interface | — |
ProtectedPlayerPrefs |
Lib | Encrypted PlayerPrefs wrapper | — |
AddressableAssetProviderService |
Service | Addressables wrapper | — |
RenderTextureCaptureService |
Service | One-shot PNG render via CaptureCamera | scene refs |
NativeGallerySaveService |
Service | Native gallery save (thin plugin shim) | — |
SceneService |
Service | Async scene loads | — |
AudioService, SfxPlayer |
Service | SFX playback | assets |
CameraService |
Service | Camera registry (MainCamera, UICamera, CaptureCamera) | — |
InputReaderSO |
Service | New Input System reader | — |
FirebaseAnalyticsSystem |
Service | Analytics events | — |
If you add a class not in this table, add it here in the same PR. This table is the cheap mental-model index — keep it honest. See §31b for the full path-by-path inventory.
Today only these rows are real on disk:
RootLifetimeScope(App),AddressableAssetProviderService,AudioService/SfxPlayer,CameraService,SceneService,InputReaderSO,FirebaseAnalyticsSystem(Services),UndoStack+HistoryServiceModule(Features.History), plusLibs.*entries (EventBus,StateMachine,IServiceModule, PlayerPrefs lib, UI toggles). Everything else is the target.