Skip to content

Feature Login Spawn

Claude edited this page Sep 28, 2026 · 4 revisions

Login spawn placement

Since: v0.83.0 — PR #120 Updated: v0.181.0 — PR #227 — every accepted spawn candidate now has a verified line of sight to the train (server-side raycast via Level.clip). When the original anchor X has no LOS-clear candidate within ±60 perpendicular blocks, the search slides the anchor along the track (±60 .. ±300 blocks) before falling to the on-train roof — terrain that rises above the carriage roof in every nearby direction is the only path to the last-resort fallback now. Updated: v0.293.0 — PR #365 — on first entry the player now spawns on the train (a flatbed deck), not on the ground. This ground-pose pipeline still runs, but its result is now the starting camera position for the spawn cinematic rather than the body — and that camera-start path now allows water (allowWater), so it can sit just above a lake/ocean surface beside the train instead of sliding away. Real body placement (world default spawn, the fudge mixin, same-dimension respawn) is unchanged and still avoids water.

Note (v0.293.0): the sections below describe the original ground-spawn behaviour. The pipeline mechanics (buffered-X, LOS, X-slide, fallbacks) are intact, but on first entry they now position the camera; the body goes on the train. See Spawn cinematic.

The first frame the player sees on a fresh world is already at the cached spawn placement next to the train — no vanilla (0, ~64, 0) flash, no visible jump, no rotation snap. This page covers the full pipeline that gets us there: cached SpawnPlacement, PlayerFudgeSpawnMixin, deferred-retry queue for the Sable race, buffered-X scan, perpendicular walk, per-candidate LOS check, X-slide along the track, and an on-train-roof last resort so the player never lands on the rails or in water.

The pre-v0.83.0 login flow (random (x, z) alongside the train, lookAt after teleport) is on Feature-Starter-Train § Player placement — on login.

What the player sees

User-reported, fresh world: "looks great." Position perfect; only the camera rotates briefly from the default (yaw=0, pitch=0) to the cached look angle, which feels like turning your head — not teleporting.

Pipeline

Step 1 — bootstrap caches the placement (server-start time)

TrainBootstrapEvents.onServerStarted (running with EventPriority.LOW so per-world data is committed first) does two things:

  1. Spawns the seed group of carriages via TrainAssembler.spawnTrain(...).
  2. Calls PlayerJoinEvents.computeAndCacheBootstrapPlacement(level, dims, trainY) to compute and cache the player's eventual spawn placement, then writes the placement's BlockPos to the level's default spawn point via level.setDefaultSpawnPos(blockPos, yaw).

The cached placement is a SpawnPlacement record:

public record SpawnPlacement(double x, int y, double z, float yaw, float pitch, BlockPos blockPos) {}

The (x, z) are aligned to (blockPos.x + 0.5, blockPos.z + 0.5) so a BlockPos round-trip via setDefaultSpawnPos lands the player at exactly (x, y, z) — no half-block drift.

Step 2 — mixin overrides vanilla's spawn-radius search (login time, before JOIN packet)

When a first-time player connects, ServerPlayer's constructor calls adjustSpawnLocation(level, sharedSpawnPos). Vanilla searches a spawnRadius=10-block square around sharedSpawnPos via PlayerRespawnLogic.getOverworldRespawnPos — which uses the MOTION_BLOCKING heightmap and stops on leaves. Our findGroundY descends through leaves to find the trunk top; the heuristics disagree, so vanilla would land the player 5–10 blocks off our cached placement.

PlayerFudgeSpawnMixin injects at @At("HEAD") of adjustSpawnLocation with cancellable = true and short-circuits to the cached placement's blockPos when one is set. The constructor's moveTo(blockPos.getBottomCenter(), 0F, 0F) then places the player at exactly the cached (x, y, z). The subsequent ClientboundLoginPacket carries those coords — so the client renders the final position from the very first frame.

Step 3 — deferred retry confirms cache + applies rotation (first server tick after login)

The mixin's position match is exact, but it's only position. Yaw/pitch are still (0, 0) from the constructor. PlayerJoinEvents finishes the job:

  1. onPlayerLogin (PlayerLoggedInEvent) adds the player UUID to a PENDING map. No teleport here — too early for Sable's Shipyards.findAll() to see the train (see Compatibility § SubLevelContainer.getAllSubLevels() is asynchronous).
  2. onLevelTick(LevelTickEvent.Post) (overworld only, to avoid double-processing) drains the PENDING map. For each player:
    • If a cached SpawnPlacement exists AND the player is at it (within 2 blocks horizontal): teleport to the same (x, y, z) with the cached yaw / pitch. Position is unchanged → no visual position move; rotation updates.
    • Otherwise (subsequent login, player saved at a non-spawn position): run the live computation pipeline (Step 4).
  3. MAX_RETRY_TICKS = 100 (5 s at 20 TPS) bounds the retry. If the train still isn't resolvable after that, the placement is skipped with a warn log — never silently retries forever.

Step 4 — live computation pipeline (subsequent logins, or first-login without a cache)

When the cache miss path runs, the pipeline computes a fresh placement:

Buffered reference X

