games / duck duck bay
I built a mobile web-based puzzle game, Duck Duck Bay.
The game is based on Duxbury, Massachusetts, home to plovers on the beach, coyotes in the woods, and seals in the harbor. The game's ecosystem is based on what we see in our backyard and on walks.
The goal is to build a food chain that attracts a dragon. Plants attract herbivores which attract predators, building up until the apex predator appears.

Architecture
A Go HTTP server serves static assets and HTML templates, hosted on Render.
The game engine is Go code compiled to WebAssembly (WASM):
#!/bin/bash
GOOS=js GOARCH=wasm go build -o web/game.wasm ./cmd/game
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" web/
The client is vanilla HTML, CSS, and JavaScript that loads the WASM and renders the game board.
Single source of truth
All game data is defined in Go and exposed to the UI:
type SpeciesInfo struct {
Name string
Emoji string
Tier int
Diet []Species
Zones []Zone
IsPlant bool
}
var speciesRegistry = map[Species]*SpeciesInfo{
Deer: {
Name: "Deer",
Emoji: "🦌",
Tier: 6,
Diet: []Species{Leaf, Tree},
Zones: []Zone{Woods},
},
// ... 22 more species
}
WASM exports functions like getSpeciesData() that JavaScript calls
to populate the UI with species information.
This means the help content, food chain diagrams,
and game mechanics are all derived from the same Go structs.
Event sourcing
Game state is derived by replaying events:
type GameState struct {
Board Board
Turn int
Score int
Won bool
Events []Event
Seed int64
rng *rand.Rand
}
func NewGame(seed int64) *GameState {
g := &GameState{
Seed: seed,
rng: rand.New(rand.NewPCG(uint64(seed), uint64(seed>>32))),
}
g.placeStartingEntities()
return g
}
func (g *GameState) Apply(e Event) {
e.Apply(g)
g.Events = append(g.Events, e)
}
Every player action generates an event (move, reproduce, skip turn).
The game state is rebuilt by replaying the event log with a seeded RNG.
Go 1.22's rand/v2 package provides rand.NewPCG,
a permuted congruential generator that's fast and deterministic.
This made debugging reproducible: copy the seed and event log from a broken game state, replay it locally, and step through to debug.
Asset fingerprinting
I reused the fingerprinting pattern from other projects.
The WASM file, CSS, and JavaScript are fingerprinted with MD5 hashes and served with 1-year cache headers:
// Fingerprint WASM
wasmBytes, _ := os.ReadFile("web/game.wasm") // error handling omitted
h := md5.New()
h.Write(wasmBytes)
wasmHash := fmt.Sprintf("%x", h.Sum(nil))
s.wasmPath = fmt.Sprintf("/game-%s.wasm", wasmHash[:8])
// Serve with long cache
mux.HandleFunc("GET "+s.wasmPath, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/wasm")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Write(s.wasmContent)
})
This was important because the WASM file is ~4 MB. With aggressive caching via CDN, returning visitors load the WASM from cache quickly.
Version checking
The fingerprinting pattern created a nice opportunity: the server knows the current game version (the WASM hash), so the client can check if an update is available.
When clicking "New Game", the client checks /api/version:
async function checkVersionAndStartGame() {
try {
const response = await fetch("/api/version");
const serverVersion = await response.text();
if (serverVersion !== window.GAME_VERSION) {
window.location.reload();
return;
}
} catch (err) {
console.warn("Failed to check game version:", err);
}
// Same version - restart in-browser without reload
hideOverlay();
startGame();
}
If a deploy happened mid-session, the player gets the update on their next game without manual refresh. If no update, the game restarts instantly in-browser.
The server endpoint is simple:
func (s *Server) version(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(s.gameVersion)) // WASM hash or "dev"
}
Balance
I wrote a simulation that plays thousands of games to test balance.
It lives in cmd/balance, separate from cmd/server and cmd/game.
Go repos can have multiple entry points like this,
which is useful for development-only tools.
The simulation scores all actions and picks from the top 30% with weighted randomness, approximating intermediate-skilled play:
func pickRandomAction(g *game.GameState, allActions []action) action {
// Score all actions
scored := make([]scoredAction, len(allActions))
for i, a := range allActions {
scored[i] = scoredAction{action: a, score: scoreAction(g, a)}
}
// Sort by score descending
sort.Slice(scored, func(i, j int) bool {
return scored[i].score > scored[j].score
})
// Pick from top 30% with weighted randomness
topN := len(scored) * 3 / 10
if topN < 1 {
topN = 1
}
// Weight: top choice gets highest weight, declining linearly
totalWeight := 0
weights := make([]int, topN)
for i := range weights {
weights[i] = topN - i
totalWeight += weights[i]
}
r := g.Rand(totalWeight)
sum := 0
for i, w := range weights {
sum += w
if r < sum {
return scored[i].action
}
}
return scored[0].action
}
The scoring function prioritizes feeding starving creatures and reproducing higher-tier species:
func scoreAction(g *game.GameState, a action) float64 {
if a.kind == "skip" {
return 0.0
}
score := 0.0
if a.kind == "feed" {
e := g.Board.Get(a.pos1)
score += float64(e.Species.Tier()) * 10.0
// Urgency: starving creatures are critical
if e.Hunger >= 4 {
score += 100.0
} else if e.Hunger >= 3 {
score += 50.0
} else if e.Hunger >= 2 {
score += 25.0
}
}
if a.kind == "reproduce" {
tier := g.Board.Get(a.pos1).Species.Tier()
score += float64(tier) * 15.0
// Mid-tier creatures are key to food chains
if tier >= 3 && tier <= 5 {
score += 20.0
}
}
return score
}
The simulation validates against target metrics and fails if balance is off:
const (
TargetWinRateMin = 15.0 // %
TargetWinRateMax = 35.0 // %
TargetAvgTurnsMin = 150.0 // ~3 years
TargetAvgTurnsMax = 350.0 // ~7 years
TargetMaxStarvation = 2.0 // deaths/turn
KeyPredatorThreshold = 5.0 // % of games
)
The simulation identifies issues like:
- Species that never appeared (thresholds too high)
- Species that appeared but died instantly (not enough food)
- Games that ended too quickly (win condition too easy)
But playtesting was more valuable than simulation. My wife, daughter, and I played many games around the house, finding edge cases the simulation never discovered: species that felt overpowered, spawn rates that felt unfair, and win conditions that felt arbitrary. Their feedback shaped the final balance.
Spawn mechanics
New creatures spawn weighted by proximity to their food. A fox is more likely to appear near rabbits than in an empty corner:
func (g *GameState) WeightedRandPosition(positions []Position, species Species) (Position, bool) {
diet := species.Diet()
var foodPositions []Position
for row := 0; row < BoardSize; row++ {
for col := 0; col < BoardSize; col++ {
pos := Position{row, col}
if entity := g.Board.Get(pos); entity != nil {
for _, food := range diet {
if entity.Species == food {
foodPositions = append(foodPositions, pos)
break
}
}
}
}
}
if len(foodPositions) == 0 {
return g.RandPosition(positions) // Uniform random fallback
}
// Weight by distance: adjacent = 10, dist 2 = 5, dist 3 = 2, further = 1
weights := make([]int, len(positions))
for i, pos := range positions {
minDist := manhattanDistance(pos, foodPositions[0])
for _, foodPos := range foodPositions[1:] {
if dist := manhattanDistance(pos, foodPos); dist < minDist {
minDist = dist
}
}
switch minDist {
case 1:
weights[i] = 10
case 2:
weights[i] = 5
case 3:
weights[i] = 2
default:
weights[i] = 1
}
}
// Weighted random selection
// ...
}
Similar species also compete for spawn slots, preferring the less common one to maintain biodiversity:
var biodiversityGroups = [][]Species{
{Squirrel, Rabbit}, // Woodland herbivores
{Fox, Owl}, // Mid-tier woodland predators
{Fox, Coyote}, // Woodland apex predators
{Fish, Lobster}, // Bay mid-tier
{Seal, Shark}, // Bay apex predators
{Turkey, Duck, Plover}, // Ground birds
}
Without biodiversity balancing, squirrels dominated because they spawn earlier and crowd out rabbits. With it, both species appear regularly.
Ecology
The help content explains why each predator eats what it does. These explanations are defined in Go and exposed to JavaScript:
type DietExplanation struct {
Predator Species
PredatorEmoji string
Prey Species
PreyEmoji string
Explanation template.HTML
}
func GetDietExplanations() []DietExplanation {
return []DietExplanation{
{Owl, "🦉", Plover, "🐦", template.HTML(
`In winter, snowy owls hunt plovers on Duxbury beaches.`)},
{Coyote, "🐺", Fox, "🦊", template.HTML(
`Coyotes kill foxes in territorial disputes (intraguild predation).`)},
{Lobster, "🦞", Crab, "🦀", template.HTML(
`Lobsters prey on crabs and are dominant in the benthic zone.`)},
// ~100 more explanations
}
}
This data drives the in-game food chain reference. Clicking a species shows what it eats, what eats it, and why.

Development workflow
I used Warp heavily throughout development. A typical feedback loop was:
- Describe a feature or bug fix to the agent
- Review and apply code changes in the diff view
- Rebuild WASM with
./build - Refresh browser to test
- Run checks
- Tell agent to write a commit
- Push to deploy
The agent handled:
- WASM/JavaScript interop boilerplate
- Ecological research (what do harbor seals eat?)
- CSS layout tweaks
- Git commit messages
I focused on:
- Game design decisions
- Balance tuning from playtesting
- Feature prioritization
Development checks
I run the standard Go checks before committing:
goimports -local "$(go list -m)" -w .
go vet ./...
go test ./...
deadcode -test ./...
The -test flag includes test binaries in the analysis.
Without it, deadcode misses functions called from WASM
because the //go:build js && wasm entry point is invisible to native analysis.
Adding tests for WASM-exported functions makes them reachable.
Play
Play the game at duckduckbay.com.