Feature Train - bh679/dungeon-train-mc GitHub Wiki

Train

Command: /dungeontrain spawn

Since: v0.3.0 β€” PR #6 β€” linked carriages, kinematic drive (supersedes the single-carriage MVP shipped in v0.2.0, PR #5) v0.5.0 β€” PR #8 β€” four carriage types (standard, windowed, solid-roof, flatbed) v0.6.0 β€” PR #10 β€” rolling-window carriages: the train follows the player and stays the same length forever v0.11.0 β€” PR #16 β€” settings page and /dungeontrain speed command (configurable carriages + speed) v0.15.0 β€” PR #19 β€” auto-spawn starter train on login + configurable spawn Y (default 78) v0.26.0 β€” PR #35 β€” forward-clear and track / tunnel generation carve silently: no break particles, no break sound, no item drops, no container spill v0.27.0 β€” PR #37 β€” starter train is assembled at ServerStartedEvent (before any player joins) and the login-time player placement uses a custom ground probe so players never land in water or in the void v0.76.0 β€” PR #107 β€” /dungeontrain carriages <count> command (config write + live-resize of every active train) and a matching Carriages submenu under X β†’ Train with presets 1 / 3 / 5 / 10 / 20 / 50 v0.83.0 β€” PR #119 + PR #120 β€” per-carriage architecture: each carriage is its own Sable sub-level, all sharing a trainId UUID. The single-ship rolling-window manager (TrainWindowManager, TrainChainManager, ShipyardShifter) was deleted; an append-only TrainCarriageAppender extends the train ahead of the player. Half-flatbed pads at each end of every spawned group give the appender a clean seam to extend from. v0.88.0 β€” PR #126 β€” built-in roster reduced to STANDARD / WINDOWED / FLATBED (solid_roof retired); legacy customs oneside, otherside, opensides, sunroof removed for re-authoring. New open custom joins the spawn pool at weight 10. New part templates: walls/open (spruce-fence rails), walls/wooden, roof/open β€” all auto-discovered via classpath scan. v0.89.0 β€” PR #127 β€” per-carriage placement state machine + bidirectional spawn. Each carriage runs a 1Γ—3Γ—5 AABB overlap check every server tick; collisions shift spawnWorldPos 0.5 blocks AWAY from the offender (direction inferred from collidingPIdx vs selfPIdx) and reset a clean-tick counter. After 60 consecutive clean ticks the carriage is marked placedSuccessfully and is permanently exempt β€” the next group spawn gates on this flag, replacing the older fixed MIN_TICKS_BETWEEN_SPAWNS=20 floor. Auto-spawn now extends backward off the tail when globalMinNeededPIdx < trainMinAnchor (player walked behind the train); the check box is positioned at the carriage end facing the existing train (LOW-X for forward spawns, HIGH-X for backward). Diagnostic green/red wireframe overlay shipped on by default in this release; v0.90.0 below makes it opt-in like the other wireframes. v0.90.0 β€” PR #128 β€” the diagnostic green/red collision wireframe is no longer always-on. It now respects a per-overlay collision toggle exposed via the new Wireframes submenu under X β†’ Debug β†’ Wireframes, alongside individual toggles for the four other overlays (gap cubes, gap line, next-spawn preview, HUD Ξ”x line) and an All On / All Off master. All five default off; recovery is two clicks (X β†’ Debug β†’ Wireframes β†’ All On). The once-per-carriage red Collision detected chat warning and the auto-spawn-rhythm Spawned group anchorPIdx=N action-bar message are gated on collision and nextSpawn respectively, so chat stays clean by default and informative when those overlays are enabled. v0.94.0 β€” PR #133 β€” forward and backward auto-spawn now run as two fully independent lanes. PR #127 introduced the backward-extension capability but serialized it: the appender's per-tick decision picked exactly ONE direction (backward preferred) and a single LAST_SPAWNED_SHIP per train gated both. Result: a player mid-train who needed extension at BOTH ends only ever got backward spawns, and the 60-tick placedSuccessfully wait at one end froze the other end's growth. v0.94.0 splits LAST_SPAWNED_SHIP, LAST_SPAWNED_TICK, and NEXT_PLANNED_SPAWNS into per-direction maps; each lane has its own placement-success gate and its own preview slot, and updateTrain evaluates needsForward and needsBackward independently every tick. Both can fire in the same tick when both ends need extension; a still-settling carriage at one end never blocks the other. The wireframe-preview overlay now shows two simultaneous green boxes per train (one at each end) when both lanes have a queued spawn. Manual J still only spawns forward. v0.95.0 β€” PR #132 β€” first bundled author-content pack on top of the v0.92.0 allow-list mechanic. New pen custom carriage (open-top farm pen, weight 4) joins the spawn pool alongside open (10), standard (10), windowed (10). Three new contents β€” cows, pigs, sheep β€” pair exclusively with pen via bundled <id>.contents-allow.json sidecars: pen excludes the 12 loot-style contents (2ndlevel, barrel, books, chest, craft, enchant, ender, kitchen, maze, sand, smelt, upgrade); standard and windowed exclude cows, pigs, sheep. New parts/floor/dirt (with the grassdirt block-variant prefab β€” grass_block weight 20, coarse_dirt 1, dirt 2, rooted_dirt 1) gives pen a natural-looking ground. v0.138.0 β€” PR #179 β€” the v0.89.0 placement state machine grew a symmetric "too far apart" branch. The existing collision branch only PUSHES carriages apart when their AABBs overlap; if adjustForCollisions's one-shot pre-spawn push uses a slightly stale reference AABB, the new carriage can land 3+ blocks past where it should sit, leaving a permanent visible gap. The tracker now measures the X-axis gap from the new carriage's train-facing face (LOW-X for forward, HIGH-X for backward) to the nearest sibling AABB on that side; if gap > MAX_GAP_BLOCKS = 3.0 it shifts spawnWorldPos 0.5 blocks TOWARD the sibling and resets the 60-tick clean counter, exactly like the collision branch but with the opposite sign. Dead-band [MIN_GAP_BLOCKS = 0.3, MAX_GAP_BLOCKS = 3.0] is 2.7 blocks wide vs the 0.5-block shift step β€” no oscillation in practice. The seed carriage of a fresh train (no sibling on the facing side) returns POSITIVE_INFINITY from the gap helper and never enters the pull-toward branch.

