Why Water Effects Matter in a Puzzle Game
Bikini Heaven is a swap puzzle game. The player rearranges scrambled tiles to recreate a reference image. It does not need water effects to function. But when we added ripples that emanate from every tile swap, the game felt fundamentally different. Suddenly each swap had a physical weight to it. The grid felt alive. Playtesters started describing the game as "satisfying" instead of just "relaxing," and that is the difference between a game people finish and a game people replay.
The challenge was building a ripple system that could handle multiple concurrent effects without burning the GPU. A single ripple is trivial. Eight overlapping ripples with trailing rings, caustic lighting, and UV distortion running on a mobile-class GPU at 60 FPS required real engineering.
The Shader Architecture
Our ripple shader uses a slot-based system. Eight vec4 uniforms each store one ripple's position (x, y), start time, and intensity. The shader iterates over all 8 slots every fragment, calculates each ripple's contribution, and composites them together. Inactive slots have their start time set to -100, which the shader detects and skips.
// water_ripple.gdshader
shader_type canvas_item;
// Ripple sources (up to 8 concurrent ripples)
// Each vec4: x, y = position (0-1 UV), z = start_time, w = intensity
uniform vec4 ripple_0 = vec4(0.0, 0.0, -100.0, 0.0);
uniform vec4 ripple_1 = vec4(0.0, 0.0, -100.0, 0.0);
// ... ripple_2 through ripple_7
uniform float ripple_speed : hint_range(0.1, 3.0) = 1.2;
uniform float ripple_width : hint_range(0.01, 0.2) = 0.06;
uniform float ripple_decay : hint_range(0.5, 5.0) = 2.0;
uniform float max_ripple_radius : hint_range(0.2, 2.0) = 0.8;
uniform float distortion_strength : hint_range(0.0, 0.1) = 0.02;
The reason we use 8 fixed uniform slots instead of a texture buffer or SSBO is simplicity. Godot's canvas_item shader does not support SSBOs, and encoding ripple data into a texture would add a texture fetch per ripple per fragment. With only 8 slots, the uniform approach is both simpler and faster.
Ripple Ring Calculation
Each ripple is not a single ring but three concentric rings with decreasing intensity. The primary ring expands outward at ripple_speed. A secondary ring trails behind at 2.5x the ripple width. A tertiary ring follows at 5x. This layering creates the look of a ripple spreading across water with realistic trailing waves.
vec4 calculate_ripple(vec2 uv, vec4 ripple_data, float current_time) {
vec2 pos = ripple_data.xy;
float start_time = ripple_data.z;
float intensity = ripple_data.w;
if (intensity <= 0.0 || start_time < 0.0)
return vec4(0.0);
float elapsed = current_time - start_time;
float radius = elapsed * ripple_speed;
if (radius > max_ripple_radius)
return vec4(0.0);
float dist = length(uv - pos);
// Primary ring
float ring = smoothstep(ripple_width, 0.0, abs(dist - radius));
// Secondary trailing ring
float radius2 = radius - ripple_width * 2.5;
if (radius2 > 0.0)
ring += smoothstep(ripple_width * 0.7, 0.0, abs(dist - radius2)) * 0.4;
// Tertiary trailing ring
float radius3 = radius - ripple_width * 5.0;
if (radius3 > 0.0)
ring += smoothstep(ripple_width * 0.5, 0.0, abs(dist - radius3)) * 0.2;
// Exponential decay + edge fade
float decay = exp(-elapsed * ripple_decay);
float edge_fade = smoothstep(max_ripple_radius, max_ripple_radius * 0.7, radius);
// UV distortion with sine wave for push/pull effect
vec2 dir = normalize(uv - pos + vec2(0.001));
float wave_phase = (dist - radius) / ripple_width * 3.14159;
float distort_amount = ring * decay * distortion_strength * intensity * sin(wave_phase);
vec2 distortion = dir * distort_amount;
float visibility = ring * decay * edge_fade * intensity;
return vec4(distortion, visibility, 0.0);
}
The sine wave phase in the distortion calculation is what makes ripples feel physical. Without it, the distortion pushes outward uniformly, which looks like a magnifying glass effect. With the sine wave, pixels on the leading edge push outward while pixels on the trailing edge pull inward, creating the characteristic in-and-out displacement of water surface waves.
Caustic Lighting
Underneath the ripples, the shader renders a continuous caustic light pattern using overlapping sine waves at different frequencies and speeds. This gives the water surface a subtle, dappled-light appearance even when no ripples are active.
float caustics(vec2 uv, float time) {
vec2 p = uv * 8.0;
float c1 = sin(p.x + time) * sin(p.y + time * 0.7);
float c2 = sin(p.x * 1.3 - time * 0.8) * sin(p.y * 0.9 + time * 1.1);
float c3 = sin((p.x + p.y) * 0.7 + time * 0.5);
float caustic = (c1 + c2 + c3) / 3.0;
caustic = caustic * 0.5 + 0.5; // Normalize to 0-1
caustic = pow(caustic, 2.0); // Sharpen
return caustic;
}
The three overlapping layers with slightly irrational frequency ratios (1.0, 1.3, 0.7) prevent the pattern from visibly tiling. The pow(caustic, 2.0) sharpening step converts the soft sine output into the bright-spot-on-dark-background look of real caustic patterns. This runs at full speed even on integrated GPUs because it is just arithmetic -- no texture lookups, no branching.
The GDScript Side: Slot Pool Management
On the CPU side, a WaterRippleEffect class manages the 8 ripple slots. When a tile swap occurs, two ripples are spawned -- one at each swap position. If all 8 slots are occupied, the oldest ripple gets replaced.
## WaterRippleEffect.gd
extends Control
class_name WaterRippleEffect
const MAX_RIPPLES: int = 8
var ripples: Array[Dictionary] = []
func _ready() -> void:
for i in range(MAX_RIPPLES):
ripples.append({
"position": Vector2.ZERO,
"start_time": -100.0,
"intensity": 0.0,
"active": false
})
func add_swap_ripples(pos1: Vector2i, pos2: Vector2i,
grid_width: int, grid_height: int) -> void:
add_ripple_at_grid_cell(pos1.x, pos1.y, grid_width, grid_height, 1.0)
add_ripple_at_grid_cell(pos2.x, pos2.y, grid_width, grid_height, 0.8)
func add_ripple_at_grid_cell(grid_x: int, grid_y: int,
grid_width: int, grid_height: int,
intensity: float = 1.0) -> void:
var cell_size = Vector2(1.0 / float(grid_width), 1.0 / float(grid_height))
var cell_center = Vector2(
(float(grid_x) + 0.5) * cell_size.x,
(float(grid_y) + 0.5) * cell_size.y
)
var slot_index = _find_inactive_slot()
if slot_index < 0:
slot_index = _find_oldest_slot()
ripples[slot_index] = {
"position": cell_center,
"start_time": _get_shader_time(),
"intensity": intensity * ripple_intensity,
"active": true
}
_update_ripple_uniform(slot_index)
The primary swap position gets intensity 1.0 and the secondary gets 0.8. This subtle difference makes the direction of the swap visually readable -- the stronger ripple is where the tile came from. Each swap consumes 2 of our 8 available slots, so we can sustain 4 rapid swaps before the oldest ripple gets recycled. At 3 seconds per ripple lifetime, this has never been a visible problem in practice.
Grid-to-UV Coordinate Conversion
The conversion from grid cell to UV coordinate is the most error-prone part of the system. The grid is not a 1:1 map to screen pixels -- it has padding, margins, and responsive sizing. We solve this by computing the grid container's bounding rect and normalizing positions within that rect, then passing UV-space coordinates (0-1) to the shader.
func add_ripple(screen_position: Vector2, intensity: float = 1.0) -> void:
var local_pos = screen_position - global_position
var uv_pos = local_pos / size
uv_pos = uv_pos.clamp(Vector2.ZERO, Vector2.ONE)
var slot_index = _find_inactive_slot()
if slot_index < 0:
slot_index = _find_oldest_slot()
ripples[slot_index] = {
"position": uv_pos,
"start_time": _get_shader_time(),
"intensity": intensity * ripple_intensity,
"active": true
}
_update_ripple_uniform(slot_index)
The clamp prevents ripples from being placed outside the UV range, which would cause distortion artifacts at the edges. We learned this the hard way -- a ripple placed at UV (-0.1, 0.5) causes a visible line of distortion along the left edge of the grid as the shader tries to sample texture coordinates that wrap or clamp unpredictably.
Performance Lessons
A few things we learned building this system:
- Fixed slot counts beat dynamic arrays on the GPU. Eight uniforms that are always present and always iterated is faster than any dynamic approach because the shader compiler can unroll the loop.
- Exponential decay is your friend.
exp(-elapsed * decay_rate)produces visually smooth fade-outs that naturally approach zero, meaning you do not need explicit lifetime checks for visual quality -- only for slot recycling. - Sine-based caustics are cheap. Three overlapping sine waves look nearly as good as FBM noise caustics at a fraction of the cost. Reserve FBM for the ripple edges where the extra organic variation actually matters.
- UV-space beats screen-space. Working in normalized 0-1 coordinates means the shader does not care about screen resolution, grid position, or window resizing. The GDScript layer handles the coordinate conversion once per ripple spawn, and the shader just works.
The entire system -- shader, effect manager, and grid integration -- is about 400 lines of code total. For the amount of visual polish it adds to every tile swap, that is an exceptionally good return on investment.