Skip to main content
View
Mystery Gift — Step 5 / 11Add a data layer with PFM
1. Prepare the assets and text before creating a UI
2. Create a UI scene
3. Create a Composition
4. Create a SpriteStack component
5. Add a data layer with PFM
6. Handle keyboard input
7. Handle mouse input
8. Add i18n text
9. Use confirmation dialogs
10. Create animations
11. Customize GenericBase

Add a data layer with PFM

This guide builds on the SpriteStack guide: you have a Composition that draws six empty rows. Here we add the PFM layer -- the data model that knows which codes are valid and which the player has already claimed -- and wire it in so the rows show real gifts.

Principle

The PFM layer is the data model of a scene: it holds the state that must be remembered, while the UI and GamePlay layers stay free of persistent state.

  • The UI layer (Composition, GiftRow) only displays data; it never stores persistent state.
  • The GamePlay layer (the scene) reads and mutates the PFM object, then asks the Composition to refresh.

Where the data lives is a deliberate choice. For Mystery Gift, the claimed codes are a small set of flags that must persist with the player's save and that no existing system already tracks, so we keep them in $user_data -- PSDK's plug-and-play hash for exactly this kind of per-save state. When the player saves, the whole game state is serialized with Marshal, so any Marshal-able data in $user_data is saved and restored with it (plain values, arrays, hashes, simple objects like our PFM::MysteryGift; never sprites, viewports or procs -- they would break saving).

$user_data is not a universal answer, though. It lives inside the save file, so it is per-save, not shared across all saves. Transient UI state (a cursor position you do not want to keep) stays in the scene and is never persisted. Data that already has a home goes there instead -- party in the party, items in the bag, story flags in game switches and variables. Pick the store that matches what your data is and how long it must live.

For Mystery Gift, the PFM layer answers three questions: is a code valid? has it been claimed already? and "give me the list of claimed gifts to display".

The PFM class

Create scripts/20 MysteryGift/001 PFM/000 MysteryGift.rb:

module PFM
# Persistence layer for the Mystery Gift scene.
# Stored in $user_data[:mystery_gift] so claimed codes survive across save files.
class MysteryGift
# Gift database -- makers add their codes here.
# @example
# 'CODE' => { type: :item, item: :potion, quantity: 5, name: 'Potions x5' }
# 'CODE' => { type: :pokemon, species: :pikachu, level: 25, name: 'Pikachu' }
GIFT_DATABASE = {
'PIKACHU2024' => { type: :pokemon, species: :pikachu, level: 25, name: 'Pikachu Lv.25' },
'POTION50' => { type: :item, item: :potion, quantity: 50, name: 'Potion x50' },
'MASTERBALL' => { type: :item, item: :master_ball, quantity: 1, name: 'Master Ball' }
}

# @return [Hash{String => Boolean}] claimed gift codes
attr_reader :claimed

# Create a new MysteryGift tracker
def initialize
@claimed = {}
end

# Check if a code is valid and not yet claimed
# @param code [String] the gift code to validate
# @return [Symbol] :valid, :invalid, or :already_claimed
def validate(code)
normalized = code.upcase
return :invalid unless GIFT_DATABASE[normalized]
return :already_claimed if @claimed[normalized]

return :valid
end

# Mark a code as claimed and give the reward to the player
# @param code [String] the gift code to claim
# @return [String, nil] the gift name if claimed, nil if invalid
def claim(code)
normalized = code.upcase
gift = GIFT_DATABASE[normalized]
return nil unless gift

@claimed[normalized] = true
give_reward(gift)
return gift[:name]
end

# Return the list of claimed gift entries for display
# @return [Array<Hash>] each entry has :code, :name, :type (and other gift data)
def claimed_gifts
return @claimed.keys.filter_map do |code|
gift = GIFT_DATABASE[code]
next unless gift

gift.merge(code: code)
end
end

# Return the number of claimed gifts
# @return [Integer]
def claimed_count
return @claimed.size
end

private

# Give the reward to the player
# @param gift [Hash] the gift data from GIFT_DATABASE
def give_reward(gift)
case gift[:type]
when :item
$bag.add_item(gift[:item], gift[:quantity] || 1)
when :pokemon
pokemon = PFM::Pokemon.new(gift[:species], gift[:level])
PFM.game_state.add_pokemon(pokemon)
end
end
end
end
  • GIFT_DATABASE is where makers declare codes and their rewards. Each entry has a :type (:item or :pokemon), the reward details, and a display :name. Edit this hash to define your own gifts.
  • @claimed is a hash of code => true. Because the whole PFM::MysteryGift object is stored in $user_data, this hash is saved with the game.
  • validate returns a symbol the scene can branch on: :invalid (unknown code), :already_claimed, or :valid.
  • claim marks the code as claimed and calls give_reward. It returns the gift name so the scene can show "You received ...".
  • claimed_gifts builds the display list: each claimed code merged with code: so a row can show both the name and the code. filter_map skips codes that no longer exist in the database.
  • give_reward uses standard PSDK APIs ($bag.add_item, PFM.game_state.add_pokemon). Adapt it to whatever your gifts grant.

Wiring it into the scene

The scene creates the PFM object once (or reuses the saved one) and passes it to the Composition. Update scripts/20 MysteryGift/003 GamePlay/001 Main.rb:

