Repository navigation
Feature Death Screen Buttons
Since: v0.315.0 — PR #427 (paginated narrative rewrite) Current layout: v0.325.0 — advancements + chest/book icons on the cargo page (PR #471)
When you die on a singleplayer Dungeon Train world, the vanilla DeathScreen is replaced
wholesale by NarrativeDeathScreen — a slow, single-button retrospective the train tells
about you. It walks through six kinds of page, one at a time, over a cinematic cross-fade,
and ends on a platform offering a fresh world or the title screen. In Dungeon Train, death
ends the run — there is no respawn-in-place, so even hardcore worlds get this screen.
This page documents the current narrative screen. The pre-#427 layout — a static "two-panel run recap" with a
New World/Title Screenbutton row (v0.177.0 – v0.288.0) — has been superseded; the run-stats it showed now live across the fall, deeds and cargo pages below.
The renderings on this page are schematic mock-ups, not in-game screenshots — they are generated from the screen's actual layout code (palette, geometry, on-screen text) by
tools/render-death-screens.py, drawn over the flat-overlay background (the no-photo fallback). In game, a ride photo usually sits behind the text.

A single centred Continue advances; a bare back-arrow returns. The number of creator's ledger pages depends on how many feedback-survey questions you have left unanswered (often one or two; none once you've answered them all). reboard and leave the line sit in the top corner throughout.
| # | Page (Kind) |
Kicker | What it shows |
|---|---|---|---|
| 1 | FALL |
the fall / death cause | This-life headline: carriage, distance, time aboard |
| 2 | DEEDS |
the deeds | This-life combat grid + a portrait of the passenger the run was about |
| 3 | GEAR |
the cargo | Most-used weapon + worn armour, loot containers, books read |
| 4 | LIVES |
all your lives | Cross-world lifetime totals |
| 5 | SURVEY |
the creator's ledger | One unanswered feedback-survey question per page |
| 6 | MODREC |
the recommendations | Which of your own mods you'd recommend — or flag as a cheat; only if you run any |
| 7 | PLATFORM |
the platform | Epitaph + Board anew / Leave the line |

The opening headline. Since v0.318.0 the
title is the second-person cause of death ("You fell from a high place"), captured from the
damage source; if the server sent none it falls back to the static the fall kicker. Below the
train the line asks its first question — by default "What is the Dungeon Train?" — over a
rolled narration ("Carriage thirty-seven. That is where the dark reached you…"), then three
cells: carriage (carriages traversed), distance (metres ridden), time aboard (run
duration). Stat figures woven into the narration are coloured white. The exact lines are rolled
from the death_lore pool — see that section for every variant.

The line asks "Why are you here?" over a 3×2 grid of this-life combat: mobs felled, passengers met (distinct PlayerMobs encountered), slain, befriended, damage dealt, damage taken. The narration recounts the same figures ("You felled eighty-four things in the dark, and bled for it with two hundred and five hearts…"). Since v0.319.0 an animated full-body portrait of the passenger the run was about sits beside the grid — the one you befriended (drawn left) or, failing that, killed (drawn right), with their name beneath. The grid shrinks to make room; if neither applies it stays full-width.

The line asks "What are you meant to do?" over a gear row: the run's most-used weapon
- your four worn armour pieces, then a chest icon and a purple (enchanted) book icon carrying the loot-containers-opened and books-read counts in the native item-stack number style (hover any slot for its tooltip — the gear shows item tooltips; the chest/book show "Containers Opened" / "Books read"). The default narration: "Iron and edge, gathered from the dark and carried this far. None of it was enough — perhaps that was never the point."
Below the gear row sits the advancements row (since v0.325.0) — see Advancements on the cargo page.

Here the line stops asking and reflects: by default the question is the wry "These are all
good questions" (a callback to the three it just posed), over six cross-world lifetime
totals (from GlobalPlayerStats, persisted by player UUID): total aboard, carriages,
distance, deaths, friends made, books read. The narration weaves in your death
count ("…Across all ten — every dark you have boarded…"). On your first death a different
variant runs — question "Is one life so easily spent?" with the sub-line "your first fall on
the line."

