Player Profiles
Profiles let several people share one machine (or one browser storage on web):
each player owns their cosmetics, lifetime tallies, tips, redeemed codes,
scores, and tutorial state, and sees their own save list. Spec:
docs/superpowers/specs/2026-07-29-player-profiles-design.md (all decisions
resolved; shipped across PRs #245–#249).
The model in one paragraph
The game always has exactly one active profile; everything personal reads
and writes through it. Identity is chosen at the TitleScreen ("profile-first"),
never derived from a save slot — the personal state exists outside any run
(title screen, code redemption). Slots stay physically global
(user://saves/slot_N.json, 12 of them); each save carries an owner tag
(run.profile_id), and the picker filters by it. Switching profiles happens
only at the TitleScreen through one heavyweight reload point.
Storage
config.json is the profile store (config_version = 2):
{ "config_version": 2, "active_profile": "p1",
"profiles": { "p1": { "name": "Mike", "...": "all personal keys" } } }
Everything about the shape, the migration-on-read (_migrate_config), the
profile-scoped accessors (_profile(cfg)), and the per-profile key inventory
lives in Save System and Migration
— that article is the storage reference; this one covers behavior and UI.
Per-run: run.cosmetics_loadout (the run's look — profiles own the wardrobe
cosmetics_owned, runs own the outfit) and run.profile_id (owner tag,
"" = unclaimed). Both arrived with SAVE_VERSION = 4.
Machine-level (settings.json): music, perf, offline speed, hud_style /
ledger_hold — deliberately not per-profile (the Legacy UI is transitional).
SaveManager API
| Method | Behavior |
|---|---|
active_profile_id() / active_profile_name() |
The current profile. |
profiles_list() |
[{id, name}]. |
set_active_profile(id) |
Persists + emits EventBus.profile_changed(id). CosmeticManager reloads on the signal; everything else re-reads on its poll tick or is rebuilt by the TitleScreen reload. |
create_profile(name) |
Cap MAX_PROFILES = 8, names ≤16 chars. |
rename_profile(id, name) |
— |
delete_profile(id) |
Deletes the profile and its tagged saves (design ruling: deleting your profile deletes your data). Refuses the active and the last-remaining profile. Deleted slots land in the one-deep trash (slot_N.deleted.json) — recoverable by renaming. |
slots_tagged(id) |
Owned slot indices — feeds the delete confirmation's doomed-runs list. |
retag_slot(idx, pid) |
Rewrites a slot's owner in place ("" = unclaimed; unknown pid refused). Raw file write — only while no run is hydrated (picker-time), same constraint as update_run_values. |
reset_active_profile() |
Deletes the profile's tagged saves (through the trash too) and resets its blob to name-only. The gentle half of the dual-scope reset; factory_reset() remains the machine wipe. |
Tagging and adoption
- New runs stamp
run.profile_id = active_profile_id()(GameManager.reset_for_new_run). - Pre-profile saves are unclaimed. Clicking one while several profiles
exist prompts "Claim it for <name>?" — claiming is permanent ownership, so
it is never silent (a silent adopt once handed a 144-day run to the wrong
player). With a single profile, adoption stays invisible
(
GameManager._apply_save_databack-fills the tag on continue). - Mis-claims are player-fixable: the ⇄ button on a slot tile opens "Whose run
is this?" — any profile, or back to Unclaimed (via
retag_slot).
TitleScreen UI (scenes/ui/TitleScreen.gd)
- Corner chip, top-right, on every title state. Solo: a dim invitation — "More than 1 person sharing this PC? + Players" (the entry point; a chip hidden until a second profile exists could never create one). With ≥2: "Playing as: <name> ▾", content-sized so 16-char names fit.
- Players menu (modal): pick to switch (● marks active), ✎ rename, † delete (never offered on the active profile), + New player while under the cap. Names via a small LineEdit dialog, Enter submits.
- Switching = the account-restore reload shape: cancel in-flight share
HTTP,
clear_active_slot(),set_active_profile(), capture music,reload_current_scene(). Creating a profile switches straight into it — a new player lands on their own empty picker, and their first run gets the tutorial (theirplayed_beforeis fresh). - Picker filtering: with ≥2 profiles the picker shows the active player's
saves + unclaimed, with a "Show all (N)" header toggle; tiles then carry a
9px owner label ("unclaimed" for untagged). Internally
_slotsstays ALL slots (_visible_slots()is display-only) so the physical 12-cap and the ≥2 guards stay correct. - Cross-profile continue: clicking another player's run prompts "This is X's run. Play it as X?" — confirming switches profile and continues. Tagged saves are never adoptable.
- Profile deletion is a typed-name confirmation (ResetConfirmModal's shape) listing every doomed run and nudging toward a backup code first.
Reset scopes (scenes/ui/ResetConfirmModal.gd)
Settings → Reset Game offers two scopes once several profiles exist — "Only
<name>" (default; reset_active_profile()) and "Everything (all players)"
(factory_reset()). Solo machines see the single full reset. Both keep the
typed-RESET guard and the backup Download/Copy buttons; both restart at the
intro. settings.json survives both.
Consequences worth remembering
- Lifetime tallies follow the person — they span a profile's slots and lineages and never mix between players.
- One-time codes are once per profile — each household member can redeem the secret suit codes.
- The account-bundle backup code snapshots every profile (it embeds the whole config); imports of pre-profile bundles migrate on the way in.
- Deleted slots are one rename from recovery — every delete path routes
through
delete_slot(), which renames toslot_N.deleted.json(per-slot, one deep; wiped by factory reset). - A fresh profile is a fresh player: tutorial, nudges, talisman-HUD
default (unless the machine has an explicit
hud_style), own Top Runs, own name rotation for default digger names. - Website ledger submissions needed no changes — score rows already carry the
player name, and each profile keeps its own
scores+shared_runslock.
Key files
scripts/core/SaveManager.gd— profile store, API, migration, reset scopes, slot trash.scenes/ui/TitleScreen.gd— chip, menu, picker filtering, claim/reassign/delete flows.scenes/ui/ResetConfirmModal.gd— dual-scope typed reset.scripts/core/GameManager.gd—profile_idstamp + adopt.scripts/core/CosmeticManager.gd— reloads onprofile_changed.