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. On success=true it uses TeleportationService to send the requesting player (sendToServer) and every other party member (teleport) to the response's serverId. Because every eligible server attempts generation and the fastest reply wins, load is balanced implicitly by whichever server finishes create() first — ServerService#findEmptiest exists but isn't wired into this path.
World & instance creation
Once a dungeon server wins the race, DungeonService#create(party, blueprint) runs entirely on that server:
World.
createWorld()creates a brand-new world (named afterSystem.currentTimeMillis()) on the main thread:Environment.NORMAL,WorldType.FLAT, no bonus chest, generatorEmptyChunkGenerator.INSTANCE— aChunkGeneratorthat produces no terrain at all (empty noise/surface/bedrock/caves, no populators) and fixes the world spawn at(0, 64, 0). 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_REGENERATIONfalseRooms.
DungeonGenerator#generate(blueprint, origin, seed)lays out the room graph (seeded fromThreadLocalRandom) — see Dungeon & Room Blueprints for the generation algorithm.Precompute, before pasting. For every generated room,
RoomUtils#findSpawnLocationscollects eachmob-typePositionRoomComponent, converts it to world space (room rotation + origin applied, matching how the schematic will be pasted), and buckets it by chunk key into aLong2ObjectMap<Collection<Location>>— this becomesDungeonInstance#spawnLocations(), consumed one location at a time as mobs activate (see Mob activation). 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 (RuntimeException, propagated assuccess=falsein the handshake above). Note this is a separate resolution from the one the boss guide trail uses — see Boss guide trail.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) starts once the instance goesACTIVE.Active. State flips to
ACTIVE.
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 first line of |
| After the world has been unloaded and the instance removed from |
Player join wiring
PlayerConnectionListener (paper module) handles PlayerJoinEvent: it resolves the player's party (PartyAPI.findByPlayer) and, if that party has a running instance (DungeonService#findByParty):
plugin.playerData().add(player)— opens the in-memoryDungeonPlayerDataentry used to accumulate this run's experience and reward commands (see Loot & Rewards).DungeonService#registerPlayer(player, instance)— adds them to bothparticipants()andaliveParticipants(), then teleports them to thespawn-idPositionRoomComponentofinstance.rooms()[0](the first room the generator laid down, i.e. the start room). A missingspawncomponent throws.PathGuidanceService#showBossTrail(instance)— shows the shared boss guide trail (see Boss guide trail). Safe to call on every join: only the first call for an instance does any work.
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.
On PlayerQuitEvent, 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. Either way, their DungeonPlayerData is flushed to storage (plugin.playerData().flush).
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 a participant — alive or not, this handler doesn't check isAlive — 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.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 neighboring room's chunks). The room's mob set —
DungeonBlueprint#roomMobs()keyed by room name — is looked up, one entry is picked uniformly at random (RandomCollection, all weights1.0), and spawned there viaMobsAPI#spawnMob.Spawned entities are tracked in
instance.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 is left available to retry on a later move.
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).
Boss guide trail
PathGuidanceService#showBossTrail(instance), first triggered on the first party member's join, computes and renders the "way to the boss" trail exactly once per instance — later calls are a no-op, claimed atomically via a map keyed by instance id.
Endpoints. Start is the
spawnPositionRoomComponentof the blueprint's declared start room (DungeonBlueprint.Rooms#start); target is thebossPositionRoomComponentof the declared end room (Rooms#end) — both converted to world space the same way as mob spawn locations. This is a separate lookup from the boss location precomputed at creation time (which scans every generated room for the firstbosscomponent); in practice they resolve to the same spot because only the end room defines one, but the two are computed independently.Pathfinding. An A* search (Pathetic library,
AStarPathfinderFactory) runs asynchronously with 3D diagonal neighbors, up to 1,000,000 iterations, and heuristic weights(1.0, 1.0, 3.0, 1.0)— the third term biases the search toward the straight start→goal line for a straighter route (mildly inadmissible, so not guaranteed shortest).Straighten & resample. The raw grid path has staircase jitter collapsed (points within 1.5 blocks of the straightened chord are dropped) and is resampled into points 5 blocks apart.
Render. Each consecutive pair of points becomes one
ItemDisplayarrow —FIXEDbillboard, non-persistent, using the configuredguidance-arrowitem (falls back to plainMaterial.ARROWif that item fails to resolve) — oriented via entity yaw toward the next point.If the search comes back with no path, or the resampled trail has fewer than 2 points, it's abandoned (the claim is released) so a later join can retry.
The trail is not torn down when the instance ends — it's still visible through the whole end sequence — only when the instance is deleted: DungeonService#delete calls PathGuidanceService#clear(instance), which despawns every arrow entity.
Death & spectating
PlayerListener turns a dead participant into a spectator without removing them from the instance:
PlayerDeathEvent— removes the player 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, and made invulnerable. 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) immediately removes every tracked entity (instance.entities() — mobs and the boss) on the main thread, then schedules an EndInstanceTask that runs once per second (runTaskTimerAsynchronously(plugin, task, 0, 20) — 0-tick delay, 20-tick/1-second period). Each run increments an internal counter; specific counts trigger the wind-down steps:
Run # | ≈ elapsed since | Action |
|---|---|---|
10 | ~9s | Sends the |
80 | ~79s | Teleports every participant ( |
90 | ~89s | Kicks every participant ( |
100 | ~99s | Deletes the instance — |
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 the same sequence described in boss guide trail teardown plus world cleanup:
State →
DESTROYING.PathGuidanceService#clear(instance)— despawns the guide trail's arrows.Every
BukkitTaskregistered on the instance is cancelled (the pot-precompute task,PotParticleTask, and — for a normal end — the still-runningEndInstanceTask).Any player still marked as a participant is kicked.
The world is unloaded on the main thread (
Bukkit.unloadWorld(world, false)— no save).State →
DESTROYED, then the instance is dropped fromDungeonService's id/world/party indexes and every participant's player→instance mapping is removed.