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:
- Pinch lane builds a narrow gate with a reward 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 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.