ADR-0011 — Character creation and multiple characters¶
Status: Accepted Date: 2026-07-31 Supersedes: — Superseded by: —
Context¶
Every system built so far — CharacterStore, CharacterNetworkComponent,
CommandDispatchComponent, the admin panel's target resolution, PawnComponent — was written
against a 1:1 relationship between an Account and a Character. That was not an oversight;
it is legacy's own precedent taken at face value: Legacy/01_FEATURE_INVENTORY.md's Character
section states it plainly — "a character is a Steam account." There was no creation flow,
no selection screen, no concept of a second character, and no biography, attributes, or skills
beyond _Gender (Legacy/01_FEATURE_INVENTORY.md).
This is named New in the Feature Inventory for a reason — there is no legacy behaviour to
preserve, only a gap to design into. But it is not a green-field module the way Banks or
Licences are: it reshapes Character, which by Milestone 13 (per ROADMAP.md) is the module
most other systems depend on directly or transitively
(Legacy/13_DEPENDENCY_GRAPH.md §4).
What forces a decision now, not later. Documentation/ROADMAP.md's Milestones 14–17
(Licences, Medical, Crafting, Criminal Records) all want to attach state to "a specific
character's" attributes, skills, or biography. Building those against today's 1:1 model and
migrating afterward means touching four modules instead of one. If we decide nothing, each of
those milestones improvises its own answer to "which character," the same class of drift
Legacy/13_DEPENDENCY_GRAPH.md §5 already names as the legacy codebase's central structural
failure.
What already exists to build on. Account/Character/CharacterId/AccountModeration
and CharacterStore (08_DATA_MODEL.md §1),
ConnectionExtensions.GetCharacter(), and CharacterNetworkComponent's
RequestCreateCharacter RPC (Milestone 4). AccountModeration already lives on Account, not
Character — precedent that account-scoped and character-scoped state are already meant to be
separate types, not one flat record.
Decision¶
- Cardinality:
Account.CharacterSlotsalready exists — build on it, don't invent a competing model. ReadingAccount.cssettles this rather than requiring a fresh design:Accountalready declaresIReadOnlyList<CharacterId> CharacterSlots(added during Milestone 11'sAccountStorework, per that file's own header comment), andAccountStore.AccountDocumentalready round-trips it through persistence. What's missing is not the collection — it's that nothing has ever written to it:CharacterStore.CreateAsynccreates aCharacterand saves it, but never appends the newCharacterIdto its owningAccount.CharacterSlots, andAdminModule's three read sites only ever pass the field through display-side, never mutate it. This ADR's real cardinality decision is therefore narrower than originally framed:CharacterSlotsis the collection (ordered by creation, no separateCharacterSlotwrapper type needed —Characteritself already carries everything a selection screen needs), andCreateAsyncstarts actually maintaining it, with a configurable cap (CharacterConfiguration.MaxCharacterSlotsor equivalent) enforced there. "Active" stays exactly the in-memory, session-scoped concept it already is —ICharacterService.GetActive(SteamId)/GetAllActive()'s existing roster, populated by whichever ofCreateAsync/LoadAsynclast ran for a connection this session. No persistedAccount.ActiveCharacterIdis added: nothing in this codebase needs "resume the same character across a server restart with no reselection," and inventing that persisted pointer would be a second source of truth for a fact the in-memory roster already owns cleanly for the lifetime that matters (one connection session). A reconnecting player goes through selection again — that actually needing to be a new interaction rather than an implicit pick is the real "before" (there was only ever one slot to implicitly pick) and "after" (selection is a real choice now) this ADR changes. - Skills and attributes live on a new
CharacterProgressiontype, composed ontoCharacter—Character.Progression, mirroringAccountModeration's precedent of sitting beside its owning type rather than flattening into it (Account.Moderation/AccountModeration), notNeedsStore's separate-store precedent (that shape is for state with its own tick/decay lifecycle — needs — which biography/ attributes/skills don't have; they load and save with the character, not on their own schedule). HoldsBiography(free text, same "not a closed set" reasoningCharacter.Genderalready uses) andAttributes/Skills(a smallDictionary<string, int>each — a fixed enum-backed set is tempting but premature: Milestone 16 (Crafting) is this ADR's own named first real consumer and hasn't been designed yet, so the exact skill list stays this milestone's own open design question per the Notes below, not answered here). KeepsCharacteritself from becoming the exact kind of ever-growing flat recordLegacy/13_DEPENDENCY_GRAPH.md §5already blames for legacy's central structural failure — every future character-scoped concern (Medical in Milestone 15, Criminal Records in Milestone 17) gets its own composed type alongsideCharacter, not a new field flattened onto it. - Active-character resolution: the shape of
ConnectionExtensions.GetCharacter()and every caller of it does not change — it already resolves throughICharacterService.GetActive, which stays the single session-scoped answer per point 1. What genuinely needs a review pass is narrower and sharper than "does every caller assume 1:1": can a still-connected client silently swap its own active character out from under already-running systems by callingRequestCreateCharacteror an eventualRequestSelectCharactera second time mid-session? Today this is structurally impossible (there is only ever one character, ever, per account), so no caller has ever needed to guard against "active changed under me." Once selection is a real, repeatable action,CharacterNetworkComponent's creation/selection RPCs need an explicit state check (e.g. refuse if the caller already has a fully-initialised active character this session, requiring an explicit "return to selection" step first, mirroring howRequestCreateCharacteralready refuses a caller that already has an active character — that refusal already exists and this milestone should confirm it still does its job correctly once "already has an active character" is checked against a growing slot list rather than an always-empty one) rather than every downstream reader (persistence, the admin panel,PawnComponent) needing its own new defensive check. - Deletion: soft delete.
Charactergains an explicitIsDeleted/DeletedAtmarker (composed the same wayAccountModeration's fields are — explicit state, not an implicit convention), following this codebase's consistent practice of never letting an identifier dangle:CharacterIdis already referenced as a stable foreign key byOwnership,ICrimeService's warrants,AccountModeration-adjacent ban/mute records, and every inventory — hard-deleting would orphan all of them, the same class of damageCharacterStore.CreateAsync's own concurrency guard (Milestone 9) was built to prevent from the opposite direction. A soft-deleted character is excluded fromGetAllActive()and any character-select listing, but its record, and every module's reference to itsCharacterId, stays valid forever. No automatic hard-purge job — out of scope for this milestone, named here as a deliberate deferral rather than solved. Delete-with-cooldown (this ADR's original third option, mirroring Jobs' rejoin cooldown) is rejected: a cooldown rate-limits a repeatable action, and deletion isn't one — there's nothing to protect against by delaying a single character's removal the way Jobs' cooldown protects against team-hopping.
Alternatives considered¶
Do nothing structural — bolt a Name/Biography field onto the existing 1:1 Character¶
Cheapest possible option. Rejected as the sole answer because it doesn't address "multiple characters," which is explicitly on the feature list, not just biography/attributes — a field addition doesn't give a creation/selection/deletion flow.
A separate CharacterSlot type wrapping Character, rather than extending Character itself¶
Would keep Character exactly as every existing module already knows it, with slot management
as a purely additive layer. Attractive for minimizing churn to Milestones 2–11's existing code.
Resolved, not deferred: rejected once Account.CharacterSlots turned out to already exist
as a plain IReadOnlyList<CharacterId> (Decision point 1) — a slot is just an entry in that
list, and Character already carries every field a selection screen needs (Name, and now
Progression.Biography). A wrapper type would duplicate that surface for no real benefit.
Consequences¶
Positive¶
- One clean surface for every later milestone (Medical, Crafting, Licences, Criminal Records) to build against, instead of four independent retrofits.
- Forces an explicit audit of every place that currently assumes "the" character for a connection — the kind of implicit assumption that caused real damage in legacy (Legacy/13_DEPENDENCY_GRAPH.md §5).
Negative¶
- Be honest: this is the one milestone in the 12–18 batch that touches already-shipped,
already-tested code (
CharacterStore,CharacterNetworkComponent, the admin panel, Movement's pawn binding) rather than adding a self-contained new module. Regression risk is real and proportional to how many of those call sites assumed 1:1. - Persistence schema migration: existing saved characters need a path into whatever the new
shape turns out to be — a real
ISchemaMigration<T>case (06_PERSISTENCE.md), not a green-field schema.
Neutral¶
Charactergains no new engine-typed field regardless of which cardinality option is chosen — same discipline ADR-0007 already held itself to.
Compliance¶
- Every existing caller of
ConnectionExtensions.GetCharacter()is reviewed and updated as part of this milestone's own PR, not left for a later cleanup pass. - A migration test proves a character saved under the pre-Milestone-13 schema loads correctly under the new one.
Notes¶
Open questions, deliberately deferred to Milestone 13's own design pass: the exact skill
list and what mechanically consumes them first (Milestone 16's Crafting is the leading
candidate); whether attributes are fixed-at-creation or grow over play; the UI for
creation/selection (a new on-demand panel, following AdminPanel's precedent from Milestone
10).