Data guidelines


Purpose

This page explains what data should go into a ReactiveEntitySet and what should stay in GameObjects. Following these guidelines leads to cleaner architecture and fewer bugs.


The fundamental rule

Only include data that is computed or managed OUTSIDE of GameObjects.

This rule determines whether data belongs in RES or in a MonoBehaviour.


Data ownership examples

Data Computed by Belongs in RES? Reason
Health Damage system (external) Yes External logic modifies it
MaxHealth Configuration/initialization Yes Reference data
SpeedMultiplier Buff system (external) Yes External logic modifies it
IsStunned Status effect system Yes External logic controls it
KilledByPlayer Death system Yes External flag
Position Transform (GameObject) No Owned by GameObject
Rotation Transform (GameObject) No Owned by GameObject
Scale Transform (GameObject) No Owned by GameObject

Why Position is excluded

Position seems like important entity data, but it usually does not belong in RES.

Reasons

Reason Detail
Ownership The GameObject’s Transform component owns position
Update frequency Position often changes every frame
Dual source of truth Storing in RES creates synchronization problems
No benefit Transform already provides this data to anyone who needs it

The exception

Network games where the server computes position are different.

// Server authoritative movement
// Server calculates position, sends to clients
// RES becomes the authoritative source

[Serializable]
public struct NetworkEntityState
{
    public Vector3 Position;      // Server-computed, OK in RES
    public Quaternion Rotation;   // Server-computed, OK in RES
    public int Health;
}

In this case, the GameObject’s Transform is a view of the authoritative position in RES.


Design checklist

Before adding data to RES, ask these questions.

graph TB
    A["Should this data<br/>go in RES?"] --> B{"Computed by<br/>external system?"}
    B -->|NO| C["Keep as<br/>GameObject field"]
    B -->|YES| D{"Change frequency<br/><10% per frame?"}
    D -->|NO| E["Consider<br/>traditional approach"]
    D -->|YES| F{"Multiple systems<br/>observe changes?"}
    F -->|NO| G["May not<br/>need RES"]
    F -->|YES| H["Add to RES"]

    style C fill:#ffcccc
    style E fill:#ffffcc
    style G fill:#ffffcc
    style H fill:#ccffcc

1. Ownership

Is this data computed by EXTERNAL logic?

  • Yes → Consider RES
  • No (GameObject owns it) → Keep as GameObject field

2. Change frequency

Does this change less than 10% of entities per frame?

  • Yes → RES is efficient
  • No → Traditional approach may be better

3. Observability

Do multiple systems need to react to changes?

  • Yes → RES provides built-in events
  • No → May not need RES

4. Scene independence

Does this data need to persist across scene loads?

  • Yes → RES provides this automatically
  • No → Either approach works

Data/logic separation

RES naturally enforces a clean architectural pattern.

The pattern

State (struct)        : Data only, no methods
Calculation logic     : Pure functions in separate classes
RES                   : Storage and notification
GameObject            : Visualization and reaction

Why this works

State structs have public fields but no business logic.

[Serializable]
public struct EnemyState
{
    public int Health;
    public int MaxHealth;
    public bool IsStunned;

    // Properties are OK
    public float HealthPercent => MaxHealth > 0 ? (float)Health / MaxHealth : 0f;

    // But NO methods that modify state
    // void TakeDamage(int damage) ← Don't do this
}

Calculation logic lives in separate classes.

public static class DamageCalculator
{
    public static EnemyState ApplyDamage(EnemyState state, int damage, bool isCritical)
    {
        int finalDamage = isCritical ? damage * 2 : damage;
        state.Health = Mathf.Max(0, state.Health - finalDamage);
        return state;
    }
}

Usage ties them together.

enemySet.UpdateData(enemyId, state =>
    DamageCalculator.ApplyDamage(state, damage, isCritical));

Benefits

Benefit Description
Testability Pure functions are easy to unit test
Reusability Same logic works with any entity
Clarity Clear separation of concerns
Thread safety Pure functions have no side effects

Correspondence with other paradigms

This data/logic separation aligns with modern architectural patterns.

Paradigm Data Logic
Functional Immutable data Pure functions
ECS Component System
Redux State Reducer
RES State struct Calculation classes

If you are familiar with any of these patterns, the RES approach will feel natural.


Async processing potential

The data/logic separation enables future async processing.

Component Property
State (struct) Copyable, can be passed between threads
Calculation logic Pure functions, thread-safe
RES update Single synchronization point on main thread

Heavy computations can run off the main thread, with results applied to RES when complete.


Common mistakes

Mistake 1: Storing Transform data

// Don't do this (usually)
[Serializable]
public struct EntityState
{
    public Vector3 Position;    // Transform owns this
    public Quaternion Rotation; // Transform owns this
    public int Health;
}

Mistake 2: Putting logic in state struct

// Don't do this
[Serializable]
public struct EntityState
{
    public int Health;

    public void TakeDamage(int damage)  // Logic doesn't belong here
    {
        Health -= damage;
    }
}

Mistake 3: Including frequently changing data

// Reconsider if this changes every frame
[Serializable]
public struct EntityState
{
    public float AnimationTime;  // Changes every frame
    public Vector3 Velocity;     // Changes every frame
}

Summary

Guideline Description
External ownership Only RES data that external systems compute
No Transform data Position/Rotation belong to GameObject (usually)
Low change frequency Less than 10% of entities per frame
Data/Logic separation State structs + pure function calculators
Exception for networking Server-authoritative data can include position

Next steps


This site uses Just the Docs, a documentation theme for Jekyll.