A minimalistic real-time competitive Wordle game built with Phoenix LiveView, utilizing PubSub and GenServers for state management with session persistence, player reconnection, and automated cleanup. Players compete to guess the most 5-letter words within a timed session.
- Create Session: Simple button to create new game session with configurable settings
- Unique Links: Each session gets a unique hash-based URL (
/:session_id) - Session Duration: Configurable time limits (1, 3, 5 minutes)
- Attempts Per Word: Fixed at 6 attempts per word (classic Wordle rules)
- No Authentication: No logins, registrations, or persistent user accounts
- Session Persistence: Sessions survive server restarts and player disconnections
- Automatic Cleanup: Sessions automatically cleaned after 1 hour of inactivity
- Unique User IDs: Each player gets a persistent UUID stored in localStorage
- Automatic Reconnection: Players automatically rejoin their session on page refresh
- Nickname Reclamation: Disconnected players' nicknames can be reclaimed after timeout
- Connection Status: Players marked as connected/disconnected with timestamps
- Graceful Degradation: Disconnected players' progress preserved; game continues for others
- Join via Link: Users join by visiting the unique session URL
- Nickname Entry: Each user can set their display name (must be unique within session)
- Individual Play: Each player competes individually (no teams)
- Ready State: All connected players must press "Ready" to start game
- Flexible Start: Can start with 1+ players (solo practice supported)
- Settings Display: Show session duration in lobby
- Session-based Gameplay: All players compete simultaneously during timed session
- Timed Session: Overall timer counting down (1/3/5 minutes) with real-time display
- Timer Audio: Sound notification when session time expires (
/sounds/timer_end.mp3) - Available Words Pool: Full list of valid 5-letter answer words loaded at app startup
- Individual Word Assignment: Each player gets random word from pool (tracked to avoid repeats within session)
- Classic Wordle Mechanics:
- 5-letter word guess input
- 6 attempts per word
- Color feedback: 🟩 Green (correct position), 🟨 Yellow (wrong position), ⬜ Gray (not in word)
- Virtual keyboard with color-coded used letters
- Word Progression: Upon guessing word correctly or exhausting 6 attempts, player immediately gets new random word from remaining pool
- Real-time Leaderboard: Live updates of player scores visible to all
- Auto Session End: Session automatically ends when timer reaches zero
- Base Score: 1 point per word guessed correctly
- Attempt Bonuses:
- Guessed in 1 attempt: +5 bonus points (total: 6 points)
- Guessed in 2 attempts: +3 bonus points (total: 4 points)
- Guessed in 3 attempts: +2 bonus points (total: 3 points)
- Guessed in 4-6 attempts: +1 bonus point (total: 2 points)
- Failed Word: 0 points (exhausted 6 attempts without guessing)
- Speed Tracking: Average attempts per word displayed
- Total Words: Count of words attempted and words guessed
- Score Tracking: Real-time score updates during session
- Victory Condition: Player with highest score when timer expires wins
- Tie Breaking: If tied on score, player with lower average attempts wins
- Game Over Screen:
- Final scores with winner highlighting
- Individual statistics: words guessed, total attempts, average attempts
- Full word history for each player
- New Game Option: Reset to lobby while preserving players
- LiveView + GenServer: Game logic in
WordleServer, UI inWordleLive - Dynamic Supervision: Each session runs as supervised GenServer process
- Registry-based Lookup: Sessions registered for efficient process discovery
- PubSub Integration: Real-time updates to all session participants
- Cleanup Workers: Automated disconnected player and session cleanup
- State Persistence: Game state survives player disconnections and reconnections
- Word Management: Simple in-memory list tracking with per-session used words
:lobby- Players joining, setting ready status, viewing settings:playing- Active session with word guessing and timer:game_over- Final scores display and restart options
:lobby -> :playing
Conditions:
- At least 1 player
- All players ready
Trigger: start_game action
Effects:
- Start session timer
- Assign first random word to each player
- Track assigned words in used_words list
- Enable word input for all players
:playing -> :game_over
Conditions:
- Session timer expires (reaches 0:00)
Trigger: Automatic (timer expiration)
Effects:
- Lock all player inputs
- Calculate final scores and rankings
- Determine winner (highest score, or lowest avg attempts if tied)
- Display game over screen
:game_over -> :lobby
Trigger: start_new_game action
Effects:
- Complete game reset
- Preserve players and nicknames
- Reset all scores to 0
- Clear used_words list
- Reset all players to not readynil -> Player{connected: true, ready: false, score: 0}
Trigger: join_session with valid nickname
Player{connected: true} -> Player{connected: false, disconnected_at: timestamp}
Trigger: leave_session or browser disconnect
Player{connected: false} -> Player{connected: true, disconnected_at: nil}
Trigger: rejoin via mark_connected or restore_session
Player{ready: false} -> Player{ready: true}
Trigger: set_ready(true)
Player{ready: true} -> Player{ready: false}
Trigger: set_ready(false) or session transition to :lobby
Player{current_word: "CRANE"} -> Player{current_word: "SLATE"}
Trigger: Word guessed correctly OR 6 attempts exhausted
Effects:
- Add score for previous word
- Clear current attempts
- Pick new random word from available pool (excluding used_words)
- Add new word to session's used_words list
- Minimum Players: 1+ players (solo practice mode supported)
- Duration Options: 1, 3, or 5 minutes
- Word Pool: All valid answer words available, randomly selected per player
- Word Tracking: Session tracks all assigned words to prevent repeats
- Independent Words: Each player can be on different word at any time
- Nickname Uniqueness: Within session scope only
- Ready Requirement: Must set ready status to enable game start
- Reconnection Grace: 10-minute window for automatic reconnection
- Progress Preservation: Current word, attempts, and score maintained during disconnect
- Ready Check: All connected players must be ready
- Minimum Players: 1+ players
- Word Assignment: Each player gets random word from available pool on start
- Word Length: Fixed at 5 letters
- Attempts Per Word: Fixed at 6 attempts
- Input Validation:
- Must be exactly 5 letters
- Must be valid English word (any valid 5-letter word accepted)
- Case insensitive (normalized to uppercase)
- Letter Feedback Rules:
- 🟩 Green: Letter in correct position
- 🟨 Yellow: Letter in word but wrong position
- ⬜ Gray: Letter not in word
- Duplicate letter handling: If word has 1 'E' and guess has 2 'E's, only first instance gets color
- Keyboard Tracking: Used letters colored on virtual keyboard (green/yellow/gray)
- Word Progression: Automatic - player gets new random word immediately after completing current one
- No Skipping: Players cannot skip words; must either guess or exhaust attempts
- Timer Enforcement: When session timer hits 0:00, all inputs immediately locked
- Success Bonus Scale:
- 1 attempt: 6 points (1 base + 5 bonus)
- 2 attempts: 4 points (1 base + 3 bonus)
- 3 attempts: 3 points (1 base + 2 bonus)
- 4-6 attempts: 2 points (1 base + 1 bonus)
- Failure Penalty: 0 points for unguessed words
- Score Accumulation: Total score is sum of all word scores
- Statistics Tracking:
- Total words attempted
- Total words guessed correctly
- Total attempts made
- Average attempts per word (for guessed words only)
- Winner Determination:
- Primary: Highest total score
- Tiebreaker: Lowest average attempts per guessed word
- Secondary tiebreaker: Fewest total attempts
- Player Cleanup: Disconnected players removed after 10 minutes
- Session Cleanup: Entire session terminated after 1 hour of inactivity
- Activity Tracking: Any player action updates session activity timestamp
- Cleanup Frequency: Automated cleanup runs every 5 minutes
%WordleState{
session_id: string(),
session_duration: integer(), # minutes: 1, 3, or 5
phase: :lobby | :playing | :game_over,
players: %{user_id => %{
nickname: string(),
ready: boolean(),
connected: boolean(),
disconnected_at: DateTime.t() | nil,
# Current game progress
current_word: string() | nil, # The word player is currently guessing
current_attempts: [%{
guess: string(),
result: [:correct | :present | :wrong, ...] # 5-element list
}],
words_completed: integer(), # Count of words attempted (guessed or failed)
words_guessed: integer(), # Count successfully guessed
total_attempts: integer(), # Sum of all attempts across all words
score: integer(), # Total score
# Per-word history for final display
word_history: [%{
word: string(),
guessed: boolean(),
attempts: integer(),
points: integer()
}]
}},
# Word management
used_words: [string()], # List of all words assigned during this session
# Session timing
session_start_time: DateTime.t() | nil,
session_end_time: DateTime.t() | nil, # start_time + duration
timer_ref: reference() | nil,
time_remaining: integer() | nil, # Seconds
# Game metadata
game_ever_started: boolean(),
cleanup_timer_ref: reference(),
created_at: DateTime.t(),
last_activity: DateTime.t(),
# Winner tracking
winner_id: user_id | nil,
final_rankings: [%{user_id: string(), nickname: string(), score: integer(), avg_attempts: float()}]
}Store words as plain Elixir lists loaded at application startup:
# In application.ex or dedicated module
defmodule WordleBattle.Dictionary do
@answer_words_file "priv/dictionary/answer_words.txt"
@valid_guesses_file "priv/dictionary/valid_guesses.txt"
def answer_words do
# Lazy load and cache in module attribute
@answer_words_file
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.map(&String.upcase/1)
end
def valid_guesses do
# All valid 5-letter words for validation
@valid_guesses_file
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.map(&String.upcase/1)
|> MapSet.new()
end
def random_word(exclude_list \\ []) do
answer_words()
|> Enum.reject(&(&1 in exclude_list))
|> Enum.random()
end
def valid_guess?(word) do
MapSet.member?(valid_guesses(), String.upcase(word))
end
end# In WordleServer
def assign_new_word(state, player_id) do
new_word = Dictionary.random_word(state.used_words)
updated_state =
state
|> put_in([:players, player_id, :current_word], new_word)
|> put_in([:players, player_id, :current_attempts], [])
|> update_in([:used_words], &[new_word | &1])
{new_word, updated_state}
endAnswer Words (priv/dictionary/answer_words.txt):
- ~2,300 common, guessable 5-letter words
- One word per line, uppercase
- Examples: CRANE, SLATE, AUDIO, PRIDE, HOUSE, TRACK, FRAME, CHESS, BLEND, GHOST, CRISP, HUMOR, LIGHT, TRADE, SMART
Valid Guesses (priv/dictionary/valid_guesses.txt):
- ~12,000 valid 5-letter English words (includes all answer words)
- Allows obscure but valid words like ZAXES, QAJAQ
- Used only for validation, not for assignment
def check_guess(guess, target) do
guess = String.upcase(guess)
target = String.upcase(target)
guess_letters = String.graphemes(guess)
target_letters = String.graphemes(target)
# First pass: mark exact matches
exact_matches = Enum.zip(guess_letters, target_letters)
|> Enum.map(fn {g, t} -> if g == t, do: :correct, else: nil end)
# Second pass: mark present letters (avoiding double-counting)
target_remaining = target_letters
|> Enum.zip(exact_matches)
|> Enum.reject(fn {_, match} -> match == :correct end)
|> Enum.map(fn {letter, _} -> letter end)
Enum.zip(guess_letters, exact_matches)
|> Enum.map_reduce(target_remaining, fn {g, match}, remaining ->
case match do
:correct ->
{:correct, remaining}
nil ->
if g in remaining do
{:present, List.delete(remaining, g)}
else
{:wrong, remaining}
end
end
end)
|> elem(0)
enddef calculate_word_score(attempts_used, guessed_correctly) do
if guessed_correctly do
case attempts_used do
1 -> 6 # 1 base + 5 bonus
2 -> 4 # 1 base + 3 bonus
3 -> 3 # 1 base + 2 bonus
_ -> 2 # 1 base + 1 bonus (4-6 attempts)
end
else
0
end
end- Header: "Wordle Battle" title
- Session Info:
- Session link display with copy button
- Settings summary: Duration (1/3/5 min)
- Player List: Connected players with ready indicators (✓ checkmark or ⏳ waiting)
- Controls:
- Ready/Not Ready toggle button (large, prominent)
- Start Game button (enabled when all ready, visible to all)
┌────────────────────────────────────────────────┐
│ WORDLE BATTLE Time: 02:47 │
├────────────────────────────────────────────────┤
│ │
│ Current Word (Attempt 3/6): │
│ │
│ [C][R][A][N][E] 🟩⬜🟨⬜⬜ │
│ [S][L][A][T][E] ⬜⬜🟩⬜🟩 │
│ [_][_][_][_][_] ← Current input │
│ [_][_][_][_][_] │
│ [_][_][_][_][_] │
│ [_][_][_][_][_] │
│ │
│ [Virtual Keyboard - color coded letters] │
│ │
├────────────────────────────────────────────────┤
│ 📊 LEADERBOARD │
│ 🥇 Player2 5 words (18 pts) avg: 3.4 │
│ 🥈 You 3 words (10 pts) avg: 3.0 │
│ 🥉 Player3 2 words (6 pts) avg: 4.0 │
└────────────────────────────────────────────────┘
Main Game Area:
- Timer Display: Large, prominent countdown with color coding:
- Green: >60 seconds remaining
- Yellow: 30-60 seconds
- Red: <30 seconds (with pulsing animation)
- Word Grid: 6 rows × 5 columns for attempts
- Completed attempts show letters with color feedback
- Current attempt shows live typing
- Unused rows show empty boxes
- Input Field: 5-letter input with live validation
- Auto-uppercase transformation
- Visual feedback: green border if valid word, red if invalid
- Submit on Enter key or button click
- Virtual Keyboard:
- QWERTY layout with letter buttons
- Color coding: Green (correct position used), Yellow (wrong position used), Gray (not in word), White (unused)
- Backspace and Enter buttons
Leaderboard Sidebar (Collapsible on mobile):
- Real-time rankings
- Player nickname
- Words guessed count
- Total score
- Average attempts (for completed words)
- Visual indicators for 1st/2nd/3rd place
Status Messages:
- "Word not in dictionary" (when invalid guess)
- "🎉 Correct! +X points" (when word guessed, shows bonus)
- "❌ Word not guessed. Moving to next word." (after 6 failed attempts)
- "⚡ Player X just guessed a word in 2 attempts!" (activity feed, optional)
┌────────────────────────────────────────────────┐
│ 🏆 GAME OVER │
│ │
│ 🥇 WINNER: Player2 │
│ Score: 24 points │
│ Words: 8 guessed │
│ Avg Attempts: 3.25 │
│ │
├────────────────────────────────────────────────┤
│ Final Rankings: │
│ │
│ 🥇 Player2 - 24 pts (8 words, 3.25 avg) │
│ 🥈 You - 18 pts (6 words, 3.50 avg) │
│ 🥉 Player3 - 12 pts (4 words, 4.25 avg) │
│ │
├────────────────────────────────────────────────┤
│ Your Game Stats: │
│ ├─ Words Attempted: 7 │
│ ├─ Words Guessed: 6 │
│ ├─ Success Rate: 85.7% │
│ ├─ Total Attempts: 21 │
│ └─ Average Attempts: 3.50 │
│ │
│ [View Word History] [Start New Game] [Home] │
└────────────────────────────────────────────────┘
Word History Modal (Optional):
- Expandable list of all words attempted
- Per word: Target word, attempts used, guessed/failed, points earned
- Filter by player
- Phoenix LiveView: Real-time UI with minimal JavaScript
- GenServer Supervision: Fault-tolerant game state management
- PubSub Broadcasting: Session-wide real-time updates
- Registry: Efficient session process lookup
- DaisyUI + Tailwind: Modern component-based styling
- Heroicons: Consistent iconography
- LocalStorage Integration: Client-side persistence via JS hooks for user_id
- Audio Support: Timer notification sounds with autoplay
- Dictionary Files: Text files with word lists loaded at startup
- Keyboard Event Handling: JavaScript hooks for physical keyboard input
Load dictionaries at application startup in application.ex:
defmodule WordleBattle.Application do
use Application
def start(_type, _args) do
# Preload dictionaries into memory
_ = WordleBattle.Dictionary.answer_words()
_ = WordleBattle.Dictionary.valid_guesses()
children = [
# ... other children
]
opts = [strategy: :one_for_one, name: WordleBattle.Supervisor]
Supervisor.start_link(children, opts)
end
end# Pick random word avoiding already used words in session
def get_next_word(used_words) do
case Dictionary.answer_words() -- used_words do
[] ->
# All words used, start over (rare in normal gameplay)
Dictionary.random_word()
available_words ->
Enum.random(available_words)
end
end- Answer words list: ~2,300 words × ~5 bytes = ~12KB
- Valid guesses MapSet: ~12,000 words × ~10 bytes = ~120KB
- Per session tracking: ~50 words × 5 bytes = ~250 bytes per session
- Total: ~132KB baseline + minimal per-session overhead
Ensure color feedback correctly handles duplicate letters using the two-pass algorithm shown above.
Use GenServer with :timer.send_interval/2 to broadcast time updates every second:
def handle_info(:tick, state) do
time_remaining = state.time_remaining - 1
if time_remaining <= 0 do
# End game
{:noreply, end_game(state)}
else
# Broadcast time update
Phoenix.PubSub.broadcast(
WordleBattle.PubSub,
"session:#{state.session_id}",
{:time_update, time_remaining}
)
{:noreply, %{state | time_remaining: time_remaining}}
end
endTrack used letters per player and broadcast updates after each guess. Virtual keyboard component updates colors based on player's letter usage history.
When player reconnects during :playing:
- Restore current word and all attempts
- Restore score and statistics
- Resume from current state
- Sync timer display
- Virtual keyboard essential for mobile (touch-friendly buttons)
- Collapsible leaderboard sidebar to maximize game area
- Larger touch targets for buttons (minimum 44×44px)
- Responsive grid layout
- ARIA labels for all interactive elements
- Keyboard navigation support (Tab, Arrow keys, Enter, Backspace)
- Color-blind friendly mode option (patterns/symbols in addition to colors)
- Screen reader announcements for timer, score changes, game phase transitions
- Word lists loaded once at startup (no per-request loading)
- Random selection using
Enum.random/1is O(n) but with small n (~2300) - Consider MapSet for
used_wordsif sessions last very long (>100 words used) - Throttle PubSub broadcasts for high-frequency updates
- Rate limiting on guess submissions (prevent spam/cheating)
- Word assignment server-side only (prevent client inspection)
- Validate all player actions server-side (no trust in client)
- Current word never sent to non-owning players (prevent cheating)