One page per unanswered feedback-survey question — a 0–N rating row plus an optional comment, or a free-text answer box for open-ended questions. All pages share the fixed intro lang line "Before the train carries you back into the dark, the line's creator keeps a ledger — not of carriages, but of the travellers who ride." The actual questions:
| Order | Question | From | Type |
|---|---|---|---|
| 1 | "How likely are you to recommend this game to a friend? (0 = not at all, 10 = extremely)" | Discord Presence (built-in NPS, SurveyRegistry) |
0–10 scale + comment |
| 100 | "If you could change one thing about the mod, what would it be?" | Dungeon Train (data/dungeontrain/dp_surveys/change_one_thing.json) |
free text |
The survey system lives in the bundled Discord Presence mod; answers post to the community Discord through its public API, and an answered question is never re-asked. If you have no unanswered questions, this page set is empty and the flow skips straight to the platform.
Since: v0.544.0 — PR #889 Hack reports: v0.628.0 — PR #1057
"Which of these would you recommend to the creator?" — a scrollable grid of the mods you're running that aren't Dungeon Train's or its modpack's, ordered by how many other Dungeon Train players run each. Pick a tile, write a line in the comment box, and Send; the tile turns green, drops to the bottom of the grid, and you can pick another. A pinned "Something not installed…" tile takes the name of a mod you don't have and would like the train to support — each sent request leaves its own card below the grid.
Every installed-mod tile also carries a small ⚠ at its right edge — the page's other half. Clicking it flags that mod as a cheat or hack instead of recommending it: the tile turns red, the prompt changes to "What does it let you do?", and the button reads Report. A flagged mod sinks to the bottom of the grid with a ⚠ where a recommended one keeps its ✓. Clicking the tile body rather than the icon still starts an ordinary recommendation, unchanged.
The "Something not installed…" tile has no icon: there is nothing to report about a mod you don't have. A mod is recommended or flagged, never both — sending the other one replaces its tile rather than leaving two.
This page usually does not exist. It only enters the deck when your loaded mod list contains something outside the roster, so a player on the plain modpack with nothing added never sees it.
A few deliberate constraints:
- A comment is required — for a report too. Send stays disabled until the box has text — a bare tile click says nothing the telemetry doesn't already say.
-
Popularity orders the tiles but is never shown. No counts, no percentages. The ranking comes
from
GET /<CAP>/mods/popularon the relay (distinct players per modId, cached 15 min); if that fetch fails or times out, the grid falls back to alphabetical and the page still works offline. -
Your comment reaches the creator privately, and nowhere public. It posts to the private
Discord survey channel, and — since v0.550.0
— also travels with the
/telemetry/mod-recreport so it can be read alongside the numbers instead of only by scrolling Discord. A hack report travels the same way, marked with ahackflag (🚩 in Discord); the relay does not store that flag yet, so for now a report is read in Discord. The relay keeps the two apart on arrival: the analytics event still carries just the modId and a "was a request" flag (no player text, the rule the book-read and world-info reports follow), while the mod's name and your comment go to a separate capped table that only the private data explorer can read. Nothing you write here is ever shown to other players. Before v0.550.0 the relay stored no text at all, so a request — which has no modId, only the name you typed — left no record of what had been asked for.
The roster (what counts as "ours") is generated into the jar at build time by the
generateModRoster Gradle task, from neoforge.mods.toml + modpack/modpack.config.json, so it
can't drift from the modpack that actually ships. Matching is on a normalised key tried against
both modId and display name, because the config stores CurseForge slugs while ModList reports
modIds. Six exceptions are hand-listed with reasons in build.gradle: iris, highlighter,
khi, sablejade (modIds matching neither slug nor name), and veil + sablecompanion (Sable's
own dependencies, which neither source file knows about). The Forgified Fabric API's ~44
fabric_* modules are excluded by raw-id prefix rather than enumeration.
If the roster resource is missing or unreadable the page fails closed — no leftovers, so it simply never opens, rather than offering you Dungeon Train's own components to recommend.

