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, pushes the board down one row, 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)
}

Even and odd rows have different diagonal neighbors, so every search goes through one parity-aware neighbor function.

Fire time

The engine resolves each shot at fire time. It steps the trajectory in 1/240-second increments until the bubble hits the ceiling or another bubble:

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, so the animation and the landing cell agree.

Match and fall

After a bubble attaches, the engine flood-fills same-type neighbors. Three or more burst, and 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
}

A second flood fill from the top row finds the detached bubbles:

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

A fall scores 30 points per bubble and a burst scores 20.

Tide

Each tide 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 tick clamps a time jump to 0.25 seconds, so a backgrounded tab does not skip several rises.

Spawn patterns

Four templates define the top row each tide spawns, picked with weighted randomness that favors the pattern used least recently:

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

Each template sets up a shot:

Ram

The Ram (🐏) is a hazard piece named for Marshfield. It spawns on the board, never loads into the cannon, and does not match, so it clutters the board.

Determinism and logging

Marshfall uses rand/v2 with a seeded PCG generator, so a seed and input sequence replay exactly. LOG=true on the dev server emits one line per turn with the seed, the fired bubble, the landing cell, the popped and fallen counts, and the board position.

Play

Play the game at marshfall.com.

← All articles