Repository navigation
Feature Login Spawn
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.
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.
TrainBootstrapEvents.onServerStarted (running with EventPriority.LOW so per-world data is committed first) does two things:
- Spawns the seed group of carriages via
TrainAssembler.spawnTrain(...). - Calls
PlayerJoinEvents.computeAndCacheBootstrapPlacement(level, dims, trainY)to compute and cache the player's eventual spawn placement, then writes the placement'sBlockPosto the level's default spawn point vialevel.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.
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.
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:
-
onPlayerLogin(PlayerLoggedInEvent) adds the player UUID to aPENDINGmap. No teleport here — too early for Sable'sShipyards.findAll()to see the train (see Compatibility § SubLevelContainer.getAllSubLevels() is asynchronous). -
onLevelTick(LevelTickEvent.Post)(overworld only, to avoid double-processing) drains thePENDINGmap. For each player:- If a cached
SpawnPlacementexists AND the player is at it (within 2 blocks horizontal): teleport to the same(x, y, z)with the cachedyaw/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).
- If a cached
-
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.
When the cache miss path runs, the pipeline computes a fresh placement:
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."
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.
pickPlayerTarget(level, anchorX, geom, tunnelGeometry, trainCenter) runs up to MAX_ATTEMPTS = 20 random samples around the anchor:
-
±X_JITTER_MAX = 30blocks along corridor axis. - Random
+Zor−Zside,PERP_MIN = 10toPERP_MAX = 40blocks perpendicular to corridor. -
findGroundYwalks down through air / fluid / leaves / vines until non-passable. -
isSafePlayerPosvalidates: 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). -
hasLineOfSightraycasts from the player's eye (groundY + 1 + EYE_HEIGHT) to(trainCenter.x, trainCenter.y + 8, trainCenter.z)usingLevel.clipwithClipContext.Block.COLLIDERandClipContext.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.
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.
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.
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.chunksAlongRaywalks the 16-block grid under the ray beforeclipruns. - The aim Y is
max(trainCenter.y + AIM_ABOVE_TRAIN, rimY + AIM_ABOVE_RIM)whererimYis 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 sayshit its time budget/exhausted, anchors tried and ms. - Server config
intro.spawnSearchSyncGen(defaultfalse) restores the force-generating search, still under the budget.
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.
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.
- Cached placement (first-time login on a fresh world): exact match. Mixin lands the player there from frame 1.
- 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.
- Lookup-only fallback: no buffered window — first non-tunnel X, accept that the player may be near a tunnel mouth.
- Widened perpendicular walk (≤ 60 blocks) with LOS check: when random retry exhausts.
- 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.)
- 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.
Constants (PlayerJoinEvents.java)
| 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.
-
PlayerJoinEvents.java— the whole pipeline: bootstrap caching, retry queue, cached-path no-op teleport, fresh-computation fallback chain. -
PlayerFudgeSpawnMixin.java— overridesServerPlayer.adjustSpawnLocationto short-circuit to the cached placement. -
TrainBootstrapEvents.java— callscomputeAndCacheBootstrapPlacementafterspawnTrainat server-start time. -
dungeontrain.mixins.json— registersPlayerFudgeSpawnMixinin themixinsarray.
-
Compatibility § Vanilla
setSpawnPoint/adjustSpawnLocationclashes with our cached placement — why the mixin exists. -
Compatibility §
SubLevelContainer.getAllSubLevels()is asynchronous after assembly — why we need the deferred retry queue.
-
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
TrainCarriageAppenderextends 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
startingDimensionis the Nether or End (see Feature-World-Type), the placement is computed in that dim but vanilla'sadjustSpawnLocationmay run on overworld for first-time players. Edge case — confirm in-game if you hit it.