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, theCallbackSystempoll 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 viaTransaction.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:
SVfor high-frequency, loss-tolerant timers (respawns, cooldowns β a tick of loss on crash is invisible);Versionedfor 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.
π Related
- Related feature: Declarative System Scheduling (CallbackSystem,
shouldRun) - Related feature: Reactive Dispatch / Change Filters (why this pattern uses a poll, not a filter)
- Related feature: Query System
- Related feature: Storage Mode β SingleVersion, Storage Mode β Versioned