initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:16:00 +02:00
commit ac9aa86d33
14 changed files with 1021 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/go-fish
.idea

26
Makefile Normal file
View file

@ -0,0 +1,26 @@
SHELL := /bin/sh
APP := go-fish
BIN := $(APP)
.PHONY: all build run clean test vet fmt
all: build
build:
go build -o $(BIN) ./cmd/$(APP)/
run:
go run ./cmd/$(APP)/
clean:
rm -f $(BIN)
test:
go test -race ./...
vet:
go vet ./...
fmt:
go fmt ./...

71
README.md Normal file
View file

@ -0,0 +1,71 @@
# Go Fish
A web-based implementation of the classic Go Fish card game.
## Overview
A single-page web application where players can join a waiting room, find opponents,
and play Go Fish against each other. Players already in a game are marked as
unavailable in the waiting room.
## Features
- **Waiting room**: View other online players, unavailable players marked in red
- **Matchmaking**: Click on available players to start a game
- **Go Fish gameplay**: Ask opponents for ranks, draw from deck if unavailable
- **Book collection**: Collect sets of 4 cards (books)
- **Win condition**: First player to collect 13 books wins
## Getting Started
### Prerequisites
- Go 1.21 or later
### Installation
```bash
# Clone the repository
git clone <repo-url> go-fish
cd go-fish
# Build the application
make build
# Or run directly
make run
```
The application will start on `http://localhost:3000`
### Running
```bash
make run
```
Or build and run manually:
```bash
./go-fish
```
## How to Play
1. Open your browser and navigate to `http://localhost:3000`
2. Click "Join Waiting Room" to enter the waiting area
3. See other players - those with green backgrounds are available
4. Players already in a game appear with red backgrounds
5. Click on an available player to be matched and start a game
6. Use the rank buttons (A, 2, 3, ..., K) to ask your opponent for cards
7. Collect sets of 4 matching cards (books)
8. First to 13 books wins!
## Game Rules
- Each player starts with 7 cards from a standard 52-card deck
- On your turn, ask your opponent for a specific rank (A, 2-10, J, Q, K)
- If the opponent has the rank, they must give you all cards of that rank
- If the opponent doesn't have the rank, say "Go Fish" and you draw from the deck
- Collect 4 of a kind to score a book
- First player to 13 books wins

306
cmd/go-fish/main.go Normal file
View file

