Character creation: UO-style first-login wizard (hair/beard + colours + name) #108

Closed
opened 2026-07-20 18:37:33 +02:00 by marco · 1 comment
Owner

Part of the Alpha epic (pillar 1 — small-world intimacy). Design agreed: docs/game-design/systems/character-creation.md. This issue is the player-facing design; technical breakdown to follow (separate critical-design-review).

The system, from the player's chair

On first login (an account with no character yet), instead of dropping into the world the player gets a short UO-style creation wizard: pick a hair style and beard style (each with an explicit "None"), pick hair / beard / skin colour from swatch palettes, and type a name with a live availability check. A live front paperdoll preview updates with every choice, and a "Randomize" button fills a complete valid look in one click for players who just want to get in. Confirm → the character is born with that look and enters the world. That look is then what nearby players see on the sprite and paperdoll — the face friends recognize.

Gender is male-only for now (female deferred). One character per account still holds; a returning player skips the wizard.

Six-lens summary

  • Player POV — a one-time identity moment: "this is my guy," built in seconds (Randomize) or tuned deliberately, then persistent across every session.
  • Motivation & loop — serves ownership/identity; the on-ramp that gives everything after it a face. Recognition is real only because the look is server-authoritative and observed via AoI.
  • Coherence — extends the existing server-validated creation step (POST /me/character) with appearance fields, and extends the #18 server-authoritative appearance state so others render it. Cosmetic-only → no fairness/economy risk. Persist stable semantic keys, not positional art indices (placeholder UO art will be replaced).
  • UI/UX — a dedicated CharacterCreationScreen (not bolted onto LoginScreen — Screen HARD GATE), viewport-derived layout, explicit "None", typed inline errors (too-short / taken / invalid-chars) that preserve appearance picks on retry, and a preview that matches the in-world sprite.
  • Breakage & mitigations — server validates every choice against server-owned sets (bad id can't poison observers; client falls back safely); the name is hardened server-side (max length, character whitelist, look-alike/zero-width normalization before the uniqueness check) because it rides every broadcast and drives recognition; palettes curated so a look can't be a stealth edge; world entry still gated by the JWT char claim.
  • Scope & MVP — below.

Scope (what to build)

  • Extend CreateCharacterRequest + POST /me/character with validated appearance choices (hair/beard style id, hair/beard/skin colour id) as semantic keys; server rejects out-of-range.
  • Harden the name server-side: max length, character whitelist, NFKC + strip zero-width/combining before the world-unique check; a small reserved-word blocklist.
  • Persist appearance on the Character (Auth EF migration).
  • Extend the server-authoritative appearance state (#18) so hair/beard/skin are observable in-world + paperdoll; bump ProtocolVersion.Current in the same change.
  • Extract UO hair/beard art (paperdoll gumps for the preview + in-world layered anims).
  • A per-layer recolouring path (hair/beard/skin hued independently) on both paperdoll and world sprite — the hard technical piece, resolved in the technical review.
  • A dedicated CharacterCreationScreen with style selectors, swatch palettes, live paperdoll preview, live name-availability, and a Randomize button; decomposed into focused components.
  • Harness: gate the empty-DB auto-create-default bypass to a debug flag; make the wizard separately drivable for screenshots.
  • All new user-facing copy via the string catalog (SystemMessageId), no loose literals.

Out of scope

  • Female / other genders, races, starting-clothes colours, in-place re-customization (barber), large style catalogues, broad name-squatting / open-registration gating.

Definition of Done (player-observable)

Base DoD applies on top (tests green, whole solution builds, zero warnings, multi-platform preserved).

  • Logging into a new account shows the creation wizard (not the world) before a character exists.
  • The player picks hair style, beard style, hair colour, beard colour, and skin tone, and types a name; the preview updates live to match every choice.
  • Choosing "None" for hair and/or beard is clearly available and previews a bald / clean-shaven look.
  • A "Randomize" button produces a complete, valid look in one click.
  • The name field shows availability live ("free / taken") before confirming; confirming with a valid, unique name creates the character with exactly the previewed look and enters the world.
  • An invalid name is refused with a specific inline message (too short / taken / invalid characters), and the appearance picks are preserved on retry.
  • A returning player (character already exists) skips the wizard and enters directly.
  • Another nearby player sees the created character's hair/beard/skin on their sprite, and the character's own paperdoll shows the same look (front preview matches the in-world sprite).
  • A tampered client cannot produce an appearance outside the offered style/colour sets, nor enter the world without a server-created character.
  • Screenshots of the whole wizard across a few combinations (different hair/beard/colour/skin) plus the resulting in-world character, captured by piloting the wizard.

Technical design (agreed — critical-design-review)

Verdict: go-with-changes. Two architectural forks decided by the owner:

  • Transport → signed JWT claim. Appearance is persisted on the Auth Character row, minted into a signed appearance claim (at login + create), read by the GameServer on connect, applied to PlayerMobile, surfaced via ToState() / PlayerAppearance. No PlayerMobile blob version bump — appearance is immutable for now (barber deferred); when an in-game look-change lands it migrates to the blob + AppearanceDirty reconcile (the IsGhost pattern). Preserves the strict JWT-only Auth↔GameServer contract (GameServer never queries Auth).
  • Recolour → runtime per-layer multiply tint. Hair/beard are extracted as grey-ramp layers, drawn as separate layers over the body each with their own Color; skin = a tint on the body layer. Colours travel as stable semantic keys; the client maps key → Color via a table in Client.Core. One texture set per style (no per-colour variants) → pack stays small, adding/removing a colour is a table edit with zero repack. Extract-time hue baking is the documented upgrade path if full UO fidelity is ever needed.
  • Q3 (hue channel on #18 layers) → dissolved. Hair/beard are not equipment (no backing Item), so EquippedItem is unchanged. Hair/beard/skin are their own fields on CharacterAppearance inside PlayerAppearance. Skin is a body-layer tint.

Invariants Check (walked against CLAUDE.md ## Design checklist)

  1. Scope ✓ — exactly the agreed wizard; no extra system; deferred list explicit.
  2. Server-authoritative ✓ — client sends choices (semantic keys) + name; Auth validates each key against the server-owned catalog (out-of-range rejected) and hardens the name; GameServer trusts only the signed claim. No rendered look / no client Color. Creation not spammable (one-char/account guard + DbUpdateException backstop).
  3. GM authorization — N/A — no admin action added (no CommandRegistry/CanExecute entry).
  4. Identity model ✓ — one char/account (existing guard), name world-unique + now hardened, silent-on-first-login unchanged, player by sub / displays char.
  5. Protocol versioned ✓ — PlayerAppearance extended with CharacterAppearance = wire change → bump ProtocolVersion.Current 8→9 same change; new wire types in Shared/Protocol.
  6. String catalog ✓-with-change — Auth returns typed reason codes (no copy on the wire); client maps to text via a catalog. Decide the home for client-local wizard copy (extend SystemMessageCatalog in Client.Core or a dedicated home) → possible CLAUDE.md index update.
  7. Single-threaded sim ✓ — appearance set on PlayerMobile inside the existing AddPlayer/RestorePlayer single InvokeAsync; claim read on the connection thread and passed in as data. No new lock.
  8. World.cs HARD GATE ✓ — AddPlayer/RestorePlayer gain an appearance parameter passed to the PlayerMobile ctor (trivial state set); no logic in World.
  9. Screen HARD GATE ✓ — dedicated CharacterCreationScreen decomposed into components (selector/swatch, PaperdollPreview, name/validation); coordinates only; not bolted onto LoginScreen.
  10. Client engine-independence ✓ — appearance keys, key→Color table, swatch catalog, layout math, validation-echo in Client.Core (no MonoGame); Client project only draws + input.
  11. Gameplay/Networking separation ✓ — appearance is gameplay state on PlayerMobile; claim read + PlayerAppearance broadcast in Networking; meet only in GameSessionHandler/WorldTick.
  12. Act on the instance ✓ — appearance set directly on the PlayerMobile instance (like name); no side-collection mirroring it; a future barber = intrinsic setter + AppearanceDirty.
  13. Extend by type, not switch ✓ — styles/colours are catalog entries (semantic keys in Shared), resolved by lookup; adding one = a catalog entry + art (hair) / Color-table row (colour); no switch.
  14. Server-paced actions — N/A — one-shot HTTP create, no per-tick progression.
  15. Persistence (GameServer) ✓ — appearance not in the blob (re-seeded from the signed claim each connect); no PlayerMobile version bump, no tick file access.
  16. Persistence (Auth) ✓ — appearance columns on Characterone migration (just migrate-auth AddCharacterAppearance); Database.Migrate() at startup; SQLite-only; never EnsureCreated.
  17. Process separation ✓ — appearance crosses only via the signed JWT claim; GameServer never queries Auth; JWT config stays duplicated intentionally.
  18. Typed options ✓ — any tunable (e.g. NameMaxLength) = a const declared once in Shared / a field on an options record; no IConfiguration read in a service.
  19. Broadcasts / AoI ✓ — appearance rides PlayerAppearance, already reconciled within AoiRadius (join on enter, in Welcome/PlayerState), absolute; hair/beard/skin static per session → part of the join snapshot, no new transient event.
  20. Multi-platform ✓ — pure .NET/MonoGame; Color/tint cross-platform; art extraction on the dev machine, output committed; no OS-specific dep.
  21. Asset fallback ✓ — hair/beard textures degrade to a visible placeholder if absent (same _equipArt filter pattern); skin is a Color (no art dep); repo runnable without assets.isoa.
  22. ModernUO reference ✓ — follow "appearance as data on the entity"; diverge on render (runtime tint on placeholder art vs UO hue tables) and transport (signed JWT claim for our Auth/Game split).
  23. Docs & DoD ✓ — this check persisted here; at build time update architecture.md + this DoD; no gm-commands.md/spells.md touched.

No , no HARD GATE tripped. Index-maintenance flag: the String-catalog invariant doesn't cover client-local UI copy (login/wizard over HTTP) — if we formalize a client-UI catalog, update that CLAUDE.md bullet.

Implementation breakdown (sequenced)

  • A · SharedCharacterAppearance record (keys) + valid-key catalog; extend PlayerAppearance; bump ProtocolVersion 8→9; claim encode/decode.
  • B · Auth — appearance columns on Character + migration; extend CreateCharacterRequest + key validation; name hardening (max length, whitelist, NFKC + strip zero-width, reserved words); mint appearance claim (login + create); name-availability endpoint.
  • C · GameServer — read claim in GameSessionHandler; thread into AddPlayer/RestorePlayer; set on PlayerMobile; ToState() includes it.
  • D · AssetExtractor — find hair/beard art ids (anim + paperdoll gump), extract grey-ramp, keys hair/<style>/… beard/<style>/… + gumps; rebuild assets.isoa; verify grey-ramp assumption.
  • E · Client renderAssetPackLoader builds hair/beard sets; Player draws hair/beard layers with own Color; skin = body tint; key→Color table in Client.Core; PaperdollGump per-layer tint + hair/beard gumps.
  • F · Client CharacterCreationScreen — dedicated screen + components; live preview; swatches; Randomize; live name-check; typed errors preserving picks; screen-flow wiring.
  • G · Harness + tests + screenshots — debug-gated auto-create-default bypass; wizard drivable; unit + integration tests; screenshots.
Part of the Alpha epic (pillar 1 — small-world intimacy). **Design agreed:** [`docs/game-design/systems/character-creation.md`](https://git.homelab.devncode.it/marco/IsoMmo/src/branch/main/docs/game-design/systems/character-creation.md). This issue is the player-facing design; **technical breakdown to follow (separate `critical-design-review`).** ## The system, from the player's chair On first login (an account with no character yet), instead of dropping into the world the player gets a short **UO-style creation wizard**: pick a **hair style** and **beard style** (each with an explicit **"None"**), pick **hair / beard / skin colour** from swatch palettes, and type a **name** with a **live availability check**. A **live front paperdoll preview** updates with every choice, and a **"Randomize"** button fills a complete valid look in one click for players who just want to get in. Confirm → the character is born with that look and enters the world. That look is then **what nearby players see** on the sprite and paperdoll — the face friends recognize. Gender is **male-only for now** (female deferred). One character per account still holds; a returning player skips the wizard. ## Six-lens summary - **Player POV** — a one-time identity moment: "this is *my* guy," built in seconds (Randomize) or tuned deliberately, then persistent across every session. - **Motivation & loop** — serves ownership/identity; the on-ramp that gives everything after it a face. Recognition is real only because the look is server-authoritative and observed via AoI. - **Coherence** — extends the existing server-validated creation step (`POST /me/character`) with appearance fields, and extends the #18 server-authoritative appearance state so others render it. Cosmetic-only → no fairness/economy risk. Persist **stable semantic keys**, not positional art indices (placeholder UO art will be replaced). - **UI/UX** — a **dedicated `CharacterCreationScreen`** (not bolted onto `LoginScreen` — Screen HARD GATE), viewport-derived layout, explicit "None", typed inline errors (too-short / taken / invalid-chars) that **preserve appearance picks** on retry, and a preview that matches the in-world sprite. - **Breakage & mitigations** — server validates every choice against server-owned sets (bad id can't poison observers; client falls back safely); the **name is hardened** server-side (max length, character whitelist, look-alike/zero-width normalization before the uniqueness check) because it rides every broadcast and drives recognition; palettes curated so a look can't be a stealth edge; world entry still gated by the JWT `char` claim. - **Scope & MVP** — below. ## Scope (what to build) - Extend `CreateCharacterRequest` + `POST /me/character` with validated appearance choices (hair/beard style id, hair/beard/skin colour id) as **semantic keys**; server rejects out-of-range. - Harden the name server-side: max length, character whitelist, NFKC + strip zero-width/combining before the world-unique check; a small reserved-word blocklist. - Persist appearance on the `Character` (Auth EF migration). - Extend the server-authoritative appearance state (#18) so hair/beard/skin are observable in-world + paperdoll; **bump `ProtocolVersion.Current`** in the same change. - Extract UO hair/beard art (paperdoll gumps for the preview + in-world layered anims). - A per-layer recolouring path (hair/beard/skin hued independently) on both paperdoll and world sprite — the hard technical piece, resolved in the technical review. - A dedicated `CharacterCreationScreen` with style selectors, swatch palettes, live paperdoll preview, live name-availability, and a Randomize button; decomposed into focused components. - Harness: gate the empty-DB auto-create-default bypass to a debug flag; make the wizard separately drivable for screenshots. - All new user-facing copy via the string catalog (`SystemMessageId`), no loose literals. ## Out of scope - Female / other genders, races, starting-clothes colours, in-place re-customization (barber), large style catalogues, broad name-squatting / open-registration gating. ## Definition of Done (player-observable) _Base DoD applies on top (tests green, whole solution builds, zero warnings, multi-platform preserved)._ - [ ] Logging into a **new** account shows the creation wizard (not the world) before a character exists. - [ ] The player picks hair style, beard style, hair colour, beard colour, and skin tone, and types a name; the **preview updates live** to match every choice. - [ ] Choosing **"None"** for hair and/or beard is clearly available and previews a bald / clean-shaven look. - [ ] A **"Randomize"** button produces a complete, valid look in one click. - [ ] The **name field shows availability live** ("free / taken") before confirming; confirming with a valid, unique name creates the character with exactly the previewed look and enters the world. - [ ] An invalid name is refused with a **specific** inline message (too short / taken / invalid characters), and the appearance picks are **preserved** on retry. - [ ] A returning player (character already exists) skips the wizard and enters directly. - [ ] Another nearby player sees the created character's hair/beard/skin on their sprite, and the character's own paperdoll shows the same look (front preview matches the in-world sprite). - [ ] A tampered client cannot produce an appearance outside the offered style/colour sets, nor enter the world without a server-created character. - [ ] Screenshots of the whole wizard across a few combinations (different hair/beard/colour/skin) plus the resulting in-world character, captured by piloting the wizard. --- # Technical design (agreed — `critical-design-review`) **Verdict:** go-with-changes. Two architectural forks decided by the owner: - **Transport → signed JWT claim.** Appearance is persisted on the Auth `Character` row, minted into a signed `appearance` claim (at login + create), read by the GameServer on connect, applied to `PlayerMobile`, surfaced via `ToState()` / `PlayerAppearance`. **No `PlayerMobile` blob version bump** — appearance is immutable for now (barber deferred); when an in-game look-change lands it migrates to the blob + `AppearanceDirty` reconcile (the `IsGhost` pattern). Preserves the strict JWT-only Auth↔GameServer contract (GameServer never queries Auth). - **Recolour → runtime per-layer multiply tint.** Hair/beard are extracted as grey-ramp layers, drawn as separate layers over the body each with their own `Color`; skin = a tint on the body layer. Colours travel as **stable semantic keys**; the client maps key → `Color` via a table in `Client.Core`. **One texture set per style** (no per-colour variants) → pack stays small, adding/removing a colour is a table edit with **zero repack**. Extract-time hue baking is the documented upgrade path if full UO fidelity is ever needed. - **Q3 (hue channel on #18 layers) → dissolved.** Hair/beard are **not** equipment (no backing `Item`), so `EquippedItem` is unchanged. Hair/beard/skin are their own fields on `CharacterAppearance` inside `PlayerAppearance`. Skin is a body-layer tint. ## Invariants Check (walked against CLAUDE.md `## Design checklist`) 1. **Scope** ✓ — exactly the agreed wizard; no extra system; deferred list explicit. 2. **Server-authoritative** ✓ — client sends **choices** (semantic keys) + name; Auth validates each key against the server-owned catalog (out-of-range rejected) and hardens the name; GameServer trusts only the signed claim. No rendered look / no client `Color`. Creation not spammable (one-char/account guard + `DbUpdateException` backstop). 3. **GM authorization** — N/A — no admin action added (no `CommandRegistry`/`CanExecute` entry). 4. **Identity model** ✓ — one char/account (existing guard), name world-unique + now hardened, silent-on-first-login unchanged, player by `sub` / displays `char`. 5. **Protocol versioned** ✓ — `PlayerAppearance` extended with `CharacterAppearance` = wire change → **bump `ProtocolVersion.Current` 8→9** same change; new wire types in `Shared/Protocol`. 6. **String catalog** ✓-with-change — Auth returns **typed reason codes** (no copy on the wire); client maps to text via a catalog. Decide the home for client-local wizard copy (extend `SystemMessageCatalog` in Client.Core or a dedicated home) → possible CLAUDE.md index update. 7. **Single-threaded sim** ✓ — appearance set on `PlayerMobile` inside the existing `AddPlayer`/`RestorePlayer` single `InvokeAsync`; claim read on the connection thread and passed in as data. No new lock. 8. **World.cs HARD GATE** ✓ — `AddPlayer`/`RestorePlayer` gain an appearance parameter passed to the `PlayerMobile` ctor (trivial state set); no logic in `World`. 9. **Screen HARD GATE** ✓ — dedicated `CharacterCreationScreen` decomposed into components (selector/swatch, `PaperdollPreview`, name/validation); coordinates only; **not** bolted onto `LoginScreen`. 10. **Client engine-independence** ✓ — appearance keys, key→Color table, swatch catalog, layout math, validation-echo in **Client.Core** (no MonoGame); Client project only draws + input. 11. **Gameplay/Networking separation** ✓ — appearance is gameplay state on `PlayerMobile`; claim read + `PlayerAppearance` broadcast in Networking; meet only in `GameSessionHandler`/`WorldTick`. 12. **Act on the instance** ✓ — appearance set directly on the `PlayerMobile` instance (like `name`); no side-collection mirroring it; a future barber = intrinsic setter + `AppearanceDirty`. 13. **Extend by type, not switch** ✓ — styles/colours are **catalog entries** (semantic keys in Shared), resolved by lookup; adding one = a catalog entry + art (hair) / Color-table row (colour); no switch. 14. **Server-paced actions** — N/A — one-shot HTTP create, no per-tick progression. 15. **Persistence (GameServer)** ✓ — appearance **not** in the blob (re-seeded from the signed claim each connect); no `PlayerMobile` version bump, no tick file access. 16. **Persistence (Auth)** ✓ — appearance columns on `Character` → **one** migration (`just migrate-auth AddCharacterAppearance`); `Database.Migrate()` at startup; SQLite-only; never `EnsureCreated`. 17. **Process separation** ✓ — appearance crosses **only** via the signed JWT claim; GameServer never queries Auth; JWT config stays duplicated intentionally. 18. **Typed options** ✓ — any tunable (e.g. `NameMaxLength`) = a const declared once in Shared / a field on an options record; no `IConfiguration` read in a service. 19. **Broadcasts / AoI** ✓ — appearance rides `PlayerAppearance`, already reconciled within `AoiRadius` (join on enter, in `Welcome`/`PlayerState`), absolute; hair/beard/skin static per session → part of the join snapshot, no new transient event. 20. **Multi-platform** ✓ — pure .NET/MonoGame; `Color`/tint cross-platform; art extraction on the dev machine, output committed; no OS-specific dep. 21. **Asset fallback** ✓ — hair/beard textures degrade to a **visible** placeholder if absent (same `_equipArt` filter pattern); skin is a `Color` (no art dep); repo runnable without `assets.isoa`. 22. **ModernUO reference** ✓ — follow "appearance as data on the entity"; diverge on render (runtime tint on placeholder art vs UO hue tables) and transport (signed JWT claim for our Auth/Game split). 23. **Docs & DoD** ✓ — this check persisted here; at build time update `architecture.md` + this DoD; no `gm-commands.md`/`spells.md` touched. No `✗`, no HARD GATE tripped. **Index-maintenance flag:** the String-catalog invariant doesn't cover client-local UI copy (login/wizard over HTTP) — if we formalize a client-UI catalog, update that CLAUDE.md bullet. ## Implementation breakdown (sequenced) - **A · Shared** — `CharacterAppearance` record (keys) + valid-key catalog; extend `PlayerAppearance`; **bump ProtocolVersion 8→9**; claim encode/decode. - **B · Auth** — appearance columns on `Character` + **migration**; extend `CreateCharacterRequest` + key validation; **name hardening** (max length, whitelist, NFKC + strip zero-width, reserved words); mint `appearance` claim (login + create); name-availability endpoint. - **C · GameServer** — read claim in `GameSessionHandler`; thread into `AddPlayer`/`RestorePlayer`; set on `PlayerMobile`; `ToState()` includes it. - **D · AssetExtractor** — find hair/beard art ids (anim + paperdoll gump), extract grey-ramp, keys `hair/<style>/…` `beard/<style>/…` + gumps; rebuild `assets.isoa`; verify grey-ramp assumption. - **E · Client render** — `AssetPackLoader` builds hair/beard sets; `Player` draws hair/beard layers with own `Color`; skin = body tint; key→Color table in Client.Core; `PaperdollGump` per-layer tint + hair/beard gumps. - **F · Client `CharacterCreationScreen`** — dedicated screen + components; live preview; swatches; **Randomize**; **live name-check**; typed errors preserving picks; screen-flow wiring. - **G · Harness + tests + screenshots** — debug-gated auto-create-default bypass; wizard drivable; unit + integration tests; screenshots.
marco added this to the Alpha milestone 2026-07-20 18:37:33 +02:00
marco changed title from Character appearance: creation screen (gender, skin hue, hair) + appearance model to Character creation: UO-style first-login wizard (hair/beard + colours + name) 2026-07-22 11:08:41 +02:00
Author
Owner

Delivered in #134 (merged to main) — closing (the PR did not auto-close it).

UO-style first-login wizard: hair/beard style + hair/beard/skin colours + name with live availability, live front-paperdoll preview, Randomize. Appearance is GameServer-owned game state (SetAppearance intent); Auth stays identity-only.

Delivered in #134 (merged to `main`) — closing (the PR did not auto-close it). UO-style first-login wizard: hair/beard style + hair/beard/skin colours + name with live availability, live front-paperdoll preview, Randomize. Appearance is GameServer-owned game state (`SetAppearance` intent); Auth stays identity-only.
marco closed this issue 2026-07-22 14:48:03 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo#108
No description provided.