The Engagement Problem
Idle games live and die by one question: does the player want to click one more time? When we started building Starbrew Station, we had all the core economic loops in place -- production chains, upgrades, prestige layers -- but the moment-to-moment gameplay felt flat. Players would set up their brewers, watch numbers tick upward, and drift away. The progression was there, but the pull was not.
We needed a reward system that felt generous without being predictable, that respected the player's time without letting them go too long without a win. The solution we landed on combines a variable reward schedule with a pity timer, layered under timed Rush Hour events and wrapped in enough audiovisual feedback to make every collection feel satisfying.
Variable Reward Schedules
Fixed reward intervals are boring. If a bonus drops every 30 seconds on the dot, players learn the rhythm and disengage between drops. Variable ratio schedules -- where the reward comes after an unpredictable number of actions -- are what keep slot machines compelling. We wanted that same uncertainty, but in a way that never punishes the player for too long.
Our RewardSpawner rolls against a base probability each spawn cycle. When the roll fails, we do not just move on -- we increment a miss counter and bump the probability for the next attempt. This creates a curve where early misses are expected, but long drought streaks become mathematically impossible.
The Pity Timer
The core mechanic is simple: every consecutive miss adds +3% to the spawn chance. After 7 consecutive misses, the system forces a guaranteed frozen reward regardless of the roll. This means the longest possible dry spell is bounded, and the escalating probability makes it feel like tension building toward a release rather than random bad luck.
## RewardSpawner.gd
const BASE_SPAWN_CHANCE := 0.15
const PITY_INCREMENT := 0.03
const PITY_THRESHOLD := 7
var consecutive_misses: int = 0
func _attempt_reward_spawn() -> void:
var effective_chance := BASE_SPAWN_CHANCE + (consecutive_misses * PITY_INCREMENT)
var roll := randf()
if roll <= effective_chance or consecutive_misses >= PITY_THRESHOLD:
_spawn_reward(consecutive_misses >= PITY_THRESHOLD)
consecutive_misses = 0
else:
consecutive_misses += 1
if consecutive_misses >= PITY_THRESHOLD - 1:
EventBus.near_miss_triggered.emit()
func _spawn_reward(is_pity: bool) -> void:
var reward_value := _calculate_reward_value()
if is_pity:
reward_value *= 1.5 # Pity rewards feel extra generous
EventBus.reward_spawned.emit(reward_value, is_pity)
A few things worth noting here. The near_miss_triggered signal fires when the player is one miss away from the pity threshold. This drives a subtle visual and audio cue -- a shimmer on the reward bar, a rising tone -- that tells the player "you are close" without spelling it out explicitly. It is a small touch, but playtesters consistently reported that it made the misses feel intentional rather than frustrating.
The pity reward itself is marked as frozen and carries a 1.5x value multiplier. We wanted the guaranteed drop to feel like the game is being generous, not like a consolation prize. Players who hit the pity timer should feel relief and a small rush, not disappointment that they had to wait.
Rush Hour Events
On top of the per-spawn reward loop, we run Rush Hour events every 8 minutes. Each Rush Hour lasts 45 seconds and changes two things: the spawn interval is halved, and all production gets a 2x multiplier. Manual clicks during Rush Hour receive a 5x bonus. This creates a predictable macro rhythm -- players learn to anticipate the event and shift from passive to active play.
## RushHourManager.gd
const RUSH_INTERVAL := 480.0 # 8 minutes between events
const RUSH_DURATION := 45.0 # 45-second window
const PRODUCTION_MULTIPLIER := 2.0
const CLICK_MULTIPLIER := 5.0
const SPAWN_INTERVAL_SCALE := 0.5
var rush_timer: float = RUSH_INTERVAL
var is_rush_active: bool = false
func _process(delta: float) -> void:
if is_rush_active:
rush_timer -= delta
if rush_timer <= 0.0:
_end_rush_hour()
else:
rush_timer -= delta
if rush_timer <= 0.0:
_start_rush_hour()
func _start_rush_hour() -> void:
is_rush_active = true
rush_timer = RUSH_DURATION
EventBus.rush_hour_started.emit(PRODUCTION_MULTIPLIER, CLICK_MULTIPLIER)
func _end_rush_hour() -> void:
is_rush_active = false
rush_timer = RUSH_INTERVAL
EventBus.rush_hour_ended.emit()
The 8-minute cadence was chosen after testing shorter and longer intervals. At 5 minutes, Rush Hours felt constant and lost their specialness. At 12 minutes, players forgot they existed. Eight minutes is long enough that the event feels like an occasion, short enough that players stick around waiting for the next one. The 45-second duration is tight enough to demand attention but not so long that it becomes exhausting.
Juice and Feedback
A reward system can have perfect math and still feel lifeless without the right feedback. We invested heavily in making every collection feel physical and immediate.
Collection Sound Tiers
We use 4 pitch tiers for the collection sound, scaled to the reward value. Low-value pickups get a short, muted tone. High-value rewards get a bright, rising chime. The tiers map to value thresholds relative to the player's current income rate, so the audio feedback stays meaningful as the player progresses.
## AudioFeedbackManager.gd
const PITCH_TIERS := [0.8, 1.0, 1.25, 1.5]
const VALUE_THRESHOLDS := [0.25, 0.5, 0.75] # Fraction of current income rate
func play_collection_sound(reward_value: float, current_income: float) -> void:
var ratio := reward_value / maxf(current_income, 0.01)
var tier := 0
for i in VALUE_THRESHOLDS.size():
if ratio >= VALUE_THRESHOLDS[i]:
tier = i + 1
var sfx := collection_sound.duplicate()
sfx.pitch_scale = PITCH_TIERS[tier]
sfx.play()
Screen Shake and Scale Pops
Our ScreenShakeManager applies a micro shake on every reward collection -- just 2-3 pixels of displacement over 0.1 seconds. It is subtle enough that you would not consciously notice it, but removing it makes the game feel noticeably less responsive. We tested this by toggling it off for a playtest group and got consistent feedback that collecting rewards felt "floaty" without it.
The UIDisplayManager handles a scale pop animation on purchases. When the player buys an upgrade, the button briefly scales up to 1.1x and snaps back with an ease-out curve. Combined with a purchase streak tracker that counts rapid consecutive buys and fires milestone celebrations at 5, 10, and 25 purchases, this turns the upgrade screen from a spreadsheet into something that feels like progress.
Floating Text Pooling
One problem we did not anticipate: floating reward labels were killing our frame rate. Every time a reward spawned, we were instantiating a new Label node, tweening it upward, and freeing it. With 200+ production units active in late-game saves, the frequent spawning caused visible frame stuttering as the garbage collector fought to keep up.
The fix was a FloatingTextPool -- an object pool that pre-allocates a fixed set of label nodes and recycles them.
## FloatingTextPool.gd
const POOL_SIZE := 50
var pool: Array[Label] = []
var pool_index: int = 0
func _ready() -> void:
for i in POOL_SIZE:
var label := Label.new()
label.visible = false
add_child(label)
pool.append(label)
func show_text(text: str, position: Vector2, color: Color) -> void:
var label := pool[pool_index]
pool_index = (pool_index + 1) % POOL_SIZE
label.text = text
label.modulate = color
label.global_position = position
label.visible = true
var tween := create_tween()
tween.tween_property(label, "global_position:y", position.y - 60.0, 0.6) \
.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_QUAD)
tween.parallel().tween_property(label, "modulate:a", 0.0, 0.6)
tween.tween_callback(func(): label.visible = false; label.modulate.a = 1.0)
The pool uses a simple ring buffer -- pool_index wraps around, and if a label is still mid-animation when it gets recycled, the new text simply takes over. With a pool size of 50, we have never seen a visible recycle collision in practice, and the frame stuttering disappeared entirely. This took the floating text system from our biggest performance liability to a non-issue.
Results
After shipping these systems, we tracked a few key metrics from playtest sessions. Average session length increased from 12 minutes to 23 minutes. The percentage of players who returned for a second session within 24 hours went from 34% to 61%. Most tellingly, players who hit their first Rush Hour event had an 82% chance of staying through the second one -- the macro rhythm was working as intended.
The pity timer turned out to be more important for perception than for actual drop rates. Because the escalating probability means most players get a reward before hitting the threshold, the pity timer rarely fires. But when it does, it prevents the worst-case experience -- the 15-second drought that makes a player alt-tab away. In idle games, the cost of losing attention for even a moment can be permanent.
If we had to distill the takeaway: the math behind a reward system matters less than how the reward feels. A perfectly tuned probability curve with no screen shake, no pitch-scaled audio, and no near-miss feedback will lose to a simpler system that makes every collection feel like a small victory. Build the math first, then layer on the juice until it feels right.