v0.150.0 — PR #192 — collide → move-together → collide lock. The v0.89.0 collision-pushback and the v0.138.0 move-together branches could fight forever on the same group — close-gap pulled the new carriage near enough to trip the collision AABB, the pushback shoved it apart, the gap then exceeded 3.0 again, move-together pulled it back, repeat. A per-carriage state machine in TrainTransformProvider now records whether the full cycle has been observed: first collision arms a flag, the first move-together that fires while that flag is set arms a second flag, and the next collision while the second flag is set permanently locks the move-together branch for that carriage. Collision pushback is never gated — only the close-gap pull is suppressed, so the carriage keeps separating until clean and then settles via the existing 60-tick clean window. Side effects: a single new move-together LOCKED (collide→move-together→collide cycle observed) log line on the trigger tick; otherwise visible behaviour matches v0.138.0 for groups that don't fight (the strict majority).

Spawns an N-carriage train 10 blocks in front of the player. Each carriage is its own kinematically-driven Sable sub-level moving at 2 m/s along the +X world axis. Carriages sit end-to-end with their door gaps aligned, so the player walks from the first carriage through to the last uninterrupted (flatbed carriages have no walls and are walked across directly). The train carries entities standing on it β€” walk around inside while it moves, summon a mob, ride the roof. Sable handles the player ↔ sub-level handoff at carriage seams.

Usage

