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.

Duck Duck Bay mid-game board

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:

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.

Duck Duck Bay ecosystem guide

Development workflow

I used Warp heavily throughout development. A typical feedback loop was:

  1. Describe a feature or bug fix to the agent
  2. Review and apply code changes in the diff view
  3. Rebuild WASM with ./build
  4. Refresh browser to test
  5. Run checks
  6. Tell agent to write a commit
  7. Push to deploy

The agent handled:

I focused on:

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.

← All articles