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>.png everywhere 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_TIERS or character_tier. The tier mapping stays — it still drives level_progress[:title] and similar text.
  • Touching the public profile view at /users/profiles/show (paperdoll only renders on /gamer for 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>" where slot_dir is helmets / armors / legs (pluralized once in a constant map, since legs is its own plural).
  • display_nameslug.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 whose gender matches gender included; 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)Item or nil. Used to validate slugs from params.

Slug parsing rule (drives gender on Item):

  • Filename naked-* → excluded from items_for entirely.
  • Filename ending in -man (last token after stripping .png) → gender: "man".
  • Filename ending in -womangender: "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)Item or nil. Resolves slot's slug through the catalog; returns nil if slug missing or no longer in catalog.
  • equip(slot, slug) — validates slot via Catalog::SLOTS, slug via Catalog.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 whose Item.gender is non-nil and doesn't match new_gender, persists. Unisex items stay equipped.
  • layers → ordered Array of asset paths to render (see below).

Layer order returned by layers:

  1. Always: naked-helmet base for current gender.
  2. If helmet equipped: helmet item asset path.
  3. Armor equipped → armor item asset path; else naked-armor base for current gender.
  4. 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 in show.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 → memoized Paperdoll::Loadout.new(user).
  • paperdoll_layerspaperdoll_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 via aspect-[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) inside button_to forms posting PATCH to toggle_paperdoll_loadout_path(slot, slug).
    • Equipped item: ring/border highlight (Tailwind ring-2 ring-amber-300 or similar matching existing gamer-* 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.

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") returns helmet-1..5 and hair-man-1..3, excludes hair-woman-* and naked-*.
  • items_for(:helmet, gender: "woman") returns helmet-1..5 and hair-woman-1..3, excludes hair-man-*.
  • items_for(:armor, gender:) returns armor-1..5 for both genders, excludes naked.
  • items_for(:legs, gender:) returns legs-1..5 for both genders, excludes naked.
  • naked_asset_path(:helmet, gender: "woman")"gamer/paperdoll/helmets/naked-helmet-1-woman.png".
  • find(:helmet, "helmet-3") returns an Item, find(:helmet, "bogus") returns nil.
  • Item#display_name formatting 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") raises ArgumentError and 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; if hair-man-1 was equipped it's pruned; if helmet-3 (unisex) was equipped it stays.
  • set_gender("alien") raises; loadout unchanged.
  • layers returns: [naked-helmet-1-man, naked-armor-1-man, naked-legs-1-man] when nothing equipped.
  • layers returns: [naked-helmet-1-man, helmet-3, armor-2, naked-legs-1-man] when helmet+armor equipped.
  • layers switches naked bases when gender flips to "woman".

test/controllers/paperdoll_loadout_controller_test.rb

  • Unauthenticated request to PATCH /paperdoll_loadout/helmet/helmet-3 redirects to sign-in (Devise default — authenticate_user! is enforced via the global before_action in ApplicationController).
  • Authenticated PATCH …/helmet/helmet-3 returns turbo_stream with three frame replacements; user's loadout is updated.
  • Invalid slot (regex fails) → 404.
  • Invalid slug (catalog lookup fails) → controller rescues ArgumentError from Loadout#toggle and responds with head :unprocessable_entity for turbo_stream / redirect_to gamer_dashboard_path, alert: … for HTML.

test/controllers/users/registrations_controller_test.rb (extension)

  • Updating profile with gender=woman persists into paperdoll_loadout.
  • Updating with gender=invalid is ignored (no change).
  • Switching from man to woman with hair-man-1 equipped 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: Catalog reads 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: legs is unusual (already plural). Centralize the slot → directory map in one constant in Catalog (SLOT_DIR = { helmet: "helmets", armor: "armors", legs: "legs" }.freeze).
  • Snapshot caching: a paperdoll_loadout getter 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_name and avatar). If Devise's super raises 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_path and the public profile view are untouched.

Files touched / added

New:

  • db/migrate/<ts>_add_paperdoll_loadout_to_users.rb
  • app/models/paperdoll/item.rb
  • app/models/paperdoll/catalog.rb
  • app/models/paperdoll/loadout.rb
  • app/controllers/paperdoll_loadout_controller.rb
  • app/policies/paperdoll_loadout_policy.rb
  • app/views/gamer_dashboard/_paperdoll_figure.html.erb
  • app/views/gamer_dashboard/_inventory_panel.html.erb
  • app/views/paperdoll_loadout/toggle.turbo_stream.erb
  • app/javascript/controllers/paperdoll_inventory_controller.js
  • Tests as listed above.

Modified:

  • config/routes.rb
  • app/views/gamer_dashboard/show.html.erb
  • app/views/gamer_dashboard/_player_panel.html.erb
  • app/models/gamer_dashboard/snapshot.rb
  • app/views/devise/registrations/edit.html.erb
  • app/controllers/users/registrations_controller.rb
  • config/locales/en.yml, config/locales/pt-BR.yml