findBufferedReferenceX(level, trainX, tunnelGeometry) walks +X first, then −X (each direction up to MAX_ABOVEGROUND_SCAN = 128 blocks), looking for the first X with at least X_BUFFER = 40 non-tunnel columns on each side. The chosen X is the center of the first qualifying clear window — so jittered spawns (±X_JITTER_MAX = 30) stay inside the clear window without leaking past a tunnel mouth.

If found, this X is BOTH the spawn anchor AND the look target. The 40-block buffer requirement guarantees the player isn't placed near a tunnel entrance, addressing user feedback on "I was still too close to a tunnel."

Fallback to lookX (above-ground but unbuffered)

If no buffered window exists in scan range, fall back to the first non-tunnel X (findAboveGroundReferenceX) for both anchor and look. Even unbuffered, the player spawns at a visible stretch of track — strictly better than spawning on top of the buried corridor at the train's X.

Random retry

pickPlayerTarget(level, anchorX, geom, tunnelGeometry, trainCenter) runs up to MAX_ATTEMPTS = 20 random samples around the anchor:

  • ±X_JITTER_MAX = 30 blocks along corridor axis.
  • Random +Z or −Z side, PERP_MIN = 10 to PERP_MAX = 40 blocks perpendicular to corridor.
  • findGroundY walks down through air / fluid / leaves / vines until non-passable.
  • isSafePlayerPos validates: above void clearance, below ceiling clearance, no fluid at body or head, no leaves at body or head (catches the "spawned inside a tree" case).
  • hasLineOfSight raycasts from the player's eye (groundY + 1 + EYE_HEIGHT) to (trainCenter.x, trainCenter.y + 8, trainCenter.z) using Level.clip with ClipContext.Block.COLLIDER and ClipContext.Fluid.NONE. The +8 Y offset lifts the aim into the open air above the carriage roof so the ray clears the trench wall — what we're actually verifying is that nothing overhead (trees, cliff faces) blocks the player's downward view into the corridor.

Widened perpendicular walk

If all 20 random attempts fail (typically deep ocean perpendicular, or every random pick fails the LOS check), walk perpendicular outward from PERP_MIN = 10 to MAX_FALLBACK_PERP = 60 blocks on alternating sides. First column whose ground+1 passes both isSafePlayerPos and hasLineOfSight wins. Tightened from 256 to 60 in v0.83.0 patch; 256 was placing players hundreds of blocks from the train.

X-slide along the track

Since: v0.181.0 — PR #227.

If the random retry + perpendicular walk both fail to find an LOS-clear candidate at the original anchor X, the search slides the anchor along the track in both directions and re-runs the whole random + perpendicular search at each new anchor. Stride = 2 * X_JITTER_MAX = 60 blocks (non-overlapping with the previous random range); cap = MAX_X_SLIDE = 300 blocks each direction, so up to 10 alternate anchors are tried before falling to the on-train roof.

Each slide anchor must be above ground at the corridor centerline — TunnelGenerator.isColumnUnderground filters out tunnel-buried X columns, so the player never spawns above a buried corridor with no train view. The camera direction stays anchored at the original lookX (the train area), so a slid spawn just means the player ends up further down the tracks looking back at the train.

In testing, sliding 60–300 blocks east or west rescues most mountain / dense-forest seeds that the original anchor couldn't satisfy. Worlds that still fall to the on-train roof are typically the rare seeds where terrain rises above trainCenter.y + 8 in every direction within ±300 blocks of the train.

Loaded chunks only, time-boxed, aimed above the slot rim (v0.1004.0)

Since: v0.1004.0 — PR #1640.

Every step above used to force-generate each candidate's chunk synchronously on the server thread (WorldgenForceGuard.forceChunk), and hasLineOfSight's Level.clip sync-loaded every chunk under the ray. On hilly terrain the aim point trainCenter.y + 8 sits inside the carriage-width slot the track is cut into, so every ray hit the slot wall, every anchor was tried, and one player's server froze for 61 s at join (22 s at world create) before falling to the on-train roof anyway.

Now:

  • A candidate, a slide anchor, or a ray whose chunk is not already loaded (getChunkNow) is skipped — the joining player's view distance is the useful search area. chunksAlongRay walks the 16-block grid under the ray before clip runs.
  • The aim Y is max(trainCenter.y + AIM_ABOVE_TRAIN, rimY + AIM_ABOVE_RIM) where rimY is the terrain top just outside the track footprint at the anchor column (aimYFor); flat terrain gives the old aim. In Phase B the ray aims above the slid anchor, not back at the train 300 blocks away.
  • The whole search is time-boxed at SPAWN_SEARCH_BUDGET_NANOS (20 ms); when it trips, the on-train roof fires and the WARN line says hit its time budget / exhausted, anchors tried and ms.
  • Server config intro.spawnSearchSyncGen (default false) restores the force-generating search, still under the budget.

On-train-roof last resort