module GamePlay
# Mystery Gift scene -- displays the player's claimed gifts
class MysteryGift < BaseCleanUpdate::FrameBalanced
include UI::MysteryGift

# Create the scene
def initialize
super
@running = true
$user_data[:mystery_gift] ||= PFM::MysteryGift.new
end

# Handle keyboard input each frame
# @return [Boolean]
def update_inputs
return automatic_input_update
end

# Update graphics each frame
def update_graphics
@base_ui.update_background_animation
@composition.update
end

private

# Create all the graphics for the scene
def create_graphics
create_viewport
create_base_ui
create_composition
Graphics.sort_z
end

# Create the base UI with button texts
def create_base_ui
@base_ui = UI::MysteryGiftBase.new(@viewport, button_texts)
end

# Create the composition
def create_composition
@composition = Composition.new(@viewport, $user_data[:mystery_gift])
end

# Return the button texts for the ctrl buttons [A, X, Y, B]
# @return [Array<String, nil>]
def button_texts
return [nil, nil, nil, 'Quit']
end

# Action triggered by the B button -- quit the scene
def action_b
@running = false
end
end
end

GamePlay.mystery_gift_class = GamePlay::MysteryGift
  • $user_data[:mystery_gift] ||= PFM::MysteryGift.new creates the data object the first time and reuses the saved one afterwards. ||= is what makes the claimed codes persist between visits and across save files.
  • create_composition now passes the data object as a second argument.

Showing the data in the Composition

The Composition receives the data object, stores it, and exposes a refresh method that reads the claimed gifts and updates the rows. Update scripts/20 MysteryGift/002 UI/999 Composition.rb:

module UI
module MysteryGift
# Visual orchestrator for the Mystery Gift UI
class Composition < SpriteStack
# Create the composition
# @param viewport [Viewport]
# @param mystery_data [PFM::MysteryGift] the data object
def initialize(viewport, mystery_data)
super(viewport, 0, 0, default_cache: :interface)
@mystery_data = mystery_data
@selected_index = 0
create_header
create_frame
create_no_gifts_text
create_gift_rows
refresh
end

# Update the composition each frame
def update; end

# Tell if all animations are done
# @return [Boolean]
def done?
return true
end

# Refresh the display with current gift data
def refresh
gifts = @mystery_data.claimed_gifts
if gifts.empty?
show_no_gifts
else
show_gift_list(gifts)
end
end

private

# Show the "no gifts" message
def show_no_gifts
@no_gifts_text.visible = true
@rows.each { |row| row.visible = false }
end

# Show the gift list
# @param gifts [Array<Hash>]
def show_gift_list(gifts)
@no_gifts_text.visible = false
@rows.each_with_index do |row, index|
gift = gifts[index]
row.data = gift
row.selected = (index == @selected_index) if gift
end
end

# Create the header bar and title
def create_header
@header = add_sprite(0, HEADER_Y, 'mystery_gift/header')
@header.set_z(2)
@title = add_text(0, 0, 320, 14, ext_text(TEXT_FILE_ID, TEXT_TITLE), 1, nil, color: 10)
@title.z = 3
end

# Create the main content frame
def create_frame
@frame = add_sprite(FRAME_X, FRAME_Y, 'mystery_gift/frame')
end

# Create the "no gifts claimed yet" text
def create_no_gifts_text
@no_gifts_text = add_text(
FRAME_X, FRAME_Y + FRAME_HEIGHT / 2 - 8,
FRAME_WIDTH, 16,
ext_text(TEXT_FILE_ID, TEXT_NO_GIFTS), 1, nil, color: 9
)
end

# Create the gift row slots
def create_gift_rows
@rows = Array.new(VISIBLE_ROWS) do |index|
row = GiftRow.new(@viewport, GIFT_ROW_X, GIFT_ROW_Y + index * GIFT_ROW_PITCH)
push_sprite(row)
end
end
end
end
end
  • @mystery_data stores the PFM object; @selected_index tracks which row is highlighted (used by navigation in the keyboard and mouse guides).
  • refresh is the single entry point for redrawing the list. It is called once at the end of initialize, and again whenever the data changes (after a claim, in the dialogs guide).
  • show_no_gifts shows the centered "No gifts claimed yet" text and hides every row; show_gift_list fills each row via row.data = gift and hides the extra rows by passing nil.
  • create_no_gifts_text must be created before refresh runs, which is why the call order in initialize matters.

Try it

Reopen the scene:

GamePlay.open_mystery_gift

Since nothing is claimed yet, you see the "No gifts claimed yet" message. To preview the list before we build the claim flow, claim a code from the console, then reopen:

$user_data[:mystery_gift].claim('POTION50')
GamePlay.open_mystery_gift

You should now see one row with "Potion x50" on the left and "POTION50" on the right.

Conclusion

  • The PFM layer is the data model. $user_data is a good home for small per-save state like ours, but it is not always the right one -- choose the store that fits your data (party, bag, switches/variables, or nothing if it should not persist).
  • Keep persistent state in PFM only -- the UI displays it, the scene mutates it.
  • validate/claim/claimed_gifts/claimed_count form the public API the scene and Composition use.
  • The Composition takes the data object as a constructor argument and exposes a refresh method as the single redraw entry point.
  • Create the "no gifts" text and the rows before the first refresh.