/dungeontrain spawn             # no arguments; carriage count comes from config
/dungeontrain speed <value>     # speed m/s ∈ [0.0, 20.0]; persists + applies to live trains
/dungeontrain carriages <count> # count ∈ [1, 50]; persists + live-resizes every active train (v0.76.0)
  • Requires permission level 2 (OP or singleplayer cheats on).
  • spawn must be run by a player; speed can be run from any command source.
  • spawn takes no carriage count (v0.542.0). There is only ever one train, and its window is config state set with carriages <count>. The old argument accepted 0 β€” the config's "auto" sentinel β€” which deleted the existing train and then refused to build a replacement, leaving the world train-less.
  • The new train is always seeded on the track, at the corridor's Y and Z and the player's X (v0.542.0). Before that the origin came from the player's look direction, which also fed the train's live TrackGeometry β€” so a spawn run off the corridor produced a train whose track definition permanently disagreed with worldgen's, and the appender laid bed and rails at the wrong height and offset for that train's whole life. The player is lifted clear of the assembly footprint and set down on the flatbed once physics settle.
  • Respawning replaces any existing Dungeon Train in the same level. Every /dungeontrain spawn deletes prior trains before placing the new one β€” you never have to clean up by hand. Any drifting carriages riding on the old train are flushed and handed back to the relay first (v0.542.0), so edits survive the reset and the builds are released to the pool rather than staying locked to your world until they time out.
  • It also resets the run (v0.542.0): the difficulty travelled-offset returns to automatic scaling, and the world's already-placed drifting-carriage exclude list is cleared so those builds can appear again.
  • The bounding volume of the new train is cleared to air before placement so terrain inside the carriage box cannot wedge the ship.
  • Tested in single-player creative. Dedicated server and multiplayer are untested.

Carriage blueprint

Every carriage is 9 Γ— 5 Γ— 4 (length Γ— width Γ— height) and sits end-to-end with its neighbours along +X at X-offsets of i * 9 β€” end walls touch and door gaps align, so the interior is one continuous corridor (except where a flatbed interrupts the walls).

The block layout inside that 9 Γ— 5 Γ— 4 footprint varies by carriage type β€” see Carriage types.

Carriage types

# Type Floor Walls Roof Door gaps
0 STANDARD stone bricks stone bricks glass yes
1 WINDOWED stone bricks stone bricks with a glass strip on each long wall at y=2 glass yes
2 FLATBED stone bricks none none n/a β€” no walls to gap

An N-carriage train deterministically cycles through the types as CarriageType.values()[i % 3] in LOOPING mode, so any train of length β‰₯ 3 shows one of each, and the order is stable for a given spawn: standard β†’ windowed β†’ flatbed β†’ standard β†’ … RANDOM and RANDOM_GROUPED modes also draw from custom variants registered on disk (e.g. open).

Door gaps (on Standard, Windowed) sit at the middle column of each end wall, y=1–2 β€” a 1 Γ— 2 opening. They align across carriages so you can walk the full length of any run of walled types. Flatbeds have no end walls, so walk-through is trivial.

How it moves

Each carriage is a Sable sub-level driven kinematically: a TrainTransformProvider implementing KinematicDriver is attached to every sub-level at assembly time. On every server tick the provider computes:

nextPos = currentPos + velocity Γ— dt

and Sable uses the returned velocity to carry ride-along entities. Because every carriage shares the same trainId and the same velocity, they advance in lockstep β€” visually one continuous train.

Kinematic drive (prescribe the transform) replaced force-based drive (applyInvariantForce with a PD controller) back in v0.4 after the force-based variant silently froze at count=20 β€” see Compatibility (VS legacy) Β§ Force-based drive silently gates above ~3–6M kg for the finding.

Collisions: train blocks vs player-added blocks (v0.1014.0)

The train's own blocks and the blocks players add to it collide in opposite ways (PR #1647):

  • Train-built blocks (the carriage templates) never break. They break any solid terrain they overlap, and they push other Sable physics objects aside, because the train body has zero mass. Terrain breaking can be switched off per world with /dungeontrain breakblocks.
  • Player-added blocks break off. When one hits solid terrain or touches a non-train Sable sub-level, the player's block is removed from the carriage and drops as items at its world position, with break particles and sound. A container's contents drop too; a shulker box keeps its contents in the item. Whatever it hit is left standing. This ignores the breakblocks toggle and the rail floor, since those only protect the world.

