Why Puzzle Games Need a Reward System

Bikini Heaven is a free-to-play swap puzzle game. The player scrambles and reassembles images by swapping tiles. The satisfaction of watching a picture come together is real, but it is not enough to drive replayability on its own. Once a player has solved a few puzzles, the novelty of the core mechanic fades. Without a reward layer on top, every puzzle completion feels the same -- correct answer, next puzzle, repeat.

We needed a system that makes each completion feel different, rewards skillful play, and gives players a sense of session-level progress. But Bikini Heaven is free with no microtransactions. There is nothing to sell. The reward system exists purely to make the game more fun to play, which means we can apply behavioral psychology honestly -- no loot boxes, no FOMO timers, no pay-to-skip mechanics. Just a variable reward schedule that makes each puzzle ending unpredictable and exciting.

The Loot Table

Our RewardManager uses a weighted loot table that rolls on every puzzle completion. Half the time, the player gets the standard reward. The other half, they get a multiplier ranging from 1.5x to 5x on their credit earnings. The jackpot at 2% is rare enough to feel genuinely exciting and common enough that most players will see one in a typical session.

## RewardManager.gd

const REWARD_TABLE: Dictionary = {
    "standard": 50,        # 50% - Base reward
    "lucky_small": 25,     # 25% - x1.5 credits
    "lucky_medium": 15,    # 15% - x2.0 credits
    "lucky_large": 8,      #  8% - x3.0 credits
    "jackpot": 2,          #  2% - x5.0 credits
}

const REWARD_MULTIPLIERS: Dictionary = {
    "standard": 1.0,
    "lucky_small": 1.5,
    "lucky_medium": 2.0,
    "lucky_large": 3.0,
    "jackpot": 5.0,
}

const REWARD_LABELS: Dictionary = {
    "standard": "",
    "lucky_small": "Lucky!  x1.5",
    "lucky_medium": "Big Win!  x2",
    "lucky_large": "Super Bonus!  x3",
    "jackpot": "JACKPOT!  x5",
}

The label dictionary is deliberately empty for "standard" rewards. We do not want to show "Normal!" or "x1.0" -- that frames the standard result as a loss relative to the bonuses. By saying nothing on a standard roll, the player just sees their credits and moves on. The bonus labels only appear when something above standard fires, which makes them feel like pleasant surprises rather than the absence of disappointment.

Stacking Multipliers: Speed, Combo, and Loot

The loot roll is just one of three multipliers that stack together. The speed multiplier rewards finishing under the par time for each difficulty level. The combo multiplier rewards chaining correct placements without mistakes. All three multiply together, which means a skilled player who is also lucky can earn dramatically more than a slow player on a standard roll.

const BASE_CREDITS: Dictionary = {3: 10.0, 5: 25.0, 8: 50.0, 16: 150.0}
const PAR_TIMES: Dictionary = {3: 90.0, 5: 240.0, 8: 600.0, 16: 1800.0}
const COMBO_CREDIT_BONUS: Dictionary = {5: 1.2, 10: 1.5, 15: 2.0}

func calculate_completion_reward(
    grid_size: int, moves: int, elapsed_time: float, combo: int
) -> Dictionary:
    var base: float = BASE_CREDITS.get(grid_size, 10.0)
    var bonus_type: String = _roll_reward_type()
    var loot_mult: float = REWARD_MULTIPLIERS[bonus_type]
    var speed_mult: float = _calculate_speed_multiplier(grid_size, elapsed_time)
    var combo_mult: float = _calculate_combo_multiplier(combo)
    var final_credits: float = base * loot_mult * speed_mult * combo_mult
    return {
        "base": base,
        "type": bonus_type,
        "multiplier": loot_mult,
        "speed_mult": speed_mult,
        "combo_mult": combo_mult,
        "final": final_credits,
    }

The math here produces some interesting emergent dynamics. A 16x16 puzzle completed under par time with a 15+ combo and a jackpot roll yields: 150 base * 5.0 jackpot * 1.5 speed * 2.0 combo = 2,250 credits. Compare that to a standard 3x3 completion: 10 base * 1.0 * 1.0 * 1.0 = 10 credits. The range spans over two orders of magnitude, which makes the high-end results feel genuinely special.

Speed Multiplier: Rewarding Mastery

The speed multiplier scales linearly from 1.0x at the par time to 1.5x at instant completion. This sounds simple, but the par times are tuned so that achieving the maximum speed bonus requires genuine puzzle-solving skill, not just frantic clicking.

func _calculate_speed_multiplier(grid_size: int, elapsed_time: float) -> float:
    if elapsed_time <= 0.0:
        return 1.0
    var par: float = PAR_TIMES.get(grid_size, 120.0)
    if elapsed_time >= par:
        return 1.0
    return clampf(1.0 + (1.0 - elapsed_time / par) * 0.5, 1.0, 1.5)

We capped the speed bonus at 1.5x rather than making it scale higher because we did not want speed to dominate the reward structure. A 1.5x bonus is nice. A 10x bonus would make players feel punished for taking their time, which directly contradicts the relaxing vibe of the game. The multiplier says "nice job, here's a little extra" rather than "you played wrong if you didn't rush."

The Juice Cascade