@ -0,0 +1,306 @@
package main
import (
"encoding/json"
"fmt"
"math/rand"
"net/http"
"text/template"
"github.com/danyel/go-fish/internal/game"
"github.com/danyel/go-fish/internal/store"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type JoinResponse struct {
PlayerID string `json:"playerId"`
}
type AskRequest struct {
PlayerID string `json:"playerId"`
OpponentID string `json:"opponentId"`
Rank string `json:"rank"`
}
type AskResponse struct {
Status string `json:"status"`
MyHand []string `json:"myHand"`
MyBookRanks []string `json:"myBookRanks"`
OpponentBookRanks []string `json:"opponentBookRanks"`
GameOver bool `json:"gameOver"`
Message string `json:"message"`
}
func main() {
s := store.NewPlayerStore()
mux := chi.NewMux()
mux.Use(middleware.Logger)
mux.Use(middleware.Recoverer)
mux.Get("/", homeHandler)
mux.Get("/waiting", waitingHandler(s))
mux.Get("/play/{id}", playHandler(s))
mux.Post("/join", joinHandler(s))
mux.Post("/ask", askHandler(s))
fmt.Printf("Starting go-fish on :3000\n")
http.ListenAndServe(":3000", mux)
}
func homeHandler(w http.ResponseWriter, r *http.Request) {
tmpl := template.Must(template.ParseFiles("web/templates/index.html"))
tmpl.Execute(w, nil)
}
func waitingHandler(s *store.PlayerStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
players := s.GetAvailablePlayers()
hasBotOption := len(players) <= 1
tmpl := template.Must(template.ParseFiles("web/templates/waiting.html"))
data := struct {
Players []*game.Player
HasBotOption bool
}{Players: players, HasBotOption: hasBotOption}
tmpl.Execute(w, data)
}
}
func playHandler(s *store.PlayerStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
_ = id
p, ok := s.GetPlayer(id)
if !ok {
http.NotFound(w, r)
return
}
available := s.GetAvailablePlayers()
var opponent *game.Player
for _, ap := range available {
if ap.ID != p.ID {
opponent = ap
break
}
}
var isBot bool
var g *game.Game
if opponent == nil && len(available) == 1 {
isBot = true
botID := generateID()
bot := game.NewPlayer(botID, "Bot")
s.AddPlayer(botID, "Bot")
opponent = bot
s.MarkPlaying(p.ID)
s.MarkPlaying(botID)
g = game.NewGame(p, opponent)
g.ID = "game-" + p.ID + "-" + botID
} else if opponent != nil {
isBot = false
s.MarkPlaying(p.ID)
s.MarkPlaying(opponent.ID)
g = game.NewGame(p, opponent)
g.ID = "game-" + p.ID + "-" + opponent.ID
} else {
isBot = true
botID := generateID()
bot := game.NewPlayer(botID, "Bot")
s.AddPlayer(botID, "Bot")
opponent = bot
s.MarkPlaying(p.ID)
s.MarkPlaying(botID)
g = game.NewGame(p, opponent)
g.ID = "game-" + p.ID + "-" + botID
}
s.CreateGame(p.ID, g)
for i := 0; i < 7; i++ {
if len(g.Deck) > 0 {
g.Player1.Draw(g.DrawCard())
g.Player2.Draw(g.DrawCard())
}
}
tmpl := template.Must(template.ParseFiles("web/templates/play.html"))
data := struct {
Game *game.Game
Player *game.Player
IsBot bool
}{Game: g, Player: p, IsBot: isBot}
tmpl.Execute(w, data)
}
}
func joinHandler(s *store.PlayerStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.FormValue("name")
if name == "" {
name = "Player"
}
id := generateID()
s.AddPlayer(id, name)
http.SetCookie(w, &http.Cookie{
Name: "player_id",
Value: id,
Path: "/",
})
respondJSON(w, http.StatusOK, JoinResponse{PlayerID: id})
}
}
func askHandler(s *store.PlayerStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req AskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
g, ok := s.GetGame(req.PlayerID)
if !ok {
respondJSON(w, http.StatusNotFound, AskResponse{Message: "Game not found"})
return
}
rank := game.RankAce
switch req.Rank {
case "A":
rank = game.RankAce
case "2":
rank = game.Rank2
case "3":
rank = game.Rank3
case "4":
rank = game.Rank4
case "5":
rank = game.Rank5
case "6":
rank = game.Rank6
case "7":
rank = game.Rank7
case "8":
rank = game.Rank8
case "9":
rank = game.Rank9
case "10":
rank = game.Rank10
case "J":
rank = game.RankJack
case "Q":
rank = game.RankQueen
case "K":
rank = game.RankKing
}
asker := g.Player1
target := g.Player2
if g.Current == 2 {
asker = g.Player2
target = g.Player1
}
var cardsRemoved []game.Card
if !asker.HasCard(rank) {
card := g.DrawCard()
target.Draw(card)
} else {
cardsRemoved = asker.RemoveCard(rank)
if len(cardsRemoved) > 0 {
asker.Hand = append(asker.Hand, cardsRemoved...)
if asker.HasBook(rank) {
g.BookPile = append(g.BookPile, []game.Card{})
}
}
}
gameOver := false
message := ""
myHand := asker.Hand
oppHand := target.Hand
myBookCount := countBooks(g.BookPile)
oppBookCount := countOpponentBooks(g.BookPile)
if len(myHand) == 0 && len(oppHand) == 0 {
gameOver = true
message = "Game over! No more cards."
}
if myBookCount >= 13 || oppBookCount >= 13 {
gameOver = true
if myBookCount >= 13 && oppBookCount >= 13 {
message = "Draw! Both players have 13 books."
} else if myBookCount >= 13 {
message = "You win! You have 13 books."
} else {
message = "You lose! Opponent has 13 books."
}
}
s.CreateGame(req.PlayerID, g)
respondJSON(w, http.StatusOK, AskResponse{
Status: "success",
MyHand: cardStrings(myHand),
MyBookRanks: bookRanks(g.BookPile),
OpponentBookRanks: bookRanks(g.BookPile),
GameOver: gameOver,
Message: message,
})
}
}
func countBooks(books [][]game.Card) int {
count := 0
for _, book := range books {
if len(book) > 0 {
count++
}
}
return count
}
func countOpponentBooks(books [][]game.Card) int {
count := 0
for _, book := range books {
if len(book) > 0 {
count++
}
}
return count
}
func bookRanks(books [][]game.Card) []string {
var ranks []string
for _, book := range books {
if len(book) > 0 {
ranks = append(ranks, book[0].Rank.String())
}
}
return ranks
}
func cardStrings(cards []game.Card) []string {
var result []string
for _, c := range cards {
result = append(result, c.Rank.String()+c.Suit.String())
}
return result
}
func respondJSON(w http.ResponseWriter, code int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(data)
}
func generateID() string {
return fmt.Sprintf("player-%d", rand.Intn(10000))
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module github.com/danyel/go-fish
go 1.21
require github.com/go-chi/chi/v5 v5.0.0

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/go-chi/chi/v5 v5.0.0 h1:DBPx88FjZJH3FsICfDAfIfnb7XxKIYVGG6lOPlhENAg=
github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=

30
internal/game/bot.go Normal file
View file

@ -0,0 +1,30 @@
package game
import "math/rand"
type Bot struct {
Player
}
func NewBot(id, name string) *Bot {
return &Bot{
Player: *NewPlayer(id, name),
}
}
func (b *Bot) ChooseRank(hand []Card) Rank {
// Bot simply picks a random card rank from its hand
if len(hand) == 0 {
return RankAce
}
idx := rand.Intn(len(hand))
return hand[idx].Rank
}
func (b *Bot) ChooseCardToAsk(availableRanks []Rank) Rank {
if len(availableRanks) > 0 {
idx := rand.Intn(len(availableRanks))
return availableRanks[idx]
}
return RankAce
}

111
internal/game/cards.go Normal file
View file

@ -0,0 +1,111 @@
package game
import "math/rand"
type Rank int
const (
RankAce Rank = iota
Rank2
Rank3
Rank4
Rank5
Rank6
Rank7
Rank8
Rank9
Rank10
RankJack
RankQueen
RankKing
)
func (r Rank) String() string {
switch r {
case RankAce:
return "A"
case Rank2:
return "2"
case Rank3:
return "3"
case Rank4:
return "4"
case Rank5:
return "5"
case Rank6:
return "6"
case Rank7:
return "7"
case Rank8:
return "8"
case Rank9:
return "9"
case Rank10:
return "10"
case RankJack:
return "J"
case RankQueen:
return "Q"
case RankKing:
return "K"
}
return "?"
}
type Suit int
const (
SuitClubs Suit = iota
SuitDiamonds
SuitHearts
SuitSpades
)
func (s Suit) String() string {
switch s {
case SuitClubs:
return "♣"
case SuitDiamonds:
return "♦"
case SuitHearts:
return "♥"
case SuitSpades:
return "♠"
}
return "?"
}
type Card struct {
Rank Rank
Suit Suit
}
func NewDeck() []Card {
var deck []Card
for suit := SuitClubs; suit <= SuitSpades; suit++ {
for rank := RankAce; rank <= RankKing; rank++ {
deck = append(deck, Card{Rank: rank, Suit: suit})
}
}
return deck
}
func Shuffle(deck []Card) []Card {
result := make([]Card, len(deck))
copy(result, deck)
for i := len(result) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
result[i], result[j] = result[j], result[i]
}
return result
}
var faceCards = map[Rank]bool{
RankJack: true,
RankQueen: true,
RankKing: true,
}
func IsFaceCard(r Rank) bool {
return faceCards[r]
}

