apps / duck duck bay
I built a mobile web-based puzzle game, Duck Duck Bay.
The player builds a food chain to attract a dragon in Duxbury, Massachusetts. 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
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 (see web / cdn):
// 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 return visits fast.
Version checking
When the player starts a new game, the client compares its WASM hash
with /api/version:
async function checkVersionAndStartGame() {
const response = await fetch("/api/version");
const serverVersion = await response.text();
if (serverVersion !== window.GAME_VERSION) {
window.location.reload();
return;
}
hideOverlay();
startGame();
}
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"
}
A deploy updates the game for the player on the next run without a manual refresh.
Balance
A simulation in cmd/balance plays thousands of games. It scores every
action and picks from the top choices with weighted randomness:
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
if e.Hunger >= 4 {
score += 100.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
}
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 identifies species that never appear or starve immediately.
Spawn mechanics
New creatures spawn near their food. The engine finds available cells, computes Manhattan distance to existing prey, and weights closer cells higher.
Similar species compete for spawn slots. The engine favors the less common species in each group:
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
}
This prevents squirrels from crowding 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.

Deadcode check
deadcode needs the -test flag to detect
functions behind the //go:build js && wasm tag:
deadcode -test ./...
A test for each WASM-exported function keeps the code reachable during static analysis.