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:
- Pinch lane: a narrow gate with targets behind it.
- Wall bank: targets in a side pocket behind blockers, for a bank shot.
- Scaffold fall: a long support bar with hangers beneath, for a cut that drops the cluster.
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.