If neither the random retry, the widened walk, nor any X-slide anchor finds an LOS-clear column within ±MAX_X_SLIDE (rare — happens on tiny-island biomes where the corridor is on a strip of land surrounded by deep ocean, OR in dense mountain biomes that wall off the train in every direction), spawn the player on top of the carriage roof at (trainCenter.x, trainCenter.y + 8, trainCenter.z). The player is literally on the train, so LOS to the train is trivially satisfied. Better than drowning on a seabed; the player can step off the roof when the train stops.

Step 5 — yaw / pitch math

Computed explicitly from the (target → referencePoint) vector and passed through teleportTo in the same packet as the position, so there's no rotation race against a separate lookAt call:

double dx = referencePoint.x - target.px;
double dy = referencePoint.y - (target.py + EYE_HEIGHT);   // EYE_HEIGHT = 1.62
double dz = referencePoint.z - target.pz;
double horizontal = Math.sqrt(dx * dx + dz * dz);
float yaw = (float) Math.toDegrees(Math.atan2(-dx, dz));
float pitch = (float) Math.toDegrees(-Math.atan2(dy, horizontal));

The referencePoint Y is bedY + 1.5 (rail surface). EYE_HEIGHT is the player's standing eye height, so the pitch math accounts for the player looking from their eyes, not their feet.

Fallback chain (summary)

  1. Cached placement (first-time login on a fresh world): exact match. Mixin lands the player there from frame 1.
  2. Buffered reference X + random retry with LOS check: 40-block clear window, jittered perpendicular spawn on natural ground, every candidate raycast-verified to have a clear sight line to the train. The common path for subsequent logins.
  3. Lookup-only fallback: no buffered window — first non-tunnel X, accept that the player may be near a tunnel mouth.
  4. Widened perpendicular walk (≤ 60 blocks) with LOS check: when random retry exhausts.
  5. X-slide along the track (≤ ±300 blocks): re-run random + perp walk at non-tunnel anchors stepped by 60 blocks. Camera still points at the train area; the player just ends up further down the tracks. (Since v0.181.0.)
  6. On-train roof: corner case, and since v0.1004.0 also whenever the loaded-chunk search runs out of candidates or its 20 ms budget. Player on the carriage roof — trivially has LOS to the train.
Constant Value Meaning
X_JITTER_MAX 30.0 ± along corridor axis from anchor X
PERP_MIN 10.0 min perpendicular distance from corridor centerline
PERP_MAX 40.0 max perpendicular distance
X_BUFFER 40 non-tunnel columns required on each side of buffered reference X
MAX_ATTEMPTS 20 random retry budget
VOID_CLEARANCE 5 min Y above level.getMinBuildHeight()
CEILING_CLEARANCE 10 min headroom below level.getMaxBuildHeight()
MAX_ABOVEGROUND_SCAN 128 each-direction scan budget for findBufferedReferenceX / findAboveGroundReferenceX
MAX_FALLBACK_PERP 60 each-side perpendicular walk budget after random retry exhausts
MAX_X_SLIDE 300 each-side X-slide budget when no LOS-clear anchor is found at the original X (since v0.181.0)
EYE_HEIGHT 1.62 standing eye height (blocks above feet)
MAX_RETRY_TICKS 100 5 s at 20 TPS — tick budget for the deferred retry queue

These are private static final in PlayerJoinEvents.java. Tunable via code edit; not currently exposed via config.

Files

  • PlayerJoinEvents.java — the whole pipeline: bootstrap caching, retry queue, cached-path no-op teleport, fresh-computation fallback chain.
  • PlayerFudgeSpawnMixin.java — overrides ServerPlayer.adjustSpawnLocation to short-circuit to the cached placement.
  • TrainBootstrapEvents.java — calls computeAndCacheBootstrapPlacement after spawnTrain at server-start time.
  • dungeontrain.mixins.json — registers PlayerFudgeSpawnMixin in the mixins array.

Related compatibility notes

Known limitations

  • Cached placement is fixed at server start, not per-login. All first-time players on the same world go to the same (x, y, z, yaw, pitch). For multiplayer that means players initially stack on the cached spot and spread out manually. Acceptable trade-off for the zero-jump first-frame experience; per-player randomisation would require a Mixin signature different enough that the JOIN packet can't carry it.
  • Subsequent-login pipeline still has perceptible teleport. Once a player has been in the world and logged out elsewhere, their saved position is wherever they were — the retry teleport from saved position to refined target is visible. Brief (~50 ms / ~3 frames at 60 FPS) but not zero.
  • Rotation update on first login is visible as a head turn. Mixin only sets position; the (yaw, pitch) no-op teleport on the first server tick after login applies rotation. Feels like turning your head — much milder than the position jump it replaces, but not literally invisible.
  • Cached placement assumes the train hasn't moved much between bootstrap and login. True for fresh-world first login (~1 s window). False if the server has been up for a while before the first player connects. The append-only TrainCarriageAppender extends the train back over the cached placement on the first tick anyway, so the player still spawns next to a carriage.
  • Non-overworld starting dimensions inherit the cached placement on the wrong dim. The cache is global; if startingDimension is the Nether or End (see Feature-World-Type), the placement is computed in that dim but vanilla's adjustSpawnLocation may run on overworld for first-time players. Edge case — confirm in-game if you hit it.

Clone this wiki locally