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, pushing the board down one row at a time, and grows faster with every rise. Let it reach the marsh edge and 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

A shot is resolved exactly 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. The landing cell is committed then and never recomputed:

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 committed state. It never re-resolves placement. Given the same seed and input sequence, the state is identical. These invariants prevent a whole class of 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 more than bursts (30 versus 20 points per bubble), rewarding shots that cut a support and drop a chunk of the board.

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 tick clamps large time jumps to 0.25 seconds so backgrounding the tab does not skip multiple tide rises at once.

Spawn patterns

The top row spawned on each tide comes from four templates. The engine picks with weighted randomness that favors patterns it has not used recently, avoiding 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 would draw a Ram, it rerolls to a normal creature. Rams cannot be cleared by matching, so they clutter the board and force awkward lines.

Determinism and logging

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

Play

Play the game at marshfall.com.

← All articles