Repository navigation
Feature Starting Books
Home > Features > Starting Books
Per-player welcome book delivered by a vibrant colored lightning strike in front of the player on first login and every respawn. Reading the book — or dropping/throwing it — burns it: 3 s of flames + a client-only fire visual on the surface beneath it, then extinguish + book consumed.
A sibling pool to Random Books (chest-loot flavour) and the Narrative Lectern (long-form story track). Starting Books are short standalone welcome messages — one per player, one per spawn cycle — that arrive with cinematic emphasis instead of sitting passively in chests.
This page's close-and-burn / burn-lifecycle mechanics (below) are the shared framework — since v0.429.0 (PR #707) narrative lectern books use this exact same machinery once taken off the lectern; see Narrative Lectern § Burn outside the lectern. Since v0.642.0 a lore book an author hand-writes inside an editor plot joins the same framework once a player takes it out of a chest on the live train — see Container variants § Hand-written lore books.
Three independent surfaces:
- Spawn-side: deferred lightning + colored particle column + ItemEntity drop.
-
Read-side: client
ScreenEvent.Closingon theBookViewScreen→ server packet → book drops + burns. -
Drop-side: any starting-book
ItemEntityjoining the world (Q-throw, death-drop, our own close-flow drop) is registered for the burn lifecycle viaEntityJoinLevelEvent.
Since v0.266.0 (PR #329), a run that starts in the Nether or the End (World Type → dungeon_train_nether / dungeon_train_end, see World Type) gets a dimension-specific first-login welcome instead of the generic one.
- Books live in context subfolders —
starting_books/nether/andstarting_books/end/— mapped byStartingBookContext.NETHER/END. -
StartingBookEvents.resolveLoginContextreads the world'sStartingDimensionand routes the first-login strike to the matching pool, ahead of the multiplayer/new-world lifecycle contexts. -
One-each playlist (per installation): each new Nether/End run shows a welcome the player hasn't seen yet. The seen set is tracked across worlds in the gamedir marker (
players_seen/<uuid>.properties, keyseen_dimension_variants) — not per-world. Once every(book, variant)in a dimension's pool has been shown, further runs in that dimension fall through to the normal new-world/default welcome. Nether and End exhaust independently. - First-spawn only — respawns are unchanged (they still roll the RESPAWN pool).
- The roll reuses the RESPAWN cycling machinery (
StartingBookFactory.rollForDimensionmirrorsrollForRespawn); an empty dimension pool is treated as "exhausted" and falls through.
Shipped books:
| Subfolder | Basename | Title | Variants |
|---|---|---|---|
nether/ |
why_the_nether |
The Nether? | 3 |
end/ |
begin_at_the_end |
The End? | 3 |
On PlayerLoggedInEvent (gated by per-player first-login flag) or PlayerRespawnEvent (skipping End-credits return), StartingBookEvents enqueues a 10-tick deferred strike. Deferring is required for first-login because PlayerJoinEvents' retry loop teleports the player to the train area over the next 1–5 ticks — firing the strike immediately would target their pre-teleport coordinates.
At fire time:
- Strike point = player position + 2 blocks along XZ-projected view direction, snapped to block grid. Uses the player's current Y (no height-map lookup) so on-train-roof respawns hit the deck, not the world ground.
-
Visual = cosmetic
LightningBoltwithsetVisualOnly(true)(screen flash + thunder, no damage, no fire) plus a 25-block vertical column + ground burst of tintedDustParticleOptions. Vanilla lightning has no color recolouring API; the tinted dust column composites the per-strike color on top of the white bolt. - Color = HSV (random hue, full saturation, full value) → RGB. Vibrant by construction — no dull or dark rolls.
-
Book entity = vanilla
WRITTEN_BOOKItemEntity at the strike point, small upward pop, 1 s pickup delay. Carries theStartingBookTagmarker — including a(basename, variant)identity (close + drop detection, and read-tracking — see below) — plus a per-entitydt_starting_spawn_bookpersistent-data flag (so the burn-on-drop handler ignores its own spawn).
Vanilla BookViewScreen is a client-only screen — opening or closing a written book never reaches the server (the screen consumes the stack via BookViewScreen.BookAccess.fromItem). To detect the close, StartingBookClientEvents hooks ScreenEvent.Closing:
- Filter to
BookViewScreen. - Check the local player's mainhand + offhand for the
StartingBookTagmarker (not the full inventory — vanilla written books can only be opened from hand, and inventory scan would false-trigger if a starting book was tucked away while the player read something else). - Send
StartingBookClosedPacket(empty payload — server identifies the player fromIPayloadContext.player()).
Server side (StartingBookEvents.handleStartingBookClosed):
- Find + remove the stamped book from mainhand → offhand → full inventory.
-
Read-tracking (v0.269.0): read the book's
StartingBookTagidentity and mark(basename, variant)seen viaNarrativeProgressData.markStartingBookVariantSeen, then re-evaluate Inter-Reality Passenger. Lifecycle books (DEFAULT /new_world/joined_world) land in the world snapshot the advancement reads; dimension books are already delivery-tracked, so their entry here is inert. Stacks without identity (random books that share the burnable close-flow, or pre-v0.269.0 welcome books) are a graceful no-op. -
Player.drop(stack, false, false)— vanilla forward-throw, returns theItemEntity. - The drop fires
EntityJoinLevelEventsynchronously; the burn handler picks it up.
A single EntityJoinLevelEvent handler covers every path that produces a starting-book ItemEntity:
- Server-side filter.
- Must be an
ItemEntitycarrying theStartingBookTagmarker. - Skip if the entity has the
dt_starting_spawn_bookpersistent flag (the lightning-spawned welcome book itself). - Skip if already tracked (defensive; never observed in practice).
Registered entries get a 60-tick burn timer in a static Map<UUID, BurnState> keyed by the ItemEntity's UUID, plus a FIRE_AMBIENT ignition sound. The shared LevelTickEvent.Post handler (also drives the spawn-strike queue) advances every active burn each tick:
-
Visual: every other tick, emit a small burst of
FLAME+SMOKEparticles at the entity's position. -
Block fire (client-only, since v0.186.0): once the item lands (
onGround()), anchor on the air block above its resting surface (provided the surface isisFaceSturdy(Direction.UP)). Every tick after that, send aClientboundBlockUpdatePacket(pos, Blocks.FIRE.defaultBlockState())to every player tracking that chunk. The server level is never modified — clients render the vanilla "block on fire" animation for the full 3 s, but no real fire block exists, so there is no fire spread, no entity damage, and no chunk state diff. Re-sending each tick is defensive against incidental chunk refreshes that would re-assert the server state (air). The packet is skipped if the server has a non-air block at that position (player placed something there during the burn — leave their block alone). -
Decrement: when the timer hits zero — send one more block-update packet with the real server state (air, or the player's block) to clear the client-only fire from every tracking client, play
FIRE_EXTINGUISH+LARGE_SMOKE, anditemEntity.discard().
If the entity disappears mid-burn (pickup-by-player can't happen — pickup delay is set to the full burn duration — but chunk unload or /kill can), the same client-side clear runs before the tracker drops the entry.
-
v0.186.0 (PR #232): switched the block-fire from a real
Blocks.FIREplacement to a per-tick client-onlyClientboundBlockUpdatePacket. The original real-block approach decayed in ~1–2 s on non-flammable surfaces (visible bug — fire vanished before the book finished burning) and had real fire side effects (spread on flammable surfaces, entity damage). The client-only approach lasts the full 3 s and has no side effects.
Starting books use a stricter paginator than Random Books or the Narrative Loader: every \n\n in the source is exactly one page break, no greedy collapsing. Authors get precise per-page control, including blank-page padding:
| Source | Pages |
|---|---|
"page A\n\npage B" |
2 — A, B |
"page A\n\n\n\npage B" |
3 — A, blank, B |
"page A\n\n \n\npage B" |
3 — A, blank, B (whitespace-only middle chunk collapses to a blank page slot) |
Single \n
|
preserved as a line break within a page |
Leading + trailing blank pages are trimmed (no opening on a blank, no dead pages at the end); internal blanks are preserved. Oversize chunks (> 256 chars between breaks) fall back to BookFactory.paginate's sentence / word splitter so an unbroken long paragraph still fits visually.
Lock-down in StartingBookPaginationTest (11 cases including doubled-up breaks, internal-only trim, whitespace-chunk handling, and overflow fallback).
-
Drop a JSON file in
data/dungeontrain/narratives/starting_books/<basename>.json:{ "id": "brennan_intro", "title": "Answers", "author": "Anonymous", "generation": 0, "weight": 1, "variants": [ "Welcome to the Dungeon Train\nWhat is the train?\n\nThis train is a vehicle through time and space.", "Welcome to the Dungeon Train\nWhat is the train?\n\nThis Train is own reality, in a series of realities." ] }- Same schema as Random Books —
RandomBookFile+RandomBookCodecare shared. - Each
\n\nis a page break (see Pagination). - Multiple
variantslets one logical book ship multiple welcome takes; the spawn flow picks one weighted-random.
- Same schema as Random Books —
-
/dungeontrain narrative startingbook reloadto re-scan the pool without restarting the server.
| Command | Purpose |
|---|---|
/dungeontrain narrative startingbook list |
Chat-print every loaded starting book with variant count + weight + generation |
/dungeontrain narrative startingbook give [basename] |
Give the calling player a starting book directly (bypasses the spawn-strike flow); no basename → pool-weighted random |
/dungeontrain narrative startingbook reload |
Re-scan the starting_books/ pool |
/dungeontrain narrative startingbook fire [context] |
Fire a welcome strike immediately for testing; context = default/new_world/joined_world/respawn/nether/end. Dimension contexts preview without consuming the per-install playlist |
/dungeontrain narrative startingbook reset |
Clear the caller's first-login flag and the per-install Nether/End playlist (seen_dimension_variants) — next login fires a fresh welcome strike |
All gated on permission level 2 (OP), inherited from the dungeontrain root literal.
Per-player first-login receipt lives in the existing NarrativeProgressData saved-data (overworld-keyed) alongside the world-scoped story + random-book maps. Schema:
root tag/
├── stories: list of {id, read[]} ← world-scoped
├── random_books: list of {id, read[]} ← world-scoped
└── starting_book_received: list of {uuid} ← per-player
Per-player rather than world-scoped: each player gets their own welcome strike on their own first join. The world-scoped flip used by random_books would surprise the second player on a multiplayer server.
Backwards-compatible: missing starting_book_received tag = empty set. Pre-v0.185.0 worlds re-fire the welcome strike for every player on their first post-update login (intended — they get the welcome they would have gotten if the feature had shipped earlier).
v0.185.0 (PR #231) ships 12 bundled welcome books generated from the original itch.io game's unused/Welcome Messages/ drafts:
| Basename | Title | Variants |
|---|---|---|
brennan_intro |
Answers | 4 |
description |
Description | 1 |
blank_pages |
Blank Pages | 1 |
infinite_vs_repeat |
Infinite vs Repeat | 1 |
journey |
Journey | 1 |
looking_for_answers |
Looking for answers | 1 |
meme_start |
The Game | 5 |
more_questions_next_time |
More Questions Next Time | 1 |
questions |
Questions | 1 |
what_is_an_end |
What is an end | 1 |
a_paper_algorithm |
A Paper Algorithm | 1 |
lighting |
Was that lightning? | 2 |
Bumps PROTOCOL_VERSION 20 → 21 with one new packet:
-
StartingBookClosedPacket(client → server, empty payload) — fired byStartingBookClientEvents.onScreenClosingwhen the player closes aBookViewScreenwhile holding a stamped starting book in hand.
-
Spawn: each player gets their own first-login strike independently — per-player
startingBookReceivedset. - Respawn: each player's respawn fires their own strike.
- Burn: every dropped starting book is tracked by entity UUID — multiple simultaneous burns work cleanly.
-
Close detection: the client sending the packet is identified by
IPayloadContext.player(); one player's close never burns another player's book.
- Custom item / texture for the starting book (uses vanilla
Items.WRITTEN_BOOK). - Per-player unseen-variant tracking for the DEFAULT pool (still plain weighted random there). The Nether/End dimension pools do cycle per-installation (see Dimension-routed welcome books); the RESPAWN pool cycles per-world.
- Authoring tools for new welcome messages (drop a JSON in the folder, run reload).
- Lightning strike at arbitrary positions (always 2 blocks in front of the player).
-
Feature: Random Books — sibling chest-loot flavour-book system; shares the
RandomBookFileschema and codec - Feature: Narrative Lectern — the lectern-based ordered-story track
- Feature: Narrative Loader — the per-Letter content loader these systems all share
- Feature: Login spawn placement — the placement pipeline the starting-book strike defers around