Skip to content

Conversation

@ethaniccc
Copy link

@ethaniccc ethaniccc commented Sep 12, 2025

Related issues & PRs

#4725
#4924
#3811

Changes

API changes

  • Added Player->syncBlocks

Behavioural changes

This PR aims to fix a long-known issue where placing two or more blocks consecutively near each other will cause them to "flicker" on the client, and cause issues with bridging mechanics on higher latency clients. Instead of syncing all nearby blocks every time a player clicks a block, we move the logic directly to World->useItemOn, so that we only synchronize blocks the client needs.

Backwards compatibility

N/A

Tests

After changes in this PR:
https://streamable.com/vochsr

Before changes in this PR:
https://streamable.com/id65yg

@ethaniccc ethaniccc requested a review from a team as a code owner September 12, 2025 15:26
Copy link
Member

@dktapps dktapps left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand why this solves the issue. It's still resending blocks on failed actions.

The cause of the flickering is because the right-click action is very noisy and gets sent even when the client can't possibly place the block, which triggers resends of air blocks which then conflict with client-side predictions when it actually does succeed in placing the block. This PR is still resending blocks on failed actions, so I don't see why this would solve anything.

Furthermore, this doesn't seem to account for the possibility that the client may have attempted to place a multi-block object like a door, which is why the original function sends so many blocks to begin with.

$newBlocks = [];
foreach($tx->getBlocks() as [$x, $y, $z, $_]){
$blockPos = new Vector3($x, $y, $z);
if (!in_array($blockPos, $originalBlocks, true)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It always baffles me how experienced developers still use in_array().

This is slow first of all, and it will also always return false because you're testing by object identity for an object which you just created.

It'd likely be faster to just accept the possibility of duplicates.

Copy link
Author

@ethaniccc ethaniccc Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I read the word "experienced" as "inexperienced") - sorry Dylan

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It wasn't intended as a personal attack. I just find it surprising.

I don't feel like taking 4-year-old discord messages out of context is constructive to the PR discussion though.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest commit should resolve this, along with the issue of multi-position block placements needing to be synced properly as well

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I'm sorry for my condescending comment. It was unnecessary and non-constructive.

@ethaniccc
Copy link
Author

I'm not sure I understand why this solves the issue. It's still resending blocks on failed actions.

Simplified example of what is happening before this PR, where the client has a ~4 tick RTT:

  • The client sends a block placement at X=1, server has not received the placement yet
  • On the next game tick (50ms), the client sends another block placement at X=2, server has not received the placement yet
  • The server eventually receives the block placement at X=1, it is successful. PMMP sends a large amount of block updates surrounding the block placement at X=1. This also contains an update for the block at X=2, which is still currently air.
  • The server eventually receives the block placement at X=2, it also sends a large amount of updates of surrounding blocks.
  • The client will eventually receive the block updates the server sends, but it will receive the block updates for X=1 and X=2 first (which were caused by the placement at X=1). It will set the block at X=2 to air, even though the client has already predicted it placing a block at X=2.
  • The client will also eventually receive the block update at X=2, and will set it back to whatever block they have placed previously.

@ethaniccc
Copy link
Author

One more thing I forgot to address:

The cause of the flickering is because the right-click action is very noisy and gets sent even when the client can't possibly place the block, which triggers resends of air blocks which then conflict with client-side predictions when it actually does succeed in placing the block. This PR is still resending blocks on failed actions, so I don't see why this would solve anything.

The PR also has an exemption where it will not synchronize blocks when the player's bounding box is colliding with the block's bounding boxes. This is made with the assumption that the player would already know if they are colliding with a block they placed, and therefore wouldn't need an air update. This is specifically helpful when a player is holding (or spamming) right click while bridging upwards.

Before PR: https://streamable.com/shkzda

After PR: https://streamable.com/wanchr

*/
public function useItemOn(Vector3 $vector, Item &$item, int $face, ?Vector3 $clickVector = null, ?Player $player = null, bool $playSound = false, array &$returnedItems = []) : bool{
$blockClicked = $this->getBlock($vector);
public function useItemOn(Vector3 $clickedBlockPos, Item &$item, int $face, ?Vector3 $clickVector = null, ?Player $player = null, bool $playSound = false, array &$returnedItems = []) : bool{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is BC breaking

$aroundBlocks[] = $otherVector;
}
}
$player->syncBlocks($aroundBlocks);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the block interaction was disabled, then the client would wrongly see a door being openable when it's actually cancelled on the server side.

Copy link
Author

@ethaniccc ethaniccc Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should sync doors properly here, as it is also sending the blocks around the clicked block's position sides, similar to how InGamePacketHandler->syncBlocksNearby does it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a plugin does $ev->setUseBlock(false), the client will predict a door being opened when it isn't. This code is gated inside $ev->useBlock() and wouldn't run in that case.

}

