games / duck duck bay

I built a mobile web-based puzzle game, Duck Duck Bay.

The setting is Duxbury, Massachusetts. The species are the ones we see on the beach, in the woods, and in the harbor.

The player builds a food chain to attract a dragon. Plants attract herbivores, and herbivores attract predators.

Duck Duck Bay mid-game board

Architecture

A Go HTTP server hosted on Render serves static assets and HTML templates.

The game engine compiles Go code 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 uses vanilla HTML, CSS, and JavaScript to load the WASM binary and render the game board.

Game data

Go defines all game data and exposes it 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
}

JavaScript calls exported functions such as getSpeciesData(), so the help content and food chain diagrams read the same structs as the engine.

Event sourcing

The engine derives game state 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, or skip turn. The engine rebuilds state by replaying the log with a seeded RNG, rand.NewPCG from rand/v2.

To debug, I copy the seed and event log from a broken game and replay them locally.

Asset fingerprinting

The server fingerprints the WASM file, CSS, and JavaScript with MD5 hashes and sets one-year cache headers, the same pattern as my other projects:

// 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)
})

The WASM file is 4 MB, so a CDN cache makes a return visit fast.

Version checking

When the player clicks "New Game", the client compares its WASM hash with /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();
}

A deploy reaches the player at their next game without a manual refresh.

The server endpoint:

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

A simulation in cmd/balance plays thousands of games. It scores every action and picks from the top 30% with weighted randomness, like an intermediate player:

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 favors 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 fails when a metric leaves its target range:

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 finds a species that never appears or starves at once.

Playtesting found more. My wife, daughter, and I played many games at home. Their feedback set the final balance.

Spawn mechanics

New creatures spawn near their food:

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 compete for spawn slots, and the engine favors the less common one:

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 this, squirrels crowd out rabbits.

Ecology

Go defines why each predator eats its prey and exposes it 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
    }
}

The in-game food chain guide reads that data.

Duck Duck Bay ecosystem guide

Development workflow

I used agents. The loop:

  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 the browser to test
  5. Run checks
  6. Tell the 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 ./...

Without -test, deadcode misses functions behind the //go:build js && wasm tag. A test for a WASM-exported function makes it reachable.

Play

Play the game at duckduckbay.com.

← All articles