Building a Real-Time Game
Case study: How BagTrax uses GO(A)HT for real-time cornhole scoring.
BagTrax is a real-time cornhole scoring app built with GO(A)HT. The useful lesson is not “how do we do everything with one tool?” It is how HTMX, Alpine, and SSE split the work cleanly.
The Split
BagTrax uses both HTMX and Alpine, but not in equal measure.
- HTMX owns the lobby and other server-rendered surfaces.
- Alpine owns the fast local scoring interactions.
- SSE keeps both surfaces fresh.
That split keeps the app simple: the server usually returns HTML directly, and HTMX moves that HTML around. Alpine only handles the parts that benefit from immediate local feedback.
The Lobby
<section id="lobby"
hx-ext="sse"
sse-connect="/game/{{ .State.Game.ID }}/events"
hx-get="/game/{{ .State.Game.ID }}/lobby"
hx-trigger="sse:refresh"
hx-target="#lobby"
hx-swap="innerHTML"
>
<!-- Game code, team slots, join buttons -->
<span class="font-mono text-3xl">{{ .State.Game.Slug }}</span>
</section>
The lobby is mostly HTML. When someone joins, the server broadcasts a refresh event and HTMX re-fetches the lobby fragment from a fragment route. The result is a live lobby without turning the page into a client app.
The Scoring Surface
Scoring needs immediate feedback, so Alpine handles the local interaction and optimistic update path.
<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>
The server still decides the final state. Alpine only bridges the gap between tap and confirmation.
SSE Keeps Both Fresh
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
}
}
The same SSE stream feeds both surfaces: HTMX uses it to refresh lobby HTML, and Alpine uses it to merge updated scoring state.
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: refresh\ndata: {}\n\n")
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, while HTMX just re-requests the HTML it needs.
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);
}
What The Case Study Shows
- HTMX is a good fit for server-rendered, event-driven pages.
- Alpine is a good fit for local interaction and optimistic UI.
- SSE is the shared refresh path that keeps both surfaces aligned.