How it works:

  • PlayerPlacedTrainBlocks records which cells of each carriage sub-level a player placed. Positions are stored relative to the plot, and only in memory: trains are deleted at server stop, so builds don't outlive the session.
    • PlayerPlacedTrainBlockEvents marks a cell on a player's place or multi-place event. Pistons, dispensers and mobs don't count.
    • SableBlockChangeGuardMixin clears a mark when the block type changes. A door opening keeps it.
    • Marks are pruned when a sub-level is deleted (SableShipyard.delete).
  • Terrain. TrainTickEvents.sweepFootprint resolves each overlap with the pure contactAction rule (BREAK_TERRAIN / BREAK_OFF / NONE). Carriages with no player blocks take the unchanged fast path.
  • Physics objects. PlayerBlockBreakOff.checkPhysicsContacts probes each player block (centre, corners and face centres, pushed 0.1 outward) against nearby non-train sub-levels. It uses its own probe because Rapier never reports contacts on the kinematic train. Both passes share the 256-per-tick break budget.
  • Debug log: [sweep.breakoff] in debug.log, throttled to once per second.

Limitations:

  • Blocks placed before v0.1014.0 aren't tracked and still behave like train blocks until they are re-placed.
  • Modded containers that don't implement vanilla Container drop only the block.

Per-carriage architecture (the train goes on forever)

Since: v0.83.0 β€” PR #119

Replaces the v0.6.0 rolling-window manager. Pre-v0.83.0, a whole train was a single VS ship with a TrainWindowManager painting carriages in/out of the ship's voxel grid. The 128-chunk per-ship shipyard wall meant successor trains had to chain (Feature-Train-Chain). The Sable port replaced both with the per-carriage architecture below.

A train is the set of Sable sub-levels sharing a TrainTransformProvider.getTrainId() UUID. Each sub-level holds groupSize adjacent carriages (default 3) and is anchored at a pIdx (the carriage index of its first carriage). Trains.byTrainId(level) is the canonical aggregation helper β€” given a ServerLevel, it returns Map<UUID, List<Carriage>> keyed by trainId.

Append-only extension. TrainCarriageAppender runs every server tick. For each train and each player within NEAR_RADIUS = 128 blocks of any of its carriages, it computes the player's pIdx window [pIdx βˆ’ halfBack, pIdx + halfFront]. If the train doesn't extend that far ahead (or behind), the appender spawns a new group at the right end. Once a group is spawned, it is never erased β€” walking back over previously-visited carriages shows them intact.

Half-flatbed pads. Every spawned group has a half-flatbed pad on each end (halfPadLen=5 blocks). The pads give the next group a clean perpendicular seam to butt against β€” without them, the irregular geometry of door frames and walls would prevent Sable's collision broad-phase from cleanly handing the player from one sub-level to the next. The middle (enclosed) section of the group renders the actual variant carriages; the pads are just floor.

Placement state machine paces spawns (since v0.89.0; symmetric pull-toward since v0.138.0). Each freshly-spawned carriage carries two flags on its TrainTransformProvider: a consecutiveCleanTicks counter and a placedSuccessfully boolean. Every server tick, TrainCarriageAppender.runPlacementCollisionTracker runs a 1Γ—3Γ—5 AABB overlap check at the carriage's "first block" against every sibling's worldAABB() (visible train βˆͺ Trains.SPAWNED_GROUPS registry) and the three-branch decision is: Colliding β†’ shift spawnWorldPos Β±0.5 blocks AWAY from the offender (sign chosen by collidingPIdx > selfPIdx), reset counter to 0. Gap > MAX_GAP_BLOCKS = 3.0 (since v0.138.0) β†’ shift Β±0.5 blocks TOWARD the train-facing sibling (sign chosen by isSpawnedBackward()), reset counter to 0. Otherwise (clean) β†’ increment counter; on hitting CLEAN_TICKS_FOR_SUCCESS = 60 the carriage transitions to placedSuccessfully = true and is permanently exempt from further checks. Result: carriages settle into the dead-band [MIN_GAP_BLOCKS = 0.3, MAX_GAP_BLOCKS = 3.0] and stay there for 60 ticks before being marked placed. The next group spawn's gate (updateTrain) reads pendingProvider.isPlacedSuccessfully() and defers until it goes true β€” no fixed 20-tick floor, no AABB-non-zero heuristic. Spawns serialize on the actual physics/placement state.

