Skip to content

Feature Starting Books

Brennan Hatton edited this page Aug 24, 2026 · 7 revisions

Home > Features > Starting Books

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.

Overview

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:

  1. Spawn-side: deferred lightning + colored particle column + ItemEntity drop.
  2. Read-side: client ScreenEvent.Closing on the BookViewScreen → server packet → book drops + burns.
  3. Drop-side: any starting-book ItemEntity joining the world (Q-throw, death-drop, our own close-flow drop) is registered for the burn lifecycle via EntityJoinLevelEvent.

Dimension-routed welcome books (Nether & End)

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/ and starting_books/end/ — mapped by StartingBookContext.NETHER / END.
  • StartingBookEvents.resolveLoginContext reads the world's StartingDimension and 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, key seen_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.rollForDimension mirrors rollForRespawn); 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

Lightning-strike spawn

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 LightningBolt with setVisualOnly(true) (screen flash + thunder, no damage, no fire) plus a 25-block vertical column + ground burst of tinted DustParticleOptions. 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_BOOK ItemEntity at the strike point, small upward pop, 1 s pickup delay. Carries the StartingBookTag marker — including a (basename, variant) identity (close + drop detection, and read-tracking — see below) — plus a per-entity dt_starting_spawn_book persistent-data flag (so the burn-on-drop handler ignores its own spawn).

Close-and-burn flow

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:

  1. Filter to BookViewScreen.
  2. Check the local player's mainhand + offhand for the StartingBookTag marker (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).
  3. Send StartingBookClosedPacket (empty payload — server identifies the player from IPayloadContext.player()).

Server side (StartingBookEvents.handleStartingBookClosed):

  1. Find + remove the stamped book from mainhand → offhand → full inventory.
  2. Read-tracking (v0.269.0): read the book's StartingBookTag identity and mark (basename, variant) seen via NarrativeProgressData.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.
  3. Player.drop(stack, false, false) — vanilla forward-throw, returns the ItemEntity.
  4. The drop fires EntityJoinLevelEvent synchronously; the burn handler picks it up.

Burn lifecycle

A single EntityJoinLevelEvent handler covers every path that produces a starting-book ItemEntity:

  • Server-side filter.
  • Must be an ItemEntity carrying the StartingBookTag marker.
  • Skip if the entity has the dt_starting_spawn_book persistent 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 + SMOKE particles 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 is isFaceSturdy(Direction.UP)). Every tick after that, send a ClientboundBlockUpdatePacket(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, and itemEntity.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.

History

  • v0.186.0 (PR #232): switched the block-fire from a real Blocks.FIRE placement to a per-tick client-only ClientboundBlockUpdatePacket. 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.

Pagination

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).

Author workflow

  1. 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 + RandomBookCodec are shared.
    • Each \n\n is a page break (see Pagination).
    • Multiple variants lets one logical book ship multiple welcome takes; the spawn flow picks one weighted-random.
  2. /dungeontrain narrative startingbook reload to re-scan the pool without restarting the server.

Commands

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.

Persistence

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).

Where it ships

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

Network protocol

Bumps PROTOCOL_VERSION 20 → 21 with one new packet:

  • StartingBookClosedPacket (client → server, empty payload) — fired by StartingBookClientEvents.onScreenClosing when the player closes a BookViewScreen while holding a stamped starting book in hand.

Multiplayer

  • Spawn: each player gets their own first-login strike independently — per-player startingBookReceived set.
  • 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.

Out of scope

  • 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).

Related

Clone this wiki locally