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:
- 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. Each line has the seed, the fired bubble, the landing
cell, the popped and fallen counts, and the board position.