Gamer Paperdoll Inventory — Design
Date: 2026-05-06 Status: Approved for planning
Summary
Replace the tier-based character image on /gamer with a layered paperdoll figure driven by an inventory of equipable items. Add an inventory panel under the player panel where the user can equip / unequip helmet, armor, and legs items via single click. Add a man / woman gender toggle to the user profile edit page; gender drives both the naked base layers and which gendered hair items appear in the inventory.
Goals
- Layered paperdoll figure (helmet base + helmet + armor + legs) replaces
gamer/characters/<tier>.pngeverywhere it currently renders on/gamer. - Inventory panel in the left aside, between the player panel and the habits panel, with its own paperdoll preview at the top and items grouped by slot below.
- Click an inventory item to equip it (auto-replacing whatever is in that slot); click the equipped item again to unequip back to the naked default.
- Gender toggle (man / woman) on the user profile edit page, persisted as part of the same paperdoll JSON. Gender controls the naked base PNGs and filters the gendered hair items.
- All updates flow through Hotwire (Turbo Streams) — no full-page reloads. Stimulus handles optimistic UI on click.
Non-goals
- Item ownership / unlocks. Every item in the catalog is equipable for now.
- Stat bonuses, set effects, item rarity, item sources, drops.
- Gender-specific equipable items beyond the existing hair files (
helmet-*,armor-*,legs-*are unisex). - Animations on equip / unequip beyond the implicit Turbo Stream swap.
- Mobile-specific layout adjustments beyond what the existing left-aside grid already does.
- Editing
Snapshot::CHARACTER_TIERSorcharacter_tier. The tier mapping stays — it still driveslevel_progress[:title]and similar text. - Touching the public profile view at
/users/profiles/show(paperdoll only renders on/gamerfor now).
Data model
Single migration on users:
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
paperdoll_loadout |
json | false | {} |
Stores gender + equipped item slugs. |
JSON shape:
{
"gender": "man", // "man" | "woman", default "man" when key missing
"helmet": "helmet-3", // slug or null/missing
"armor": null,
"legs": "legs-1"
}
Reads of any missing key default to a sensible value ("man" for gender, nil for slot slugs). No partial-object validation at the column level — the Paperdoll::Loadout model owns shape integrity.
Domain model
Three new classes under the Paperdoll namespace, all in app/models/paperdoll/. No service objects.
Paperdoll::Item (value object)
Plain Ruby Data.define(:slot, :slug, :filename, :gender) with helpers:
asset_path→"gamer/paperdoll/<slot_dir>/<filename>"whereslot_dirishelmets/armors/legs(pluralized once in a constant map, sincelegsis its own plural).display_name→slug.tr("-", " ").titleize(e.g."hair-man-1"→"Hair Man 1","helmet-3"→"Helmet 3").gendered?→!gender.nil?. Used by the loadout to prune on gender change.
Paperdoll::Catalog (constant module)
Pure-Ruby module with frozen data, populated once at boot from the filesystem under app/assets/images/gamer/paperdoll/.
Public API:
Paperdoll::Catalog::SLOTS→%i[helmet armor legs]Paperdoll::Catalog.items_for(slot, gender:)→Array<Item>filtered by gender. Unisex items always included; items whosegendermatchesgenderincluded; mismatched gendered items filtered out. Naked bases excluded.Paperdoll::Catalog.naked_asset_path(slot, gender:)→"gamer/paperdoll/<slot_dir>/naked-<slot>-1-<gender>.png".Paperdoll::Catalog.find(slot, slug)→Itemor nil. Used to validate slugs from params.
Slug parsing rule (drives gender on Item):
- Filename
naked-*→ excluded fromitems_forentirely. - Filename ending in
-man(last token after stripping.png) →gender: "man". - Filename ending in
-woman→gender: "woman". - Otherwise →
gender: nil(unisex).
So helmet-1.png is unisex, hair-man-1.png is "man", hair-woman-2.png is "woman", naked-helmet-1-man.png is excluded from items_for.
The catalog is built once at boot from a Dir glob and frozen. No request-time filesystem access.
Paperdoll::Loadout (user-state wrapper)
Initialized with a User. Wraps user.paperdoll_loadout JSON.
Public API:
-
gender→"man""woman", defaulting to"man"when missing. equipped(slot)→Itemornil. Resolves slot's slug through the catalog; returns nil if slug missing or no longer in catalog.equip(slot, slug)— validates slot viaCatalog::SLOTS, slug viaCatalog.find, persists.unequip(slot)— clears slot, persists.toggle(slot, slug)— equips if slot empty or holding a different slug; unequips if slug already equipped.set_gender(new_gender)— validates%w[man woman], prunes any equipped slot whoseItem.genderis non-nil and doesn't matchnew_gender, persists. Unisex items stay equipped.layers→ ordered Array of asset paths to render (see below).
Layer order returned by layers:
- Always: naked-helmet base for current gender.
- If helmet equipped: helmet item asset path.
- Armor equipped → armor item asset path; else naked-armor base for current gender.
- Legs equipped → legs item asset path; else naked-legs base for current gender.
All persistence is a single user.update!(paperdoll_loadout: …) call per mutation. No callbacks, no broadcasts.
Routing
# config/routes.rb
resource :paperdoll_loadout, only: [], controller: "paperdoll_loadout" do
patch ":slot/:slug",
action: :toggle,
as: :toggle,
constraints: { slot: /helmet|armor|legs/, slug: /[a-z0-9\-]+/ }
end
Resolved URL: PATCH /paperdoll_loadout/helmet/helmet-3.
Controller
# app/controllers/paperdoll_loadout_controller.rb
class PaperdollLoadoutController < ApplicationController
def toggle
authorize :paperdoll_loadout
Paperdoll::Loadout.new(current_user).toggle(params[:slot].to_sym, params[:slug])
@snapshot = GamerDashboard::Snapshot.new(current_user)
respond_to do |format|
format.turbo_stream
format.html { redirect_to gamer_dashboard_path }
end
end
end
The turbo_stream view replaces three frames:
paperdoll_top_badge(small badge inshow.html.erb)paperdoll_player_panel(player panel character)paperdoll_inventory(inventory panel — preview + items list re-render)
Pundit policy
# app/policies/paperdoll_loadout_policy.rb
class PaperdollLoadoutPolicy < ApplicationPolicy
def toggle? = user.present?
end
Snapshot integration
GamerDashboard::Snapshot gains:
paperdoll_loadout→ memoizedPaperdoll::Loadout.new(user).paperdoll_layers→paperdoll_loadout.layers.
character_asset_path stays for now (it's still a public method on the snapshot — leave it untouched; /gamer views just stop calling it). The tier-based fallback logic in character_tier / CHARACTER_TIERS remains driving level titles.
Views
Shared partial: _paperdoll_figure.html.erb
Lives at app/views/gamer_dashboard/_paperdoll_figure.html.erb. Inputs:
layers:— Array of asset paths.size_class:— Tailwind class string for the wrapper height (e.g."h-8","h-24","h-40"). Width is computed from the 168/266 aspect ratio viaaspect-[168/266].
Renders:
<div class="<%= size_class %> aspect-[168/266] relative">
<% layers.each do |path| %>
<%= image_tag path, class: "absolute inset-0 w-full h-full object-contain image-render-pixel", alt: "" %>
<% end %>
</div>
Top badge in show.html.erb
Replace the image_tag @snapshot.character_asset_path, class: "size-8 …" with:
<%= turbo_frame_tag "paperdoll_top_badge" do %>
<%= render "paperdoll_figure", layers: @snapshot.paperdoll_layers, size_class: "h-8" %>
<% end %>
Player panel character
In _player_panel.html.erb, replace the size-24 image_tag inside gamer-avatar-frame with:
<%= turbo_frame_tag "paperdoll_player_panel" do %>
<%= render "paperdoll_figure", layers: snapshot.paperdoll_layers, size_class: "h-24" %>
<% end %>
The gamer-avatar-frame wrapper stays. Padding may need a slight tweak so the rectangular figure breathes inside the rounded square — verify visually and adjust the frame's padding if needed.
Inventory panel: _inventory_panel.html.erb
New partial in app/views/gamer_dashboard/. Wrapped in <%= turbo_frame_tag "paperdoll_inventory" do %>.
Structure:
- Header row: title
t("gamer_dashboard.inventory.title"). - Preview: paperdoll figure with
size_class: "h-40"(rectangular preview, ~100×158px). - Three slot sections: Helmets / Armors / Legs. Each lists its items as a small grid of square-ish thumbs (
aspect-[168/266],w-12) insidebutton_toforms postingPATCHtotoggle_paperdoll_loadout_path(slot, slug).- Equipped item: ring/border highlight (Tailwind
ring-2 ring-amber-300or similar matching existinggamer-*palette). - Hover:
title="<%= item.display_name %>"for native tooltip. - Each thumb shows just that one item's PNG (not the full layered figure) — pre-rendered against transparency.
- Buttons live inside a Stimulus container
data-controller="paperdoll-inventory"for optimistic state.
- Equipped item: ring/border highlight (Tailwind
Rendered from show.html.erb between _player_panel and _habits_panel:
<aside class="space-y-3">
<%= render "player_panel", snapshot: @snapshot %>
<%= render "inventory_panel", snapshot: @snapshot %>
<%= render "habits_panel", snapshot: @snapshot %>
</aside>
Turbo Stream view: toggle.turbo_stream.erb
<%= turbo_stream.replace "paperdoll_top_badge" do %>
<%= turbo_frame_tag "paperdoll_top_badge" do %>
<%= render "gamer_dashboard/paperdoll_figure", layers: @snapshot.paperdoll_layers, size_class: "h-8" %>
<% end %>
<% end %>
<%= turbo_stream.replace "paperdoll_player_panel" do %>
<%= turbo_frame_tag "paperdoll_player_panel" do %>
<%= render "gamer_dashboard/paperdoll_figure", layers: @snapshot.paperdoll_layers, size_class: "h-24" %>
<% end %>
<% end %>
<%= turbo_stream.replace "paperdoll_inventory" do %>
<%= render "gamer_dashboard/inventory_panel", snapshot: @snapshot %>
<% end %>
Stimulus controller
app/javascript/controllers/paperdoll_inventory_controller.js. Targets: slot (one per slot section), item (every clickable item button).
Behavior:
- On submit-start: add an "is-equipping" class to the clicked button, disable it briefly to prevent double-submit. Optimistically clear the equipped highlight from any other item in the same slot and add it to the clicked one.
- On submit-end: server response replaces the inventory frame, which restores authoritative state. The optimistic class is naturally discarded by the swap.
Registered via existing importmap controllers convention (autoloaded from app/javascript/controllers/).
Profile edit — gender toggle
View
In app/views/devise/registrations/edit.html.erb, add a new section after the Profile section and before the Account info section:
<%# ===== CHARACTER ===== %>
<div class="rounded-xl p-6 bg-white dark:bg-[#161b22] border border-gray-200/60 dark:border-white/[0.06]">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-5 flex items-center gap-3">
<%# icon %>
<%= t("user_profile.character_section") %>
</h3>
<div class="inline-flex rounded-lg overflow-hidden border border-gray-200 dark:border-white/10">
<% %w[man woman].each do |g| %>
<label class="px-4 py-2 text-sm cursor-pointer <%= 'bg-blue-500/15 text-blue-500' if @paperdoll_gender == g %>">
<%= radio_button_tag "gender", g, @paperdoll_gender == g, class: "sr-only" %>
<%= t("user_profile.gender_#{g}") %>
</label>
<% end %>
</div>
</div>
@paperdoll_gender is set in Users::RegistrationsController#edit:
def edit
@display_name = display_name_for(current_user)
@paperdoll_gender = Paperdoll::Loadout.new(current_user).gender
super
end
Controller change
Users::RegistrationsController#update calls a new save_gender:
def update
save_display_name
save_avatar
save_gender
super
end
def save_gender
return unless %w[man woman].include?(params[:gender])
Paperdoll::Loadout.new(current_user).set_gender(params[:gender])
end
Order matters only loosely: gender persists before super runs Devise's update so the form re-renders with the new gender if validation fails on password fields.
i18n
New keys in config/locales/en.yml and config/locales/pt-BR.yml (both files exist in the repo):
gamer_dashboard.inventory.title→ "Inventory" / "Inventário"gamer_dashboard.inventory.helmets→ "Helmets" / "Capacetes"gamer_dashboard.inventory.armors→ "Armors" / "Armaduras"gamer_dashboard.inventory.legs→ "Legs" / "Pernas"user_profile.character_section→ "Character" / "Personagem"user_profile.gender_man→ "Man" / "Homem"user_profile.gender_woman→ "Woman" / "Mulher"
(Verify final locale shape — codebase appears to use both en and pt-BR.)
Tests
Minitest, fixtures-only, no FactoryBot.
test/models/paperdoll/catalog_test.rb
items_for(:helmet, gender: "man")returnshelmet-1..5andhair-man-1..3, excludeshair-woman-*andnaked-*.items_for(:helmet, gender: "woman")returnshelmet-1..5andhair-woman-1..3, excludeshair-man-*.items_for(:armor, gender:)returnsarmor-1..5for both genders, excludes naked.items_for(:legs, gender:)returnslegs-1..5for both genders, excludes naked.naked_asset_path(:helmet, gender: "woman")→"gamer/paperdoll/helmets/naked-helmet-1-woman.png".find(:helmet, "helmet-3")returns anItem,find(:helmet, "bogus")returns nil.Item#display_nameformatting cases.
test/models/paperdoll/loadout_test.rb
- Default gender is
"man"when JSON empty. equip(:helmet, "helmet-3")sets the slug;equipped(:helmet).slug == "helmet-3".equip(:helmet, "bogus")raisesArgumentErrorand does not mutate the loadout.toggle(:helmet, "helmet-3")equips when empty, unequips when already equipped, switches when a different slug is equipped.unequip(:helmet)clears the slot.set_gender("woman")switches gender; ifhair-man-1was equipped it's pruned; ifhelmet-3(unisex) was equipped it stays.set_gender("alien")raises; loadout unchanged.layersreturns:[naked-helmet-1-man, naked-armor-1-man, naked-legs-1-man]when nothing equipped.layersreturns:[naked-helmet-1-man, helmet-3, armor-2, naked-legs-1-man]when helmet+armor equipped.layersswitches naked bases when gender flips to"woman".
test/controllers/paperdoll_loadout_controller_test.rb
- Unauthenticated request to
PATCH /paperdoll_loadout/helmet/helmet-3redirects to sign-in (Devise default —authenticate_user!is enforced via the globalbefore_actioninApplicationController). - Authenticated
PATCH …/helmet/helmet-3returns turbo_stream with three frame replacements; user's loadout is updated. - Invalid slot (regex fails) → 404.
- Invalid slug (catalog lookup fails) → controller rescues
ArgumentErrorfromLoadout#toggleand responds withhead :unprocessable_entityfor turbo_stream /redirect_to gamer_dashboard_path, alert: …for HTML.
test/controllers/users/registrations_controller_test.rb (extension)
- Updating profile with
gender=womanpersists intopaperdoll_loadout. - Updating with
gender=invalidis ignored (no change). - Switching from man to woman with
hair-man-1equipped clears that slot.
test/system/gamer_dashboard/paperdoll_equip_test.rb
- Visit
/gamer, see the naked default character. - Click an inventory helmet item; without page reload, the player panel and inventory preview both show the equipped helmet on top of the naked base; the item shows the equipped highlight.
- Click the equipped item again; both previews revert to naked default.
Open considerations / risks
- Performance:
Catalogreads filesystem at boot. If autoloading reloads it in development, that's fine — it's only ~30 small files. Add a freeze on the resulting hash to make accidental mutation loud. - Slot/folder pluralization:
legsis unusual (already plural). Centralize the slot → directory map in one constant inCatalog(SLOT_DIR = { helmet: "helmets", armor: "armors", legs: "legs" }.freeze). - Snapshot caching: a
paperdoll_loadoutgetter on the snapshot is fine; the controller builds a fresh snapshot per request, so no stale data. - Profile form interaction: gender persists outside the Devise transaction (same pattern as
display_nameand avatar). If Devise'ssuperraises and we've already set gender, the gender change persists — acceptable, matches the existing behaviour for those siblings. - Public profile: out of scope (see Non-goals).
Snapshot#character_asset_pathand the public profile view are untouched.
Files touched / added
New:
db/migrate/<ts>_add_paperdoll_loadout_to_users.rbapp/models/paperdoll/item.rbapp/models/paperdoll/catalog.rbapp/models/paperdoll/loadout.rbapp/controllers/paperdoll_loadout_controller.rbapp/policies/paperdoll_loadout_policy.rbapp/views/gamer_dashboard/_paperdoll_figure.html.erbapp/views/gamer_dashboard/_inventory_panel.html.erbapp/views/paperdoll_loadout/toggle.turbo_stream.erbapp/javascript/controllers/paperdoll_inventory_controller.js- Tests as listed above.
Modified:
config/routes.rbapp/views/gamer_dashboard/show.html.erbapp/views/gamer_dashboard/_player_panel.html.erbapp/models/gamer_dashboard/snapshot.rbapp/views/devise/registrations/edit.html.erbapp/controllers/users/registrations_controller.rbconfig/locales/en.yml,config/locales/pt-BR.yml