81
internal/game/game.go Normal file
View file

@ -0,0 +1,81 @@
package game
type GameState int
const (
StateWaiting GameState = iota
StatePlaying
)
type Game struct {
ID string
Player1 *Player
Player2 *Player
Current int // 1 or 2, whose turn it is
State GameState
Deck []Card
BookPile [][]Card
}
func NewGame(p1, p2 *Player) *Game {
deck := Shuffle(NewDeck())
return &Game{
ID: "",
Player1: p1,
Player2: p2,
Current: 1,
State: StatePlaying,
Deck: deck,
BookPile: [][]Card{},
}
}
func (g *Game) DrawCard() Card {
if len(g.Deck) == 0 {
// Reshuffle book pile
var all []Card
for _, book := range g.BookPile {
all = append(all, book...)
}
if len(all) > 0 {
g.Deck = Shuffle(all)
g.BookPile = [][]Card{}
}
}
card := g.Deck[0]
g.Deck = g.Deck[1:]
return card
}
func (g *Game) Ask(target *Player, rank Rank) bool {
if !target.HasCard(rank) {
// Player draws from deck
card := g.DrawCard()
target.Draw(card)
return false // turn passes
}
// Target gives the card
removed := target.RemoveCard(rank)
if len(removed) > 0 {
// Add to asker's hand
g.Player1.Hand = append(g.Player1.Hand, removed...)
// Check for book
if g.Player1.HasBook(rank) {
// Find and remove the 4 cards from hand
var book []Card
var remaining []Card
count := 0
for _, c := range g.Player1.Hand {
if c.Rank == rank && count < 4 {
book = append(book, c)
count++
} else {
remaining = append(remaining, c)
}
}
g.Player1.Hand = remaining
g.BookPile = append(g.BookPile, book)
}
}
return true // same player continues
}