Bidirectional auto-spawn (since v0.89.0; independent lanes since v0.94.0). When globalMinNeededPIdx < trainMinAnchor β€” i.e. a player has walked behind the train's tail β€” the appender queues trainMinAnchor βˆ’ groupSize; when globalMaxNeededPIdx > trainMaxAnchor it queues trainMaxAnchor + groupSize. As of v0.94.0 these are evaluated INDEPENDENTLY each tick and the two directions own separate LAST_SPAWNED_SHIP_FORWARD / _BACKWARD placement-success gates, so a still-settling carriage at one end never blocks the other end's spawn. Both directions can fire in the same tick when both ends need extension; the per-tick spawn cap is therefore 2 (one per direction). The spawnedBackward flag on TrainTransformProvider flips the collision-check box position from the LOW-X corner to the HIGH-X corner so the box sits at the end of the carriage that faces the existing train. Same shift / clean-tick / placed-successfully loop in either direction. Manual J still always spawns forward.

Diagnostic wireframe overlay (since v0.89.0; gated since v0.90.0). A small green box at the back-pad corner of every still-being-placed carriage, broadcast every 4 ticks via CarriageSpawnCollisionPacket. Turns red when the 1Γ—3Γ—5 box overlaps a sibling. Disappears once the carriage transitions to placedSuccessfully. Originally always-on, now respects the collision toggle in the Wireframes submenu (defaults off). The once-per-carriage chat warning that fires on the first detected collision (carriage pIdx, sub-level UUID, offender pIdx, ticks since spawn) is suppressed while the toggle is off and resumes the moment it's flipped on.

Range filter. Players further than NEAR_RADIUS = 128 blocks from any carriage of a given train are ignored. If no player is in range, the appender pauses; the train keeps drifting at 2 m/s but doesn't extend.

Internal layout

All Sable API usage is confined to the games.brennan.dungeontrain.ship package + the kinematic driver, so a physics-backend swap remains a one-package change:

  • Shipyards.java β€” per-ServerLevel cache of SableShipyard (the Shipyard interface implementation).
  • SableShipyard, SableManagedShip, SableKinematicTicker β€” Sable-specific implementations behind the Shipyard / ManagedShip / KinematicDriver interfaces.
  • TrainAssembler.java β€” spawnTrain for the initial train; spawnGroup for both the seed group and every subsequent appender extension. Places blocks, calls Shipyards.of(level).assemble(...), attaches the kinematic driver.
  • TrainTransformProvider.java β€” the per-tick KinematicDriver implementation; carries trainId, pIdx, dims, canonicalPos. Also the marker class used by deleteAllTrains to identify our sub-levels (vs probe pairs, etc.).

Supporting files use only vanilla MC + NeoForge types:

  • Trains.java β€” aggregation helpers (byTrainId, lead, tail, min/maxPIdx) + the authoritative SPAWNED_GROUPS registry.
  • TrainCarriageAppender.java β€” per-tick append-only group spawner.
  • CarriageTemplate β€” the blueprint (placeAt for one carriage, placeGroupAt for the half-pad + enclosed run, typeForIndex for the deterministic variant selector).
  • TrainCommand β€” Brigadier registration (/dungeontrain spawn, speed, carriages, debug pair etc).
  • TrainTickEvents β€” NeoForge LevelTickEvent.Post subscriber (drives the appender, kinematic ticker, terrain-clearing).
  • DungeonTrain β€” NeoForge mod entry point.

Leaving and returning

