- Godot
- Survival
- Shooter
- Action
- Multiplayer
Candles Out
A chaotic 2-player multiplayer survival shooter where living candles fight monsters, manage stamina, and wield unpredictable power-ups. A fun, short party game. Inspired by Vampire Survivors.
This game is designed for a UM Game Jam event.
Most cooperative survival games treat teamwork as two players performing identical actions side by side: both aim, both shoot, both kite enemies across an open arena. So when player coordination fails, it is usually because someone was swarmed or missed their shots.
We wanted to explore a different dynamic centered around the theme of “Losing Control”:
- The Vulnerability Inversion: In traditional survival games, staying together makes you safe. Here, moving and shooting constantly drains your candle’s stamina until your wick gives out. When exhausted, your candle drops into an immobile sleep, leaving your physical body helpless while enemies close in.
- Asymmetric Spirit Separation: The real fun starts when your candle body sleeps to regenerate health! Your spirit detaches into an immortal Ghost. The ghost cannot attack enemies, but it can fly across the arena, scavenge items, and throw supplies to defend your sleeping partner (or help your teammate).
- Chaotic Item Backfires: We designed items to be unpredictable yet fun. In keeping with the “Losing Control” theme, every item thrown by a ghost has a 50/50 chance to trigger a chaotic Backfire: for example turning speed boost shoes into an uncontrollable charge, shields into stone prison walls that trap yourself, and defensive bombs into homing missiles that chase you down.
- The Local-to-LAN Bridging Problem: Building a game that plays equally well as a local couch co-op experience (two players on one keyboard/with two controllers) and as an online LAN multiplayer game required some engineering tricks.
We built Candles Out in Godot to solve these design and networking challenges: an asymmetric co-op game with host-authoritative ENet simulation, unreliable-ordered 30 Hz input streaming, exponential movement interpolation, and a three-tier network audio architecture.
The Candles Out main menu, highlighting the dual-role motif (awake flame vs sleeping candle).
High-Level System Architecture
Candles Out separates authoritative gameplay simulation from presentation, enabling the exact same entity tree to run in single-machine Local Co-op, as an Online LAN Host, or as a remote Joining Peer.
flowchart TD
subgraph Input["Input Layer"]
LOCAL_IN["Local Input Adapter"]
REMOTE_IN["Online Input Adapter"]
RPC_IN["Input RPC Receiver"]
end
subgraph HostSim["Authoritative Simulation (Host / Local)"]
P_SIM["Player Simulation"]
ITEM_SIM["Item Lifecycle"]
ENEMY_SIM["Enemy & Boss AI"]
WAVE_SIM["Wave Spawner"]
AUTH_CLOCK["Match State & Timers"]
end
subgraph Sync["Replication Fabric (ENet)"]
MSPAWN["Multiplayer Spawner"]
MSYNC["State Synchronizer"]
SMOOTH["Movement Smoothing"]
AUD_RPC["Audio RPC Dispatcher"]
end
subgraph Pres["Presentation Layer"]
HUD["HUD & Overlays"]
RENDER["Shaders & VFX"]
AUDIO["Positional Audio"]
FOOT["Footstep Derivation"]
end
LOCAL_IN --> P_SIM
REMOTE_IN --> RPC_IN
RPC_IN --> P_SIM
P_SIM <--> ITEM_SIM
P_SIM <--> ENEMY_SIM
WAVE_SIM --> MSPAWN
P_SIM --> MSYNC
ITEM_SIM --> MSYNC
ENEMY_SIM --> MSYNC
MSYNC --> SMOOTH
SMOOTH --> RENDER
SMOOTH --> FOOT
P_SIM --> AUD_RPC
ITEM_SIM --> AUD_RPC
ENEMY_SIM --> AUD_RPC
AUD_RPC --> AUDIO
P_SIM -.-> HUD
Architectural Principles
- Strict Host Authority: In Online Co-op, the Joining Peer never makes independent simulation choices. Item roll tables (for the ghost), enemy pathfinding, projectile hits, and wave transitions run exclusively on the Host. The remote machine is only presenting what the host computed.
- Unified Command Convergence: Whether an input command originates from Player 1’s WASD keys, Player 2’s arrow keys on the same physical keyboard, or an ENet UDP packet across the local network, commands converge onto an identical
apply_command()code path inplayer.gd. - Decoupled Identity vs Transport: Gameplay identity is defined by
player_slot(1or2), which owns health, stamina, status effects, and HUD data. Network identity is defined bycontrolling_peer_id. This is done so that transport peer IDs never bleed into gameplay mechanics. - Stable Scene Paths for Dynamic Entities: To avoid the replication bugs caused by dynamic spawning and scene reparenting, ghosts are permanent nodes pre-allocated in the scene tree, and held items follow a logical state machine rather than node hierarchy re-attachment.
The Dual-Role Asymmetric Loop: Candle Bodies vs Detached Ghosts
The core tension of Candles Out lies in balancing the waking combat state with the sleeping spirit state.
Game showing Player 1 asleep inside a dimly flickering candle aura while Player 1’s ethereal Ghost flies outward to retrieve a spawned item.
The Candle Sleep & Stamina Engine
Each player character operates on a ticking stamina pool (max_stamina = 100.0):
- Walking consumes no stamina, but sprinting rapidly depletes it (
depletion_rate = 10.0/s). - When stamina hits zero, the player cannot simply stop running; their candle flame snuffs out and they enter deep sleep (
enter_sleep()):
func enter_sleep():
is_transitioning_to_ghost = true
is_sleeping = true
transition.show()
transition.play("default")
velocity = Vector2.ZERO
audio.play_sleep_sfx()
active_ghost = get_parent().get_node_or_null("Ghosts/PlayerGhost%d" % player_slot) as CharacterBody2D
if active_ghost == null:
is_transitioning_to_ghost = false
return
active_ghost.input_prefix = input_prefix
active_ghost.player_slot = player_slot
active_ghost.controlling_peer_id = controlling_peer_id
active_ghost.global_position = global_position
active_ghost.active = true
active_ghost.modulate.a = 0.0
# Smooth tween: float upward and fade in
var end_pos = global_position + Vector2(0, -60)
var tween = create_tween().set_parallel(true)
tween.tween_property(active_ghost, "modulate:a", 1.0, 0.5)
tween.tween_property(active_ghost, "global_position", end_pos, 0.8)\
.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
await tween.finished
is_transitioning_to_ghost = false
SignalBus.emit_signal("ghost_mode_started", player_slot)
The Awakening Cycle & Health Regeneration
Sleep is not a death sentence—it is the game’s sole natural healing mechanic:
- While sleeping, the candle recovers stamina (
recharge_rate = 5.0/s). - Crucially, it also trickles health back into the candle body (
health_regen_rate = 0.25 hearts/s), restoring half a heart every 2 seconds. - While the candle sleeps, the player’s controls are seamlessly transferred to the Ghost.
- When stamina reaches
max_stamina, the waking transition triggers: the ghost is pulled back into the candle body via an ease-in tween, any held item is dropped safely to the ground, and the candle reignites.
stateDiagram-v2
[*] --> Walking
Walking --> Sprinting: Sprint
Sprinting --> Walking: Release
Sprinting --> Sleep_Transition: Stamina Depleted
Sleep_Transition --> Sleeping_Body
Sleep_Transition --> Ghost_Active
Sleeping_Body --> Wake_Transition: Stamina Restored
Ghost_Active --> Wake_Transition: Spirit Recalled
Wake_Transition --> Walking: Flame Reignited
The “Losing Control” Backfire System: 50/50 Item Physics
In most rogue-lites, items provide flat incremental buffs (+10% move speed, +1 projectile). In Candles Out, items are physical objects tossed during the fight by spirits (it can be you or your teammate at any given time), designed to introduce fun unpredictability.
When a ghost hurls an item into a player character, the item resolves one of two wildly different outcomes:
| Item | Blessing (50% Chance) | Chaos Backfire (50% Chance) | Gameplay Impact |
|---|---|---|---|
| Shoe | Speed Boost: 2x movement speed for 10s. | Hedgehog Shoes: +250% uncontrollable speed. Ramming enemies deals damage and bounces you backward. | Turns the player into an erratic pinball who can wipe hordes or bounce directly into enemy projectiles. |
| Oil | Triple Shot: Fires a 3-way spread of projectiles for 10s. | Minigun: Panicked continuous flamethrower spray with zero cooldown. | Extreme burst DPS, but the erratic spray forces the player to constantly pivot. |
| Shield | Immunity: Invulnerability barrier absorbing all incoming damage for 10s. | Social Distancing: Trapped inside an immovable wall prison for 10s (speed = 0.0). | Complete immunity, but you cannot move an inch to protect your sleeping teammate. |
| Lighter | Emergency Revival: Restores 3 full hearts instantly. | Player Swap: Swaps the physical positions of Player 1 and Player 2! | Instantly teleports a resting, sleeping candle into the middle of the enemy swarm. |
| Coffee | Stamina Surge: Restores 50 stamina instantly. | None | Guaranteed wake-up tool when a sleeping partner must be revived immediately. |
| Bomb | Detonation: 10-damage massive AOE clearing all normal enemies. | Stalker Bomb: Bomb grows angel wings and hunts down the players! | The ultimate risk in high-density waves. |
Items spawn only when at least one ghost is roaming the arena. The moment both candles wake up, any uncollected items in the arena fade out and despawn. This ensures ghosts cannot hoard items safely after a wave ends; they must scavenge under active combat pressure.
The Stalker Bomb homing in on Player 1, showing the AOE explosion preview.
Dual-Mode Network Architecture: Bridging Local & LAN Play
One of the greatest architectural challenges in Candles Out was supporting two distinct ways to play without writing separate game loops:
- Local Co-op: Two friends sharing one desktop keyboard (WASD for Player 1, Arrow Keys for Player 2).
- Online LAN Co-op: Two native desktop builds communicating via Godot’s
ENetMultiplayerPeerover UDP port7000.
flowchart TD
subgraph Local["Mode A: Local Couch Co-op"]
P1_KEYS["Player 1 Keys"]
P2_KEYS["Player 2 Keys"]
end
subgraph LAN["Mode B: Online LAN Co-op"]
HOST_IN["Host Inputs"]
CLIENT_IN["Client Inputs"]
ENET["ENet UDP Transport"]
CLIENT_IN --> ENET
end
subgraph Authority["Authoritative Engine"]
CMD1["Slot 1 Command"]
CMD2["Slot 2 Command"]
SIM["Unified Simulation"]
end
P1_KEYS --> CMD1
P2_KEYS --> CMD2
HOST_IN --> CMD1
ENET --> CMD2
CMD1 --> SIM
CMD2 --> SIM
Host waiting in lobby.
Decoupling Scene-Tree Reparenting for Held Items
In a basic implementation of item pickup mechanics, the item node is reparented under the player’s node hierarchy, which breaks network replication and tree paths:
item.reparent(ghost_node)
In an online replicated game, modifying node hierarchy mid-match breaks MultiplayerSpawner tracking and leads to desynchronization errors.
In Candles Out, items never leave the world root. Instead, their lifecycle is governed by an explicit state machine:
enum ItemState { WORLD, HELD, THROWN }
var item_state: ItemState = ItemState.WORLD
var held_by_slot: int = 0
- When picked up,
item_state = ItemState.HELDandheld_by_slotis set to the ghost’s slot (1or2). - In
_process(), if an item isHELD, it simply clamps its coordinates to follow the holding ghost with an offset:global_position = holding_ghost.global_position + Vector2(0, -40) - When thrown, it transitions to
THROWN, fires toward the aiming vector, checks player collision, applies its effect, and is authoritatively freed on the Host.
Latency Compensation & Movement Smoothing
In LAN play, sending continuous transform updates over UDP creates two distinct problems:
- Snap Judder: If transforms update at 30 Hz while the client displays at 60 Hz or 144 Hz, entities visually stutter without interpolation.
- Rubber-Banding Snaps: If interpolation is too aggressive or ignores state transitions, an entity respawning or teleporting will drag awkwardly across the screen.
Exponential Smoothing Formula
Think of exponential smoothing like pulling an object through viscous oil with a rubber band. When the target suddenly jumps forward with each network packet, the stretched band exerts high force, pulling the entity rapidly. As the gap closes, the tension smoothly decays to zero without oscillating or overshooting. Unlike linear interpolation, which moves at a fixed mechanical speed, exponential decay naturally matches the urgency of large displacements while easing gently into rest.
To eliminate judder while maintaining instant responsiveness, Candles Out implements a framerate-independent exponential smoothing filter:
x_render(t + delta) = x_render(t) + (1 - exp(-k * delta)) * (x_target - x_render(t))
Where:
k = 18.0(INTERPOLATION_SPEED), providing immediate tracking with zero overshoot.deltais the frame delta time.- A hard distance threshold (
INTERPOLATION_SNAP_DISTANCE = 96.0 px) guards against visual dragging on teleports, sleep switches, and respawns:
func interpolate_movement(root: Node2D, delta: float, force_snap: bool = false) -> void:
if int(root.get(&"network_generation")) != match_generation:
return
var target: Vector2 = root.get(&"network_position")
# Snap immediately if distance is too great or state transitioned
if force_snap or root.position.distance_to(target) >= INTERPOLATION_SNAP_DISTANCE:
root.position = target
return
var weight := 1.0 - exp(-INTERPOLATION_SPEED * delta)
root.position = root.position.lerp(target, weight)
Protecting Against Stale Input via Generation IDs
Stale network packets in fast-paced multiplayer is like receiving mail with an outdated postmark. When connections experience micro-bursts or packet drops, delayed input packets can arrive long after a match has restarted or changed scenes. Candles Out stamps all input RPCs and transform synchronizations with a match_generation integer:
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
func submit_input(generation: int, direction: Vector2, sprinting: bool) -> void:
if not multiplayer.is_server()
or generation != NetworkSession.match_generation
or NetworkSession.session_state != NetworkSession.SessionState.IN_MATCH:
return
var sender := multiplayer.get_remote_sender_id()
if sender != controlling_peer_id or player_slot != 2:
return
remote_direction = direction.limit_length(1.0)
remote_sprinting = sprinting
last_remote_input_msec = Time.get_ticks_msec()
If the Host does not receive an input update for 500ms (INPUT_STALE_MSEC), it substitutes neutral movement and disables sprint until fresh input arrives.
Three-Tier Audio Replication Architecture
Audio synchronization is often an afterthought in multiplayer indie games. A common pitfall is placing audio calls inside gameplay functions that only execute on the host, leaving the remote player in complete silence. Conversely, wrapping every sound effect in a reliable RPC can quickly saturate network bandwidth.
Candles Out splits audio into three distinct operational tiers:
flowchart LR
subgraph Audio["Audio Category"]
T1_CAT["Durable Music"]
T2_CAT["Continuous Footsteps"]
T3_CAT["One-Shot SFX"]
end
subgraph Transport["Replication Strategy"]
T1_NET["Reliable RPC"]
T2_NET["Local Velocity Derivation"]
T3_NET["Unreliable RPC with Seed"]
end
subgraph Playback["Presentation"]
T1_OUT["Synchronized OST"]
T2_OUT["Positional Footsteps"]
T3_OUT["Seeded Pitch SFX"]
end
T1_CAT --> T1_NET --> T1_OUT
T2_CAT --> T2_NET --> T2_OUT
T3_CAT --> T3_NET --> T3_OUT
- Tier 1: Durable Music State (Reliable RPC): Music changes occur infrequently (wave start, boss phase transition, victory, game over). These use reliable RPCs (
broadcast_music(wave_number)). If the client finishes loading after the host, the current wave music begins playing immediately after the loading barrier. - Tier 2: Continuous Sounds (Zero Network Packets): Footsteps and continuous walking sounds are never transmitted over the wire. Instead, each client observes its local synchronized velocity vector (
network_velocity). If an entity’s velocity exceeds 1.0 px/s, the local presentation loop advances the footstep timer and triggers positional audio directly. - Tier 3: Transient One-Shot SFX (Unreliable RPC with Deterministic Seed): Combat sounds, bomb explosions, and item effects must play at exact world coordinates. These are dispatched via lightweight unreliable RPCs carrying an integer
variant_seed:
func broadcast_sfx(cue: int, world_position: Vector2, variant_seed: int = -1) -> void:
if variant_seed < 0:
_audio_variant_seed += 1
variant_seed = _audio_variant_seed
if not is_online():
GameplayAudio.play_sfx(cue, world_position, variant_seed)
elif is_online_host() and session_state == SessionState.IN_MATCH:
_receive_sfx.rpc(match_generation, cue, world_position, variant_seed)
The variant seed guarantees that both players hear the exact same randomized pitch variations without having to negotiate sound files over the network.
Swarm Intelligence: Boids Separation & Standoff Deadbands
To create fluid horde movement that doesn’t collapse into the sprites clustering together, Candles Out uses lightweight steering behaviors across its enemy hierarchy.
flowchart LR
subgraph Melee["Melee Swarmer"]
M_TGT["Target Vector"]
M_SEP["Boids Separation"]
M_STEER["Net Steering Velocity"]
end
subgraph Ranged["Ranged Kiter"]
R_CHECK{"Distance Check"}
R_FAR["Advance Toward Player"]
R_CLOSE["Retreat Away"]
R_BAND["Halt & Fire"]
end
M_TGT & M_SEP --> M_STEER
R_CHECK -->|"Dist > Max"| R_FAR
R_CHECK -->|"Dist < Min"| R_CLOSE
R_CHECK -->|"In Deadband"| R_BAND
1. Boids-Style Melee Separation
All enemies inherit from enemy_base.gd. Every physics tick, each enemy iterates through its neighbors in the Enemies group within separation_radius = 24.0 px and computes an inverse-distance repulsive push:
func compute_separation() -> Vector2:
if separation_radius <= 0.0 or separation_force <= 0.0:
return Vector2.ZERO
var enemies = get_tree().get_nodes_in_group("Enemies")
var push = Vector2.ZERO
var count = 0
for e in enemies:
if e == self:
continue
var dist = global_position.distance_to(e.global_position)
if dist < separation_radius and dist > 0:
var away = (global_position - e.global_position).normalized()
push += away * (separation_radius - dist)
count += 1
if count > 0:
push = (push / count).normalized() * separation_force
return push
This simple O(N) local neighborhood repulsion causes swarms to flank naturally around obstacles and wrap around players without having to do complicated grid pathfinding.
2. Ranged Standoff Deadband
Ranged enemies (enemy_ranged.gd) introduce tactical variety by holding a standoff distance (preferred_distance = 180.0, distance_tolerance = 25.0), also known as kiting.
Instead of oscillating rapidly across a single distance boundary, the enemy implements a deadband buffer:
- If
dist > preferred + tolerance: Advance toward player. - If
dist < preferred - tolerance: Backpedal away from player. - If inside the deadband: Halt movement, face the player, and unleash projectiles on cooldown.
Combat Dynamics: Auto-Aim, Knockback & The Final Boss
In local co-op on a single keyboard, players do not have mouse aim or secondary thumbsticks. Combat had to feel punchy, satisfying, and strategic using directional movement keys alone.
Forward-Cone Auto-Aim
To avoid frustrating misses while running away from enemies, projectiles employ an intelligent Forward Cone Auto-Aim system:
- When standing still, the projectile fires along
last_move_dir. - When moving, the game searches all nodes in the
Enemiesgroup withinautoaim_range = 300.0 px. - It calculates the angle between
last_move_dirand the vector toward the enemy. If any enemy sits within a forward cone of ±35 degrees, the projectile snaps its trajectory toward that target. - Enemies behind or to the side are ignored, preserving the tactical necessity of facing the danger.
Player 1 firing projectiles with forward-cone auto-aim snapping onto incoming enemies.
Additive Knockback & Invincible Stun
Hard-locking players into hit animations can make movement feel stiff. Instead, combat uses additive impulse vectors layered directly over the player’s movement input:
var move_velocity = direction * speed
velocity = move_velocity + knockback_velocity
# Exponential knockback decay
knockback_velocity *= 0.91
if knockback_velocity.length() < 15.0:
knockback_velocity = Vector2.ZERO
Rather than locking controls, hits apply an instant velocity impulse that decays rapidly via a drag multiplier. Cutting off velocity below 15 px/s prevents ice-like sliding, while an invincible window keeps enemy swarms from instantly combo-attacking the player to death.
The Two-Phase Boss
Wave 4 introduces the dungeon’s apex predator: Birthday Boy.
stateDiagram-v2
[*] --> Phase1
state Phase1 {
[*] --> P1_Idle
P1_Idle --> Radial_Spread
P1_Idle --> Charge_Dash
P1_Idle --> Cone_Slash
Radial_Spread --> P1_Idle
Charge_Dash --> P1_Idle
Cone_Slash --> P1_Idle
}
Phase1 --> Enraged_Transition: HP <= 3 Hearts
Enraged_Transition --> Phase2
state Phase2 {
[*] --> P2_Idle
P2_Idle --> Ground_Stomp
P2_Idle --> Charge_Slash_Combo
Ground_Stomp --> P2_Idle
Charge_Slash_Combo --> P2_Idle
}
Phase2 --> Defeated: HP <= 0
The boss is governed by a finite state machine:
- Radial Burst: Teleports or centers itself and fires a 10-projectile radial spread across the arena.
- Charge Dash: Winds up for 0.45s, locks heading toward the nearest candle, and dashes forward at 360 px/s, dealing 2 hearts and heavy knockback on collision.
- Cone Slash: Sweeps a $70^\circ$ forward arc dealing 2 hearts of damage to any player trapped inside.
Normal bullets deal zero damage in Phase 2. The boss is vulnerable exclusively to Bombs.
To make this possible, ItemSpawner.gd receives set_bomb_phase(true), skewing spawn weights toward bombs. This forces the two players into an intense ritual:
- One player must voluntarily exhaust stamina and fall asleep.
- Their ghost wakes up and races around the arena collecting bombs.
- The awake player must solo-kite the enraged boss, dodging 360 px/s charge attacks and new rotating AOE death rings.
- The ghost must carefully aim and throw bombs at the charging boss, praying the bomb doesn’t roll a 50% backfire and turn into a death threat chasing their partner!
Boss attacking with ranged attacks.
Visual Polish: Shaders and Glow Harmonics
To evoke the fragile intimacy of candlelight in a suffocating crypt, Candles Out combines dynamic procedural lighting with full-screen post-processing shaders.
Multi-Harmonic Candle Flicker
Rather than using basic random noise that jitters erratically, the candle’s light aura is synthesized from three out-of-phase trigonometric harmonics:
func update_candle_glow(delta: float) -> void:
if not candle_light:
return
glow_time += delta * glow_flicker_speed
# Superposition of 3 prime-frequency harmonics
var flicker := 0.0
flicker += sin(glow_time) * 0.55
flicker += sin((glow_time * 2.23) + 1.7) * 0.3
flicker += sin((glow_time * 3.97) + 0.4) * 0.15
flicker = clamp(flicker, -1.0, 1.0)
var target_energy = glow_base_energy + (flicker * glow_flicker_strength)
if is_sleeping:
target_energy *= glow_sleep_multiplier # 42% energy while sleeping
candle_light.modulate.a = clamp(target_energy, 0.28, 0.95)
This creates organic, lifelike flickering that naturally dims down to a faint ember when the candle collapses into sleep.
Candle glow.
Retro CRT & TV Static Shaders
The viewport is processed through a custom GLSL canvas item shader (Scenes/crt.gdshader):
- Curvature UV Distortion: Curves coordinates outward to simulate the convex glass of a 1990s cathode ray tube.
- RGB Chromatic Aberration: Offsets the red and blue color channels horizontally:
col.r = texture(SCREEN_TEXTURE, cuv + vec2(offset, 0.0)).r;col.b = texture(SCREEN_TEXTURE, cuv - vec2(offset, 0.0)).b; - Scanlines & Radial Vignette: Multiplies horizontal scanline intensity by
sin(cuv.y * 600.0)and darkens the viewport borders based on quadratic distance from center.
Vignette effect.
Architectural Takeaways & Lessons Learned
- State Machines Beat Scene Reparenting in Multiplayer: Attempting to reparent held items between the world and player nodes created endless network synchronization headaches. Keeping items permanently anchored in the world while using a pure state enum (
WORLD | HELD | THROWN) reduced network bugs to zero. - Mechanical Interdependence Breeds True Co-op: Players don’t talk to each other when they have infinite self-sufficiency. By linking healing to total vulnerability (sleep) and item delivery to an incorporeal ghost, we forced players into continuous verbal triage.
- Exponential Interpolation Over Linear Snapping: Basic lerping with fixed steps causes visible latency lag at low FPS and jitter at high FPS. Exponential decay smoothing
(1 - exp(-k * delta))automatically adapts to any refresh rate while remaining mathematically stable. - Chaos is Fun When the Rules are Clear: The 50/50 backfire system works because every backfire is shown with distinctive sound effects, visual sprites, and clear consequences. We observed players scream in laughter when a bomb grows wings or when a shoe turns them into an uncontrollable hedgehog. The failures become a fun part of the game.