Skip to content

kids.kapish.content -- Developer Notes

unity

Scope of the first pass

This package is domain only. The goal is to pin down types, interfaces, and contracts so every package that consumes content can be written against a stable foundation. It is not a finished tool. Editor integration, persistence, and the providers package all land later.

Things explicitly NOT in this package (ever):

  • Network, auth, cloud downloads
  • Schema sync with the portal
  • Codegen from portal schemas
  • "Update" button / editor window for the content backend
  • Marketplace / entitlement enforcement
  • Cross-scope / cross-org schema resolution

All of the above live in kids.kapish.content.providers (optional, separate Tier 4 package).

Namespace organisation

Files are grouped into focused sub-namespaces to keep the package navigable as it grows. See contents.md for the full map. When adding new types, place them in the appropriate sub-namespace; create a new sub-namespace if a sensible grouping emerges (don't let any single folder accumulate 20+ files).

Key design decisions

IAsset is tiny and instance-clean. Three properties: Id, Source, Availability. Schema identity and version are a type-level fact — declared on [ContentSchema] attributes, resolved via ContentSchemaRegistry, never carried on live instances. The instance holds field values; type-level attributes hold schema metadata; ContentHub holds unlock overlay. One source of truth per concern.

AssetId is namespaced. Format @scope/local-id. The scope is required; it enables cross-scope references, permissions, collision-free DLC, and is how the portal's namespaced schemas (@kapish/book) map to runtime ids.

AssetSource vs AssetAvailability are orthogonal. Source = where the asset came from (Local/Remote/Generated). Availability = runtime state (Always/Locked/Unlocked). A single asset could be Source.Remote, Availability.Locked — downloaded but not yet unlocked.

Registries are per-domain, typed. Every subscribing package (inventory, audio, locales, etc.) owns its own registry via the hub. Providers fan out to the correct registry by inspecting the asset's runtime type.

Schemas are type-level. A class declares [ContentSchema(id, version)] once (or many times, for multi-schema composition) and every instance of that class shares the declaration. Serialisation layers (save, wire, editor) ask the type, not the instance. The reflection builder derives a descriptor from [ContentSchema]-attributed classes; portal-sourced schemas produce the same descriptor via codegen (in providers). Both paths yield identical runtime models.

Multi-schema composition. [ContentSchema] is AllowMultiple = true. A class can be @carrot/item v1 + @carrot/lootable v1. Each schema versions independently; bumping a parent requires a matching bump on every subschema that wants the new shape. Fields partition across schemas via [ContentSchemaField(SchemaId = ...)]; unscoped fields apply to every schema the class declares. Prefixing fields (Item_Name, Lootable_Rarity) is a convention for avoiding name clashes.

Two axes of versioning, separate. Schema version (portal-authoritative, data-shape, migration-based) is orthogonal to C# package version (Unity-authoritative, code-shape, semver). The C# interface abstracts over schema versions; the save/wire envelope carries the saved version; load-time migration bridges to whatever the compiled type declares.

Local-first is not a fallback. LocalAssetProvider is the default and works with zero configuration. Content-driven games built purely in Unity never need to touch a backend. The providers package simply adds a second provider into the same hub.

Load boundary owns schema metadata

Instances never carry schema ids or versions. On the way in:

  1. Envelope (save chunk, downloaded JSON) carries {schemaId, version, data}.
  2. Loader looks up the compiled type targeting schemaId; reads its declared version.
  3. If saved version < declared → run migrations up, deserialise.
  4. If saved version == declared → deserialise direct.
  5. If saved version > declared → warn/reject (or stash as opaque carrier for round-trip).
  6. If schemaId is unknown → this DLC needs a rebuild (Tier 4 Phase 2 trade-off).

On the way out: reflect the type's declared schemas + versions, stamp the envelope, serialise. Same pipeline, two directions. The instance stays clean.

Integration pattern

See usage.md.

Follow-on work (v1.x, not in this pass)

  • kids.kapish.content.editor sub-asmdef: generic schema-aware property drawer, validation UI, source badges, read-only overlay for Remote assets.
  • SchemaSaveSection<T> in kids.kapish.persistence: any [ContentSchema]-attributed type saves/loads via its schema. JSON + tagless binary variants. Schema version drives migration. Nested sub-object fields become nested subchunks, each carrying their own schema id + version header, each migrating independently.

Follow-on work (providers, much later)

See kids.kapish.content.providers spec in the Unity libraries plan. Phases:

  1. Platform provider + DLC flow (editor window, gameId, schema sync, codegen, addressables-backed content packs, handler registration, unlock flow).
  2. Marketplace (schemas + engine code + default content delivered together; editor-time integration; documented rebuild-for-new-schemas trade-off).
  3. Cross-org and global schema sharing.

Usage Guide

Making a package content-compatible

Every package that wants to be content-compatible follows the same pattern:

1. Define a domain interface

csharp
using Carrot.Content.Assets;

namespace MyGame.Inventory
{
    public interface IItemDefinition : IAsset
    {
        string DisplayName { get; }
        float Weight { get; }
    }
}

2. Author assets as ScriptableObjects implementing the interface

csharp
using Carrot.Content.Assets;
using Carrot.Content.Schemas.Attributes;
using UnityEngine;

namespace MyGame.Inventory
{
    [ContentSchema("@mygame/item", version: 1)]
    [CreateAssetMenu(menuName = "MyGame/Item")]
    public class ItemDefinition : ScriptableObject, IItemDefinition
    {
        [SerializeField] private AssetId id;

        [ContentSchemaField, ContentSchemaRequired]
        [SerializeField] private string displayName;

        [ContentSchemaField, ContentSchemaRange(0, 1000)]
        [SerializeField] private float weight;

        public AssetId Id => this.id;
        public AssetSource Source => AssetSource.Local;
        public AssetAvailability Availability => AssetAvailability.Always;
        public string DisplayName => this.displayName;
        public float Weight => this.weight;
    }
}

Schema identity and version live on the [ContentSchema] attribute — the type — not on the instance. The save layer, wire layer, and editor look up ContentSchemaRegistry.GetDescriptors(instance.GetType()) when they need that information; the instance itself carries only field values.

3. Register a typed registry with the hub

csharp
using Carrot.Content.Hub;
using Carrot.Content.Registry;

var items = ContentHub.Instance.Registry<IItemDefinition>();

if (items.TryGet(new AssetId("@mygame/sword"), out var sword))
{
    // use sword...
}

4. Let LocalAssetProvider do the work

On startup, LocalAssetProvider walks the project for SOs implementing IAsset and registers them with the appropriate registry. No manual wiring required for the offline case.

Reading registries

csharp
var items = ContentHub.Instance.Registry<IItemDefinition>();

// Single lookup
if (items.TryGet(new AssetId("@mygame/sword"), out var sword))
{
    Debug.Log(sword.DisplayName);
}

// Enumerate all
foreach (var item in items.GetAll())
{
    Debug.Log(item.Id);
}

// React to changes
var unsub = items.AssetRegistered.Add(asset =>
    Debug.Log($"New asset: {asset.Id}"));

Unlocking content

csharp
// Content that arrives as Locked stays hidden until explicitly unlocked.
ContentHub.Instance.Unlock(new AssetId("@mygame/secret_sword"));

// Observe unlocks globally
ContentHub.Instance.ContentUnlocked.Add(id => /* show notification, etc. */);

// Check state
var locked = !ContentHub.Instance.IsUnlocked(id);

Querying the schema registry

csharp
using Carrot.Content.Schemas;

var descriptors = ContentSchemaRegistry.GetDescriptors(sword.GetType());

// What schemas does this asset implement?
foreach (var d in descriptors)
{
    Debug.Log($"{d.SchemaId} v{d.SchemaVersion}");
}

// Specific lookup
if (ContentSchemaRegistry.TryGetDescriptor(sword.GetType(), "@mygame/item", out var itemSchema))
{
    // inspect fields, validators, etc.
}

bool isLootable = ContentSchemaRegistry.ImplementsSchema(sword.GetType(), "@carrot/lootable");

Code-first schemas

Attribute a class to declare it as a schema:

csharp
using Carrot.Content.Assets;
using Carrot.Content.Schemas.Attributes;

[ContentSchema("@mygame/character", version: 1)]
[ContentSchemaRoles(ContentSchemaRoles.IDlcPackage)]
public class CharacterDefinition : ScriptableObject, IAsset
{
    [ContentSchemaField, LocalisedString]
    public string DisplayName;

    [ContentSchemaField, ContentSchemaSubObject]
    public CharacterEffects Effects;

    [ContentSchemaField, ContentSchemaReference("@mygame/trait-set")]
    public AssetRef<TraitSet> Traits;
}

The reflection builder derives a ContentSchemaDescriptor from any [ContentSchema]-attributed class. Portal-sourced schemas (when providers are added) produce the same descriptor via codegen — both paths yield identical runtime models.

Multi-schema composition

A single class can declare multiple [ContentSchema] attributes to compose schemas. Fields opt in via [ContentSchemaField(SchemaId = ...)] — a field with no SchemaId is shared across every schema the class declares; one with an explicit id belongs only to that schema.

csharp
[ContentSchema("@carrot/item", version: 1)]
[ContentSchema("@carrot/lootable", version: 1)]
public class CrystalDefinition : ScriptableObject, IAsset
{
    [ContentSchemaField(SchemaId = "@carrot/item"), ContentSchemaRequired]
    public string Item_Name;

    [ContentSchemaField(SchemaId = "@carrot/item"), ContentSchemaRange(0, 100)]
    public float Item_Weight;

    [ContentSchemaField(SchemaId = "@carrot/lootable"), ContentSchemaRange(0, 1)]
    public float Lootable_DropChance;

    public AssetId Id { get; }
    public AssetSource Source => AssetSource.Local;
    public AssetAvailability Availability => AssetAvailability.Always;
}

Field prefixes (Item_, Lootable_) are a convention, not a mechanism — they stop name clashes when two schemas both define a field called Name. The SchemaId property on [ContentSchemaField] is the real dividing line.

What's not in this package yet

  • No generic schema editor UI (coming as a follow-on sub-asmdef).
  • No save/load integration (SchemaSaveSection<T> lands in persistence separately).
  • No providers package (remains an optional Tier 4 add-on).

For a fully offline game, this package plus your own ScriptableObject assets is enough. No other integration required.

Carrot