Mechanics
Entity stacking
Only mobs spawned with CreatureSpawnEvent.SpawnReason.SPAWNER are ever stacked or merged — naturally spawned, bred, or summoned mobs are left untouched.
Creation and merge
On spawn (
EntityListener#onEntitySpawn,LOWpriority): if a nearby stack of the same class withinstack-distancehas room for one more, the new mob is absorbed (EntityStack#addToStack()) and the spawn event is cancelled. Otherwise the mob spawns normally andEntityUtils.setup()is applied to it immediately (see below) so unstacked spawner mobs still behave inertly while waiting to be picked up on the next sync pass.Periodic merge (
StackService's 3-second sync task →EntityStackService#slowTick): for every spawner-sourcedCreaturein the world, refresh its tracked location, then either merge it into (or grow) an existing nearby stack, or attempt to create a new stack out of it and any other qualifying nearby mobs (attemptStackCreation) — collecting entities until the combined amount would exceed the per-entity cap (max-stack-size, or acustom-stack-sizeoverride), writing the final total to the surviving mob's PDC before constructing theEntityStackso its display name is correct on the first render, then removing the absorbed mobs.A candidate stack is only considered a merge target if
stack.getAmount() + amountToAdd <= maxStackSize— a full stack never blocks merging with the next nearby stack over.
Spawner-mob setup
EntityUtils.setup() runs on every spawner-sourced mob (stacked or not): disables item pickup, clears equipment, force-adults any AgeableMob/Zombie, makes piglins/hoglins immune to zombification, and — via reflection into NMS fields not exposed by the Paper API — marks the mob unaware/silent /non-colliding, maxes out knockback resistance, clears its target/targeting goals (keeping only FloatGoal), replaces its look/move/jump controls with no-op implementations, replaces its Brain with an empty one, and unignites any Creeper. The intent is a mob that stands still, can't be aggroed or knocked around, and costs near-nothing in AI ticks.
An EntityStack's underlying mob additionally has its MAX_HEALTH attribute set to 100000 and removeWhenFarAway(false) — the mob itself becomes effectively invulnerable and won't despawn from distance; the stack's own amount /health bookkeeping (below) is what actually governs its lifetime.
Damage & death
EntityStackListener#onEntityDamageEvent (HIGHEST, ignoreCancelled) intercepts damage to any tracked entity stack:
A creative-mode player sneak-hitting the mob removes the entire stack instantly (
setForRemoval+remove).Otherwise the mob is healed back to full (undoing the damage against its inflated 100000 HP) and the raw damage is instead applied to
EntityStack#damage, which runs against a per-mob health pool seeded from the original mob's cachedmax_healthPDC value: while the incoming damage exceeds the remaining pool,death()kills one mob's worth, carries the leftover damage into the next mob's pool, and repeats.death()computes drops from the mob's loot table (LootTable#populateLoot, equipment/active-item cleared first) and fires a realEntityDeathEventwith them; a cancelled event aborts (no decrement, no drops). On successamountdecrements by one. Drops are only actually given out if the death location is inside aTownwith theMOB_DROPSsetting enabled: if so, and the killer hasITEM_PICKUPpermission there, drops go straight into the killer's inventory (overflow dropped naturally); otherwise they drop naturally. Exp is applied viaPlayer#applyMendingthen given directly. A smallPOOFparticle burst plays — sent only to the killer (async) when the server's average tick time exceeds 40ms, otherwise broadcast to everyone within 16 blocks.When
amountreaches0, the underlying entity is removed and the stack is marked for removal.
Other spawner-mob restrictions
EntityListener also cancels a set of vanilla behaviors for any mob with fromMobSpawner() == true (stacked or not): targeting (EntityTargetEvent), teleporting endermen, non-block/entity combustion, entity transforms, pig-zap lightning transforms, chicken egg drops, sheep shearing (player or dispenser), and breeding (except cows, which are exempt). Guardians and slimes from spawners also can't spike a player attacker with reflected damage (EntityDamageByEntityEvent cancelled when the damager is spawner-sourced and the victim is a player).
Renaming blocked
EntityStackListener#onEntityRename cancels PlayerNameEntityEvent (name-tag use) for any entity carrying the astral_stack PDC key — stacked mobs can't be renamed.
Item stacking
Every dropped or spawned Item entity (EntityDropItemEvent, ItemSpawnEvent) goes through ItemListener#trigger: if it's already tracked, attempt a proximity merge into another stack; otherwise look for a compatible nearby stack to absorb into (ItemStackService#getFirstAvailableStack — first non-full, non-removing stack within stack-distance whose contents are ItemStack#isSimilar, i.e. same type/meta/enchants/PDC/name); failing that, look for another untracked nearby Item entity to found a new stack with. Items with getMaxStackSize() <= 1 (already-unstackable items) are never stacked. New/merged totals are capped at max-item-stack-size (8192 by default).
A tracked item stack forces the physical entity's own ItemStack#amount to 1 in the world — the real count lives entirely in the custom stack's amount — and calls setWillAge(false) so vanilla despawn never fires; despawning is handled manually (below). ItemMergeEvent between two already-tracked stacks is cancelled so vanilla item merging never fights with the plugin's own bookkeeping.
Pickup
Both player pickup (EntityPickupItemEvent) and hopper/inventory pickup (InventoryPickupItemEvent) are cancelled outright and replaced with ItemStack#pickupAttempt, which manually simulates Inventory#addItem using cloned stacks sized to the destination inventory's own max stack size, decrementing the tracked amount by whatever was actually absorbed. For a player pickup, any amount left over after the main inventory is also offered to the off-hand slot (which addItem never touches). If amount reaches 0 the stack entity is removed; otherwise its display name/PDC amount is updated. A hopper pickup also force-marks the hopper's block entity setChanged() via NMS so the container's own logic notices the change.
Despawn
ItemStackService#cleanUp (part of the 2-second cleanup task) calls ItemStack#shouldDespawn on every tracked item stack, which removes it once 5 minutes (300000ms) have passed since its last updateName() call — i.e. since the last time its amount changed via merge or pickup.
Chunk persistence & TTL
ChunkLoadListener listens for EntitiesLoadEvent rather than ChunkLoadEvent, because on Paper entities load after the chunk itself — ChunkLoadEvent would never see them.
Each chunk's PDC carries a
sessionkey (the plugin'spluginStartTime) so a chunk is only processed once per plugin session. On the first load in a session:If the chunk has a
ttlkey (set when it was last unloaded) and more thanentity-stack-ttlhas elapsed since then, every entity on the chunk carrying theastral_stackkey is removed outright instead of reloaded, and thettlkey is cleared.Otherwise, each
astral_stack-tagged entity is either removed (if the boot was classified as a normal restart and the entity's own stored session differs from the current one — see Startup classification) or reloaded viaStackService#loadStack, which reconstructs anEntityStack/ItemStackfrom the entity's storedamount_stackvalue (hiding the custom name instead of stacking if the stored amount is< 2).On a subsequent load within the same session (Paper recreating entity Java objects from NBT mid-session, which loses runtime NMS state like
mob.collidesor goal selectors), everyastral_stack-tagged entity not already tracked hasloadStackre-applied so spawner-mob setup is reapplied.
ChunkUnloadEventstamps the chunk'sttlkey with the current time whenever the chunk was previously session-marked, seeding the TTL check above for the chunk's next load.
Spawner items
SpawnerItemStackSupplier is registered under the spawners namespace with CommonRegistries .itemStackSuppliers() unconditionally at onEnable (not gated by enabled-groups). It supplies a SPAWNER-material item per spawnable, alive EntityType, with the entity type name stored in a spawner_key PDC key on the item and a translatable "<Type> Spawner" custom name; its key format is spawners:<entity-type-key> (e.g. spawners:zombie), surfacing in item configs as material: "spawners-zombie".
BlockListener wires this into spawner blocks:
Breaking a
SPAWNERblock (BlockBreakEvent,HIGHEST,ignoreCancelled) is always cancelled and replaced with manual handling: the block is logged as removed via CoreProtect (if hooked and API v11+), set toAIR, and the matching spawner item for itsgetSpawnedType()is given to the breaking player — via AstralCore'sMailboxServiceif present, otherwise a direct inventory give.Placing a spawner item (
BlockPlaceEvent) reads thespawner_keyPDC value off the held item (falling back to the item's ownCreatureSpawnerblock-state meta if absent) and sets the placed spawner'sspawnedTypeaccordingly. Zombies are special-cased: instead ofsetSpawnedType, aSpawnerEntrywith an NMSCompoundTag-built entity snapshot and aSpawnRule(minBlockLight=0, maxBlockLight=7, minSkyLight=0, maxSkyLight=7)is set as the spawner's sole potential spawn, at weight100.
See also
Overview — services and persistence model.
Configuration —
config.ymlfields referenced throughout this page.Commands —
/astralstacker,/stackill.