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:

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.