Astral Realms Documentation Help

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, LOW priority): if a nearby stack of the same class within stack-distance has room for one more, the new mob is absorbed (EntityStack#addToStack()) and the spawn event is cancelled. Otherwise the mob spawns normally and EntityUtils.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-sourced Creature in 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 a custom-stack-size override), writing the final total to the surviving mob's PDC before constructing the EntityStack so 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 cached max_health PDC 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 real EntityDeathEvent with them; a cancelled event aborts (no decrement, no drops). On success amount decrements by one. Drops are only actually given out if the death location is inside a Town with the MOB_DROPS setting enabled: if so, and the killer has ITEM_PICKUP permission there, drops go straight into the killer's inventory (overflow dropped naturally); otherwise they drop naturally. Exp is applied via Player#applyMending then given directly. A small POOF particle 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 amount reaches 0, 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 session key (the plugin's pluginStartTime) so a chunk is only processed once per plugin session. On the first load in a session:

    • If the chunk has a ttl key (set when it was last unloaded) and more than entity-stack-ttl has elapsed since then, every entity on the chunk carrying the astral_stack key is removed outright instead of reloaded, and the ttl key 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 via StackService#loadStack, which reconstructs an EntityStack/ItemStack from the entity's stored amount_stack value (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.collides or goal selectors), every astral_stack-tagged entity not already tracked has loadStack re-applied so spawner-mob setup is reapplied.

  • ChunkUnloadEvent stamps the chunk's ttl key 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 SPAWNER block (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 to AIR, and the matching spawner item for its getSpawnedType() is given to the breaking player — via AstralCore's MailboxService if present, otherwise a direct inventory give.

  • Placing a spawner item (BlockPlaceEvent) reads the spawner_key PDC value off the held item (falling back to the item's own CreatureSpawner block-state meta if absent) and sets the placed spawner's spawnedType accordingly. Zombies are special-cased: instead of setSpawnedType, a SpawnerEntry with an NMS CompoundTag-built entity snapshot and a SpawnRule(minBlockLight=0, maxBlockLight=7, minSkyLight=0, maxSkyLight=7) is set as the spawner's sole potential spawn, at weight 100.

See also

  • Overview — services and persistence model.

  • Configurationconfig.yml fields referenced throughout this page.

  • Commands/astralstacker, /stackill.

Last modified: 25 July 2026