Character Roster
Status: SHIPPED — multi-character support landed via (2026-05-18). End-to-end create / switch / delete were broken on arrival and fixed 2026-05-21 (player Beowulf report) — see the Persistence Model
Last updated: October 8, 2026
Darkness Falls — Character Roster
Status: SHIPPED — multi-character support landed via (2026-05-18). End-to-end create / switch / delete were broken on arrival and fixed 2026-05-21 (player Beowulf report) — see the Persistence Model section. The physical Character Select Zone shipped via (2026-05-21) — see the Character Select Zone section.
Every Darkness Falls account owns up to a small fixed number of characters. Each character is a separate save: name, class, level, gear, zone progress, blood emerald and gold balances, and Star Map allocations are scoped per character. The Trophy Room and account-wide cosmetic / map ownership are NOT — those live on the account and are shared across every character on the account.
Players can list their roster, switch the active character mid-session, create another character up to the cap, and permanently delete a character to free a slot. There are two surfaces for this: the voice character menu and the walkable Character Select Zone.
Why this exists
Schema-wise the engine has always supported a 1-to-many account-to-character relationship (darkness_falls_characters.account_ref), but until the creation flow only fired through the missing-character forced-interaction gate, so every account ended at exactly one character. 2820 of 2820 prod accounts had a roster of one when player Beowulf reported wanting a second character on 2026-05-18.
Roster Cap
| Constant | Value | Source |
|---|---|---|
MAX_CHARACTERS_PER_ACCOUNT |
3 |
Three was chosen over five because the voice character menu reads each character aloud as name plus class plus level plus active marker. Three rows fits a single Alexa response without pushing past the spoken-response budget; five risks truncation on card.alexa.amazon.com clients that cap at around 8 kilobytes per response. If voice copy gets meaningfully shorter the cap can move; raise it in the constant, not in scattered checks.
Active Character
darkness_falls_accounts.current_character_ref is the canonical pointer to the active character. Every wrapper, handler, cache lookup, and zone hydration reads through currentCharacterRef. There is exactly one active character per account at any time.
darkness_falls_accounts.account_data.characterCount and account_data.maxCharacters are bookkeeping fields populated lazily on the first character-menu render. They MUST NEVER be treated as authoritative — characterCount is recomputed from getAllByAccountRef(...) on every menu render, and maxCharacters mirrors the Java constant for surfaces that need to read the cap without a backend call. Legacy accounts ship with both fields null; the menu fills them in on first interaction.
Surfaces
Character Menu
Tier 4 NLU entries route to :
characters, character menu, my characters, list characters, switch character, change character, character select, select character, character roster
The menu renders one line per character: Slot N, NAME, level L CLASS, currently playing. for the active row, Slot N, NAME, level L CLASS. for the others. Each non-active row adds two Tier 2 OPTION rows: play as NAME and delete NAME. A new character option appears when the roster is under cap; an at-cap message replaces it when the roster is full.
Create New Character
Tier 4 NLU entries route to :
new character, create character, create new character, make new character, start new character, make a new hero
The handler short-circuits when the current character is empty (no name set). This is the brand-new-player path: the missing-character gate already allocated an empty character row before routing here, and re-allocating would orphan it. The forced-interaction provider drives the name and class prompts on the next turn.
When the current character has a name, the handler checks the cap, then delegates the allocation to (allocate a fresh characterRef, point currentCharacterRef at it, persist, re-run the account wrapper, register the pending name interaction, emit the welcome). See Shared Transition Logic below.
At-cap responses tell the player to free a slot via the character menu and offer a characters option as a shortcut.
Switch Character
Tier 2 OPTION rows on the menu carry DARKNESS_FALLS_CHANGE_PLAYER with the target characterRef in the matched-key slot. resolves by characterRef first, then case-insensitive name, then delegates to . Saying play as for the already-active character emits an idempotent acknowledgement and does not reload.
Delete Character
Tier 2 OPTION rows on the menu carry DARKNESS_FALLS_DELETE_CHARACTER with the target characterRef. The handler refuses to delete the currently-active character — the player has to switch first. Otherwise it routes through , which has been the canonical delete path since the zone-square slot system shipped. On YES the provider drops the row, the zone-location entry, the stat cache, the character cache, and every inventory item. also removes every auction house listing the character has, in any status, before the hard delete, so no listing stays purchasable from, or keyed on, a seller that no longer exists.
Per-account state survives: Trophy Room, cosmetics, character-map ownership, and the stash all live on the account and remain after a delete.
Character Select Zone
Status: SHIPPED — (2026-05-21), follow-up to the multi-character voice menu.
CHARACTER_ZONE ("Character Select Zone") is a walkable 5-by-5 room — the physical, in-world counterpart of the voice character menu. The voice menu remains the shortcut path; the zone is the discoverable equivalent for players who navigate the hideout.
Reaching it
The HIDEOUT_ZONE square at (9,10) — "the Character Hall" — carries a zone transition into CHARACTER_ZONE. Saying enter zone there drops the player at CHARACTER_ZONE (2,2), the chamber's central portal square, which transitions back to the hideout at HIDEOUT_ZONE (9,10). directs Standard-season players to the Character Hall to create or switch to a Current Season character.
Slot squares
Three CHARACTER_SLOT squares ring the central portal — (2,1) north, (1,2) west, (3,2) east — one per MAX_CHARACTERS_PER_ACCOUNT. They are FREE: there is no purchase or ownership gate, matching the voice menu, which grants three slots at no cost. (The pre-19925 CHARACTER_ZONE JSON carried eight slot squares, four of them goldPriceToUnlock-gated, but with a malformed specialRef key — squareSpecialTypes instead of darknessFallsSquareSpecialTypes — so the slot type deserialized null and the squares were inert. 19925 cut it to three free slots with the correct key.)
Slot squares map to the roster positionally: the CHARACTER_SLOT squares in the zone are enumerated and sorted by coordinate (y, then x), and slot index N is roster entry N — the same "Slot N" the voice menu renders. is the single source of that mapping. Standing on a slot reads it out as its character (name, class, level) or as an empty slot.
- Activate an occupied slot → switch to that character (idempotent if already active).
- Activate an empty slot → create a new character (name prompt follows).
- Deactivate an occupied slot → delete that character via the
TYPE_DELETE_CHARACTERconfirmation flow. The active character cannot be deleted — switch away first.
routes CHARACTER_SLOT squares to BEFORE the cosmetic/trophy/star-map ownership gate, so slots stay free; do not reintroduce purchase-gating without a separate economy decision.
Shared Transition Logic
(zone) and (voice menu) both delegate the actual roster mutation to — switchToCharacter and beginNewCharacter — so both surfaces honour the same persist-before-repopulate ordering and pending TYPE_NAME registration (see Persistence Model). The zone path previously duplicated this logic with two bugs: 's create branch set currentCharacterRef to null (hydrating a character with a null ref) and never persisted before repopulating. Both are gone — the zone and the menu now create and switch characters identically.
characterMapOwnership is vestigial
DarknessFallsAccount.characterMapOwnership (a per-account slotKey → characterRef map) fed the old purchase-gated slot system. The zone now maps slots to the roster positionally, so nothing reads the map. populateDarknessFallsAccount no longer writes the legacy CHARACTER_ZONE_1,2 entry. The field is retained on the model only to keep Kryo deserialization of existing cached account blobs stable; it is no longer load-bearing for any character surface.
Cache Invalidation
Cache invalidation is per characterRef, not per accountRef. The is the only account-keyed cache and is invalidated on every switch and create via . Per-character caches — , , — key on characterRef and are cleaned only on delete, never on switch (so re-switching back warms instantly).
NLU Tier Placement
Character-menu commands live at Tier 4 (MENU_AND_CORE / AMAZON_AND_SETTINGS), not Tier 1, so they never preempt option chips on a rendered menu. On-menu options (play as X, delete X) live at Tier 2 (OPTIONS) via runtime addOption synonym maps so the player can speak them naturally while the menu is up. Inside the Character Select Zone, activate and deactivate are the standard square-interaction commands — the same ones the Star Map uses.
Persistence Model
Three cache-versus-database invariants govern the multi-character surfaces. All three were root causes of the create, switch, and delete failures fixed on 2026-05-21 (reported by player Beowulf two days after shipped).
currentCharacterRef must be saved before re-population.
beginNewCharacterandswitchToCharacter(in ) setcurrentCharacterRefthen call to re-hydrate the turn.populateDarknessFallsAccountRELOADS the account via , which deserializes a fresh copy from Redis, so an in-memory-onlysetCurrentCharacterRefis discarded. The service MUST call between the two — otherwise the create or switch silently no-ops and the turn stays on the old character.The roster enumerates from the database but hydrates from the cache. A new character lives in the write-behind Redis cache; its
darkness_falls_charactersrow is flushed lazily.populateDarknessFallsAccountcalls the moment a brand-new character is built, so the row exists forgetAllByAccountRefto enumerate immediately.getAllByAccountRefthen hydrates each enumerated ref throughget(ref)— the cache is the freshest copy, and reading the lagging row directly would show a just-created character as Unnamed character, no class yet.Delete hard-deletes the row. removes the
darkness_falls_charactersrow, evicts the per-character cache, and drops the accountRef shortcut. A cache-only eviction is not enough —getAllByAccountRefreads the database, so a deleted character whose row survived re-appears on the next render. It also removes the character'sdarkness_falls_auction_houselistings, in any status, before the hard delete — see items-economy.md § Auction House (Technical) for why a listing left behind is a ghost listing rather than a currency loss.
beginNewCharacter also registers a pending TYPE_NAME value-entry forced interaction
after allocating the new character. The per-turn checkForForcedInteraction gate runs
before the handler and still sees the old named character on the create turn, so without
the stored pending the new character name prompt is only raised fresh on the next turn,
discarding the my-name-is input the player gives that turn.
Scenario tests reset the roster between runs via the clearRoster PlayRequest flag — see
.
Load-Failure Safety
Status: SHIPPED — fixed 2026-06-17 (player feedback 9051, 9052, 9053; PROVE , FIX ). The decision below is the SENSITIVE-writeback design ruling validated through the Stage-3 code-checkin review gate.
A transient character-load failure MUST NEVER recreate the character. The cache layer
() can return Optional.empty() for an EXISTING character on two cold paths:
a Kryo decode miss after a prod Java deploy changes a serialized class, or a transient DB-read
exception whose outer catch swallows the error to empty. Before this fix,
populateDarknessFallsAccount treated that empty as "brand-new character" and built a fresh level-1
character on the SAME currentCharacterRef, then eagerly persisted it over the real row — a level-4
character was reset to level 1 ten seconds after deploy 1318, and the player re-ground ~16 hours to
recover. This was systemic: the 7-day cache TTL produced the same loss for players away a week.
The ruling — never recreate on an existing ref
When returns empty,
populateDarknessFallsAccount distinguishes a genuinely-new ref from a failed load of an existing
one via a direct database read (loadFromDatabaseDirect, which PROPAGATES exceptions rather than
swallowing them to empty):
get()present → use the loaded character (unchanged normal path).get()empty, durable DB row present → the character exists but failed to hydrate through the cache (decode miss). RECOVER it straight from the database row. Never recreate.get()empty, direct DB read THROWS → a transient blip. FAIL SOFT: throw so the turn aborts with the generic "try again" message and NOTHING is persisted. The player retries once the transient condition clears.populateGameDetailsruns during request setup, before the handler's persistencetry/finally, so a throw here cannot trigger a destructive save.get()empty, direct DB read cleanly returns no row → a genuinely-new ref (brand-new account, or a fresh ref allocated bybeginNewCharacter). Build the starting character. This is the only path that creates a fresh level-1 character.
Defense in depth — regressive-write guard
and persistNewCharacter REFUSE a write that would regress an
existing character: if the incoming character looks fresh (experience == 0) but a durable row
already exists for the same ref with experience earned, the write is dropped and logged. A normal
leveled save (experience > 0) short-circuits before any database read, so the hot path is
unaffected. No legitimate Darkness Falls flow resets an existing character's experience to 0 — delete
allocates a NEW ref, switch reuses existing refs, paragon is post-cap — so the guard has no false
positives.
Deploy-time hardening — shutdown flush
(@PreDestroy) flushes the write-behind DF caches
(character, stat, account, item, zone, quest, auction, party, teleport-cooldown) to MySQL on graceful
shutdown — which a docker stop SIGTERM triggers before the container dies. This keeps the database
holding the true-latest state at deploy time, so a post-deploy Kryo decode miss reloads real progress
(via path 2 above) instead of a stale row. The 3-minute scheduled flush remains the primary; this only
narrows the deploy-time gap between the last scheduled flush and shutdown.
Regression coverage: (JUnit) pins the recreate-refusal and
no-false-positive behaviour. The load-failure path is service-layer cache logic that a play_game
scenario test cannot reproduce hermetically (it cannot force get() empty while a DB row exists), so
JUnit is the correct surface per the test-type decision tree.
Generalized: the shared `getOrLoadStrict` cache primitive (sf-5078)
The recreate-on-empty hazard is not unique to the Darkness Falls character — it applies to EVERY
session-load that does cacheService.get(ref).orElseGet(buildFresh + persist) for a player's primary
entity (session finding sf-5078). The discriminator was generalized into a shared cache primitive,
: it behaves like get() on every healthy path (cache hit, DB
recovery after a decode miss, genuine empty for a brand-new player) but PROPAGATES a database-read
failure instead of swallowing it to empty. A caller writes getOrLoadStrict(ref).orElseGet(buildFresh),
so only a genuine absence builds fresh; a transient failure throws and the request fails soft before any
destructive save runs. It now backs:
- Darkness Falls account — (a fresh
account would overwrite the real
skillCustomerIdrow and orphan every character). - Dark Tower account + character — and
. The character case is the DT-22217 "new-char
sidelining": a transient load of an EXISTING
currentCharacterKeymust not mint a fresh character under a new key, orphaning the progressed one. - Zoo account — .
- Lineage account cache — (createDefault eagerly persists, so a fresh default would overwrite the real mode/active-game pointers).
Regression coverage for the primitive: (JUnit) pins that
getOrLoadStrict propagates a DB exception (vs get() swallowing to empty), recovers a DB row on a
Redis miss, and returns empty only on a genuine no-row. Each per-game load-failure path stays a
JUnit-surface concern (a scenario test cannot force get() empty while a DB row exists).
Known limits
- Cross-character party play already works; nothing changes there.
- A character's zone state, inventory, and stash tab content survive a switch — there is no "log out the old character" beat. Time-based events (daily subscriber rewards, ISP cooldowns) are scoped to the account, so switching characters does NOT reset them.
- A second-account-on-the-same-device case is not in scope here; the cap is per Alexa customer id, not per character name.
- The Character Select Zone slots map to the roster positionally; a genuine cross-character switch through the zone cannot be exercised by a single-square-pinned scenario test (the test would need two characters on distinct slots). That path is covered by manual playtest and by the voice-menu switch surface, which shares
switchToCharacter.