Table of Contents

Data-Driven Timers / Scheduled Entities

Model respawns, expiries, and cooldowns as queryable entities β€” no separate timer subsystem.

Status: πŸ“‹ Planned Β· Visibility: Public Β· Level: 🟣 Advanced Β· Category: Runtime

🎯 What it solves

Game servers need scheduled work β€” respawns, buff/cooldown expiry, auction timeouts, periodic events β€” that fires by passage of time rather than by data change. A dedicated timer/scheduler subsystem (heap of callbacks, wheel timer, etc.) duplicates machinery Typhon already has: storage, transactions, indexes, queries. It also loses the properties those give you for free β€” a timer living outside the entity model isn't transactional, isn't crash-recoverable, and isn't queryable ("show me all pending respawns") without bespoke code.

βš™οΈ How it works (in brief)

A timer is an ordinary entity: an archetype carrying a time-of-expiry field plus whatever payload the timer needs (player id, item id, …). A dedicated CallbackSystem runs every tick and queries for entities whose expiry field has passed now, processes them, and destroys (one-shot) or re-arms (repeating) the timer entity. Because expiry is driven by wall-clock or tick-count passage β€” not by a write to the entity β€” this must be a proactive CallbackSystem poll, not a reactive changeFilter/View trigger: nothing writes to the timer component when it silently expires, so a change-tracking mechanism would never see the event.

πŸ’» Usage

// 1. A payload component + your own expiry component (any unmanaged struct works;
//    Typhon does not ship a built-in ScheduleAt type today).
public struct ScheduleAt
{
    public long ExpiresAtTicks;   // Stopwatch.GetTimestamp()-based
    public bool IsExpired(long nowTicks) => nowTicks >= ExpiresAtTicks;
    public static ScheduleAt After(TimeSpan delay) =>
        new() { ExpiresAtTicks = Stopwatch.GetTimestamp() + (long)(delay.TotalSeconds * Stopwatch.Frequency) };
}

public struct RespawnData
{
    public EntityId PlayerId;
}

[Archetype]
public class RespawnTimer : Archetype<RespawnTimer>
{
    public static readonly Comp<ScheduleAt> Timer = Register<ScheduleAt>();
    public static readonly Comp<RespawnData> Data = Register<RespawnData>();
}

// 2. Schedule a respawn from any system, inside its Transaction:
var timer = ScheduleAt.After(TimeSpan.FromSeconds(30));
var data = new RespawnData { PlayerId = deadPlayerId };
tx.Spawn<RespawnTimer>(RespawnTimer.Timer.Set(in timer), RespawnTimer.Data.Set(in data));

// Cancel by destroying the timer entity:
tx.Destroy(respawnTimerId);

// 3. A CallbackSystem polls and fires expired timers every tick:
var dag = schedule.PublicTrack.DeclareDag("Game");
dag.CallbackSystem("ProcessRespawns", ctx =>
{
    var now = Stopwatch.GetTimestamp();
    foreach (var id in ctx.Transaction.Query<RespawnTimer>()
                 .Where<ScheduleAt>(t => t.IsExpired(now))
                 .Execute())
    {
        var entity = ctx.Transaction.Open(id);
        var data = entity.Read(RespawnTimer.Data);
        RespawnPlayer(ctx.Transaction, data.PlayerId);
        ctx.Transaction.Destroy(id);   // one-shot: remove after firing
    }
}, after: "Input");

⚠️ Guarantees & limits

  • Transactional, crash-durable, queryable, cancellable β€” these come for free from being a normal entity: rollback undoes scheduling, recovery restores it per the archetype's storage mode, tx.Query<RespawnTimer>().Execute() lists pending timers, tx.Destroy(id) cancels one.
  • No built-in scheduler β€” this is a documented pattern, not Typhon infrastructure. You write the ScheduleAt-style component, the CallbackSystem poll loop, and the re-arm/destroy logic.
  • Current query cost is a broad scan, not an index seek β€” EcsQuery<T>.Where<T> evaluates the predicate per entity via Transaction.Open + TryRead; targeted (index-first) scan isn't wired up yet. An index-backed range query (ExpiresAtTicks <= now) is the eventual goal but cost today scales with the archetype's entity count, not the expired subset.
  • Pick storage mode per timer cost/criticality: SV for high-frequency, loss-tolerant timers (respawns, cooldowns β€” a tick of loss on crash is invisible); Versioned for timers that must never silently vanish (auction expiry, trade lockouts).
  • Repeating timers must be re-armed explicitly (ExpiresAtTicks += interval) by your processing code β€” there is no automatic repeat semantics.