56
internal/game/player.go Normal file
View file

@ -0,0 +1,56 @@
package game
type Player struct {
ID string
Name string
Hand []Card
Booked [][]Card // sets of 4 cards
Connected bool
}
func NewPlayer(id, name string) *Player {
return &Player{
ID: id,
Name: name,
Hand: []Card{},
Booked: [][]Card{},
Connected: true,
}
}
func (p *Player) Draw(card Card) {
p.Hand = append(p.Hand, card)
}
func (p *Player) RemoveCard(rank Rank) []Card {
var removed []Card
var remaining []Card
for _, c := range p.Hand {
if c.Rank == rank && len(removed) < 4 {
removed = append(removed, c)
} else {
remaining = append(remaining, c)
}
}
p.Hand = remaining
return removed
}
func (p *Player) HasCard(rank Rank) bool {
for _, c := range p.Hand {
if c.Rank == rank {
return true
}
}
return false
}
func (p *Player) HasBook(rank Rank) bool {
count := 0
for _, c := range p.Hand {
if c.Rank == rank {
count++
}
}
return count == 4
}

80
internal/store/store.go Normal file
View file

@ -0,0 +1,80 @@
package store
import (
"github.com/danyel/go-fish/internal/game"
"sync"
)
type PlayerStore struct {
mu sync.Mutex
players map[string]*game.Player
games map[string]*game.Game // keyed by player ID
}
func NewPlayerStore() *PlayerStore {
return &PlayerStore{
players: make(map[string]*game.Player),
games: make(map[string]*game.Game),
}
}
func (s *PlayerStore) AddPlayer(id, name string) *game.Player {
s.mu.Lock()
defer s.mu.Unlock()
p := game.NewPlayer(id, name)
s.players[id] = p
return p
}
func (s *PlayerStore) GetPlayer(id string) (*game.Player, bool) {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.players[id]
return p, ok
}
func (s *PlayerStore) GetAvailablePlayers() []*game.Player {
s.mu.Lock()
defer s.mu.Unlock()
var available []*game.Player
for _, p := range s.players {
if p.Connected && len(p.Hand) < 7 {
available = append(available, p)
}
}
return available
}
func (s *PlayerStore) RemovePlayer(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.players, id)
delete(s.games, id)
}
func (s *PlayerStore) MarkPlaying(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.players[id]
if !ok {
return false
}
p.Connected = false // player is now in a game
return true
}
func (s *PlayerStore) GetGame(id string) (*game.Game, bool) {
s.mu.Lock()
defer s.mu.Unlock()
g, ok := s.games[id]
return g, ok
}
func (s *PlayerStore) CreateGame(playerID string, g *game.Game) {
s.mu.Lock()
defer s.mu.Unlock()
// Store game under the player's ID so ask handler can find it
s.games[playerID] = g
// Also store under game ID
s.games[g.ID] = g
}

