Appearance
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:
- Envelope (save chunk, downloaded JSON) carries
{schemaId, version, data}. - Loader looks up the compiled type targeting
schemaId; reads its declared version. - If saved version < declared → run migrations up, deserialise.
- If saved version == declared → deserialise direct.
- If saved version > declared → warn/reject (or stash as opaque carrier for round-trip).
- 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.editorsub-asmdef: generic schema-aware property drawer, validation UI, source badges, read-only overlay forRemoteassets.SchemaSaveSection<T>inkids.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:
- Platform provider + DLC flow (editor window, gameId, schema sync, codegen, addressables-backed content packs, handler registration, unlock flow).
- Marketplace (schemas + engine code + default content delivered together; editor-time integration; documented rebuild-for-new-schemas trade-off).
- 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.