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. It pushes the board down one row at a time
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)
}
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
The engine resolves each shot 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. It commits the landing cell then and never recomputes it:
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. It never recalculates placement. Given the same seed and input sequence, the state is identical. These rules prevent 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 30 points per bubble and bursts score 20. This rewards shots that cut a support and drop a cluster of bubbles.
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 game tick clamps large time jumps to 0.25 seconds, so backgrounding the tab does not skip several tide rises at once.
Spawn patterns
Four templates define the top row spawned on each tide. The engine uses weighted randomness to favor patterns not used recently and avoid 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:
- Pinch lane builds a narrow gate with targets behind it, rewarding a squeeze shot.
- Wall bank hides targets in a side pocket behind blockers, rewarding a bank shot off the wall.
- Scaffold fall spawns a long support bar with hangers beneath, rewarding a cut that drops the whole cluster.
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 draws a Ram, the engine rolls again for a normal creature.
Matches do not clear Rams, so they clutter the board and force awkward shots.
Determinism and logging
Like Duck Duck Bay, Marshfall uses Go 1.22's rand/v2 with a seeded PCG
generator. A seed and input sequence replay exactly.
Start the dev server with LOG=true to emit one line per turn with the
seed, the fired bubble, the landing cell, popped and fallen counts, and the
board position. Copy the seed from a failed game and replay it locally
to debug a visual glitch.
Play
Play the game at marshfall.com.