14 KiB
Jovian Tag System
A hierarchical, strongly-typed tag system for Unity. Define tags as dot-delimited hierarchies (Damage.Fire.AOE), auto-generate type-safe C# constants, and query relationships like ancestor/descendant/sibling at runtime with zero allocations.
Table of Contents
- Installation
- Quick Start
- Important: Tags Are Not Inherited
- Hierarchical Tags
- Tag Editor Window
- Using Tags in Code
- Inspector Integration
- Tag Picker Popup
- Code Generation
- Tag Containers
- Tag Containers with Data
- Runtime API Reference
- Performance
Quick Start
1. Create a Tag Settings asset
Right-click in the Project window > Create > Jovian > Tag System > Tag Settings
2. Add tags
Open the Tag Editor via Jovian > Tag System > Tag Editor...
Add tags like:
Character
Character.Player
Character.Enemy
Character.Enemy.Boss
Damage
Damage.Fire
Damage.Ice
Status
Status.Burning
Status.Frozen
3. Click Save & Generate
This creates a GameTags.cs file with strongly-typed constants:
// Auto-generated
public static partial class GameTags {
public static class Character {
public static JovianTag Character_Tag;
public static class Enemy {
public static JovianTag Enemy_Tag;
public static JovianTag Boss_Tag;
}
public static JovianTag Player_Tag;
}
public static class Damage {
public static JovianTag Damage_Tag;
public static JovianTag Fire_Tag;
public static JovianTag Ice_Tag;
}
}
4. Use in code
if (projectile.IsDescendantOf(GameTags.Damage.Damage_Tag)) {
// This is any damage type (Fire, Ice, etc.)
}
Important: Tags Are Not Inherited
TL;DR — Tagging an object with
Enemy.Name.Butcher.Size.Largedoes NOT automatically assignEnemy.Name.ButcherorEnemyto that object.
Each dot-delimited path is a distinct, independent tag. The hierarchy is structural — it enables tree queries — but it does not imply membership.
Concrete Example
Register this tag in the Tag Editor:
Enemy.Name.Butcher.Size.Large
The system creates 5 separate tag definitions (one per segment), parented in the tree:
EnemyEnemy.NameEnemy.Name.ButcherEnemy.Name.Butcher.SizeEnemy.Name.Butcher.Size.Large
Now tag a game object with Enemy.Name.Butcher.Size.Large. On that object, only that one tag is set. The ancestor tags exist as definitions, but they are not active on the object.
What This Means in Practice
| You want to match... | Do this |
|---|---|
| Only Large Butcher | Tag object with Enemy.Name.Butcher.Size.Large, query with Contains |
| Any Butcher (any size) via separate tag | Add a second tag Enemy.Name.Butcher to the object |
| Any Butcher (any size) via hierarchy | Tag with Enemy.Name.Butcher.Size.Large, query with ContainsDescendantOf(GameTags.Enemy.Name.Butcher_Tag) |
Exact vs Hierarchy Queries
| Query | Behavior |
|---|---|
Contains(tag) |
Exact match only — matches the literal tag you assigned |
ContainsDescendantOf(tag) |
Matches any descendant of tag, including tag itself |
ContainsAncestorOf(tag) |
Matches any ancestor of tag |
The tag itself does not decide matching behavior — the query does. Choose the query type based on your intent: Contains for "this exact thing," ContainsDescendantOf for "anything in this category."
Hierarchical Tags
Tags are organized in a tree using dot-delimited names. Each segment creates a level in the hierarchy.
Damage ← root tag (depth 0)
├── Damage.Fire ← child of Damage (depth 1)
│ └── Damage.Fire.AOE ← child of Fire (depth 2)
├── Damage.Ice ← child of Damage (depth 1)
└── Damage.Lightning ← child of Damage (depth 1)
Hierarchy Queries
Every tag knows its parent. This enables three types of queries:
IsDescendantOf — "Is this tag a child/grandchild/etc. of another?"
var fire = GameTags.Damage.Fire_Tag;
var damage = GameTags.Damage.Damage_Tag;
var aoe = GameTags.Damage.Fire.AOE_Tag;
fire.IsDescendantOf(damage); // true — Fire is under Damage
aoe.IsDescendantOf(damage); // true — AOE is under Damage (via Fire)
aoe.IsDescendantOf(fire); // true — AOE is directly under Fire
damage.IsDescendantOf(fire); // false — Damage is above Fire
fire.IsDescendantOf(fire); // true — a tag is a descendant of itself
IsAncestorOf — "Is this tag a parent/grandparent/etc. of another?"
damage.IsAncestorOf(fire); // true
damage.IsAncestorOf(aoe); // true
fire.IsAncestorOf(damage); // false
IsSiblingTo — "Do these tags share the same parent?"
var fire = GameTags.Damage.Fire_Tag;
var ice = GameTags.Damage.Ice_Tag;
var player = GameTags.Character.Player_Tag;
fire.IsSiblingTo(ice); // true — both under Damage
fire.IsSiblingTo(player); // false — different parents
Practical Use Cases
Category matching — check if something belongs to a broad category:
// Does this entity have ANY damage tag?
if (entity.tags.ContainsDescendantOf(GameTags.Damage.Damage_Tag)) {
ApplyDamageEffect();
}
Specific matching — check for an exact tag:
if (entity.tags.Contains(GameTags.Damage.Fire_Tag)) {
ApplyBurnEffect();
}
Resistance system — use hierarchy for type matching:
// Entity is immune to all fire damage (Fire, Fire.AOE, etc.)
if (incomingDamage.IsDescendantOf(GameTags.Damage.Fire_Tag) && entity.HasResistance(GameTags.Damage.Fire_Tag)) {
return 0;
}
Tag Editor Window
Open via Jovian > Tag System > Tag Editor...
Features
- Hierarchical tree view — tags displayed as an expandable/collapsible tree
- Add root tags — type a name in the "New Tag" field at the bottom and click "+ Add"
- Add child tags — click the green + button on any tag to add a child under it
- Delete tags — click the red ✕ button. If the tag has children, you'll be asked to confirm deletion of the entire branch
- Search — filter tags by name
- Save & Generate — saves all changes and regenerates the C# constants file
- Vertical indent guides — visual lines showing parent-child relationships
Adding hierarchical tags
You can type full paths in the "New Tag" field:
Damage.Fire.AOE
This automatically creates Damage and Damage.Fire as parents if they don't exist.
Or use the + button on an existing tag to add a child — a popup asks for just the child name.
Validation
Tags must:
- Start with a letter
- Contain only letters, digits, or underscores
- Not be a C# reserved keyword (
class,int,static, etc.)
Invalid names are rejected with an error message. Duplicates are detected and shown as warnings.
Using Tags in Code
Generated Constants
After clicking Save & Generate, use the generated constants:
using Jovian.TagSystem;
public class Projectile : MonoBehaviour {
public void OnHit(JovianTag targetTag) {
if (targetTag.IsDescendantOf(GameTags.Character.Enemy.Enemy_Tag)) {
DealDamage();
}
}
}
JovianTag Struct
The core type — 8 bytes, zero heap allocation:
JovianTag tag = GameTags.Damage.Fire_Tag;
tag.IsValid(); // true (not the empty/None tag)
tag.IsNone(); // false
tag.Id; // unique int identifier
tag.ParentId; // parent's int identifier (0 = root)
tag.ToString(); // "Damage.Fire" (when manager is initialized)
tag.IsDescendantOf(otherTag); // hierarchy query
tag.IsAncestorOf(otherTag); // hierarchy query
tag.IsSiblingTo(otherTag); // same parent check
tag == otherTag; // equality by ID
Inspector Integration
JovianTagsGroup
Use JovianTagsGroup on any MonoBehaviour or ScriptableObject to select tags in the inspector:
public class Enemy : MonoBehaviour {
public JovianTagsGroup tags;
private void Start() {
// Check tags at runtime
if (tags.ContainsDescendantOf(GameTags.Character.Enemy.Enemy_Tag)) {
Debug.Log("This is an enemy!");
}
// Convert to a runtime container for efficient repeated queries
var container = tags.ToContainer();
}
}
Inspector Display
- Collapsible — shows
Tags (3)when collapsed, full list when expanded - Tag list — each tag shown with a red ✕ remove button
- + Add Tags button — opens the tag picker popup
- Edit Tags button — opens the Tag Editor window
Tag Picker Popup
The picker popup opens when you click + Add Tags in the inspector. Features:
- Hierarchical tree with checkboxes — check/uncheck any tag at any depth
- Search — filter by name
- +all / -all buttons — bulk select/deselect all children of a parent tag
- Already-selected tags are pre-checked when the popup opens
- Confirm / Cancel — apply or discard the selection
- Edit Tags button — jump to the Tag Editor window
Code Generation
How It Works
The Tag Editor generates a C# file with:
- Nested static classes mirroring the tag hierarchy
- Static
JovianTagfields for each tag (postfixed with_Tag) [RuntimeInitializeOnLoadMethod]that registers all tags and resolves the fields at startup[InitializeOnLoadMethod](editor-only) for editor play mode
Manual Edits
You can add fields by hand to the generated file. They will be preserved on regeneration as long as the file compiles.
Regenerate Without the Window
Use Jovian > Tag System > Regenerate Tag Constants to regenerate from the menu without opening the Tag Editor.
Settings Inspector
The JovianTagsSettings ScriptableObject inspector has:
- Default array editor for direct tag editing
- Save & Generate Tags button
- Open Tag Editor button
- Validation errors shown inline (invalid names, duplicates)
Tag Containers
JovianTagsContainer (tags only)
A runtime collection for holding and querying multiple tags:
var container = new JovianTagsContainer(4);
container.Add(GameTags.Damage.Fire_Tag);
container.Add(GameTags.Status.Burning_Tag);
container.Contains(GameTags.Damage.Fire_Tag); // true
container.ContainsDescendantOf(GameTags.Damage.Damage_Tag); // true
container.ContainsAncestorOf(GameTags.Damage.Fire.AOE_Tag); // true
container.ContainsSibling(GameTags.Damage.Ice_Tag); // true (Fire and Ice are siblings)
container.Count; // 2
container.Remove(GameTags.Damage.Fire_Tag);
container.Clear();
From JovianTagsGroup
Convert a serialized selection to a runtime container:
public JovianTagsGroup selectedTags;
void Start() {
var container = selectedTags.ToContainer();
// Use container for efficient queries
}
Tag Containers with Data
JovianTagsContainer<T> (tags + values)
Pair tags with typed data — like a dictionary with hierarchy-aware queries:
// Damage resistances
var resistances = new JovianTagsContainer<float>(4);
resistances.Add(GameTags.Damage.Fire_Tag, 0.5f); // 50% fire resistance
resistances.Add(GameTags.Damage.Ice_Tag, 0.75f); // 75% ice resistance
// Exact lookup
if (resistances.TryGetValue(GameTags.Damage.Fire_Tag, out float fireRes)) {
Debug.Log($"Fire resistance: {fireRes}"); // 0.5
}
// Hierarchy query — do I resist ANY damage type?
resistances.ContainsDescendantOf(GameTags.Damage.Damage_Tag); // true
// Get all resistances under a parent
var results = new List<TagEntry<float>>();
resistances.GetByAncestor(GameTags.Damage.Damage_Tag, results);
// results contains Fire(0.5) and Ice(0.75)
TagEntry<T>
Each entry in a generic container:
TagEntry<float> entry = ...;
entry.Tag; // the JovianTag
entry.Value; // the float value
entry.IsDescendantOf(someAncestor); // hierarchy query on the tag
entry.Is(someTag); // exact match
Runtime API Reference
JovianTagsHandler (static)
The central tag registry. Initialized automatically by the generated code.
| Method | Description |
|---|---|
Initialize() |
Reset and initialize the registry |
RegisterTag(string) |
Register a dot-delimited tag and all parents |
GetTag(string) |
Get tag by full name |
GetTag(int) |
Get tag by ID |
TryGetGameTag(string, out JovianTag) |
Safe lookup by name |
TagToString(JovianTag) |
Get the full name of a tag |
DisplayName(string) |
Get the last segment ("Damage.Fire" → "Fire") |
IsInitialized |
Whether the registry is ready |
JovianTag (struct, 8 bytes)
| Member | Description |
|---|---|
Id |
Unique integer identifier |
ParentId |
Parent's ID (0 = root) |
IsValid() |
Not the empty tag |
IsNone() |
Is the empty tag |
IsDescendantOf(tag) |
Hierarchy: child/grandchild check |
IsAncestorOf(tag) |
Hierarchy: parent/grandparent check |
IsSiblingTo(tag) |
Same parent check |
==, !=, Equals() |
Equality by ID |
JovianTagsGroup (serializable struct)
| Member | Description |
|---|---|
tags |
string[] of tag names (serialized) |
ToContainer() |
Resolve to JovianTagsContainer |
Contains(tag) |
Check if any tag matches |
ContainsDescendantOf(tag) |
Hierarchy query |
ContainsAncestorOf(tag) |
Hierarchy query |
HasAny() |
True if any tags selected |
Performance
- JovianTag — 8 bytes (
int id+int parentId), no heap allocation - Hierarchy queries — O(depth) parent chain walk via
JovianTagsHandler.GetTag(int)dictionary lookup. Typical depth is 1-4 hops. - Equality — O(1) integer comparison
- Container queries — O(n) linear scan, optimal for typical small tag counts (<10 per entity)
- ToString — uses cached static
StringBuilderto avoid per-call allocation - Registration — uses
ReadOnlySpan<char>for segment parsing to minimize string allocations