37
web/templates/index.html Normal file
View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Go Fish - Welcome</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
h1 { text-align: center; margin-bottom: 2rem; color: #f0f0f0; }
.btn { padding: 0.75rem 1.5rem; background: #e94560; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
.btn:hover { background: #ff6b7a; }
</style>
</head>
<body>
<div class="container">
<h1>Go Fish</h1>
<p>Click below to join the waiting room and find an opponent</p>
<button class="btn" onclick="joinWaitingRoom()">Join Waiting Room</button>
<script>
function joinWaitingRoom() {
fetch('/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Player' })
})
.then(r => r.json())
.then(data => {
window.location.href = '/play/' + data.playerId;
});
}
</script>
</div>
</body>
</html>

154
web/templates/play.html Normal file
View file

@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Go Fish - Game</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
h1 { text-align: center; margin-bottom: 1rem; color: #f0f0f0; }
.game-status { text-align: center; margin-bottom: 2rem; font-size: 1.2rem; }
.deck { text-align: center; margin: 2rem 0; }
.card { width: 70px; height: 100px; background: #1a1a2e; border: 2px solid #444; border-radius: 4px; margin: 2px; display: inline-block; position: relative; min-height: 100px; }
.card.flip { transition: transform 0.3s; transform: rotateY(180deg); }
.card.face { background: #e94560; color: white; }
.hand { min-height: 120px; }
.book-area { min-height: 80px; margin-top: 1rem; }
.book { width: 60px; height: 20px; background: #ff6b7a; color: white; border-radius: 3px; margin: 2px; display: inline-block; }
.controls { text-align: center; margin-top: 2rem; }
.controls button { margin: 0 0.5rem; }
.instruction { text-align: center; margin: 1rem 0; color: #888; }
</style>
</head>
<body>
<div class="container">
<h1>Go Fish</h1>
<div class="game-status" id="gameStatus" data-currentplayerid="{{ .Player.ID }}" data-opponentid="{{ .Game.ID }}" data-isbot="{{ .IsBot }}">
{{ .Player.Name }}'s turn - Ask for a rank
{{ if .IsBot }}(playing against a bot){{ end }}
</div>
<div class="deck" id="deck">
<!-- Deck cards -->
</div>
<div class="hand" id="hand">
<!-- Player's hand cards -->
</div>
<div class="book-area" id="bookArea">
<!-- Booked sets -->
</div>
<div class="controls">
<button onclick="askRank('A')">Ask for A</button>
<button onclick="askRank('2')">Ask for 2</button>
<button onclick="askRank('3')">Ask for 3</button>
<button onclick="askRank('4')">Ask for 4</button>
<button onclick="askRank('5')">Ask for 5</button>
<button onclick="askRank('6')">Ask for 6</button>
<button onclick="askRank('7')">Ask for 7</button>
<button onclick="askRank('8')">Ask for 8</button>
<button onclick="askRank('9')">Ask for 9</button>
<button onclick="askRank('10')">Ask for 10</button>
<button onclick="askRank('J')">Ask for J</button>
<button onclick="askRank('Q')">Ask for Q</button>
<button onclick="askRank('K')">Ask for K</button>
</div>
<div class="instruction" id="instruction">
Click a card in your hand to see its rank, then ask opponent for rank
</div>
</div>
<script>
let currentPlayerId = null;
let opponentId = null;
let myHand = [];
let myBooks = [];
let opponentBooks = [];
let isBot = false;
// Load game state from rendered template
document.addEventListener('DOMContentLoaded', function() {
const statusDiv = document.getElementById('gameStatus');
if (statusDiv) {
currentPlayerId = statusDiv.dataset.currentplayerid || null;
opponentId = statusDiv.dataset.opponentid || null;
isBot = statusDiv.dataset.isbot === 'true' || false;
}
});
function askRank(rank) {
if (!currentPlayerId || !opponentId) return;
fetch('/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playerId: currentPlayerId, opponentId: opponentId, rank: rank })
})
.then(r => r.json())
.then(data => {
updateGameState(data);
});
}
function updateGameState(data) {
myHand = data.myHand || [];
myBooks = data.myBooks || [];
opponentBooks = data.opponentBooks || [];
renderHand();
renderBooks();
renderStatus(data.status);
// Check if game over
if (data.gameOver) {
alert(data.message || 'Game over!');
}
}
function renderHand() {
const handDiv = document.getElementById('hand');
handDiv.innerHTML = '';
myHand.forEach((card, i) => {
const cardEl = document.createElement('div');
cardEl.className = 'card' + (i === 0 ? ' flip' : '');
cardEl.textContent = card;
cardEl.dataset.rank = card;
cardEl.onclick = function() { askRank(card); };
handDiv.appendChild(cardEl);
});
}
function renderBooks() {
const bookArea = document.getElementById('bookArea');
bookArea.innerHTML = '';
// My books
myBooks.forEach(rank => {
const book = document.createElement('div');
book.className = 'book';
book.textContent = rank + ' set';
bookArea.appendChild(book);
});
// Opponent books
opponentBooks.forEach(rank => {
const book = document.createElement('div');
book.className = 'book';
book.textContent = rank + ' set (opp)';
bookArea.appendChild(book);
});
}
function renderStatus(status) {
const statusDiv = document.getElementById('gameStatus');
statusDiv.textContent = status || 'Your turn';
}
</script>
</body>
</html>

View file

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Go Fish - Waiting Room</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
.container { max-width: 800px; margin: 0 auto; padding: 2rem; }
h1 { text-align: center; margin-bottom: 2rem; color: #f0f0f0; }
.player-list { background: #2a2a3e; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; }
.player-item { padding: 0.5rem; border-bottom: 1px solid #444; cursor: pointer; margin-bottom: 0.5rem; }
.player-item:hover { background: #3a3a52; }
.player-item.eligible { background: #2ecc71; color: #fff; }
.player-item.unavailable { background: #e74c3c; color: #fff; }
.btn { padding: 0.75rem 1.5rem; background: #e94560; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; width: 100%; margin-top: 1rem; }
.btn-secondary { background: #444; width: 100%; margin-top: 1rem; }
.status { text-align: center; margin: 1rem 0; font-size: 0.9rem; color: #888; }
</style>
</head>
<body>
<div class="container">
<h1>Go Fish - Waiting Room</h1>
<div class="status" id="status">Looking for opponent...</div>
<div class="player-list" id="playerList">
{{ range .Players }}
<div class="player-item eligible" data-target-id="{{ .ID }}">
{{ .Name }}
<span style="font-size: 0.8rem; margin-left: 1rem;">(available)</span>
</div>
{{ else }}
<div class="player-item unavailable">No available players</div>
{{ end }}
</div>
<button class="btn" onclick="window.location='/join'">Refresh List</button>
<div class="status">Players online: <span id="onlineCount">0</span></div>
</div>
<script>
// Poll for available players
setInterval(function() {
fetch('/waiting')
.then(r => r.text())
.then(html => {
document.getElementById('playerList').innerHTML = html;
// Re-attach click handlers
document.querySelectorAll('.player-item.eligible').forEach(el => {
el.addEventListener('click', function() {
const targetId = this.dataset.targetId;
window.location.href = '/play/' + targetId;
});
});
});
}, 3000);
</script>
</body>
</html>