mojira.dev
MC-302347

Server entity updates are arbitrarily delayed and display incorrectly to the client

Forced and unnecessary update intervals are placed onto entities every time they are loaded in ChunkMap. These delays are defined in EntityType, which the default for almost all entities is 3 ticks. Minus the player and allay, which are at 2 ticks, and the projectile entity, which for some reason is at 20 ticks. 3 ticks is measured at 150ms if 1 tick is 50ms. This makes entity updates ignore 2-3 ticks of important updates, which makes movement, velocity, interaction, and collision with entities delayed and unresponsive.

In ServerEntity, strict boolean checks make it so that important micro movements are ignored by the server, only sending substantial movement or velocity changes. This causes entities to skip smaller movement frames, which once again makes it feel very unresponsive.

On top of that, the client-side interpolation is very aggressive with its smoothing. In InterpolationHandler, clients interpolate updates through 3 ticks. With 3 ticks of server entity delay aswell, this adds on essentially 300ms of visual delay to clients.

All of these cause significant client and server disagreement on entity position, which affects many aspects of the game such as block validation, removing or placing blocks while changing velocity, potion effect areas, hitboxes, projectiles, and entity interaction registration, even in ideal server conditions.

This effect gets worsened with higher ping players or higher server MSPT, as clients have to wait for both the server and the network to send updates. Clients should not have to wait for the server to send updates, as all delays should be handled by packet processing already, given some delay considering no connection via the internet is 0 latency. This effect is even more worsened by incorrectly handled update tick ordering, seen in https://bugs.mojang.com/browse/MC/issues/MC-297196

Compared to singleplayer in pre 1.3, this is a stark contrast of responsiveness that can be handled much better. Since Minecraft PVP can be competitive, it’s most important to have 0 unnecessary delays on the server for the most responsiveness.

Steps to reproduce the issue

  1. Load a world in singleplayer 1.2.5, singleplayer/multiplayer 1.3, 1.8, and 1.14.

  2. Interact with entities. Test and measure update times of collision, state changes, knockback, and movement and velocity between the versions

  3. Test and measure what the server sees versus what the client sees

Expected result

Feedback of these entity interactions would update as soon as possible, providing accurate position data to the client.

Actual Result

Feedback of these entity interactions update at a delay, making all interactions with entities sludgy and delayed, providing inaccurate data to the client.

Potential Fix

Decompiled with VineFlower with 1.21.9-pre1 Mojang Mappings

ServerEntity will only send updates if these conditions are reached. Unless entities receive an impulse, they will follow their regular updateInterval at 150ms.

