games / marshfall

I built a mobile web-based bubble shooter game, Marshfall.

You defend the marsh from creatures arriving on the tides. Aim a cannon at the bottom of the board and fire creature bubbles (🐼 🐸 🐷 🦊 🐶 🐯) into a staggered hex grid. Match three or more of a kind to burst them. Bubbles that lose their anchor to the top row fall. The tide rises on a timer. It pushes the board down one row at a time and speeds up with each rise. If the board reaches the marsh edge, you lose.

Architecture

The architecture is the same as Duck Duck Bay: a Go game engine compiled to WebAssembly, a Go HTTP server for static assets and templates, and vanilla HTML, CSS, and JavaScript on the client.

Staggered grid

The board is a 22-row by 10-column grid with alternating row offsets, approximating hex packing. Odd rows shift half a bubble and drop one playable cell at the far right:

func isPlayableCellWithOffset(row, col, rowParityOffset int) bool {
    if row < 0 || row >= GridRows || col < 0 || col >= GridCols {
        return false
    }
    // Offset rows: odd rows have one fewer playable cell at the far right.
    return !((row+rowParityOffset)%2 == 1 && col == GridCols-1)
}

Neighbor lookups depend on row parity, so even and odd rows use different diagonal offsets. Every cluster search, anchor check, and match walk goes through this parity-aware neighbor function.

Resolve once at fire time

The engine resolves each shot once, at fire time. The engine simulates the trajectory in fixed 1/240-second steps, bouncing off the side walls, until the bubble hits the ceiling or another bubble. It commits the landing cell then and never recomputes it:

for elapsed := 0.0; elapsed <= trajectoryMaxSeconds; elapsed += trajectoryStepSeconds {
    x += vx * trajectoryStepSeconds
    y += vy * trajectoryStepSeconds

    if x < minX {
        x = minX + (minX - x)
        vx *= -1
    } else if x > maxX {
        x = maxX - (x - maxX)
        vx *= -1
    }
    // ... ceiling and collision checks
}

Rendering reads the committed state. It never recalculates placement. Given the same seed and input sequence, the state is identical. These rules prevent timing bugs where the landing cell computed at fire time disagrees with the one computed after animation.

Match and fall

After a bubble attaches, the engine flood-fills same-type neighbors. Three or more burst. Bubbles no longer connected to the top row fall:

func (g *GameState) resolvePostAttach(row, col int) (popped int, fallen int) {
    cluster := g.sameTypeCluster(row, col)
    if len(cluster) >= 3 {
        g.PoppedBubbles = g.clearCells(cluster)
    }
    popped = len(g.PoppedBubbles)
    fallen = g.captureDetachedBubbles()
    return popped, fallen
}

Detachment is a second flood fill, this time from the top row. Anything unreachable is loose and falls:

for col := 0; col < GridCols; col++ {
    if !g.isPlayableCell(0, col) || g.Grid[0][col] == nil {
        continue
    }
    visited[0][col] = true
    queue = append(queue, [2]int{0, col})
}
// BFS from the ceiling; unvisited occupied cells have lost their anchor

Falls score 30 points per bubble and bursts score 20. This rewards shots that cut a support and drop a cluster of bubbles.

Tide pressure

A timer drives the tide. Each rise shifts the board down a row, flips the row parity, spawns a fresh top row, and shortens the next interval by a tenth of a second down to a two-second floor:

func (g *GameState) nextTideIntervalSeconds() float64 {
    next := g.TideIntervalSeconds - TideIntervalStepSeconds
    if next < MinimumTideIntervalSeconds {
        return MinimumTideIntervalSeconds
    }
    return next
}

The game tick clamps large time jumps to 0.25 seconds, so backgrounding the tab does not skip several tide rises at once.

Spawn patterns

Four templates define the top row spawned on each tide. The engine uses weighted randomness to favor patterns not used recently and avoid repeats:

pinchWeight := 2 + minInt(g.turnsSincePinchLane, 4)
wallWeight := 2 + minInt(g.turnsSinceWallBank, 4)
scaffoldWeight := 1 + minInt(g.turnsSinceScaffoldFall/2, 4)

Each template sets up a distinct shot:

Special Ram

The Ram (🐏) is a hazard piece, a nod to Marshfield. It spawns on the board but never loads into the cannon. If the shooter draws a Ram, the engine rolls again for a normal creature. Matches do not clear Rams, so they clutter the board and force awkward shots.

Determinism and logging

Like Duck Duck Bay, Marshfall uses Go 1.22's rand/v2 with a seeded PCG generator. A seed and input sequence replay exactly. Start the dev server with LOG=true to emit one line per turn with the seed, the fired bubble, the landing cell, popped and fallen counts, and the board position. Copy the seed from a failed game and replay it locally to debug a visual glitch.

Play

Play the game at marshfall.com.

← All articles