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 β
βββββββββββββββ ββββββββββββββββ βββββββββββββ
- Alpine.js sends scoring actions to JSON API endpoints
- Go handlers process the action and update PostgreSQL
- The handler broadcasts the new state via SSE (through Redis for multi-instance)
- 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)withease-out-backon 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
- HTMX for navigation, Alpine for interaction. Don’t force one tool to do everything.
- Optimistic updates are essential for games. 200ms latency is unacceptable for scoring.
- SSE > WebSockets for this use case. One-directional serverβclient is all you need. Auto-reconnect is free.
- Redis pub/sub is cheap insurance. Even if you start with one instance, add Redis from day one.
- 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. π