Timers & Cooldowns
Problem
You need time-based mechanics: ability cooldowns, buff durations, delayed actions, or periodic effects.
Solution
Basic Timer Component
[Component]
public partial struct Timer : IComponent
{
public float Duration;
public float Elapsed;
public bool Loop;
public readonly float Remaining => Duration - Elapsed;
public readonly float Progress => Duration > 0 ? Elapsed / Duration : 1f;
public readonly bool IsComplete => Elapsed >= Duration;
}
Ability Cooldowns
[Component]
public partial struct AbilityCooldown : IComponent
{
public float FireballCooldown;
public float DashCooldown;
public float ShieldCooldown;
}
public class AbilitySystem : SystemBase
{
public override void Update(float deltaTime)
{
// Reduce all cooldowns
foreach (var entity in World.Query<AbilityCooldown>())
{
ref var cd = ref World.Get<AbilityCooldown>(entity);
cd.FireballCooldown = MathF.Max(0, cd.FireballCooldown - deltaTime);
cd.DashCooldown = MathF.Max(0, cd.DashCooldown - deltaTime);
cd.ShieldCooldown = MathF.Max(0, cd.ShieldCooldown - deltaTime);
}
}
public bool TryCastFireball(Entity caster)
{
ref var cd = ref World.Get<AbilityCooldown>(caster);
if (cd.FireballCooldown > 0)
return false; // Still on cooldown
// Cast the spell
SpawnFireball(caster);
cd.FireballCooldown = 5f; // 5 second cooldown
return true;
}
}
Buff/Debuff System
[Component]
public partial struct SpeedBuff : IComponent
{
public float Multiplier;
public float RemainingDuration;
}
[Component]
public partial struct PoisonDebuff : IComponent
{
public int DamagePerTick;
public float TickInterval;
public float NextTickIn;
public float RemainingDuration;
}
public class BuffSystem : SystemBase
{
public override void Update(float deltaTime)
{
var buffer = World.GetCommandBuffer();
// Process speed buffs
foreach (var entity in World.Query<SpeedBuff>())
{
ref var buff = ref World.Get<SpeedBuff>(entity);
buff.RemainingDuration -= deltaTime;
if (buff.RemainingDuration <= 0)
{
buffer.Remove<SpeedBuff>(entity);
}
}
// Process poison (with periodic damage)
foreach (var entity in World.Query<PoisonDebuff>())
{
ref var poison = ref World.Get<PoisonDebuff>(entity);
poison.RemainingDuration -= deltaTime;
poison.NextTickIn -= deltaTime;
// Apply damage on tick
if (poison.NextTickIn <= 0)
{
buffer.Add(entity, new DamageReceived { Amount = poison.DamagePerTick });
poison.NextTickIn = poison.TickInterval;
}
// Remove expired debuff
if (poison.RemainingDuration <= 0)
{
buffer.Remove<PoisonDebuff>(entity);
}
}
buffer.Execute();
}
}
// Usage
public void ApplySpeedBoost(Entity target)
{
world.Add(target, new SpeedBuff
{
Multiplier = 1.5f,
RemainingDuration = 10f // 10 second boost
});
}
public void ApplyPoison(Entity target, Entity source)
{
world.Add(target, new PoisonDebuff
{
DamagePerTick = 5,
TickInterval = 1f,
NextTickIn = 1f,
RemainingDuration = 8f // 8 seconds of poison
});
}
Delayed Actions
[Component]
public partial struct DelayedAction : IComponent
{
public float Delay;
public ActionType Type;
public Entity Target;
}
public enum ActionType
{
Explode,
Spawn,
Despawn,
Heal
}
public class DelayedActionSystem : SystemBase
{
public override void Update(float deltaTime)
{
var buffer = World.GetCommandBuffer();
foreach (var entity in World.Query<DelayedAction>())
{
ref var action = ref World.Get<DelayedAction>(entity);
action.Delay -= deltaTime;
if (action.Delay <= 0)
{
ExecuteAction(buffer, entity, action);
buffer.Despawn(entity); // Action entity served its purpose
}
}
buffer.Execute();
}
private void ExecuteAction(ICommandBuffer buffer, Entity actionEntity, DelayedAction action)
{
switch (action.Type)
{
case ActionType.Explode:
CreateExplosion(buffer, World.Get<Position>(actionEntity));
break;
case ActionType.Spawn:
// Spawn logic
break;
case ActionType.Despawn:
if (World.IsAlive(action.Target))
buffer.Despawn(action.Target);
break;
case ActionType.Heal:
if (World.IsAlive(action.Target))
buffer.Add(action.Target, new HealReceived { Amount = 50 });
break;
}
}
}
// Usage: Grenade explodes after 3 seconds
var grenade = world.Spawn()
.With(new Position { X = 100, Y = 100 })
.With(new DelayedAction { Delay = 3f, Type = ActionType.Explode })
.Build();
Periodic Events
[Component]
public partial struct PeriodicSpawner : IComponent
{
public float Interval;
public float Timer;
}
public class PeriodicSpawnerSystem : SystemBase
{
public override void Update(float deltaTime)
{
var buffer = World.GetCommandBuffer();
foreach (var entity in World.Query<PeriodicSpawner, Position>())
{
ref var spawner = ref World.Get<PeriodicSpawner>(entity);
ref readonly var pos = ref World.Get<Position>(entity);
spawner.Timer += deltaTime;
while (spawner.Timer >= spawner.Interval)
{
spawner.Timer -= spawner.Interval;
// Spawn something
buffer.Spawn()
.With(new Position { X = pos.X, Y = pos.Y })
.WithTag<SpawnedEntity>();
}
}
buffer.Execute();
}
}
Why This Works
Timers as Components
Making timers components means:
- They're updated automatically by systems
- They're serializable for save/load
- They're queryable: "Find all entities with active buffs"
- No hidden
Update()callbacks or coroutines
Separation of Timer and Effect
The timer tracks when; the system decides what:
SpeedBuffhas duration but no movement logic- Movement system reads
SpeedBuff.Multiplierwhen active BuffSystemhandles expiration
Accumulator Pattern for Periodic Effects
Using a timer that accumulates and subtracts intervals:
while (spawner.Timer >= spawner.Interval)
{
spawner.Timer -= spawner.Interval;
// ...
}
This handles:
- Large delta times (multiple triggers in one frame)
- Consistent timing regardless of frame rate
- No drift over time
Variations
Generic Timer Entity
[Component]
public partial struct TimerCallback : IComponent
{
public Entity Target;
public int CallbackId; // Identifies which action to take
}
// Use an entity just for timing
var timer = world.Spawn()
.With(new Timer { Duration = 5f })
.With(new TimerCallback { Target = player, CallbackId = 1 })
.Build();
Buff Stacking
[Component]
public partial struct StackingBuff : IComponent
{
public int Stacks;
public int MaxStacks;
public float DurationPerStack;
public float RemainingDuration;
}
public void AddStack(Entity target)
{
if (World.TryGet<StackingBuff>(target, out var buff))
{
// Refresh or add stack
buff.Stacks = Math.Min(buff.Stacks + 1, buff.MaxStacks);
buff.RemainingDuration = buff.DurationPerStack * buff.Stacks;
World.Set(target, buff);
}
else
{
// First stack
World.Add(target, new StackingBuff
{
Stacks = 1,
MaxStacks = 5,
DurationPerStack = 3f,
RemainingDuration = 3f
});
}
}
Cooldown Reduction
[Component]
public partial struct CooldownReduction : IComponent
{
public float Percentage; // 0.2 = 20% CDR
}
public class AbilitySystem : SystemBase
{
public bool TryCastAbility(Entity caster, ref float cooldown, float baseCooldown)
{
if (cooldown > 0)
return false;
// Apply cooldown reduction
float finalCooldown = baseCooldown;
if (World.TryGet<CooldownReduction>(caster, out var cdr))
{
finalCooldown *= (1f - cdr.Percentage);
}
cooldown = finalCooldown;
return true;
}
}
Visual Feedback Integration
[Component]
public partial struct CooldownUI : IComponent
{
public int AbilitySlot;
}
public class CooldownUISystem : SystemBase
{
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<AbilityCooldown, CooldownUI>().With<Player>())
{
ref readonly var cd = ref World.Get<AbilityCooldown>(entity);
ref readonly var ui = ref World.Get<CooldownUI>(entity);
// Update UI with cooldown progress
UIManager.SetCooldownProgress(0, cd.FireballCooldown / 5f); // 5s max
UIManager.SetCooldownProgress(1, cd.DashCooldown / 3f); // 3s max
UIManager.SetCooldownProgress(2, cd.ShieldCooldown / 15f); // 15s max
}
}
}
See Also
- Health & Damage - Combine with damage-over-time
- State Machines - Time-based state transitions
- Systems Guide - System execution timing