Lag Compensation
Altruist's lag compensation system lets the server validate time-sensitive gameplay against where entities were when the client actually performed the action, not where they are a few ticks later when the packet finally arrives.
The current implementation is wired into the 3D world pipeline. Every engine tick, Altruist records IWorldObject3D position history, then exposes a rewind API that gameplay systems can use for historical hit validation.
Architecture
What Altruist Handles Automatically
When the altruist:game:lag-compensation config section exists:
ILagCompensationServiceis registeredGameWorldOrganizer3Drecords allIWorldObject3Dpositions after movement and physics each tick- packets implementing
ILagCompensatedautomatically populatePacketContext.ClientTick CombatService.Sweep()andCombatService.Attack()automatically wrap execution inRewindWorld(...)- requested ticks are clamped into the retained history window
- rewind uses an internal override map, so live entity transforms are never mutated
RewindWorld<T>(...)can return any value from the rewound callback, so custom gameplay code does not need outer mutable flags
Note:
The built-in Sweep() path directly uses compensated target positions. Its query origin and direction are still whatever values you pass in, so for fully historical AoE validation you should build the query inside RewindWorld(...) using compensated attacker position and yaw. Attack() is also wrapped, but the default single-target attack path does not do melee range or facing checks by itself.
Enable and Configure
Add the lag-compensation section under altruist:game. The section's presence enables the service; history-ticks and snapshot-strategy are optional.
altruist:
environment:
mode: "3D"
game:
lag-compensation:
history-ticks: 64
snapshot-strategy: nearest
| Setting | Meaning |
|---|---|
altruist:game:lag-compensation | Enables lag compensation by registering the service |
history-ticks | How many engine ticks of history are retained per entity |
snapshot-strategy | Historical lookup mode: nearest or interpolate |
At the default 25 Hz engine rate, 64 ticks covers about 2.56 seconds of history.
Snapshot Strategy
Lag compensation now supports two rewind sampling modes:
| Strategy | Behavior |
|---|---|
nearest | Uses the stored snapshot with the smallest tick difference to the requested client tick |
interpolate | Blends between the surrounding snapshots when the requested tick falls between them |
nearest is the default and is usually the safest starting point for melee validation. interpolate can produce smoother historical positions for fast-moving entities, and falls back to the nearest snapshot if the requested tick cannot be bracketed by two stored samples.
Packet Contract
To participate in lag compensation, the incoming packet must implement ILagCompensated and carry the client-perceived tick at the moment the action happened.
[MessagePackObject]
public sealed class AttackPacket : IPacketBase, ILagCompensated
{
[Key(0)] public uint MessageCode { get; set; }
[Key(1)] public byte Type { get; set; }
[Key(2)] public uint TargetEntityId { get; set; }
[Key(3)] public long ClientTick { get; set; }
}
If the packet does not implement ILagCompensated, or ClientTick is 0, Altruist uses the current server tick and no rewind happens.
Note:
Your client needs some notion of gameplay time that tracks the server's simulation timeline. How you expose that on the client is game-specific, but the packet should carry the tick value the player perceived when they pressed the button.
Sweep Queries in Historical Space
CombatService.Sweep() already checks candidate targets through Compensate(...). For the most accurate result, build the sweep query inside RewindWorld(...) so the attack origin and facing also come from the rewound frame.
[MessagePackObject]
public sealed class CleavePacket : IPacketBase, ILagCompensated
{
[Key(0)] public uint MessageCode { get; set; }
[Key(1)] public float Direction { get; set; }
[Key(2)] public long ClientTick { get; set; }
}
[Gate("cleave")]
public async Task OnCleave(CleavePacket packet, string clientId)
{
var attacker = await ResolveAttacker(clientId) as PlayerEntity;
if (attacker == null) return;
var result = _lagCompensation.RewindWorld(packet.ClientTick, () =>
{
var (ax, ay, az) = _lagCompensation.Compensate(
attacker.VirtualId, attacker.X, attacker.Y, attacker.Z);
var yaw = _lagCompensation.CompensateYaw(
attacker.VirtualId, attacker.FacingYaw);
var query = SweepQuery.Cone(
x: ax,
y: ay,
z: az,
range: 250f,
direction: yaw,
angle: 100f);
// Already inside a rewind callback, so CombatService will not double-rewind.
return Combat.Sweep(attacker, query, damage: 180);
});
await BroadcastSweep(attacker, result);
}
That flow looks like this:
Custom Validation with RewindWorld
For targeted melee attacks, projectile confirmation, or any other game-specific validation, inject ILagCompensationService and do the checks inside RewindWorld(...).
The most useful overload is now:
T RewindWorld<T>(long toTick, Func<T> callback);
That lets you return any result directly from the rewound callback:
boolfor accepted / rejected validationHitResultorSweepResultfor combat workflows- tuples for compact multi-value decisions
- nullable references when the callback may decide "no result"
[WorldObject("player")]
public sealed class PlayerEntity : WorldObject3D, ICombatEntity, IHasFacingYaw
{
public float FacingYaw { get; set; }
int ICombatEntity.Health { get; set; }
int ICombatEntity.MaxHealth => 100;
bool ICombatEntity.IsDead => false;
float ICombatEntity.X => Transform.Position.X;
float ICombatEntity.Y => Transform.Position.Y;
float ICombatEntity.Z => Transform.Position.Z;
public int GetAttackPower() => 10;
public int GetDefensePower() => 5;
}
IHasFacingYaw is useful when the entity's authoritative facing is maintained outside Transform.Rotation. The recorder prefers that value when storing yaw snapshots.
[MessagePackObject]
public sealed class HeavySwingPacket : IPacketBase, ILagCompensated
{
[Key(0)] public uint MessageCode { get; set; }
[Key(1)] public uint TargetEntityId { get; set; }
[Key(2)] public long ClientTick { get; set; }
}
[Gate("heavy-swing")]
public async Task OnHeavySwing(HeavySwingPacket packet, string clientId)
{
var attacker = await ResolveAttacker(clientId) as PlayerEntity;
var target = FindTarget(packet.TargetEntityId);
if (attacker == null || target == null) return;
var accepted = _lagCompensation.RewindWorld(packet.ClientTick, () =>
{
var (ax, ay, az) = _lagCompensation.Compensate(
attacker.VirtualId, attacker.X, attacker.Y, attacker.Z);
var (tx, ty, tz) = _lagCompensation.Compensate(
target.VirtualId, target.X, target.Y, target.Z);
var attackerYaw = _lagCompensation.CompensateYaw(
attacker.VirtualId, attacker.FacingYaw);
if (!IsInsideMeleeArc(ax, ay, az, attackerYaw, tx, ty, tz))
return false;
Combat.ApplyDamage(attacker, target, 240, DamageFlags.Normal);
return true;
});
if (!accepted)
return;
// broadcast hit, trigger effects, consume stamina, etc.
}
That pattern is preferred over mutating outer variables from inside the callback. RewindWorld<T> can simply return the decision you need.
Inside the callback:
Compensate(...)returns the rewound position for the requested entityCompensateYaw(...)returns the rewound yaw for the requested entity- outside the callback, both methods pass current values through unchanged
Cleanup and Virtual ID Reuse
The lag compensation history is keyed by VirtualId. If an entity is despawned permanently, or you plan to reuse its virtual ID later, clear its history explicitly:
public async Task DespawnMonster(MonsterEntity monster)
{
_lagCompensation?.RemoveEntity(monster.VirtualId);
await _world.DestroyObject(monster);
}
This is especially important for long-running worlds where IDs may be recycled.
Behavior and Limits
- Current history recording is fed by
GameWorldOrganizer3D, so this implementation is currently a 3D-world feature. - Rewind is clamped to
CurrentTick - historyTicksthroughCurrentTick. - Snapshot lookup strategy is configurable with
snapshot-strategy = nearest | interpolate. - Rewind does not mutate live entities; only
CompensateandCompensateYawsee historical values. - Custom gameplay code only benefits if it actually uses
Compensate,CompensateYaw, or the built-in services that already do.