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

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

  2. World (main thread). createWorld() creates a brand-new world named after System.currentTimeMillis(): Environment.NORMAL, WorldType.FLAT, no bonus chest, generator DungeonChunkGenerator(blueprint, rooms) — which produces no terrain and carries the DungeonBiomeProvider as the world's default biome provider. 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

  3. Precompute (async), before pasting. For every generated room, each mob-type PositionRoomComponent is converted to world space (room rotation + origin applied, matching how the schematic will be pasted) and bucketed by chunk key into a Long2ObjectMap<Collection<Location>> — this becomes DungeonInstance#spawnLocations(), consumed as mobs activate (see Mob activation). The chunk key is derived arithmetically rather than through Location#getChunk(), which would load — and generate — a chunk per marker before a single block of the dungeon exists. 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 (IllegalStateException, propagated as success=false in the handshake above). The pass logs how many markers it found across how many rooms, and how long it took.

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

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

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

  7. 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, after a 1s delay) starts alongside.

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

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 experience) only act while state() == ACTIVE.

ENDING

DungeonService#end won the transition — the end sequence is running (messages, teleport out, kick) but the world still exists.

DESTROYING

DungeonService#delete won the transition — before guidance is cleared, tasks are cancelled, or players are kicked.

DESTROYED

The world was unloaded and the instance removed from DungeonService's registries.

DESTROY_FAILED

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 max-instances; the world stays loaded and has to be removed by hand. Terminal — nothing retries from here.

Two transitions are guarded rather than assumed, because a player death, a quit and the boss death can all land at once:

  • beginEnding() moves ACTIVE → ENDING and returns true for exactly one caller. A second end() is ignored, so the end sequence never runs twice (duplicate messages, double kick, double delete).

  • beginDestroying() moves anything to DESTROYING the 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):

  1. plugin.playerData().add(player, instance.blueprint()) — opens the in-memory DungeonPlayerData entry used to accumulate this run's experience, kill count and reward commands, stamped with the blueprint id and the current time (see Loot & Rewards).

  2. DungeonService#registerPlayer(player, instance) — adds them to both participants() and aliveParticipants(), then teleports them to the spawn-id PositionRoomComponent of the room whose blueprint name matches rooms.start. A missing start room or missing spawn component throws; a failed teleport is logged.

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

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

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

  6. Which mob spawns is deterministic, not random. Each RoomInstance keeps a set of the mob types it has already spawned. The listener takes the first id in the room's room-mobs set 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.

  7. MobsAPI#spawnMob places it; a spawn failure is logged and that location is left for a later move. 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 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:

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

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

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

  4. 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 a DiagonalAccessProcessor so 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 LINEAR with 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

stopGuidance(player) — that session's displays are disposed.

Instance deleted

clear(instance) — every session for that instance, plus the cached backbone.

Plugin disables

clearAll().

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 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, 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 (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-scoped — PlayerListener 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) 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 end()

Action

1

immediately

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

50

~12s

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

80

~20s

Kicks every participant (Player#kick()), hopped onto the main thread — kicking is main-thread-only.

100

~25s

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

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

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:

  1. beginDestroying() claims the transition to DESTROYING; a second caller is ignored. Both the async timeout task and the end sequence can reach this for the same instance.

  2. PathGuidanceService#clear(instance) — disposes every player's guidance session for this instance and drops the cached backbone route.

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

  4. On the main thread, unloadWorld kicks 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.

  5. The plugin chunk tickets taken during creation are released.

  6. Bukkit.unloadWorld(world, false) — no save. On failure it retries up to 3 times, 1 second apart.

  7. Whether or not the world came down, the instance moves to DESTROYED (success) or DESTROY_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.

Last modified: 25 September 2026