Building a Real-Time Game

Case study: How Airmailed uses the GOaT stack for real-time cornhole scoring.

Airmailed is a real-time cornhole scoring app built entirely with the GOaT stack. This guide walks through the architecture decisions and patterns that make it work.

The Challenge

A real-time scoring app needs:

  • Instant feedback β€” tap a button, see the score update immediately
  • Multi-device sync β€” both teams see the same state in real time
  • Offline resilience β€” handle network blips gracefully
  • Mobile-first UX β€” big tap targets, haptic feedback, one-handed use

Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Browser    │────▢│  Go Server   │────▢│ PostgreSQLβ”‚
β”‚  (Alpine.js) │◀───│  (handlers)  │◀───│           β”‚
β”‚              β”‚ SSE β”‚              β”‚     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚              │◀───│   SSE Hub    │────▢│   Redis   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  1. Alpine.js sends scoring actions to JSON API endpoints
  2. Go handlers process the action and update PostgreSQL
  3. The handler broadcasts the new state via SSE (through Redis for multi-instance)
  4. All connected browsers receive the state update and re-render

The Lobby (HTMX)

The game lobby uses HTMX β€” it’s a standard server-rendered page with real-time updates:

<div
  hx-ext="sse"
  sse-connect="/game/{{ .State.Game.ID }}/events"
  hx-get="/game/{{ .State.Game.ID }}"
  hx-trigger="sse:refresh"
  hx-target="main"
  hx-swap="innerHTML"
>
  <!-- Game code, team slots, join buttons -->
  <span class="font-mono text-3xl">{{ .State.Game.Slug }}</span>
</div>

When a player joins, the server broadcasts event: refresh\ndata: player-joined. HTMX re-fetches the full lobby page and swaps it in. Simple, reliable, zero JavaScript.

The Game (Alpine.js)

Once the game starts, we switch to Alpine.js. Scoring needs optimistic updates β€” you can’t wait 200ms for a server round-trip when someone taps “IN +3”.

<div x-data="gameStore({{ .State.Game.ID }})" x-init="init()">
  <!-- Scoreboard -->
  <span class="text-6xl font-bold" x-text="state.team1.score">0</span>

  <!-- Scoring buttons -->
  <button @click="throwBag(1, 'hole')" :disabled="state.team1.bags_used >= 4">
    IN +3
  </button>
</div>

Optimistic Updates

The key UX pattern: update the UI instantly, confirm with the server, handle conflicts.

async throwBag(teamId, throwType) {
    const team = teamId === 1 ? this.state.team1 : this.state.team2;
    if (team.bags_used >= 4) return;

    // 1. Optimistic update (instant)
    team.bags_used++;
    if (throwType === 'hole') team.bags_in++;
    else if (throwType === 'board') team.bags_on++;

    // 2. Haptic feedback (mobile)
    if (navigator.vibrate) {
        if (throwType === 'hole') navigator.vibrate([50, 30, 50]);
        else if (throwType === 'board') navigator.vibrate([30]);
    }

    // 3. Server confirmation
    try {
        const res = await fetch('/api/game/' + this.gameId + '/throw', {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: 'team_id=' + teamId + '&throw_type=' + throwType,
        });
        this.mergeState(await res.json()); // Server is source of truth
    } catch (e) {
        await this.fetchState(); // Full rollback on error
    }
}

SSE for Multi-Device Sync

The server broadcasts state updates to all connected clients:

func (h *GameHandler) APIRecordThrow(w http.ResponseWriter, r *http.Request) {
    // ... process throw ...

    // Get updated state
    state, _ := h.gameSvc.GetGameState(ctx, gameID)
    resp := stateToJSON(&state)

    // Broadcast to all clients via SSE
    go func() {
        jsonBytes, _ := json.Marshal(resp)
        h.sseHub.Broadcast(gameID, "event: state\ndata: "+string(jsonBytes))
    }()

    // Return to the requesting client
    writeJSON(w, resp)
}

On the client, SSE updates are merged into Alpine state:

connectSSE() {
    const es = new EventSource('/game/' + this.gameId + '/events');
    es.addEventListener('state', (e) => {
        // Don't merge while a local action is in-flight
        if (!this.loading) this.mergeState(JSON.parse(e.data));
    });
    es.onerror = () => setTimeout(() => this.connectSSE(), 2000);
}

The JSON API

Dedicated endpoints for Alpine, separate from HTMX HTML endpoints:

// JSON state for Alpine
mux.Handle("GET /api/game/{id}/state", auth.OptionalAuth(http.HandlerFunc(handlers.Game.APIGetState)))
mux.Handle("POST /api/game/{id}/throw", auth.OptionalAuth(http.HandlerFunc(handlers.Game.APIThrow)))
mux.Handle("POST /api/game/{id}/next-round", auth.OptionalAuth(http.HandlerFunc(handlers.Game.APINextRound)))
mux.Handle("POST /api/game/{id}/clear-round", auth.OptionalAuth(http.HandlerFunc(handlers.Game.APIClearRound)))

// SSE stream
mux.HandleFunc("GET /game/{id}/events", handlers.SSE.GameStream)

Mobile UX Details

Small details that make the game feel native:

  • Large tap targets β€” scoring buttons are full-width, 48px+ minimum height
  • Haptic feedback β€” navigator.vibrate() on every action
  • Score pop animation β€” CSS scale(1.25) with ease-out-back on score changes
  • Bag indicators β€” visual dots showing remaining bags per team
  • Celebration overlay β€” confetti animation when a team hits 21
.score-pop { animation: score-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); }
@keyframes score-pop {
    0% { transform: scale(1); }
    50% { transform: scale(1.25); }
    100% { transform: scale(1); }
}

What We Learned

  1. HTMX for navigation, Alpine for interaction. Don’t force one tool to do everything.
  2. Optimistic updates are essential for games. 200ms latency is unacceptable for scoring.
  3. SSE > WebSockets for this use case. One-directional server→client is all you need. Auto-reconnect is free.
  4. Redis pub/sub is cheap insurance. Even if you start with one instance, add Redis from day one.
  5. The GOaT stack works for real-time apps. You don’t need React or a SPA framework for interactive, real-time experiences.

Total JavaScript shipped: Alpine.js (17KB) + the inline game store (~3KB). No framework. No build step. No bundle. 🐐