Dungeon Instances
A dungeon instance (DungeonInstance) is one running copy of a DungeonBlueprint — a freshly generated world, its pasted rooms, precomputed mob/boss spawn points, and the party playing it. This page covers the full lifecycle: how an instance gets created across the network, what happens inside the world once it exists, and how it ends and gets cleaned up. For the request/response flow at a glance and the bridge/paper module split, see Overview.
Cross-server creation handshake
Creation is a two-hop handshake: the lobby (bridge module) asks, every dungeon server (paper module) races to answer.
Bridge → dungeon servers. ServerService#createInstance(player, party, blueprintId) sends a CreateDungeonRequestPacket{playerId, partyId, blueprintId} as an RPC request (plugin.messaging().sendWithReply(...)) over the dungeons.servers exchange (DungeonConstants.SERVERS_MESSAGING_CHANNEL) — every subscribed dungeon server receives it.
Each dungeon server. DungeonService registers a DungeonCreationListener on that same exchange (plugin.messaging().registerExchange(DungeonConstants.SERVERS_MESSAGING_CHANNEL, ...)) in its constructor. The listener validates in order, stopping (and sending no reply at all — that server simply drops out of the race) the moment a check fails:
# | Check | On failure |
|---|---|---|
1 |
| No reply — server is full. |
2 |
| No reply — server doesn't host that blueprint. |
3 |
| Replies |
4 |
| Replies |
5 | — | Calls |
Bridge, on response. ServerService#createInstance acts on whichever CreateDungeonResponsePacket the RPC reply resolves with, waiting up to 1 minute — long enough for a full world generation and paste. On success=true it uses TeleportationService#sendToServer for the requesting player and every other party member, sending them to the response's serverId; a per-player teleport failure is logged and does not abort the others.
Because every eligible server attempts generation and the fastest reply wins, load is balanced implicitly by whichever server finishes create() first. ServerService#findEmptiest is used one step earlier — by the portal, as an up-front "is anyone able to host this at all?" check against the heartbeat cache — not to choose the winner.
World & instance creation
Once a dungeon server wins the race, DungeonService#create(party, blueprint) runs entirely on that server, as an async chain. The rooms come before the world, which is the load-bearing ordering detail: the world is painted from where the boss room actually landed, and a BiomeProvider is fixed at world creation.
Rooms (async).
DungeonGenerator#generate(blueprint, origin, seed)lays out the room graph — see Dungeon & Room Blueprints for the algorithm. Pure CPU, no world access, so it runs on the async pool rather than stalling the main thread.World (main thread).
createWorld()creates a brand-new world named afterSystem.currentTimeMillis():Environment.NORMAL,WorldType.FLAT, no bonus chest, generatorDungeonChunkGenerator(blueprint, rooms)— which produces no terrain and carries theDungeonBiomeProvideras the world's default biome provider. Auto-save is disabled and difficulty is set toNORMAL. A combat/idle-neutral set of gamerules is applied:GameRule
Value
GameRule
Value
RAIDSfalseSHOW_DEATH_MESSAGESfalseADVANCE_WEATHERfalseRANDOM_TICK_SPEED0ADVANCE_TIMEfalseSPAWN_MOBSfalseMOB_GRIEFINGfalseLOCATOR_BARfalseNATURAL_HEALTH_REGENERATIONfalsePrecompute (async), before pasting. For every generated room, each
mob-typePositionRoomComponentis converted to world space (room rotation + origin applied, matching how the schematic will be pasted) and bucketed by chunk key into aLong2ObjectMap<Collection<Location>>— this becomesDungeonInstance#spawnLocations(), consumed as mobs activate (see Mob activation). The chunk key is derived arithmetically rather than throughLocation#getChunk(), which would load — and generate — a chunk per marker before a single block of the dungeon exists. The boss location is the firstboss-typePositionRoomComponentfound while scanning the same rooms (a later room'sbosscomponent doesn't override an earlier one). If no room declares one, creation fails outright (IllegalStateException, propagated assuccess=falsein the handshake above). The pass logs how many markers it found across how many rooms, and how long it took.Chunk preload. Every chunk the layout is about to write to is loaded and pinned with a plugin chunk ticket before the first block is placed. The paste runs off the main thread into a world that owns no chunks at all; an async writer cannot load a chunk itself — it hands the request to the main thread and waits a tick. Done one chunk at a time mid-copy that costs roughly two ticks per chunk, which is most of the time a dungeon takes to build. The tickets are what keep the chunks resident: a world with no players has nothing else holding them. They are released when the instance is deleted.
Paste.
RoomUtils#pasteruns the WorldEdit paste for every room's schematic asynchronously, rotated per-room about its origin.Instantiate & register.
DungeonInstanceis constructed — its state starts atCREATING(see Instance states) — andDungeonServiceregisters it by instance id, world UID, and party id.Reward pots. One tick later, every loaded chunk is scanned for
DECORATED_POTtile entities; the blocks found seedDungeonInstance#alivePots(). See Loot & Rewards for pot loot; aPotParticleTask(every 5 ticks, after a 1s delay) starts alongside.Active. State flips to
ACTIVE.
If any stage after world creation fails, the world is discarded — released from its tickets and unloaded — rather than being left loaded forever with no instance owning it.
Instance states
InstanceState is a simple forward-only pipeline, tracked with a lastStateChangeTime timestamp that's stamped on every transition (DungeonInstance#state(InstanceState)) and read by InstanceTimeoutTask:
State | Set when |
|---|---|
| The |
| The last step of |
|
|
|
|
| The world was unloaded and the instance removed from |
| The world could not be unloaded after every retry. The instance is unregistered anyway so its party is not locked out and it stops counting against |
Two transitions are guarded rather than assumed, because a player death, a quit and the boss death can all land at once:
beginEnding()movesACTIVE → ENDINGand returnstruefor exactly one caller. A secondend()is ignored, so the end sequence never runs twice (duplicate messages, double kick, double delete).beginDestroying()moves anything toDESTROYINGthe same way, and refuses once deletion has started or finished.
Player join wiring
PlayerConnectionListener (paper module) handles PlayerJoinEvent, suppressing the join message, then resolves the player's party (PartyAPI.findByPlayer) and, if that party has a running instance (DungeonService#findByParty):
plugin.playerData().add(player, instance.blueprint())— opens the in-memoryDungeonPlayerDataentry used to accumulate this run's experience, kill count and reward commands, stamped with the blueprint id and the current time (see Loot & Rewards).DungeonService#registerPlayer(player, instance)— adds them to bothparticipants()andaliveParticipants(), then teleports them to thespawn-idPositionRoomComponentof the room whose blueprint name matchesrooms.start. A missing start room or missingspawncomponent throws; a failed teleport is logged.PathGuidanceService#startGuidance(player, instance)— starts this player's own guidance session to the boss. Per-player, not shared: calling it again for the same player is a no-op. See Boss guidance.
If the party is recorded as having an instance but DungeonService doesn't actually have it registered, this is logged as an error rather than silently ignored.
Separately, AstralSync's PlayerDataLoadedEvent drives EquipmentListener, which wipes and rebuilds the player's inventory from their dungeon inventory — see Dungeon Inventory & Presets.
On PlayerQuitEvent (quit message also suppressed), guidance is torn down, DungeonService#unregisterPlayer removes the player from the instance entirely (participants and alive sets) — if that empties aliveParticipants(), the instance ends immediately with cause NO_ALIVE_PARTICIPANTS — and their DungeonPlayerData is stamped with the run's duration and flushed to storage.
Mob activation
MobListener#onPlayerMove (PlayerMoveEvent, MONITOR priority, ignoreCancelled = true) only reacts when the mover crosses a block boundary (e.hasChangedBlock()), and only while they are an alive participant of an ACTIVE instance in that world:
The boss check runs first — see Boss.
DungeonInstance#findSpawnLocationsAround(chunkX, chunkZ, detectionRange)collects every not-yet-consumed spawn location withindetection-rangechunks of the mover's chunk (a square window, not a circle).Each candidate is filtered to
activation-rangeblocks — a squared-distance check against the mover's exact position, finer-grained than the chunk prefilter.Visibility gate. A candidate further than 25 blocks away must additionally pass
player.hasLineOfSight(location). Within 25 blocks, line of sight is not required — mobs behind a corner or a thin wall still appear when the player is right on top of them. The distance test runs first because it is free, while line of sight is a raytrace per location. 25 is a hard-coded constant, not configurable.For a location that passes, the owning room is resolved from that location's own chunk, not the mover's (detection can reach into a neighbouring room's chunks).
Which mob spawns is deterministic, not random. Each
RoomInstancekeeps a set of the mob types it has already spawned. The listener takes the first id in the room'sroom-mobsset that is not in that set; if every type has already been placed, the set is cleared first and the cycle restarts. So a room works through its whole roster before repeating any type. Ordering follows the YAML set's iteration order.MobsAPI#spawnMobplaces it; a spawn failure is logged and that location is left for a later move. Spawned entities are tracked ininstance.entities()(bulk-removed when the instance ends). Only locations that actually produced a mob are consumed (DungeonInstance#removeSpawnLocations) — a location whose room has no configured mobs stays available.
See Configuration: mobs for the detection-range/activation-range defaults and Dungeon & Room Blueprints for the room-mobs YAML shape.
Boss
MobListener#trySpawnBoss runs before the mob-activation pass on every qualifying move, guarded by DungeonInstance#isBossSpawned() so it fires at most once (safe without extra synchronization since it's only ever invoked on the main thread, from PlayerMoveEvent). The boss spawns the first time any participant's squared distance to DungeonInstance#bossLocation() is within activation-range blocks — the same radius used for ambient mobs. Its mob id comes from DungeonBlueprint#boss(); if it's blank or unset, spawning is skipped and a warning is logged. MobsAPI#spawnMob spawns it and DungeonInstance#spawnBoss records it (also tracked in instance.entities()).
Defeating it — EntityDeathEvent matched against DungeonInstance#isBoss, only while state() == ACTIVE — ends the instance with cause WON (DungeonService#end) and sets completed(true) on every alive participant's DungeonPlayerData, which is what feeds the time and completion leaderboards.
Boss guidance
Every guided player has their own GuidanceSession, created by PathGuidanceService#startGuidance(player, instance) on join. A single main-thread ticker runs at 1 tick while any session exists and stops itself when the last one goes away.
What the player sees
Roughly once a second the session derives a route from the player's current position to the boss and launches a pulse along it. A pulse travels the route at 20 blocks/second, rendering a Particle.TOTEM_OF_UNDYING every tick with 0.25 blocks of random spread. Every other pulse also carries a single oriented ItemDisplay arrow — the configured guidance-arrow item, falling back to plain Material.ARROW if it fails to resolve — travelling at half the particle speed, so the arrow lingers while the particles rush ahead.
Older pulses keep flowing while new ones start, so the effect is a continuous stream from the player's feet to the target. Both the particles and the arrows are per-client: only the owning player sees their own stream. A pulse still travelling after 15 seconds is removed.
Guidance stops once the player is within 30 blocks of the boss — no more pathfinding, no new pulses; the ones already in flight simply finish. At that range the boss is found without help.
Why refreshes are cheap
A full A* across the dungeon once per second per player would be untenable, so the service borrows Typewriter's trick of routing through a precomputed network:
The first successful full search for an instance is cached as that instance's backbone route (block-centred floor positions, in order), shared by every player in it.
A refresh first tries to snap the player to the nearest backbone point. Within 3.5 blocks of one they are "on the route" — the backbone is followed from there with no pathfinding at all. This is the common case, since they are walking along it.
If they have strayed, a short join search runs from the player to the nearest backbone point, capped at 25,000 iterations; the backbone is followed from where it lands.
Only if even the join search fails does a full search rerun, capped at 1,000,000 iterations.
Pathfinding is asynchronous; entities and particles are only ever touched on the main thread.
Pathfinding configuration
The A* (Pathetic, AStarPathfinderFactory) is tuned for a route a player can actually walk, planned for a body height of 1.8 blocks:
Neighbours
DIAGONAL_3D, with aDiagonalAccessProcessorso a diagonal step cannot slip between two blocks that only touch at a corner.Validation
StandableProcessor(which also fills the shared block-property cache the rest of the chain reads) — every node needs two blocks of clearance, and a wall only counts as a wall if it reaches that high.Costs a floor-preference processor, a corridor-centering processor and a vertical-movement processor, so the route hugs the floor and runs down the middle of corridors rather than scraping walls.
Heuristic
LINEARwith weights(1.0, 0.4, 4.0, 1.2): a heavy perpendicular term keeps the search near the straight start→goal line, a light octile term stops it paying for every diagonal, and the height term discourages wandering up and down.Fallback enabled — an unreachable target yields the best partial route rather than nothing.
Teardown
Trigger | Effect |
|---|---|
Player quits |
|
Instance deleted |
|
Plugin disables |
|
Player goes offline mid-tick | The ticker drops and disposes their session on the spot. |
Note that guidance survives the end sequence: it is cleared on deletion, not on end(), so pulses keep flowing while the party is being messaged and teleported out.
Death & spectating
PlayerListener turns a dead participant into a spectator without removing them from the instance:
PlayerDeathEvent— first, unconditionally: keep-inventory off, death messages off, dropped XP zeroed and the drop list cleared, so dying in a dungeon costs and yields nothing. Then the player is removed fromaliveParticipants()(they remain inparticipants()). If that was the last alive participant, the instance ends immediately withNO_ALIVE_PARTICIPANTS; otherwise theplayer-deathmessage is broadcast to every participant.PlayerRespawnEvent— respawn location is the vanilla last-death location if set, else an arbitrary still-alive participant's location. The player is set toGameMode.ADVENTURE, healed to max health, made invulnerable, and has flight revoked (setFlying(false)+setAllowFlight(false)). Visibility is then resynced across every participant: alive participants hide the (now-dead) respawned player, dead participants are shown to them — so spectators can see each other but stay invisible to the living, and vice versa.EntityTargetEvent— cancelled when the target is a dead participant, so mobs won't path onto spectators.PlayerAttemptPickupItemEvent— cancelled for dead participants, so they can't collect loot drops.EntityDamageEvent(LOWESTpriority) — cancelled if the damage source has no direct entity (fall, fire, starvation, drowning, etc.) or if the direct source is another player. This first check is global, not instance-scoped —PlayerListeneris registered once for the whole plugin, so it disables PvP and any directionless damage for every player on the dungeon server, dungeon instance or not. Only the remaining case — the damaged player being a dead participant of a specific instance — is scoped byDungeonService#findByPlayer.
End sequence
DungeonService#end(instance, cause) first claims the ACTIVE → ENDING transition (see Instance states); a second caller is ignored. It then removes every tracked entity (instance.entities() — mobs and the boss) on the main thread, and schedules an EndInstanceTask that runs every 5 ticks (runTaskTimerAsynchronously(plugin, task, 0, 5) — no delay, quarter-second period). Each run increments an internal counter; specific counts trigger the wind-down steps:
Run # | ≈ elapsed since | Action |
|---|---|---|
1 | immediately | Sends the |
50 | ~12s | Teleports every participant ( |
80 | ~20s | Kicks every participant ( |
100 | ~25s | Deletes the instance — |
The teleport out is what starts the reward claim: the player reconnects to the lobby, AstralSync fires PlayerDataLoadedEvent, and the bridge pays out — see Cross-server reward claim.
End causes (InstanceEndCause)
Cause | Triggered by |
|---|---|
| The boss is killed while the instance is |
| The last alive participant dies ( |
Deletion causes (InstanceDeletionCause)
Cause | Triggered by |
|---|---|
| Normal wind-down — run 100 of |
|
|
|
|
| Declared on the enum but not passed to |
Timeout & cleanup
InstanceTimeoutTask is started from DungeonService's constructor (runTaskTimerAsynchronously(plugin, task, 100, 20) — first run 5s after enable, then once per second) and iterates every registered instance each run:
An instance that is
ACTIVEwith zero participants, pastinstance-timeoutsince it last changed state (i.e. since it wentACTIVE), is deleted with causeNEVER_JOINED.An instance still
CREATING, pastinstance-timeoutsince it last changed state (i.e. since creation started), is deleted with causeNEVER_CREATED.
Both checks read DungeonConfiguration#instanceTimeout() — see Configuration for its format and default (10m).
Deletion (DungeonService#delete, regardless of cause) runs:
beginDestroying()claims the transition toDESTROYING; a second caller is ignored. Both the async timeout task and the end sequence can reach this for the same instance.PathGuidanceService#clear(instance)— disposes every player's guidance session for this instance and drops the cached backbone route.Every
BukkitTaskregistered on the instance is cancelled (the pot-precompute task,PotParticleTask, and — for a normal end — the still-runningEndInstanceTask).On the main thread,
unloadWorldkicks everyone in the world — not just registered participants: a staff member or anyone else still in there pins the world and would make the unload fail permanently.The plugin chunk tickets taken during creation are released.
Bukkit.unloadWorld(world, false)— no save. On failure it retries up to 3 times, 1 second apart.Whether or not the world came down, the instance moves to
DESTROYED(success) orDESTROY_FAILED(gave up) and is unregistered from the id/world/party indexes, with every participant's player→instance mapping removed.
Step 7 running unconditionally is deliberate. A failed unload used to leave the instance registered forever in DESTROYING — and since beginDestroying() refuses a second attempt, nothing could retry it. The party was then rejected from every future dungeon request and the instance kept counting against max-instances until the server restarted. Leaking a loaded world is bad but recoverable by hand; leaking a registration is neither.