435 lines
11 KiB
Go
435 lines
11 KiB
Go
// HTMX Reactive Patterns Example - Go 1.22+
|
|
// Production-ready HTMX patterns for game backends without JavaScript frameworks
|
|
//
|
|
// Last Updated: 2026-03-14
|
|
// Go Version: 1.22+
|
|
// HTMX Version: 1.9.x
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
//go:embed templates/*.tmpl
|
|
var templatesFS embed.FS
|
|
|
|
// GameState represents the server-authority game state
|
|
type GameState struct {
|
|
Players map[string]*Player
|
|
Events chan GameEvent
|
|
mu sync.RWMutex
|
|
Sequence uint64
|
|
Timestamp time.Time
|
|
}
|
|
|
|
// Player represents a game player with HTMX-compatible state
|
|
type Player struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Level int `json:"level"`
|
|
HP int `json:"hp"`
|
|
MP int `json:"mp"`
|
|
XP int `json:"xp"`
|
|
LastAction string `json:"last_action"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// GameEvent represents a server-driven game event
|
|
type GameEvent struct {
|
|
Type string `json:"type"`
|
|
Payload interface{} `json:"payload"`
|
|
Sequence uint64 `json:"sequence"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Source string `json:"source"` // "server" for server authority
|
|
}
|
|
|
|
// GameStateServer implements server-authority pattern
|
|
type GameStateServer struct {
|
|
state *GameState
|
|
clients map[string]chan GameEvent
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewGameStateServer creates server with proper initialization
|
|
func NewGameStateServer() *GameStateServer {
|
|
return &GameStateServer{
|
|
state: &GameState{
|
|
Players: make(map[string]*Player),
|
|
Events: make(chan GameEvent, 100),
|
|
Sequence: 0,
|
|
Timestamp: time.Now(),
|
|
},
|
|
clients: make(map[string]chan GameEvent),
|
|
}
|
|
}
|
|
|
|
// GetPlayer returns player with server authority validation
|
|
func (s *GameStateServer) GetPlayer(id string) (*Player, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
player, ok := s.state.Players[id]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return player, true
|
|
}
|
|
|
|
// UpdatePlayer performs server-authority update with audit logging
|
|
func (s *GameStateServer) UpdatePlayer(id string, action string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
player, ok := s.state.Players[id]
|
|
if !ok {
|
|
return fmt.Errorf("player not found: %s", id)
|
|
}
|
|
|
|
// Server-authority validation
|
|
if err := s.validateAction(player, action); err != nil {
|
|
log.Printf("[AUDIT] Rejected action %s for player %s: %v", action, id, err)
|
|
return err
|
|
}
|
|
|
|
// Apply server-authority update
|
|
oldState := *player
|
|
s.state.Sequence++
|
|
s.state.Timestamp = time.Now()
|
|
|
|
switch action {
|
|
case "attack":
|
|
player.HP = max(0, player.HP-10)
|
|
player.XP += 5
|
|
player.LastAction = "attack"
|
|
case "heal":
|
|
if player.MP >= 10 {
|
|
player.HP = min(100, player.HP+20)
|
|
player.MP -= 10
|
|
player.LastAction = "heal"
|
|
player.XP += 2
|
|
}
|
|
case "rest":
|
|
player.MP = min(100, player.MP+15)
|
|
player.LastAction = "rest"
|
|
}
|
|
|
|
player.UpdatedAt = s.state.Timestamp
|
|
|
|
// Broadcast event with sequence number for eventual consistency
|
|
event := GameEvent{
|
|
Type: "PLAYER_UPDATE",
|
|
Payload: player,
|
|
Sequence: s.state.Sequence,
|
|
Timestamp: s.state.Timestamp,
|
|
Source: "server",
|
|
}
|
|
|
|
s.state.Events <- event
|
|
log.Printf("[AUDIT] Player %s updated: %s -> %+v", id, action, player)
|
|
|
|
// Broadcast to WebSocket clients
|
|
s.broadcastEvent(event)
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateAction implements server-authority validation
|
|
func (s *GameStateServer) validateAction(player *Player, action string) error {
|
|
switch action {
|
|
case "attack":
|
|
if player.HP <= 0 {
|
|
return fmt.Errorf("player HP is 0")
|
|
}
|
|
case "heal":
|
|
if player.MP < 10 {
|
|
return fmt.Errorf("insufficient MP")
|
|
}
|
|
case "rest":
|
|
// Always valid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// broadcastEvent sends event to all WebSocket clients
|
|
func (s *GameStateServer) broadcastEvent(event GameEvent) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for clientID, ch := range s.clients {
|
|
select {
|
|
case ch <- event:
|
|
log.Printf("[WS] Event sent to client %s", clientID)
|
|
default:
|
|
log.Printf("[WS] Client %s buffer full, dropping event", clientID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// pollHandler implements HTMX polling pattern for metrics
|
|
func (s *GameStateServer) pollHandler(w http.ResponseWriter, r *http.Request) {
|
|
// Polling interval matches game tick rate (2s default)
|
|
w.Header().Set("Content-Type", "text/html")
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
stats := struct {
|
|
TotalPlayers int
|
|
TotalEvents int
|
|
LastUpdate time.Time
|
|
}{
|
|
TotalPlayers: len(s.state.Players),
|
|
TotalEvents: cap(s.state.Events),
|
|
LastUpdate: s.state.Timestamp,
|
|
}
|
|
|
|
// HTMX polling response - updates metrics dashboard
|
|
fmt.Fprintf(w, `
|
|
<div id="metrics" hx-trigger="every 2s" hx-get="/metrics">
|
|
<p>Players: %d</p>
|
|
<p>Events Buffer: %d</p>
|
|
<p>Last Update: %s</p>
|
|
<span aria-live="polite">Updated at %s</span>
|
|
</div>
|
|
`, stats.TotalPlayers, stats.TotalEvents, stats.LastUpdate, stats.LastUpdate)
|
|
}
|
|
|
|
// wsHandler implements HTMX WebSocket extension for real-time events
|
|
func (s *GameStateServer) wsHandler(w http.ResponseWriter, r *http.Request) {
|
|
// HTMX WebSocket extension pattern
|
|
// Note: Production implementation should use gorilla/websocket
|
|
clientID := r.URL.Query().Get("client_id")
|
|
if clientID == "" {
|
|
http.Error(w, "client_id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
eventChan := make(chan GameEvent, 10)
|
|
s.mu.Lock()
|
|
s.clients[clientID] = eventChan
|
|
s.mu.Unlock()
|
|
|
|
log.Printf("[WS] Client %s connected", clientID)
|
|
|
|
// SSE fallback for non-WebSocket clients
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
fmt.Fprintf(w, "data: WebSocket connected\n\n")
|
|
|
|
// Stream events with sequence validation
|
|
for event := range eventChan {
|
|
// Eventual consistency: sequence numbers for conflict resolution
|
|
fmt.Fprintf(w, "data: {%q: %q, %q: %d}\n\n",
|
|
"type", event.Type, "sequence", event.Sequence)
|
|
}
|
|
}
|
|
|
|
// playerHandler demonstrates component composition with template blocks
|
|
func (s *GameStateServer) playerHandler(w http.ResponseWriter, r *http.Request) {
|
|
id := mux.Vars(r)["id"]
|
|
|
|
player, ok := s.GetPlayer(id)
|
|
if !ok {
|
|
http.Error(w, "player not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
|
|
// Template block pattern from templates.go
|
|
// hx-swap="innerHTML" for partial updates
|
|
fmt.Fprintf(w, `
|
|
<div class="player-card" id="player-%s" hx-swap="innerHTML">
|
|
<span class="name">%s</span>
|
|
<span class="level">Level %d</span>
|
|
<span class="hp" aria-label="Hit Points">HP: %d</span>
|
|
<span class="mp" aria-label="Magic Points">MP: %d</span>
|
|
<span class="xp">XP: %d</span>
|
|
<button
|
|
hx-post="/api/player/%s/action"
|
|
hx-vals="js:{action: 'attack'}"
|
|
hx-target="#player-%s"
|
|
hx-confirm="Confirm attack action?"
|
|
aria-label="Attack action for %s">
|
|
Attack
|
|
</button>
|
|
<button
|
|
hx-post="/api/player/%s/action"
|
|
hx-vals="js:{action: 'heal'}"
|
|
hx-target="#player-%s"
|
|
hx-confirm="Confirm heal action?"
|
|
aria-label="Heal action for %s">
|
|
Heal
|
|
</button>
|
|
<button
|
|
hx-post="/api/player/%s/action"
|
|
hx-vals="js:{action: 'rest'}"
|
|
hx-target="#player-%s"
|
|
aria-label="Rest action for %s">
|
|
Rest
|
|
</button>
|
|
<span aria-live="polite" class="last-action">Last: %s</span>
|
|
</div>
|
|
`,
|
|
player.ID, player.Name, player.Level,
|
|
player.HP, player.MP, player.XP,
|
|
player.ID, player.ID, player.Name,
|
|
player.ID, player.ID, player.Name,
|
|
player.ID, player.ID, player.Name,
|
|
player.LastAction)
|
|
}
|
|
|
|
// actionHandler implements optimistic UI with rollback
|
|
func (s *GameStateServer) actionHandler(w http.ResponseWriter, r *http.Request) {
|
|
id := mux.Vars(r)["id"]
|
|
action := r.FormValue("action")
|
|
|
|
// Optimistic UI: display change immediately
|
|
// Server validates and may reject
|
|
if err := s.UpdatePlayer(id, action); err != nil {
|
|
// Rollback on rejection - hx-swap="none" pattern
|
|
w.Header().Set("HX-Swap", "none")
|
|
w.Header().Set("Content-Type", "text/html")
|
|
fmt.Fprintf(w, `
|
|
<div class="error" role="alert" aria-invalid="true">
|
|
Action rejected: %v
|
|
</div>
|
|
`, err)
|
|
return
|
|
}
|
|
|
|
// Success: re-render player card
|
|
s.playerHandler(w, r)
|
|
}
|
|
|
|
// adminHandler demonstrates HTMX boost mode for navigation
|
|
func (s *GameStateServer) adminHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
|
|
// HTMX boost mode for full page transitions
|
|
// hx-push-url="true" for browser history sync
|
|
fmt.Fprint(w, `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Game Admin Panel</title>
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
<script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>
|
|
<script src="https://unpkg.com/htmx.org/dist/ext/sse.js"></script>
|
|
</head>
|
|
<body>
|
|
<main hx-boost="true">
|
|
<nav hx-push-url="true">
|
|
<a href="/admin">Admin</a> |
|
|
<a href="/metrics">Metrics</a> |
|
|
<a href="/players">Players</a>
|
|
</nav>
|
|
|
|
<section id="admin-panel">
|
|
<h1>Game Admin Panel</h1>
|
|
<div hx-get="/metrics" hx-trigger="every 2s">
|
|
<!-- Metrics loaded via polling -->
|
|
</div>
|
|
<div hx-get="/players" hx-trigger="load">
|
|
<!-- Players loaded on page load -->
|
|
</div>
|
|
<div
|
|
hx-ext="ws"
|
|
hx-ws="connect:/ws?client_id=admin"
|
|
aria-live="polite">
|
|
<!-- WebSocket event stream -->
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Accessibility: focus management -->
|
|
<div id="focus-target" tabindex="-1"></div>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
`)
|
|
}
|
|
|
|
// Initialize demo players for example
|
|
func (s *GameStateServer) initDemoPlayers() {
|
|
s.state.Players["player-1"] = &Player{
|
|
ID: "player-1",
|
|
Name: "Hero",
|
|
Level: 10,
|
|
HP: 100,
|
|
MP: 50,
|
|
XP: 500,
|
|
LastAction: "idle",
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
s.state.Players["player-2"] = &Player{
|
|
ID: "player-2",
|
|
Name: "Mage",
|
|
Level: 8,
|
|
HP: 60,
|
|
MP: 100,
|
|
XP: 400,
|
|
LastAction: "idle",
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
server := NewGameStateServer()
|
|
server.initDemoPlayers()
|
|
|
|
r := mux.NewRouter()
|
|
|
|
// Admin panel with boost mode
|
|
r.HandleFunc("/admin", server.adminHandler).Methods("GET")
|
|
|
|
// Metrics polling (2s interval matches game tick rate)
|
|
r.HandleFunc("/metrics", server.pollHandler).Methods("GET")
|
|
|
|
// WebSocket event streaming
|
|
r.HandleFunc("/ws", server.wsHandler).Methods("GET")
|
|
|
|
// Player cards with component composition
|
|
r.HandleFunc("/players", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
server.mu.Lock()
|
|
defer server.mu.Unlock()
|
|
for _, player := range server.state.Players {
|
|
server.playerHandler(w, r)
|
|
}
|
|
}).Methods("GET")
|
|
|
|
r.HandleFunc("/players/{id}", server.playerHandler).Methods("GET")
|
|
|
|
// Action handler with optimistic UI + rollback
|
|
r.HandleFunc("/api/player/{id}/action", server.actionHandler).Methods("POST")
|
|
|
|
fmt.Println("HTMX Game Admin Server starting on :8080")
|
|
fmt.Println("Access admin panel at http://localhost:8080/admin")
|
|
|
|
if err := http.ListenAndServe(":8080", r); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
} |