The end of the line. The line asks "Will you board again, or step off into the quiet?" over a closing narration ("The platform is empty. The whistle sounds far down the line…") and a muted epitaph ("the tenth to fall. perhaps not the last."), then the two actions — Board anew (the prominent primary) launches a fresh world; Leave the line exits to the title screen. There is no Continue button here; the train motif has filled the whole rail, "a train that completes itself exactly as the narrative ends."
Every question, narration, sub-line and epitaph on pages 1–4 + 6 is data-driven, rolled per
death from a weighted pool — data/dungeontrain/death_lore/default.json.
Per-install files in config/dungeontrain/user/death_lore/*.json add more variety (they
never override by id). At death the server rolls one weighted entry per page whose
conditions match, substitutes placeholders, and ships the result inside DeathStatsPacket
(see DeathLoreStore).
The creator's ledger (page 5) is not here — those come from the survey system.
Placeholders are substituted as English words and drawn white in-screen:
{carriage} {friends} {books} {mobs} {met} {slain} {hearts} {deaths} — plus
{distance} as grouped numerals. So {deaths} = 10 → "ten".
⚠ Voice note for any new copy: because
{deaths}renders as a word, the platform epitaph templatethe {deaths}th to fallyields "the tenth to fall" — clean at 10, but "the twenty-threeth to fall" at 23 (no ordinal grammar). Avoid{number}thordinals in new lines, or phrase around them.
the fall — the question is always "What is the Dungeon Train?"
| Wt | Condition | Narration |
|---|---|---|
| 3 | — | "Carriage {carriage}. That is where the dark reached you. Do you know it now? The Dungeon Train does not stop — it only goes on, deeper, forever." |
| 1 | ≥100 carriages | "{carriage} carriages into the dark. Few ride so far before the line takes them. Fewer still board again." |
| 1 | ≤3 carriages | "Carriage {carriage}. Barely aboard, and already the dark had you. The line is patient — it can afford to be." |
the deeds
| Wt | Condition | Question | Narration |
|---|---|---|---|
| 3 | — | "Why are you here?" | "You felled {mobs} things in the dark, and bled for it with {hearts} hearts.Of the {met} souls you met, {slain} you ended — and {friends} you called friend." |
| 1 | ≥5 friends | "Were they worth it?" | "You made {friends} friends on the line, and still it took you. Perhaps that is the only number that was ever yours to keep." |
| 1 | ≥200 mobs | "How much of the dark did you answer in kind?" | "{mobs} things fell to your hand before you fell to theirs. The line does not judge. It only counts." |
the cargo (gear)
| Wt | Condition | Question | Narration |
|---|---|---|---|
| 3 | — | "What are you meant to do?" | "Iron and edge, gathered from the dark and carried this far. None of it was enough — perhaps that was never the point." |
| 1 | ≥5 books | "What are you meant to do?" | "You read {books} of the line's books and still carried steel. Knowledge and a blade, and the dark took both." |
all your lives (lives)
| Wt | Condition | Question | Narration / sub-line |
|---|---|---|---|
| 3 | ≥2 deaths | "These are all good questions" | "These are not the marks of a single life. Across all {deaths} — every dark you have boarded, every time the line took you and still you returned — this is the whole of your journey." |
| 1 | =1 death | "Is one life so easily spent?" | sub-line "your first fall on the line." — "There are no other lives to weigh against this one — not yet. This is the whole of you: everything done since first you boarded. The line will remember it. So should you." |
| 1 | ≥10 deaths | "These are all good questions" | "These are not the marks of a single life. {deaths} times the dark has taken you, and {deaths} times you boarded again." |
the platform
| Wt | Condition | Question | Narration + epitaph |
|---|---|---|---|
| 3 | ≥2 deaths | "Will you board again, or step off into the quiet?" | "The platform is empty. The whistle sounds far down the line. It runs without end — and so, it seems, do you." · epitaph: "the {deaths}th to fall. perhaps not the last." |
| 1 | =1 death | "Will you board again — now that you know how it ends?" | "The platform is empty, the same as it was when first you stepped aboard. The whistle sounds far down the line. You have fallen once. Whether there is a second time is, as ever, yours to decide." · epitaph: "the first to fall. the line is patient — it will wait for the rest." |
| 1 | ≥20 deaths | "Need you even ask?" | "The platform knows your face by now. The whistle sounds the way it always does. You already know what comes next." · epitaph: "the {deaths}th to fall — and not, it seems, the last." |
Voice summary (for new copy): second person, addressed to the dead passenger by the line itself; archaic and unhurried; recurring imagery — the dark, the line, the platform, the whistle, boarding/falling, the train that does not stop. The fall/deeds/cargo questions are existential ("What is…", "Why are you…", "What are you meant to…"); all your lives deflects ("These are all good questions"); the platform turns outward ("Will you board again…?").
-
Top bar — the
dungeon trainbrand on the left; reboard and leave the line chips on the right, present on every page. They mirror the platform's two actions, so you can bail to a fresh world or the title at any point. -
Footer — a centred Continue on every page except the platform, and a bare back-arrow
(
←) to its left (hidden on the first page). - Advancing — only a click on Continue advances. Since v0.323.0 Space and Enter no longer advance the screen (they were dismissing it instantly on respawn); a click during a running fade skips that fade rather than acting on the controls beneath it.
-
Ride-photo backgrounds — since v0.317.0
the game quietly takes third-person photos of your journey and uses them as page backdrops.
Each page is assigned one photo up-front, preferring unused shots so pages don't repeat
(v0.319.0), chosen from thematic chains
(scenic / combat / gear / social). With ride snapshots off or none captured, the screen
falls back to the flat dark overlay shown in the renderings above. Captures are down-scaled to
a 1080px long edge (
RideSnapshotCapture.MAX_EDGE— 640px before v0.469.0) and stored losslessly (PNG) for the in-game gallery/disk cache; the copy sent over the network (to the server for the Discord embed below) is re-encoded as JPEG at that point instead, since a lossless 1080px PNG of a busy 3D scene runs close to the 1 MB packet cap. - Cinematic page turns — since v0.321.0 moving between pages plays a slow fade: the UI fades out over the held photo (fully revealing it), the bare photo holds for a beat, then the next page's UI fades in while the photo dips to black and the next one rises from black beneath it over a long ~10 s reveal.
-
The death moment — since v0.1198.0
the recap no longer opens on the frame you die. For ~0.75 s the live world keeps rendering —
vanilla's death-camera tilt, a red wash, the train still rolling with you carried along on your
carriage (
DeadRiderCarry; Sable only carries living riders) — and the recap's photos are decoded, uploaded and JPEG-encoded off the render thread meanwhile (DeathMomentScreen,DeathPhotoUploads). Before this, all of that ran synchronously on the death frame and read as a lag spike that "killed you". The moment is hard-capped at death tick 19: vanilla removes the dead player at tick 20, which drops the train's Sable tracking, so the recap must be up before then. Click / Space / Enter skips it after half a second. The recap then opens fully black and its first photo rises from black over the same slow reveal a page turn uses. -
Train engine — the engine sound is full on the first page (as if still aboard) and fades
evenly to silence by the last, rising again if you step back. The screen does not pause the
world (it keeps ticking behind the fade), and
Escdoes nothing.
DeathScreenLayoutHandler
listens for ScreenEvent.Opening. When the screen being opened is a vanilla DeathScreen and
an integrated (singleplayer) server is present, it swaps in a DeathMomentScreen, which opens a
fresh NarrativeDeathScreen itself once the death moment has played (see above). Neither
replacement is a DeathScreen, so the re-fired Opening event for it is a no-op — no recursion. LAN / dedicated-server deaths are left on the vanilla screen: the singleplayer
guard (getSingleplayerServer() == null) bails, because the mod can't tear down and recreate a
world it doesn't own.
Everything the screen renders is gathered server-side per life and pushed to the dying client in one packet:
-
RunStatsEventsaccumulates counters onPlayerRunState(a per-player attachment that resets on respawn). OnLivingDeathEvent(LOW priority, so other kill-credit handlers finish first) it builds aDeathStatsPacketcarrying: the per-run figures, worn armour + resolved most-used weapon, the cross-world lifetime totals (fromGlobalPlayerStats), the second-person death cause, the DEEDS portrait subject, and aDeathNarrative. -
DeathNarrativeis the server-rolled story (question / narration / sub-line / epitaph per page), drawn from thedeath_loredata pool byDeathLoreStore. The client lacks the death context and the pool, so it renders exactly what the server picked. The creator's ledger pages are not in here — they come from the Discord Presence survey system. -
DeathStatsCacheholds the packet client-side; the screen reads it live each frame, so a one-tick-late packet simply fills in. Any change to the packet layout must bumpDungeonTrainNet.PROTOCOL_VERSION.
DeathScreenLayoutHandler.launchWorld(...) reads the live integrated server for the dying
world's settings and creates a fresh save:
- Capture
LevelSettings/WorldOptions/ data-pack config from the running server. - Read
DungeonTrainWorldDatafrom the overworld and stash it inPendingWorldChoices(train Y, starts-with-train, carriage dims) along with the generation mode + group size; roll a fresh starting dimension intoPendingStartingDimension.WorldLifecycleEventscommits these to the new world on first start. - If
keepInventoryis on, snapshot the player's inventory + XP (re-applied on the next world's first login byKeepInventoryCarryEvents) and mirror the gamerule onto the new world. -
Pre-drain Sable train sub-levels on the server
thread, then tear down the integrated server with the vanilla "Save and Quit to Title"
sequence (
mc.level.disconnect()→mc.disconnect(Screen)) beforecreateFreshLevel(...).
Leave the line runs the same pre-drain + disconnect dance, then mc.setScreen(new TitleScreen())
— skipping vanilla's "Are you sure you want to give up?" confirm.
Board anew reuses the dying world's settings on a fresh random seed:
| Setting | Source | Carried? |
|---|---|---|
| World seed | WorldOptions.randomSeed() |
New random seed |
| Game mode, difficulty, hardcore, allowCommands | WorldData.getLevelSettings() |
Same |
| Data pack configuration | WorldData.getDataConfiguration() |
Same |
generateStructures, generateBonusChest
|
WorldData.worldGenOptions() |
Same |
| Train Y, starts-with-train, carriage dims |
DungeonTrainWorldData on the live overworld |
Same (via PendingWorldChoices) |
| Generation mode, group size |
DungeonTrainConfig (per-save TOML) |
Same (via PendingWorldChoices) |
| Starting dimension | always Overworld as of PR #643 (v0.390.0) — previously rolled fresh 94 % Overworld / 5 % Nether / 1 % End |
StartingDimension.OVERWORLD (via PendingStartingDimension) |
Train carriages are Sable sub-levels. launchWorld / goToTitleScreen delete them and pump
ServerSubLevelContainer.tick() for up to ~1 s per level (10 s hard ceiling) before triggering
disconnect, so MinecraftServer.stopServer's while (chunkMap.hasWork()) loop doesn't spin
forever on the "freshly-created world → immediate disconnect" path. See the project's
"save-and-quit hang" notes for upstream context.
When a server enables deathReportToDiscord, the same recap is posted to Discord as a
"💀 <player> — Run Ended" embed through the bundled
Discord Presence mod: the death cause as the description, the
run-stats + combat figures as embed fields, and the most-used weapon + worn armour composed into
the embed image.
RunStatsEvents.postRunSummary
builds the field list from the same DeathStatsPacket the screen renders, so the embed stays
in lockstep with the on-screen pages. It is off by default and posts via the same relay /
webhook the Discord Presence integration uses. Damage values reuse the rounded-HP / k·M
abbreviation via the shared, unit-tested
DeathReportFormat
formatters. The death message in #minor-updates is in third person (the screen's is second
person — see SecondPersonDeathMessage).
Separately, a top-level dev/public report (DeathReportBuffer)
waits up to 5 s for the client to deliver this run's scenic ride photo
(ride.jpg, JPEG-compressed as of v0.469.0 per the note above) via DeathPhotoPacket, using it as
the embed image; if it never arrives (ride snapshots off, none captured, vanilla client, or a
dropped packet) the report posts with a gear-composite icon instead so it always lands.
- Singleplayer (incl. hardcore): the narrative screen replaces the vanilla death screen. Death ends the run regardless of hardcore — Board anew / Leave the line are the only ways off.
-
LAN / multiplayer:
getSingleplayerServer()isnull, so the swap is skipped and the vanilla death screen is shown — we can't recreate someone else's world.
Shipped as a second row on the cargo page, below the gear row: the Dungeon Train advancements you earned this life, each drawn in its real vanilla advancement frame box (per-tier — ⬛ task / 🟡 goal / 🟣 challenge), floating on the Dungeon Train advancements-tab background (the same texture the L-screen tiles) inside a thin framed panel.
- Scope: Dungeon Train advancements only — excludes vanilla, other mods, and the Editor tab — matching the "see all advancements" button, which opens the Dungeon Train tab.
- Hover a box for the menu-style tooltip (title + tier-coloured description).
- Scrollable when you've earned more than fit (~10): the row clips to a scroll viewport you can wheel through; a thin scrollbar shows position.
- The "see all advancements" icon button sits outside the scroll viewport and opens the
vanilla advancements screen on the Dungeon Train tab (via the existing
DefaultAdvancementsTabauto-select); closing it returns to the cargo page (new AdvancementsScreen(advancements, this)). - The whole section fades with the page on Continue — background + frame boxes via
setColor+RenderSystem.enableBlend()(the vanillaLoadingOverlaypattern), border / scrollbar / button via the page fade. The item icons can't alpha-fade (Minecraft's GUI item renderer ignores tint — the same limit the loadout row has), so they pop in/out with the page's other item icons.
Per-life earned advancements are tracked server-side: AchievementEvents.onAdvancementEarn
records each genuine (non-login-replay) dungeontrain: non-editor earn into a per-life set on
PlayerRunState
(in-memory — the codec is at its 16-field cap, like narrativeLetters), shipped in
DeathStatsPacket
(PROTOCOL_VERSION 30→31) and resolved client-side from the connection's advancement tree
(icon / title / description / tier). Frame boxes are blitted directly from the obtained-frame
textures (task/goal/challenge_frame_obtained) so they fade with the page.
See Advancements for the full tab + cross-world-persistence model.
| Path | Purpose |
|---|---|
NarrativeDeathScreen.java |
The paginated screen — page renderers, chrome, transitions, click handling. |
DeathScreenLayoutHandler.java |
Swaps in the screen on Opening; world launch + Sable pre-drain. |
DeathScreenMoveTextMixin.java |
Vanilla-death-screen text tweak (the fallback / multiplayer path). |
DeathStatsPacket.java / DeathNarrative.java
|
Carry the run + lifetime stats, narrative, death cause, portrait to the client. |
DeathStatsCache.java |
Client-side cache the screen reads each frame. |
RunStatsEvents.java / PlayerRunState.java
|
Per-run stat tracking; builds the packet; the Discord mirror. |
client/snapshot/ (DeathBackgroundAssigner, DeathBackgroundPainter, RideSnapshotGallery) |
Ride-photo capture, per-page assignment, and painting. |
DeathPortraitRenderer.java |
The DEEDS-page animated PlayerMob portrait. |
assets/dungeontrain/lang/en_us.json |
gui.dungeontrain.death.* keys (kickers, cell labels, button text). |
wiki/tools/render-death-screens.py |
Generates the schematic renderings on this page. |
The mock-ups are generated from the layout code, so re-run the tool after a layout change:
cd wiki
python3 tools/render-death-screens.py # writes images/death-screen/*.pngIt mirrors NarrativeDeathScreen's palette + geometry with Pillow
(no game launch required). Edit the example stat values / narration at the top of each page_*
function. If the on-screen layout changes materially, update the matching draw helper so the
renderings stay faithful.