Skip to content

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

  1. Cardinality: Account.CharacterSlots already exists — build on it, don't invent a competing model. Reading Account.cs settles this rather than requiring a fresh design: Account already declares IReadOnlyList<CharacterId> CharacterSlots (added during Milestone 11's AccountStore work, per that file's own header comment), and AccountStore.AccountDocument already round-trips it through persistence. What's missing is not the collection — it's that nothing has ever written to it: CharacterStore.CreateAsync creates a Character and saves it, but never appends the new CharacterId to its owning Account.CharacterSlots, and AdminModule'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: CharacterSlots is the collection (ordered by creation, no separate CharacterSlot wrapper type needed — Character itself already carries everything a selection screen needs), and CreateAsync starts actually maintaining it, with a configurable cap (CharacterConfiguration.MaxCharacterSlots or equivalent) enforced there. "Active" stays exactly the in-memory, session-scoped concept it already isICharacterService.GetActive(SteamId)/GetAllActive()'s existing roster, populated by whichever of CreateAsync/LoadAsync last ran for a connection this session. No persisted Account.ActiveCharacterId is 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.
  2. Skills and attributes live on a new CharacterProgression type, composed onto CharacterCharacter.Progression, mirroring AccountModeration's precedent of sitting beside its owning type rather than flattening into it (Account.Moderation/AccountModeration), not NeedsStore'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). Holds Biography (free text, same "not a closed set" reasoning Character.Gender already uses) and Attributes/Skills (a small Dictionary<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). Keeps Character itself from becoming the exact kind of ever-growing flat record Legacy/13_DEPENDENCY_GRAPH.md §5 already 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 alongside Character, not a new field flattened onto it.
  3. Active-character resolution: the shape of ConnectionExtensions.GetCharacter() and every caller of it does not change — it already resolves through ICharacterService.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 calling RequestCreateCharacter or an eventual RequestSelectCharacter a 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 how RequestCreateCharacter already 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.
  4. Deletion: soft delete. Character gains an explicit IsDeleted/DeletedAt marker (composed the same way AccountModeration's fields are — explicit state, not an implicit convention), following this codebase's consistent practice of never letting an identifier dangle: CharacterId is already referenced as a stable foreign key by Ownership, ICrimeService's warrants, AccountModeration-adjacent ban/mute records, and every inventory — hard-deleting would orphan all of them, the same class of damage CharacterStore.CreateAsync's own concurrency guard (Milestone 9) was built to prevent from the opposite direction. A soft-deleted character is excluded from GetAllActive() and any character-select listing, but its record, and every module's reference to its CharacterId, 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

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