ServerEntity.class
if (this.tickCount % this.updateInterval == 0 || this.entity.hasImpulse || this.entity.getEntityData().isDirty()) {
 byte b = Mth.packDegrees(this.entity.getYRot());
            byte c = Mth.packDegrees(this.entity.getXRot());
            boolean bl = Math.abs(b - this.lastSentYRot) >= 1 || Math.abs(c - this.lastSentXRot) >= 1;
          

These booleans are making it so important micro movements are filtered out.

                    ++this.teleportDelay;
                    Vec3 vec3 = this.entity.trackingPosition();
                    boolean bl2 = this.positionCodec.delta(vec3).lengthSqr() >= (double)7.6293945E-6F;
                    Packet<ClientGamePacketListener> packet2 = null;
                    boolean bl3 = bl2 || this.tickCount % 60 == 0;
                    boolean bl4 = false;
                    boolean bl5 = false;
                    long l = this.positionCodec.encodeX(vec3);
                    long m = this.positionCodec.encodeY(vec3);
                    long n = this.positionCodec.encodeZ(vec3);
                    boolean bl6 = l < -32768L || l > 32767L || m < -32768L || m > 32767L || n < -32768L || n > 32767L;

By overriding these movement checks, and forcing all entities in EntityType to return updateInterval=0, this helps more accurately sync entity positions between the client and server. Through testing, this isn’t costly on client or server performance, even on large player count servers.

Environment

Java 21
Windows 11

Linked issues

Attachments

Comments 6

So, few complaints: This is a duplicate of MC-297196 and also is incorrect.

Whilst your code analysis is fundamentally true, all behaviour mentioned in the code analysis already existed before 1.14 and was merely updated across versions. The actual changes in 1.14 responsible for this are to do with the entity trackers being moved to the ChunkMap class and thus happening before the entity ticks. The fix you have described treats the symptoms, but not the actual bug.

The knockback example was attached before I knew that bug report was the root cause. That’s what I thought was related to this report. This is a separate issue, maybe more so a feature request, because if you actually try forcing impulses to true at all times, you will notice a difference. Try it out for yourself.

Oh, okay, I would like to note that the way entities are updated actually predates even 1.3 from the server side, 1.2.5 singleplayer is indeed much more responsive however that is because of no integrated server. This is from 1.2.5 server code using MCP mappings:

// EntityTrackerEntry.java
        if (this.updateCounter++ % this.field_9234_e == 0 || this.trackedEntity.isAirBorne)
        {
            int var2 = MathHelper.floor_double(this.trackedEntity.posX * 32.0D);
            int var3 = MathHelper.floor_double(this.trackedEntity.posY * 32.0D);
            int var4 = MathHelper.floor_double(this.trackedEntity.posZ * 32.0D);
            int var5 = MathHelper.floor_float(this.trackedEntity.rotationYaw * 256.0F / 360.0F);
            int var6 = MathHelper.floor_float(this.trackedEntity.rotationPitch * 256.0F / 360.0F);
            int var7 = var2 - this.encodedPosX;
            int var8 = var3 - this.encodedPosY;
            int var9 = var4 - this.encodedPosZ;
            Object var10 = null;
            boolean var11 = Math.abs(var7) >= 4 || Math.abs(var8) >= 4 || Math.abs(var9) >= 4;
            boolean var12 = Math.abs(var5 - this.encodedRotationYaw) >= 4 || Math.abs(var6 - this.encodedRotationPitch) >= 4;

            if (var7 >= -128 && var7 < 128 && var8 >= -128 && var8 < 128 && var9 >= -128 && var9 < 128 && this.field_28165_t <= 400)
            {
                if (var11 && var12)
                {
                    var10 = new Packet33RelEntityMoveLook(this.trackedEntity.entityId, (byte)var7, (byte)var8, (byte)var9, (byte)var5, (byte)var6);
                }
                else if (var11)
                {
                    var10 = new Packet31RelEntityMove(this.trackedEntity.entityId, (byte)var7, (byte)var8, (byte)var9);
                }
                else if (var12)
                {
                    var10 = new Packet32EntityLook(this.trackedEntity.entityId, (byte)var5, (byte)var6);
                }
            }
            else
            {
                this.field_28165_t = 0;
                this.trackedEntity.posX = (double)var2 / 32.0D;
                this.trackedEntity.posY = (double)var3 / 32.0D;
                this.trackedEntity.posZ = (double)var4 / 32.0D;
                var10 = new Packet34EntityTeleport(this.trackedEntity.entityId, var2, var3, var4, (byte)var5, (byte)var6);
            }

there is a slight difference in that the update counter is updated before the check rather than after however this should not result in a substantial gameplay difference.
This was not changed in 1.3, as can be seen here:

            if (this.ticks++ % this.updateFrequency == 0 || this.myEntity.isAirBorne)
            {
                int var2 = this.myEntity.myEntitySize.multiplyBy32AndRound(this.myEntity.posX);
                int var3 = MathHelper.floor_double(this.myEntity.posY * 32.0D);
                int var4 = this.myEntity.myEntitySize.multiplyBy32AndRound(this.myEntity.posZ);
                int var5 = MathHelper.floor_float(this.myEntity.rotationYaw * 256.0F / 360.0F);
                int var6 = MathHelper.floor_float(this.myEntity.rotationPitch * 256.0F / 360.0F);
                int var7 = var2 - this.lastScaledXPosition;
                int var8 = var3 - this.lastScaledYPosition;
                int var9 = var4 - this.lastScaledZPosition;
                Object var10 = null;
                boolean var11 = Math.abs(var7) >= 4 || Math.abs(var8) >= 4 || Math.abs(var9) >= 4;
                boolean var12 = Math.abs(var5 - this.lastYaw) >= 4 || Math.abs(var6 - this.lastPitch) >= 4;

                if (var7 >= -128 && var7 < 128 && var8 >= -128 && var8 < 128 && var9 >= -128 && var9 < 128 && this.ticksSinceLastForcedTeleport <= 400)
                {
                    if (var11 && var12)
                    {
                        var10 = new Packet33RelEntityMoveLook(this.myEntity.entityId, (byte)var7, (byte)var8, (byte)var9, (byte)var5, (byte)var6);
                    }
                    else if (var11)
                    {
                        var10 = new Packet31RelEntityMove(this.myEntity.entityId, (byte)var7, (byte)var8, (byte)var9);
                    }
                    else if (var12)
                    {
                        var10 = new Packet32EntityLook(this.myEntity.entityId, (byte)var5, (byte)var6);
                    }
                }
                else
                {
                    this.ticksSinceLastForcedTeleport = 0;
                    var10 = new Packet34EntityTeleport(this.myEntity.entityId, var2, var3, var4, (byte)var5, (byte)var6);
                }

Though by 1.8 they did add more conditions, oddly enough:

        if (this.updateCounter % this.updateFrequency == 0 || this.trackedEntity.isAirBorne || this.trackedEntity.getDataWatcher().hasObjectChanged())
        {
            if (this.trackedEntity.ridingEntity == null)
            {
                ++this.ticksSinceLastForcedTeleport;
                int k = MathHelper.floor_double(this.trackedEntity.posX * 32.0D);
                int j1 = MathHelper.floor_double(this.trackedEntity.posY * 32.0D);
                int k1 = MathHelper.floor_double(this.trackedEntity.posZ * 32.0D);
                int l1 = MathHelper.floor_float(this.trackedEntity.rotationYaw * 256.0F / 360.0F);
                int i2 = MathHelper.floor_float(this.trackedEntity.rotationPitch * 256.0F / 360.0F);
                int j2 = k - this.encodedPosX;
                int k2 = j1 - this.encodedPosY;
                int i = k1 - this.encodedPosZ;
                Packet packet1 = null;
                boolean flag = Math.abs(j2) >= 4 || Math.abs(k2) >= 4 || Math.abs(i) >= 4 || this.updateCounter % 60 == 0;
                boolean flag1 = Math.abs(l1 - this.encodedRotationYaw) >= 4 || Math.abs(i2 - this.encodedRotationPitch) >= 4;

                if (this.updateCounter > 0 || this.trackedEntity instanceof EntityArrow)
                {
                    if (j2 >= -128 && j2 < 128 && k2 >= -128 && k2 < 128 && i >= -128 && i < 128 && this.ticksSinceLastForcedTeleport <= 400 && !this.ridingEntity && this.onGround == this.trackedEntity.onGround)
                    {
                        if ((!flag || !flag1) && !(this.trackedEntity instanceof EntityArrow))
                        {
                            if (flag)
                            {
                                packet1 = new S14PacketEntity.S15PacketEntityRelMove(this.trackedEntity.getEntityId(), (byte)j2, (byte)k2, (byte)i, this.trackedEntity.onGround);
                            }
                            else if (flag1)
                            {
                                packet1 = new S14PacketEntity.S16PacketEntityLook(this.trackedEntity.getEntityId(), (byte)l1, (byte)i2, this.trackedEntity.onGround);
                            }
                        }
                        else
                        {
                            packet1 = new S14PacketEntity.S17PacketEntityLookMove(this.trackedEntity.getEntityId(), (byte)j2, (byte)k2, (byte)i, (byte)l1, (byte)i2, this.trackedEntity.onGround);
                        }
                    }
                    else
                    {
                        this.onGround = this.trackedEntity.onGround;
                        this.ticksSinceLastForcedTeleport = 0;
                        packet1 = new S18PacketEntityTeleport(this.trackedEntity.getEntityId(), k, j1, k1, (byte)l1, (byte)i2, this.trackedEntity.onGround);
                    }
                }

The differences between versions show that with only the fix to tracker update timings you should have the same core functionality if not more fluid in modern versions. Thus, this is more a suggestion than a bug.

But I do agree, though it might have minor performance impacts this would in general be an improvement, but not necessarily a bug since it’s how the server code was intentionally designed and has always worked, 1.3 just made it impact singleplayer.

If I had to speculate, the performance impact was more substantial back when the game was older, so these checks were an optimisation made to account for that. my preferred fix would be bumping the base tick speed for the game up to either run entirely on delta time or to run at a higher tick rate since this also minimises tick jitter and makes the update intervals less important.

I do admit my analyzation was a little rough because I’m only checking modern code rather than old code. But it does seem that it’s simply just outdated networking code that wouldn’t hurt to be updated. Making it configurable even, allowing server/clients to control the update/interpolation rates depending on what their system can handle.

Duffy Springpat

(Unassigned)

Plausible

Platform

Normal

Networking, Performance

1.21.8, 1.21.9 Release Candidate 1, 1.21.9

Retrieved