When the reward is calculated, the RewardManager fires a cascade of events through our EventBus system. Each reward tier triggers different juice effects -- screen shakes, hit-stops, and floating text with escalating intensity. The result is that a standard reward feels clean and pleasant, a lucky reward feels exciting, and a jackpot feels like winning the lottery.

func _apply_reward(result: Dictionary) -> void:
    var final: float = result.final
    GameManager.add_credits(final)

    EventBus.emit(EventBus.REWARD_ROLLED, result)
    EventBus.emit(EventBus.CREDITS_EARNED, {
        "amount": final,
        "source": "puzzle_completion",
    })

    # Escalating juice based on reward rarity
    match result.type:
        "jackpot":
            EventBus.emit(EventBus.JUICE_HIT_STOP, {"duration": 0.08})
            EventBus.emit(EventBus.JUICE_SCREEN_SHAKE, {"preset": "jackpot"})
        "lucky_large":
            EventBus.emit(EventBus.JUICE_HIT_STOP, {"duration": 0.05})
            EventBus.emit(EventBus.JUICE_SCREEN_SHAKE, {"preset": "lucky"})
        "lucky_medium":
            EventBus.emit(EventBus.JUICE_SCREEN_SHAKE, {"preset": "medium"})

    FloatingTextHelper.show_bonus(REWARD_LABELS.get(result.type, ""), result.type)

The hit-stop on jackpots is 80 milliseconds -- barely perceptible as a pause, but enough to create a micro-moment of "wait, what just happened?" before the jackpot label and screen shake hit. On a lucky_large roll, the hit-stop is 50ms, which is just enough to punctuate the result without the dramatic pause of a full jackpot. Standard and lucky_small rolls get no hit-stop at all, keeping the flow smooth for common results.

The key insight here is that the RewardManager does not play sounds or animate anything directly. It emits events. The JuiceManager listens to those events and handles all the sensory feedback. This separation means we can tune the feeling of a jackpot without touching the reward math, and vice versa. It also means the entire juice layer is independently testable.

Session XP: The Macro-Loop

On top of the per-puzzle reward roll, we run a session XP system that tracks cumulative progress. Every puzzle completion grants XP based on difficulty, efficiency, speed, and combo count. Leveling up triggers a separate celebration with its own juice effects.

const XP_PER_LEVEL: int = 500
const MAX_SESSION_LEVEL: int = 50

const BASE_XP: Dictionary = {3: 50, 5: 120, 8: 250, 16: 600}

func _calculate_total_xp(
    grid_size: int, moves: int, elapsed_time: float, combo: int
) -> int:
    var base_xp: int = BASE_XP.get(grid_size, 50)
    var bonus: int = 0
    var total_pieces: int = grid_size * grid_size

    # Efficiency bonus: fewer moves than 2x piece count
    if moves < total_pieces * 2:
        bonus += int(float(total_pieces) * 0.5)

    # Speed bonus: under half par time
    var par: float = PAR_TIMES.get(grid_size, 120.0)
    if elapsed_time > 0.0 and elapsed_time < par * 0.5:
        bonus += int(float(base_xp) * 0.3)

    # Combo bonus: 5 XP per consecutive correct placement
    bonus += combo * 5

    return base_xp + bonus

At 500 XP per level, a player doing 5x5 puzzles at a moderate pace levels up roughly every 4-5 puzzles. A player grinding 16x16 puzzles efficiently can level up in 1-2 puzzles. The variable XP per difficulty means that harder puzzles feel appropriately more rewarding without requiring a separate progression track.

Event-Driven Architecture

The entire reward system communicates through our EventBus -- a pub-sub singleton where systems subscribe to event constants and emit data dictionaries. The RewardManager has zero direct dependencies on any other manager. It listens for PUZZLE_VICTORY, runs its math, and emits REWARD_ROLLED, CREDITS_EARNED, and various JUICE_* events. Other systems pick those up independently.

func _ready() -> void:
    _rng.randomize()
    EventBus.subscribe(EventBus.PUZZLE_VICTORY, _on_puzzle_victory)

func _exit_tree() -> void:
    EventBus.unsubscribe(EventBus.PUZZLE_VICTORY, _on_puzzle_victory)

This decoupling is not just architectural neatness -- it directly enables testing. We can instantiate a RewardManager, feed it a fake PUZZLE_VICTORY event, and assert that the output reward falls within expected ranges. We run 10,000 roll simulations in our test suite to verify that jackpots fire approximately 2% of the time and that the weighted distribution matches the declared weights. You cannot do this easily when your reward system is tightly coupled to your game state.

Design Philosophy

The guiding principle behind all of this is that variable rewards should amplify satisfaction, not manufacture anxiety. In a predatory system, the variability exists to make the player feel they are missing out unless they play more or pay more. In ours, the variability exists because "you might get a 5x bonus" is simply more fun than "you will always get 1x."

The difference is structural, not just philosophical. Our system has no premium currency, no way to buy rolls, no timer that makes you wait for your next chance, and no loss condition. The worst outcome of any puzzle completion is the standard reward, which is still positive. The variable layer only adds upside. That is what non-predatory dopamine engineering looks like: all of the engagement benefits of unpredictability, none of the dark patterns that exploit it.