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.

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.

Development workflow
I used agents. The loop:
- Describe a feature or bug fix to the agent
- Review and apply code changes in the diff view
- Rebuild WASM with
./build - Refresh the browser to test
- Run checks
- Tell the agent to write a commit
- Push to deploy
The agent handled:
- WASM and JavaScript interop code
- Ecological research, such as harbor seal diets
- CSS layout adjustments
- Git commit messages
I focused on:
- Game design decisions
- Balance tuning from playtesting
- Feature priorities
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.