Astral Realms Documentation Help

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

service.instances().size() >= configuration.maxInstances()

No reply — server is full.

2

plugin.blueprints().findDungeon(blueprintId) resolves

No reply — server doesn't host that blueprint.

3

service.findByParty(partyId) is empty

Replies CreateDungeonResponsePacket{serverId, success=false} — party already has a running instance.

4

PartyAPI.findById(partyId) resolves

Replies success=false — party couldn't be resolved.

5

Calls DungeonService#create(party, blueprint). Replies success=true once it completes, or success=false (and logs the exception) if it throws.

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:

  1. World. createWorld() creates a brand-new world (named after System.currentTimeMillis()) on the main thread: Environment.NORMAL, WorldType.FLAT, no bonus chest, generator EmptyChunkGenerator.INSTANCE — a ChunkGenerator that 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 to NORMAL. A combat/ idle-neutral set of gamerules is applied:

    GameRule

    Value

    GameRule

    Value

    RAIDS

    false

    SHOW_DEATH_MESSAGES

    false

    ADVANCE_WEATHER

    false

    RANDOM_TICK_SPEED

    0

    ADVANCE_TIME

    false

    SPAWN_MOBS

    false

    MOB_GRIEFING

    false

    LOCATOR_BAR

    false

    NATURAL_HEALTH_REGENERATION

    false

  2. Rooms. DungeonGenerator#generate(blueprint, origin, seed) lays out the room graph (seeded from ThreadLocalRandom) — see Dungeon & Room Blueprints for the generation algorithm.

  3. Precompute, before pasting. For every generated room, RoomUtils#findSpawnLocations collects each mob-type PositionRoomComponent, converts it to world space (room rotation + origin applied, matching how the schematic will be pasted), and buckets it by chunk key into a Long2ObjectMap<Collection<Location>> — this becomes DungeonInstance#spawnLocations(), consumed one location at a time as mobs activate (see Mob activation). The boss location is the first boss-type PositionRoomComponent found while scanning the same rooms (a later room's boss component doesn't override an earlier one). If no room declares one, creation fails outright (RuntimeException, propagated as success=false in the handshake above). Note this is a separate resolution from the one the boss guide trail uses — see Boss guide trail.

  4. Paste. RoomUtils#paste runs the WorldEdit paste for every room's schematic asynchronously, rotated per-room about its origin.

  5. Instantiate & register. DungeonInstance is constructed — its state starts at CREATING (see Instance states) — and DungeonService registers it by instance id, world UID, and party id.

  6. Reward pots. One tick later, every loaded chunk is scanned for DECORATED_POT tile entities; the blocks found seed DungeonInstance#alivePots(). See Loot & Rewards for pot loot — a PotParticleTask (every 5 ticks) starts once the instance goes ACTIVE.

  7. 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

CREATING

The DungeonInstance constructor runs — the instant the object exists, before rooms are pasted or it's registered.

ACTIVE

The last step of DungeonService#create — rooms pasted, instance registered, reward pots precomputed. Gameplay listeners (mob/boss activation, mob-kill loot) only act while state() == ACTIVE.

DESTROYING

The first line of DungeonService#delete — before the guide trail is cleared, tasks are cancelled, or players are kicked.

DESTROYED

After the world has been unloaded and the instance removed from DungeonService's registries.

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):

  1. plugin.playerData().add(player) — opens the in-memory DungeonPlayerData entry used to accumulate this run's experience and reward commands (see Loot & Rewards).

  2. DungeonService#registerPlayer(player, instance) — adds them to both participants() and aliveParticipants(), then teleports them to the spawn-id PositionRoomComponent of instance.rooms()[0] (the first room the generator laid down, i.e. the start room). A missing spawn component throws.

  3. 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:

  1. The boss check runs first — see Boss.

  2. DungeonInstance#findSpawnLocationsAround(chunkX, chunkZ, detectionRange) collects every not-yet-consumed spawn location within detection-range chunks of the mover's chunk (a square window, not a circle).

  3. Each candidate is filtered to activation-range blocks — a squared-distance check against the mover's exact position, finer-grained than the chunk prefilter.

  4. 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 weights 1.0), and spawned there via MobsAPI#spawnMob.

  5. 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.

  1. Endpoints. Start is the spawn PositionRoomComponent of the blueprint's declared start room (DungeonBlueprint.Rooms#start); target is the boss PositionRoomComponent of 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 first boss component); in practice they resolve to the same spot because only the end room defines one, but the two are computed independently.

  2. 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).

  3. 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.

  4. Render. Each consecutive pair of points becomes one ItemDisplay arrow — FIXED billboard, non-persistent, using the configured guidance-arrow item (falls back to plain Material.ARROW if that item fails to resolve) — oriented via entity yaw toward the next point.

  5. 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 from aliveParticipants() (they remain in participants()). If that was the last alive participant, the instance ends immediately with NO_ALIVE_PARTICIPANTS; otherwise the player-death message 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 to GameMode.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 (LOWEST priority) — 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-scopedPlayerListener is 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 by DungeonService#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 end()

Action

10

~9s

Sends the won or lost message (depending on cause == WON) to every participant.

80

~79s

Teleports every participant (TeleportationService#sendToGroup) to the configured spawn-group.

90

~89s

Kicks every participant (Player#kick()).

100

~99s

Deletes the instance — DungeonService#delete(instance, InstanceDeletionCause.COMPLETED).

End causes (InstanceEndCause)

Cause

Triggered by

WON

The boss is killed while the instance is ACTIVE (MobListener#onBossDeath).

NO_ALIVE_PARTICIPANTS

The last alive participant dies (PlayerListener#onPlayerDeath) or disconnects (DungeonService#unregisterPlayer) — aliveParticipants() becomes empty.

Deletion causes (InstanceDeletionCause)

Cause

Triggered by

COMPLETED

Normal wind-down — run 100 of EndInstanceTask, ~99s after the instance ended.

NEVER_JOINED

InstanceTimeoutTask: instance is ACTIVE, has zero participants, and has been in that state longer than instance-timeout.

NEVER_CREATED

InstanceTimeoutTask: instance is still CREATING (generation stuck or failed silently) longer than instance-timeout.

UNKNOWN

Declared on the enum but not passed to DungeonService#delete from anywhere in the current codebase — reserved, currently unused.

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 ACTIVE with zero participants, past instance-timeout since it last changed state (i.e. since it went ACTIVE), is deleted with cause NEVER_JOINED.

  • An instance still CREATING, past instance-timeout since it last changed state (i.e. since creation started), is deleted with cause NEVER_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:

  1. State → DESTROYING.

  2. PathGuidanceService#clear(instance) — despawns the guide trail's arrows.

  3. Every BukkitTask registered on the instance is cancelled (the pot-precompute task, PotParticleTask, and — for a normal end — the still-running EndInstanceTask).

  4. Any player still marked as a participant is kicked.

  5. The world is unloaded on the main thread (Bukkit.unloadWorld(world, false) — no save).

  6. State → DESTROYED, then the instance is dropped from DungeonService's id/world/party indexes and every participant's player→instance mapping is removed.

Last modified: 25 July 2026