Table of Contents

Change Tracking Guide

Change tracking enables efficient detection of modified entities for network synchronization, undo/redo systems, and reactive updates. This guide covers the change tracking API in KeenEyes.

Overview

The change tracking system provides:

  • Manual dirty marking - Explicitly flag entities as modified
  • Automatic tracking - Optionally track Set() calls automatically
  • Per-component tracking - Separate dirty flags for each component type
  • Efficient querying - Get all dirty entities for a component type

Basic Usage

Manual Dirty Marking

Mark entities as dirty when you modify their components:

using var world = new World();

var entity = world.Spawn()
    .With(new Position { X = 0, Y = 0 })
    .Build();

// Modify component via ref
ref var pos = ref world.Get<Position>(entity);
pos.X = 100;

// Mark as dirty for change tracking
world.MarkDirty<Position>(entity);

Querying Dirty Entities

Get all entities that have been marked dirty for a component type:

foreach (var dirtyEntity in world.GetDirtyEntities<Position>())
{
    ref var pos = ref world.Get<Position>(dirtyEntity);
    Console.WriteLine($"Entity {dirtyEntity} modified: ({pos.X}, {pos.Y})");
}

Clearing Dirty Flags

After processing, clear the dirty flags:

// Process dirty entities
foreach (var entity in world.GetDirtyEntities<Position>())
{
    SyncPositionToNetwork(entity);
}

// Clear flags after processing
world.ClearDirtyFlags<Position>();

Automatic Tracking

Enabling Auto-Tracking

Enable automatic tracking to mark entities dirty when using Set():

// Enable auto-tracking for Position
world.EnableAutoTracking<Position>();

var entity = world.Spawn()
    .With(new Position { X = 0, Y = 0 })
    .Build();

// This automatically marks entity dirty for Position
world.Set(entity, new Position { X = 100, Y = 200 });

// Check
Console.WriteLine(world.IsDirty<Position>(entity)); // True

Disabling Auto-Tracking

world.DisableAutoTracking<Position>();

// Set() no longer auto-marks as dirty
world.Set(entity, new Position { X = 50, Y = 50 });
Console.WriteLine(world.IsDirty<Position>(entity)); // False (after clear)

Checking Auto-Tracking Status

if (world.IsAutoTrackingEnabled<Position>())
{
    Console.WriteLine("Position changes are being auto-tracked");
}

Important: Auto-tracking only works with Set(). Direct modifications via ref are not tracked:

world.EnableAutoTracking<Position>();

// NOT tracked - uses ref directly
ref var pos = ref world.Get<Position>(entity);
pos.X = 100;

// IS tracked - uses Set()
world.Set(entity, new Position { X = 100, Y = 200 });

Checking Individual Entities

IsDirty

Check if a specific entity is dirty for a component type:

if (world.IsDirty<Position>(entity))
{
    Console.WriteLine($"Entity {entity} has modified position");
}

GetDirtyCount

Get the count of dirty entities without iterating:

int dirtyCount = world.GetDirtyCount<Position>();
Console.WriteLine($"{dirtyCount} entities have modified positions");

Use Cases

Network Synchronization

Replicate only modified entities across the network:

public class NetworkSyncSystem : SystemBase
{
    public override void Update(float deltaTime)
    {
        // Sync positions
        foreach (var entity in World.GetDirtyEntities<Position>())
        {
            ref readonly var pos = ref World.Get<Position>(entity);
            NetworkManager.SendPositionUpdate(entity.Id, pos.X, pos.Y);
        }
        World.ClearDirtyFlags<Position>();

        // Sync health
        foreach (var entity in World.GetDirtyEntities<Health>())
        {
            ref readonly var health = ref World.Get<Health>(entity);
            NetworkManager.SendHealthUpdate(entity.Id, health.Current, health.Max);
        }
        World.ClearDirtyFlags<Health>();
    }
}

Undo/Redo System

Track changes for reversal:

public class UndoManager
{
    private readonly Stack<UndoAction> undoStack = [];
    private readonly World world;

    public void CaptureChanges()
    {
        // Capture position changes
        foreach (var entity in world.GetDirtyEntities<Position>())
        {
            ref readonly var currentPos = ref world.Get<Position>(entity);
            undoStack.Push(new PositionUndoAction(entity, currentPos));
        }
        world.ClearDirtyFlags<Position>();
    }

    public void Undo()
    {
        if (undoStack.TryPop(out var action))
        {
            action.Undo(world);
        }
    }
}

Reactive Rendering

Only update visuals for changed entities:

public class RenderSystem : SystemBase
{
    public override void Update(float deltaTime)
    {
        // Only update sprites for entities with changed positions
        foreach (var entity in World.GetDirtyEntities<Position>())
        {
            if (World.Has<Sprite>(entity))
            {
                ref readonly var pos = ref World.Get<Position>(entity);
                ref var sprite = ref World.Get<Sprite>(entity);
                sprite.ScreenX = pos.X;
                sprite.ScreenY = pos.Y;
            }
        }
        World.ClearDirtyFlags<Position>();
    }
}

Physics Integration

Sync with external physics engine:

public class PhysicsSyncSystem : SystemBase
{
    public override void Update(float deltaTime)
    {
        // Push dirty transforms to physics engine
        foreach (var entity in World.GetDirtyEntities<Position>())
        {
            if (World.Has<PhysicsBody>(entity))
            {
                ref readonly var pos = ref World.Get<Position>(entity);
                ref readonly var body = ref World.Get<PhysicsBody>(entity);
                physicsWorld.SetBodyPosition(body.BodyId, pos.X, pos.Y);
            }
        }
        World.ClearDirtyFlags<Position>();
    }
}

Per-Component Tracking

Dirty flags are tracked separately per component type:

var entity = world.Spawn()
    .With(new Position { X = 0, Y = 0 })
    .With(new Velocity { X = 1, Y = 0 })
    .Build();

// Mark dirty for Position only
world.MarkDirty<Position>(entity);

Console.WriteLine(world.IsDirty<Position>(entity)); // True
Console.WriteLine(world.IsDirty<Velocity>(entity)); // False

// Clear Position, Velocity unaffected
world.ClearDirtyFlags<Position>();

Combining with Events

Change tracking works well alongside the event system:

// Use events for immediate reactions
world.OnComponentChanged<Health>((entity, old, newVal) =>
{
    if (newVal.Current <= 0 && old.Current > 0)
    {
        PlayDeathAnimation(entity);
    }
});

// Use change tracking for batched updates
public class HealthBarSystem : SystemBase
{
    public override void Update(float deltaTime)
    {
        // Update UI for all health changes this frame
        foreach (var entity in World.GetDirtyEntities<Health>())
        {
            UpdateHealthBar(entity);
        }
        World.ClearDirtyFlags<Health>();
    }
}

Performance Considerations

Minimal Memory Overhead

Dirty flags use HashSet<int> per component type, only allocated when first marked.

Efficient Clearing

ClearDirtyFlags<T>() is O(1) - it clears the hash set without reallocating.

No Overhead When Unused

If you never call MarkDirty<T>() for a component type, no tracking structures are allocated.

Best Practices

Do: Clear After Processing

// Always clear after processing to avoid stale data
foreach (var entity in world.GetDirtyEntities<Position>())
{
    ProcessChange(entity);
}
world.ClearDirtyFlags<Position>();

Do: Use Auto-Tracking for Set-Heavy Code

// If you primarily use Set(), enable auto-tracking
world.EnableAutoTracking<Position>();

// All Set() calls automatically mark dirty
foreach (var entity in world.Query<Position, Velocity>())
{
    ref readonly var vel = ref world.Get<Velocity>(entity);
    ref var pos = ref world.Get<Position>(entity);
    world.Set(entity, pos with { X = pos.X + vel.X });
}

Do: Use Manual Marking for Ref-Heavy Code

// If you primarily use ref, mark manually
foreach (var entity in world.Query<Position, Velocity>())
{
    ref readonly var vel = ref world.Get<Velocity>(entity);
    ref var pos = ref world.Get<Position>(entity);
    pos.X += vel.X;
    pos.Y += vel.Y;
    world.MarkDirty<Position>(entity);
}

Don't: Forget to Clear

// Bad: Never clearing causes entities to stay "dirty" forever
foreach (var entity in world.GetDirtyEntities<Position>())
{
    SyncToNetwork(entity);
}
// Missing: world.ClearDirtyFlags<Position>();

Don't: Mix Auto and Manual Without Care

// Be consistent within a component type
world.EnableAutoTracking<Position>();

// Manual mark still works, but may be redundant if using Set()
world.MarkDirty<Position>(entity);  // Fine, but check if needed

API Summary

Method Description
MarkDirty<T>(entity) Mark entity as dirty for component type T
GetDirtyEntities<T>() Get all dirty entities for component type T
ClearDirtyFlags<T>() Clear all dirty flags for component type T
IsDirty<T>(entity) Check if entity is dirty for component type T
GetDirtyCount<T>() Get count of dirty entities for component type T
EnableAutoTracking<T>() Auto-mark dirty on Set() calls
DisableAutoTracking<T>() Disable auto-tracking
IsAutoTrackingEnabled<T>() Check if auto-tracking is enabled

Next Steps