Since v0.783.0 (PR #1276).

Before v0.783.0 a player counted as near the train only within 128 blocks of a loaded carriage group. Walk off, let Sable unload the train, and come back to the track elsewhere: nothing was near, nothing generated, and a fully unloaded train never even reached the appender (it is built from loaded sub-levels). The train was simply gone.

Now the appender works along the line:

  • Corridor near test. A player within 128 blocks of the track line in Y/Z, at any X, is near. Standing beside a loaded group keeps the exact carriage index as before; a player with no loaded group in reach is remote and gets an estimated index from a per-train line fix (one group's back-pad X at a known tick, extrapolated along the train's velocity, minus ticks spent frozen). The stride used is the padded group stride plus the 0.4-block seam, so the estimate stays within a group over the whole catch-up range.
  • Wake pass. A registered train with no loaded group is asked back from Sable holding (the held group nearest the player's estimate) when a corridor-near player is within the cap. Budgeted: six attempts, 40 ticks apart, moving to the next-nearest group after any snatch that loads nothing, and a fresh episode after 30 s.
  • Catch-up. For a remote player the backward lane fills down from the lowest loaded group above them; held groups inside the player's window are reloaded on demand; the forward lane holds a frontmost working edge so it is not unloaded between spawns. Intermediate groups are spawned sequentially (every registry consumer assumes one contiguous lattice) through the usual FILL runs, so a full-cap catch-up takes roughly 20 s plus chunk generation.
  • Every reload is adopted the same tick. Sable's force-load tickets are keyed by sub-level instance; a group reloaded far from any player has none and would be unloaded again next tick unless the new wrapper is held immediately (adoptReloadedGroup, which also refreshes the registry handle).
  • The whole train keeps ticking while any group settles. A parked (soft-frozen) body stays where it was while its slot moves on; a catch-up run spawning into that zone read "colliding" against it every tick until the safety valve. While a lane is settling a group, no group of that train parks.
  • Walking away still unloads. Beyond 128 blocks from the line, force-loads are released and Sable culls the train exactly as before.

Diagnostics: /dungeontrain debug traingen on (armed automatically on dev builds) β€” [bwdgen] samples carry remote=true and the reasons TOO_FAR / EDGE_FROZEN; [DungeonTrain][remote] INFO lines mark wakes and in-window reloads; scripts/traingen/analyse-bwdgen.py summarises a ride.

Known limits: a server restart clears the in-memory registry, so a train unloaded before the restart cannot be woken (Sable's own reload on chunk load still applies); returning ahead of the head (forward catch-up) is implemented but was not exercised in the Gate 2 rides.

Known limitations

  • Append depends on at least one player near the line. Since v0.783.0 "near" means within 128 blocks of the track line (Y/Z), at any X β€” see Leaving and returning. If everyone is further than that from the line, no new carriages are added and the train unloads as before.
  • Catch-up is capped. A player who returns to the line more than ~2,400 blocks (64 groups) from the nearest existing carriage is treated as not near; the train stays gone.
  • Carriages persist across world reload, but the appender doesn't auto-resume. Previously-spawned sub-levels survive a save/restart (they're regular Sable sub-levels with serialized blocks). The kinematic driver is reattached on world load. The appender resumes the next time a player gets within range.
  • Kinematic drive cannot be bumped. External collisions don't push the train β€” desired for "train on rails" but means you can't TNT-bump it.
  • Hardcoded direction. Speed is now configurable (see Settings and Speed), but the train still moves along the +X axis only.
  • Fixed type distribution. Carriage type cycles deterministically by position; no command arg yet to force a specific type or a custom sequence.
  • No per-carriage procgen interior. Carriages differ only in walls/roof shape β€” no furniture, mobs, or procgen content inside yet (interior contents are templated via the Container Contents menu, but that's authored, not procgen).
  • Flatbed has no safety rails. Players and mobs can fall off a moving flatbed. Intentional for the "open car" look.
  • Spawn location uses horizontal look direction only. Looking straight up or down still spawns at your own Y level.
⚠️ **GitHub.com Fallback** ⚠️