apps / 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 and pushes the board down. 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:

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, and spawns a fresh top row. It also 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 spawn patterns define the top row each tide spawns: a baseline row and three templates. The engine picks one with weighted randomness that favors the template 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. Each line has the seed, the fired bubble, the landing cell, the popped and fallen counts, and the board position.

← All articles