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

Loading diagram...

What Altruist Handles Automatically

When the altruist:game:lag-compensation config section exists:

  • ILagCompensationService is registered
  • GameWorldOrganizer3D records all IWorldObject3D positions after movement and physics each tick
  • packets implementing ILagCompensated automatically populate PacketContext.ClientTick
  • CombatService.Sweep() and CombatService.Attack() automatically wrap execution in RewindWorld(...)
  • 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
SettingMeaning
altruist:game:lag-compensationEnables lag compensation by registering the service
history-ticksHow many engine ticks of history are retained per entity
snapshot-strategyHistorical 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:

StrategyBehavior
nearestUses the stored snapshot with the smallest tick difference to the requested client tick
interpolateBlends 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:

Loading diagram...

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:

  • bool for accepted / rejected validation
  • HitResult or SweepResult for 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 entity
  • CompensateYaw(...) 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 - historyTicks through CurrentTick.
  • Snapshot lookup strategy is configurable with snapshot-strategy = nearest | interpolate.
  • Rewind does not mutate live entities; only Compensate and CompensateYaw see historical values.
  • Custom gameplay code only benefits if it actually uses Compensate, CompensateYaw, or the built-in services that already do.