if($item->isNull() || !$item->canBePlaced()){
$player?->syncBlocks($mainBlocks);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember too well how this code works. But I'm pretty sure it's possible for the client & server to disagree about the currently held item. So the client might think it's able to place a door while the server doesn't in network races.

$item->getPlacementTransaction($blockReplace, $blockClicked, $face, $clickVector, $player);
if($tx === null){
//no placement options available
$player?->syncBlocks($mainBlocks);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same again here - the client may think the placement will succeed if it doesn't have an up to date copy of the terrain

if ($player !== null && $player->getBoundingBox()->intersectsWith($collisionBox)) {
$allowed = false;
$playerCollision = true;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a solution I didn't want to go for too, because it's possible the server and client may have different ideas about the block's bounding box (or the player's own). I think this is the workaround most people went for, but I'd still be happier if there was a better way to do it.

@dktapps
Copy link
Member

dktapps commented Sep 12, 2025

To address something you said earlier:

I did try to fix this problem a couple of times in the past, but couldn't figure out a way to do it that covered all bases. I did also find this issue annoying, and the only reason it wasn't fixed is because I couldn't figure out a way to do it that didn't require some sort of hack or expose edge cases, and didn't fancy wasting my time on it. (Had hoped someone else would solve it so I didn't have to...)

So, one concern I've had with changes like this is that we don't really know what the client is predicting.
For example, it's possible that a client may mispredict the final state of a block, which is why we do the syncing on success. This problem got a fair bit worse because of the new APIs added by #4683, which makes it possible for the action to succeed differently than the client expected.

It occurred to me today that it might be possible to solve this just by delaying the outbound block syncs instead of having complicated conditions for the syncing. A delay before sending out the block updates would allow newer actions to arrive without spamming conflicting ones. What do you think about that? I will admit I haven't thought about it in depth.

@dktapps
Copy link
Member

dktapps commented Sep 12, 2025

Really the ideal world would be if the client would tell us what predictions it made, or if we could associate a transaction ID with block updates so that the client would know to ignore stale block updates. But unless something has changed in the protocol lately that I'm not aware of, that's probably not possible.

@ethaniccc
Copy link
Author

ethaniccc commented Sep 12, 2025

Really the ideal world would be if the client would tell us what predictions it made, or if we could associate a transaction ID with block updates so that the client would know to ignore stale block updates. But unless something has changed in the protocol lately that I'm not aware of, that's probably not possible.

The client (as of 1.21.20) does send a prediction of whether it predicted it's interaction failed or not. We could use that to determine if Player->interactBlock should be called in InGamePacketHandler, but that could break certain interactions from going through if certain servers still do expect them. One that comes to mind is the client being able to open Iron Doors on PM, but there probably are others as well.

It occurred to me today that it might be possible to solve this just by delaying the outbound block syncs instead of having complicated conditions for the syncing. A delay before sending out the block updates would allow newer actions to arrive without spamming conflicting ones. What do you think about that? I will admit I haven't thought about it in depth.

This solution I think would reduce the amount of times the scenario I mentioned previously (the 4 tick RTT one), but it would still happen. This mainly affects jump-bridging, as the blocks being set to air has the potential to make the client predict it should place a block upward instead of the direction the player was building in

@dktapps
Copy link
Member

dktapps commented Sep 12, 2025

So my understanding of that field is that we can interpret it as "did the client make any predictions". If it didn't, we should be able to avoid syncing in those cases (if the action succeeds in spite of this, the world will broadcast updates anyway), which should make towering less buggy without the need for AABB checks.

It wouldn't help much when it did predict though, since we'd still have no way to know what it predicted.

@dktapps
Copy link
Member

dktapps commented Sep 12, 2025

This solution I think would reduce the amount of times the scenario I mentioned previously (the 4 tick RTT one), but it would still happen. This mainly affects jump-bridging, as the blocks being set to air has the potential to make the client predict it should place a block upward instead of the direction the player was building in

Yeah, I suppose the delay would have to be fairly significant and/or based on RTT to be able to solve the issue fully. But it'd allow for actions that are currently in the pipeline to show up without trashing the client's predictions.
Essentially what I'm saying is that we could keep a map<coordinate, {UpdateBlockPacket, tick}> and only send out the updates when the packets reach a certain age. If the map got freshened by a newer action, then the delay would reset. It'd also reduce the amount of overall block updates we send since we wouldn't be sending the same block multiple times in such a short time.

The only reason I mention it is because I think it'd probably be less complicated to implement. But I'm not married to any particular solution.

@ethaniccc
Copy link
Author

ethaniccc commented Sep 12, 2025

Essentially what I'm saying is that we could keep a map<coordinate, {UpdateBlockPacket, tick}> and only send out the updates when the packets reach a certain age. If the map got freshened by a newer action, then the delay would reset. It'd also reduce the amount of overall block updates we send since we wouldn't be sending the same block multiple times in such a short time.

Ah, I see - yeah, that would probably work well in combination to the stuff in this PR to make sure the client is synced 1:1 with the server's view of the world while also not affecting actions like bridging. This is what I'm thinking right now:

  1. The initial handling in World->useItemOn() should instantly send the block updates that we assume are needed for the client to see cancelled interactions ASAP
  2. In Player/InGamePacketHandler (I don't really know), we mark the blocks around the placement like in InGamePacketHandler->syncBlocksNearby for a delayed update of a certain amount of time, with the cooldown resetting if there's another block update queued at the same position (like you mentioned)

dktapps added a commit that referenced this pull request Sep 15, 2025
…nteract fail

if the prediction was a fail, we can assume that the client didn't do anything visual on its end,
which avoids the need to resend blocks.
This fixes block lag when towering as discussed in #6803.

The more general issue in #6803 remains unresolved (that the server's block resyncing may overwrite
additional client side predictions if it places a block before the server's resync for the initial
placement arrives), but that's more complicated to fix and I'm not convinced on the correct method
to resolve it yet.

In any case, this change nets a decent improvement for everyone, regardless.
@dktapps
Copy link
Member

dktapps commented Sep 15, 2025

I implemented a check based on the predicted result field, and it completely eliminated the lag on my localhost client & server. Basically we can avoid failure syncing if the client predicted that nothing would happen.

There still remains a wider issue in higher-latency conditions of server-side success syncing from placing block1 overwriting client-side predictions for placing block2 in high latency conditions. As far as I know, this sort of conflict wasn't an issue until #4683 was implemented. The syncing was changed in that PR to address the possibility of actions succeeding differently to the client predictions.

The simplest solution for this would be to have useItemOn() tell us somehow whether the result was expected to match vanilla or not (i.e. whether a plugin changed the outcome of PlayerInteractEvent without cancelling it). I did note this in a comment in InGamePacketHandler previously, but I didn't bother to do anything about it because I wasn't aware of the issues it would cause, and it would be BC breaking to change it. Possibly we could add a second method and change the return type, and have useItemOn() wrap the second method and convert its result into a bool for plugin consumers.

On a last note, I will note that there seems to be a lot of other things that don't work very well under high latency conditions apart from this issue. For example it appears that the server syncs the player's flying state back to it unnecessarily, which causes weird issues when toggling flying in creative with a high ping. Would be worth looking into that sort of issue too if you want to improve the experience for high-latency players.

@dktapps dktapps added Category: Gameplay Related to Minecraft gameplay experience Type: Fix Bug fix, typo fix, or any other fix Category: Network Related to the internal network architecture Opinions Wanted Request for comments & opinions from the community labels Sep 18, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Category: Gameplay Related to Minecraft gameplay experience Category: Network Related to the internal network architecture Opinions Wanted Request for comments & opinions from the community Type: Fix Bug fix, typo fix, or any other fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants