Skip to content

Cancel blocked natural spawns before the entity is created - #2300

Open
RasmusKD wants to merge 5 commits into
EngineHub:version/7.0.xfrom
RasmusKD:paper-pre-spawn-cancel
Open

Cancel blocked natural spawns before the entity is created#2300
RasmusKD wants to merge 5 commits into
EngineHub:version/7.0.xfrom
RasmusKD:paper-pre-spawn-cancel

Conversation

@RasmusKD

@RasmusKD RasmusKD commented Aug 9, 2026

Copy link
Copy Markdown

The mob-spawning and deny-spawn checks currently run in CreatureSpawnEvent, which fires at the very end of the spawn pipeline. By that point the server has picked a spawn position, run the placement checks, constructed the mob and run finalizeSpawn, and the cancelled mob is thrown away. Because a cancelled spawn never counts toward the mob cap, the natural spawner keeps retrying the same area at full rate for as long as those conditions hold, so regions that deny mob spawning become permanent spawn attempt hotspots that pay entity construction over and over for nothing. On a production server (61 players) we profiled recently, natural spawn machinery accounted for roughly a quarter of the main thread, and areas denying spawns contribute to that without anything visible happening.

This PR adds a listener for Paper's PreCreatureSpawnEvent that applies the same natural spawn checks before the entity exists: activity halt, block-creature-spawn, the mob-spawning and deny-spawn region flags, and block-ground-slimes. Cancelling at the pre event stage also makes the server end the remaining spawn attempts for that chunk in the current cycle, so the retry pressure disappears as well.

Measured on a flat test world with a region denying mob-spawning across the whole spawn range and the mob cap kept empty: the attempt rate around a single player collapsed from roughly 75000 attempts per second reaching the pre spawn stage to roughly 60 per second, with no entities constructed at all.

Only NATURAL spawns are handled here. Every other spawn reason keeps going through the existing CreatureSpawnEvent checks unchanged, so there is no behavior change for spawners, breeding, plugins spawning entities and so on. The listener is registered only when the Paper event class is present, using the same PaperLib gate style as elsewhere in the plugin.

One thing worth noting for review: plugins that watch CreatureSpawnEvent will no longer see the natural spawns WorldGuard denies, since those never reach entity creation anymore. Anything that wants to observe or override them can use PreCreatureSpawnEvent.

Edit, measurement details after a follow-up test:

The attempt counter counts PreCreatureSpawnEvent dispatches, so positions that already passed the placement checks and are one step from entity construction. It was a LOWEST priority listener on an otherwise idle flat world with a single stationary player, difficulty hard, permanent night.

Scenario Attempts reaching pre spawn stage Entities constructed
No denial, mob cap kept empty ~75,000/s normal spawns
Denial cancelled at CreatureSpawnEvent (current behavior, simulated with a cancel-all listener) ~97,000/s sustained ~4,500/s constructed and discarded, sustained for the whole measurement window
Denial cancelled at PreCreatureSpawnEvent (this PR) ~60/s none

So the current late cancel does not just waste the construction work, it sustains the maximum spawn attempt pressure under the tested conditions, while the pre event cancel lets the spawn cycle stop early and the whole workload collapses.

The mob-spawning and deny-spawn checks in WorldGuardEntityListener fire
on CreatureSpawnEvent, at the very end of the spawn pipeline. By that
point the server has picked a spawn position, run the placement checks,
constructed the mob and run finalizeSpawn, and the cancelled mob is
thrown away. Since a cancelled spawn never counts toward the mob cap,
the natural spawner keeps retrying the same area at full rate, so
regions that deny mob spawning become permanent spawn attempt hotspots
that pay entity construction over and over for nothing.

On Paper servers the same checks can run in PreCreatureSpawnEvent,
before the entity exists. Cancelling there also ends the remaining
attempts for the chunk in that spawn cycle, so the wasted work is gone
almost entirely. Measured on a flat test world with a region denying
mob-spawning over the whole spawn range and the mob cap kept empty, the
attempt rate around a single player collapsed from roughly 75000
attempts per second to roughly 60 per second, with no entities
constructed at all.

The new listener mirrors the natural spawn conditions from
onCreatureSpawn exactly: activity halt, block-creature-spawn,
mob-spawning and deny-spawn region flags, and block-ground-slimes. All
other spawn reasons keep going through the existing CreatureSpawnEvent
checks unchanged, and the listener is only registered when the Paper
event class is present.
Review feedback: platform specific code lives in the Paper inner
classes of the main listeners, and WorldGuardEntityListener already has
one for EntityZapEvent, so the handler moves in there and the separate
listener class is gone.
return;
}

Location eventLoc = event.getSpawnLocation();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So from what I can tell here, this line onwards is basically identical to the current CreatureSpawnEvent listener. Would it be possible to please extract this into its own method, similar to how the pig zap event handler does it? As these don't have a shared ancestor, you could just pass the event in as a Cancellable to allow cancelling it, and the necessary data in as parameters.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done, extracted it into handleCreatureSpawn. the entity-specific checks stayed in onCreatureSpawn since there's no entity yet at pre-spawn time. re-tested on a live server, behaviour is identical.

The pre spawn handler duplicated the tail of onCreatureSpawn. Both now
call handleCreatureSpawn, which takes the event as a Cancellable and
the location, entity type and spawn reason as parameters, following
the same shape as handlePigZap. The entity specific checks (plugin
spawning, armor stands, tamed animals) stay in onCreatureSpawn since
no entity exists yet at pre spawn time, and the activity halt check
stays in each handler so its ordering relative to those checks is
unchanged.
}

private static void handleCreatureSpawn(Cancellable event, Location location, EntityType entityType, SpawnReason spawnReason) {
ConfigurationManager cfg = getConfig();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for making these other fixes. Just a final one IMO, if you'd be able to please make cfg & wcfg passed in here too. Both are already available in the CreatureSpawnEvent handler, and the Pre one already pulls in cfg, so it'd remove the need for a second lookup (performance-wise)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done, both passed in now

Both handlers already have them, so the shared method no longer does a
second lookup.
@RasmusKD
RasmusKD requested a review from me4502 August 10, 2026 18:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants