6-Card Golf Rules
Single Source of Truth for all game rules, variants, and house rules.
This document is the canonical reference - all implementations must match these specifications.
Document Structure
This document follows a vertical documentation structure:
- Rules - Human-readable game rules
- Implementation - Code references (file:line)
- Tests - Verification test references
- Edge Cases - Documented edge case behaviors
Part 1: Core Game Rules
Overview
Golf is a card game where players try to achieve the lowest score over multiple rounds ("holes"). The name comes from golf scoring - lower is better.
Players & Equipment
- Players: 2-6 players
- Deck: Standard 52-card deck (optionally with 2 Jokers)
- Multiple decks: For 5+ players, use 2 decks
| Implementation |
File |
| Deck creation |
game.py:89-104 |
| Multi-deck support |
game.py:92-103 |
| Tests |
File |
| Standard 52 cards |
test_game.py:501-504 |
| Joker deck 54 cards |
test_game.py:506-509 |
| Multi-deck |
test_game.py:516-519 |
Setup
- Dealer shuffles and deals 6 cards face-down to each player
- Players arrange cards in a 2 row x 3 column grid:
[0] [1] [2] <- Top row
[3] [4] [5] <- Bottom row
- Remaining cards form the draw pile (face-down)
- Top card of draw pile is flipped to start the discard pile
- Each player flips 2 of their cards face-up (configurable: 0, 1, or 2)
| Implementation |
File |
| Deal 6 cards |
game.py:304-311 |
| Start discard pile |
game.py:313-317 |
| Initial flip phase |
game.py:326-352 |
| Tests |
File |
| Initial flip 2 cards |
test_game.py:454-469 |
| Initial flip 0 skips phase |
test_game.py:471-478 |
| Game starts after all flip |
test_game.py:480-491 |
Card Values
| Card |
Points |
Notes |
| Ace |
1 |
Low card |
| 2 |
-2 |
Negative! Best non-special card |
| 3-10 |
Face value |
3=3, 4=4, ..., 10=10 |
| Jack |
10 |
Face card |
| Queen |
10 |
Face card |
| King |
0 |
Zero points |
| Joker |
-2 |
Negative (requires use_jokers option) |
Card Value Quality Tiers
| Tier |
Cards |
Strategy |
| Excellent |
Joker (-2), 2 (-2) |
Always keep, never pair |
| Good |
King (0) |
Safe, good for pairing |
| Decent |
Ace (1) |
Low risk |
| Neutral |
3, 4, 5 |
Acceptable |
| Bad |
6, 7 |
Replace when possible |
| Terrible |
8, 9, 10, J, Q |
High priority to replace |
| Implementation |
File |
| DEFAULT_CARD_VALUES |
constants.py:3-18 |
| RANK_VALUES derivation |
game.py:40-41 |
| get_card_value() |
game.py:44-67 |
| Tests |
File |
| Ace worth 1 |
test_game.py:29-30 |
| Two worth -2 |
test_game.py:32-33 |
| 3-10 face value |
test_game.py:35-43 |
| Jack worth 10 |
test_game.py:45-46 |
| Queen worth 10 |
test_game.py:48-49 |
| King worth 0 |
test_game.py:51-52 |
| Joker worth -2 |
test_game.py:54-55 |
| Card.value() method |
test_game.py:57-63 |
| Rank quality classification |
test_analyzer.py:47-74 |
Column Pairing
Critical Rule: If both cards in a column have the same rank, that column scores 0 points regardless of the individual card values.
Column Positions
Column 0: positions (0, 3)
Column 1: positions (1, 4)
Column 2: positions (2, 5)
Examples
Matched column:
[K] [5] [7] K-K pair = 0
[K] [3] [9] 5+3 = 8, 7+9 = 16
Total: 0 + 8 + 16 = 24
All columns matched:
[A] [5] [K] All paired = 0 total
[A] [5] [K]
Edge Case: Paired Negative Cards
IMPORTANT: Paired 2s score 0, not -4. The pair cancels the value, it doesn't double it.
This is a common source of bugs. When two 2s are paired:
- Individual values: -2 + -2 = -4
- Paired value: 0 (pair rule overrides)
The same applies to paired Jokers (standard rules) - they score 0, not -4.
| Implementation |
File |
| Column pair detection |
game.py:158-178 |
| Pair cancels to 0 |
game.py:174-175 |
| Tests |
File |
| Matching column scores 0 |
test_game.py:83-91 |
| All columns matched = 0 |
test_game.py:93-98 |
| No columns matched = sum |
test_game.py:100-106 |
| Paired 2s = 0 (not -4) |
test_game.py:108-115 |
| Unpaired negatives keep value |
test_game.py:117-124 |
Turn Structure
1. Draw Phase
Choose ONE:
- Draw the top card from the draw pile (face-down deck)
- Take the top card from the discard pile (face-up)
2. Play Phase
If you drew from the DECK:
- Swap: Replace any card in your grid (old card goes to discard face-up)
- Discard: Put the drawn card on the discard pile (optionally flip a face-down card)
If you took from the DISCARD PILE:
- You MUST swap - you cannot re-discard the same card
- Replace any card in your grid (old card goes to discard)
Important Rules
- Swapped cards are always placed face-up
- You cannot look at a face-down card before deciding to replace it
- When swapping a face-down card, reveal it only as it goes to discard
| Implementation |
File |
| Draw from deck/discard |
game.py:354-384 |
| Swap card |
game.py:409-426 |
| Cannot re-discard from discard |
game.py:428-433, game.py:443-445 |
| Discard from deck draw |
game.py:435-460 |
| Flip after discard |
game.py:462-476 |
| Tests |
File |
| Can draw from deck |
test_game.py:200-205 |
| Can draw from discard |
test_game.py:207-214 |
| Can discard deck draw |
test_game.py:216-221 |
| Cannot discard discard draw |
test_game.py:223-228 |
| Must swap discard draw |
test_game.py:230-238 |
| Swap makes card face-up |
test_game.py:240-247 |
| Cannot peek before swap |
test_game.py:249-256 |
Round End
Triggering the Final Turn
When any player has all 6 cards face-up, the round enters "final turn" phase.
Final Turn Phase
- Each other player gets exactly one more turn
- The player who triggered final turn does NOT get another turn
- After all players have had their final turn, the round ends
Scoring
- All remaining face-down cards are revealed
- Calculate each player's score (with column pairing)
- Add round score to total score
| Implementation |
File |
| Check all face-up |
game.py:478-483 |
| Final turn phase |
game.py:488-502 |
| End round scoring |
game.py:504-555 |
| Tests |
File |
| Revealing all triggers final turn |
test_game.py:327-341 |
| Other players get final turn |
test_game.py:343-358 |
| Finisher doesn't get extra turn |
test_game.py:360-373 |
| All cards revealed at round end |
test_game.py:375-388 |
Winning
- Standard game: 9 rounds ("9 holes")
- Player with the lowest total score wins
- Optionally play 18 rounds for a longer game
| Implementation |
File |
| Multi-round tracking |
game.py:557-567 |
| Total score accumulation |
game.py:548-549 |
| Tests |
File |
| Next round resets hands |
test_game.py:398-418 |
| Scores accumulate across rounds |
test_game.py:420-444 |
Part 2: House Rules
Our implementation supports these optional rule variations. All are disabled by default.
Standard Options
| Option |
Description |
Default |
initial_flips |
Cards revealed at start (0, 1, or 2) |
2 |
flip_mode |
What happens when discarding from deck (see below) |
never |
knock_penalty |
+10 if you go out but don't have lowest score |
Off |
use_jokers |
Add Jokers to deck (-2 points each) |
Off |
Flip Mode Options
The flip_mode setting controls what happens when you draw from the deck and choose to discard (not swap):
| Value |
Name |
Behavior |
never |
Standard |
No flip when discarding - your turn ends immediately. This is the classic rule. |
always |
Speed Golf |
Must flip one face-down card when discarding. Accelerates the game by revealing more information each turn. |
endgame |
Suspense |
May optionally flip if any player has ≤1 face-down card. Creates tension near the end of rounds. |
Standard (never): When you draw from the deck and choose not to use the card, simply discard it and your turn ends.
Speed Golf (always): When you discard from the deck, you must also flip one of your face-down cards. This accelerates the game by revealing more information each turn, leading to faster rounds.
Suspense (endgame): When any player has only 1 (or 0) face-down cards remaining, discarding from the deck gives you the option to flip a card. This creates tension near the end of rounds - do you reveal more to improve your position, or keep your cards hidden?
| Implementation |
File |
| GameOptions dataclass |
game.py:200-222 |
| FlipMode enum |
game.py:12-24 |
| flip_on_discard property |
game.py:449-470 |
| flip_is_optional property |
game.py:472-479 |
| skip_flip_and_end_turn() |
game.py:520-540 |
Point Modifiers
| Option |
Effect |
Standard Value |
Modified Value |
lucky_swing |
Single Joker in deck |
2 Jokers @ -2 each |
1 Joker @ -5 |
super_kings |
Kings are negative |
King = 0 |
King = -2 |
ten_penny |
10s are low |
10 = 10 |
10 = 1 |
| Implementation |
File |
| LUCKY_SWING_JOKER_VALUE |
constants.py:23 |
| SUPER_KINGS_VALUE |
constants.py:21 |
| TEN_PENNY_VALUE |
constants.py:22 |
| Value application |
game.py:58-66 |
| Tests |
File |
| Super Kings -2 |
test_game.py:142-149 |
| Ten Penny |
test_game.py:151-158 |
| Lucky Swing Joker -5 |
test_game.py:160-173 |
| Lucky Swing single joker |
test_game.py:511-514 |
Bonuses & Penalties
| Option |
Effect |
When Applied |
knock_bonus |
First to reveal all cards gets -5 |
Round end |
underdog_bonus |
Lowest scorer each round gets -3 |
Round end |
tied_shame |
Tying another player's score = +5 penalty to both |
Round end |
blackjack |
Exact score of 21 becomes 0 |
Round end |
wolfpack |
2 pairs of Jacks = -5 bonus |
Scoring |
| Implementation |
File |
| Blackjack 21->0 |
game.py:513-517 |
| Knock penalty |
game.py:519-525 |
| Knock bonus |
game.py:527-531 |
| Underdog bonus |
game.py:533-538 |
| Tied shame |
game.py:540-546 |
| Wolfpack |
game.py:180-182 |
| Tests |
File |
| Blackjack 21 becomes 0 |
test_game.py:175-183 |
| House rules integration |
test_house_rules.py (full file) |
Special Rules
| Option |
Effect |
eagle_eye |
Jokers worth +2 unpaired, -4 paired (reward spotting pairs) |
| Implementation |
File |
| Eagle eye unpaired value |
game.py:60-61 |
| Eagle eye paired value |
game.py:169-173 |
Part 3: AI Decision Making
AI Profiles
8 distinct AI personalities with different play styles:
| Name |
Style |
Swap Threshold |
Pair Hope |
Aggression |
| Sofia |
Calculated & Patient |
4 |
0.2 |
0.2 |
| Maya |
Aggressive Closer |
6 |
0.4 |
0.85 |
| Priya |
Pair Hunter |
7 |
0.8 |
0.5 |
| Marcus |
Steady Eddie |
5 |
0.35 |
0.4 |
| Kenji |
Risk Taker |
8 |
0.7 |
0.75 |
| Diego |
Chaotic Gambler |
6 |
0.5 |
0.6 |
| River |
Adaptive Strategist |
5 |
0.45 |
0.55 |
| Sage |
Sneaky Finisher |
5 |
0.3 |
0.9 |
| Implementation |
File |
| CPUProfile dataclass |
ai.py:164-182 |
| CPU_PROFILES list |
ai.py:186-253 |
Key AI Decision Functions
should_take_discard()
Decides whether to take from discard pile or draw from deck.
Logic priority:
- Always take Jokers (and pair if Eagle Eye)
- Always take Kings
- Take 10s if ten_penny enabled
- Take cards that complete a column pair (except negative cards)
- Take low cards based on game phase threshold
- Consider end-game pressure
- Take if we have worse visible cards
| Implementation |
File |
| should_take_discard() |
ai.py:333-412 |
| Negative card pair avoidance |
ai.py:365-374 |
| Tests |
File |
| Maya doesn't take 10 with good hand |
test_maya_bug.py:52-83 |
| Unpredictability doesn't take bad cards |
test_maya_bug.py:85-116 |
| Pair potential respected |
test_maya_bug.py:289-315 |
choose_swap_or_discard()
Decides whether to swap the drawn card into hand or discard it.
Logic priority:
- Eagle Eye: Pair Jokers if visible match exists
- Check for column pair opportunity (except negative cards)
- Find best swap among BAD face-up cards (positive value)
- Consider Blackjack (21) pursuit
- Swap excellent cards into face-down positions
- Apply profile-based thresholds
Critical: When placing cards into face-down positions, the AI must avoid creating wasteful pairs with visible negative cards.
| Implementation |
File |
| choose_swap_or_discard() |
ai.py:414-536 |
| Negative card pair avoidance |
ai.py:441-446 |
| Tests |
File |
| Don't discard excellent cards |
test_maya_bug.py:179-209 |
| Full Maya bug scenario |
test_maya_bug.py:211-254 |
Part 4: Edge Cases & Known Issues
Edge Case: Pairing Negative Cards
Problem: Pairing 2s or Jokers wastes their negative value.
- Unpaired 2: contributes -2 to score
- Paired 2s: contribute 0 to score (lost 2 points!)
AI Safeguards:
should_take_discard(): Only considers pairing if discard_value > 0
choose_swap_or_discard(): Sets should_pair = drawn_value > 0
filter_bad_pair_positions(): Filters out positions that would create wasteful pairs when placing negative cards into face-down positions
| Implementation |
File |
| get_column_partner_position() |
ai.py:163-168 |
| filter_bad_pair_positions() |
ai.py:171-213 |
| Applied in choose_swap_or_discard |
ai.py:517, ai.py:538 |
| Applied in forced swap |
ai.py:711-713 |
| Tests |
File |
| Paired 2s = 0 (game logic) |
test_game.py:108-115 |
| AI avoids pairing logic |
ai.py:365-374, ai.py:441-446 |
| Filter with visible two |
test_maya_bug.py:320-347 |
| Filter allows positive pairs |
test_maya_bug.py:349-371 |
| Choose swap avoids 2 pairs |
test_maya_bug.py:373-401 |
| Forced swap avoids 2 pairs |
test_maya_bug.py:403-425 |
| Fallback when all bad |
test_maya_bug.py:427-451 |
Edge Case: Forced Swap from Discard
When drawing from discard pile and choose_swap_or_discard() returns None (discard), the AI is forced to swap anyway. The fallback picks randomly from face-down positions, or finds the worst face-up card.
| Implementation |
File |
| Forced swap fallback |
ai.py:665-686 |
| Tests |
File |
| Forced swap uses house rules |
test_maya_bug.py:143-177 |
| All face-up finds worst |
test_maya_bug.py:260-287 |
Edge Case: Deck Exhaustion
When the deck is empty, the discard pile (except top card) is reshuffled back into the deck.
| Implementation |
File |
| Reshuffle discard pile |
game.py:386-407 |
Edge Case: Empty Discard Pile
Cannot draw from empty discard pile.
| Tests |
File |
| Empty discard returns None |
test_game.py:558-569 |
Part 5: Test Coverage Summary
Test Files
| File |
Tests |
Focus |
test_game.py |
44 |
Core game rules |
test_house_rules.py |
10+ |
House rule integration |
test_analyzer.py |
18 |
AI decision evaluation |
test_maya_bug.py |
18 |
Bug regression & AI edge cases |
Total: 83+ tests
Coverage by Category
| Category |
Tests |
Files |
Status |
| Card Values |
8 |
test_game.py, test_analyzer.py |
Complete |
| Column Pairing |
5 |
test_game.py |
Complete |
| House Rules Scoring |
4 |
test_game.py |
Complete |
| Draw/Discard Mechanics |
7 |
test_game.py |
Complete |
| Turn Flow |
4 |
test_game.py |
Complete |
| Round End |
4 |
test_game.py |
Complete |
| Multi-Round |
2 |
test_game.py |
Complete |
| Initial Flip |
3 |
test_game.py |
Complete |
| Deck Management |
4 |
test_game.py |
Complete |
| Edge Cases |
3 |
test_game.py |
Complete |
| Take Discard Evaluation |
6 |
test_analyzer.py |
Complete |
| Swap Evaluation |
6 |
test_analyzer.py |
Complete |
| House Rules Evaluation |
2 |
test_analyzer.py |
Complete |
| Maya Bug Regression |
6 |
test_maya_bug.py |
Complete |
| AI Edge Cases |
3 |
test_maya_bug.py |
Complete |
| Bad Pair Avoidance |
5 |
test_maya_bug.py |
Complete |
Test Plan: Critical Paths
Game State Transitions
WAITING -> INITIAL_FLIP -> PLAYING -> FINAL_TURN -> ROUND_OVER -> GAME_OVER
| ^
v (initial_flips=0) |
+-------------------------+
| Transition |
Trigger |
Tests |
| WAITING -> INITIAL_FLIP |
start_game() |
test_game.py:454-469 |
| WAITING -> PLAYING |
start_game(initial_flips=0) |
test_game.py:471-478 |
| INITIAL_FLIP -> PLAYING |
All players flip |
test_game.py:480-491 |
| PLAYING -> FINAL_TURN |
Player all face-up |
test_game.py:327-341 |
| FINAL_TURN -> ROUND_OVER |
All final turns done |
test_game.py:343-358 |
| ROUND_OVER -> PLAYING |
start_next_round() |
test_game.py:398-418 |
| ROUND_OVER -> GAME_OVER |
Final round complete |
test_game.py:420-444 |
AI Decision Tree
Draw Phase:
├── should_take_discard() returns True
│ └── Draw from discard pile
│ └── MUST swap (can_discard_drawn=False)
└── should_take_discard() returns False
└── Draw from deck
├── choose_swap_or_discard() returns position
│ └── Swap at position
└── choose_swap_or_discard() returns None
└── Discard drawn card
└── flip_on_discard?
├── flip_mode="always" -> MUST flip (choose_flip_after_discard)
└── flip_mode="endgame" -> should_skip_optional_flip()?
├── True -> skip flip, end turn
└── False -> flip (choose_flip_after_discard)
| Decision Point |
Tests |
| Take Joker/King from discard |
test_analyzer.py:96-114 |
| Don't take bad cards |
test_maya_bug.py:52-116 |
| Swap excellent cards |
test_maya_bug.py:179-209 |
| Avoid pairing negatives |
test_maya_bug.py:320-451 |
| Forced swap from discard |
test_maya_bug.py:143-177, test_maya_bug.py:403-425 |
Scoring Edge Cases
| Scenario |
Expected |
Test |
| Paired 2s |
0 (not -4) |
test_game.py:108-115 |
| Paired Jokers (standard) |
0 |
Implicit |
| Paired Jokers (eagle_eye) |
-4 |
game.py:169-173 |
| Unpaired negative cards |
-2 each |
test_game.py:117-124 |
| All columns matched |
0 total |
test_game.py:93-98 |
| Blackjack (21) |
0 |
test_game.py:175-183 |
Running Tests
# Run all tests
cd server && python -m pytest -v
# Run specific test file
python -m pytest test_game.py -v
# Run specific test class
python -m pytest test_game.py::TestCardValues -v
# Run with coverage
python -m pytest --cov=. --cov-report=html
# Run tests matching pattern
python -m pytest -k "pair" -v
Test Quality Checklist
Disadvantageous Moves (AI Quality Metrics)
Definition: "Dumb Moves"
Moves that are objectively suboptimal and should occur at minimal background noise level (< 1% of opportunities).
| Move Type |
Severity |
Expected Prevalence |
Test Coverage |
| Discarding Joker/2 |
Blunder |
0% |
test_maya_bug.py:179-209 |
| Discarding King |
Mistake |
0% |
test_analyzer.py:183-192 |
| Taking 10/J/Q without pair |
Blunder |
0% |
test_maya_bug.py:52-116 |
| Pairing negative cards |
Mistake |
0% |
test_maya_bug.py:373-401 |
| Swapping good card for bad |
Mistake |
0% |
test_analyzer.py:219-237 |
Definition: "Questionable Moves"
Moves that may be suboptimal but have legitimate strategic reasons. Should be < 5% of opportunities.
| Move Type |
When Acceptable |
Monitoring |
| Not taking low card (3-5) |
Pair hunting, early game |
Profile-based |
| Discarding medium card (4-6) |
Full hand, pair potential |
Context check |
| Going out with high score |
Pressure, knock_bonus |
Threshold based |
AI Quality Assertions
These assertions should pass when running extended simulations:
# In test suite or simulation
def test_ai_quality_metrics():
"""Run N games and verify dumb moves are at noise level."""
stats = run_simulation(games=1000)
# ZERO tolerance blunders
assert stats.discarded_jokers == 0
assert stats.discarded_twos == 0
assert stats.took_bad_card_without_pair == 0
assert stats.paired_negative_cards == 0
# Near-zero tolerance mistakes
assert stats.discarded_kings < stats.total_turns * 0.001 # < 0.1%
assert stats.swapped_good_for_bad < stats.total_turns * 0.001
# Acceptable variance
assert stats.questionable_moves < stats.total_turns * 0.05 # < 5%
Tracking Implementation
Decision quality should be logged for analysis:
| Field |
Description |
decision_type |
take_discard, swap, discard, flip |
decision_quality |
OPTIMAL, GOOD, QUESTIONABLE, MISTAKE, BLUNDER |
expected_value |
EV calculation for the decision |
profile_name |
AI personality that made decision |
game_phase |
early, mid, late |
See game_analyzer.py for decision evaluation logic.
Recommended Additional Tests
| Area |
Description |
Priority |
| AI Quality Metrics |
Simulation-based dumb move detection |
Critical |
| WebSocket |
Integration tests for real-time communication |
High |
| Concurrent games |
Multiple simultaneous rooms |
Medium |
| Deck exhaustion |
Reshuffle when deck empty |
Medium |
| All house rule combos |
Interaction between rules |
Medium |
| AI personality variance |
Verify distinct behaviors |
Low |
| Performance |
Load testing with many players |
Low |
Part 6: Strategic Notes
Card Priority (for AI and Players)
Always Keep
- Jokers (-2 or -5): Best cards in game
- 2s (-2): Second best, but don't pair them!
Keep When Possible
- Kings (0): Safe, excellent for pairing
- Aces (1): Low risk
Replace When Possible
- 6, 7 (6-7 points): Moderate priority
- 8, 9 (8-9 points): High priority
- 10, J, Q (10 points): Highest priority
Pairing Strategy
- Pairing is powerful - column score goes to 0
- Never pair negative cards (2s, Jokers) - you lose the negative benefit
- Target pairs with mid-value cards (3-7) for maximum gain
- High-value pairs (10, J, Q) are valuable (+20 point swing)
When to Go Out
- Go out with score <= 10 when confident you're lowest
- Consider opponent visible cards before going out early
- With
knock_penalty, be careful - +10 hurts if you're wrong
- With
knock_bonus, more incentive to finish first
Part 7: Configuration
Configuration Files
| File |
Purpose |
pyproject.toml |
Project metadata, dependencies, tool config |
server/config.py |
Centralized configuration loader |
server/constants.py |
Card values and game constants |
.env.example |
Environment variable documentation |
.env |
Local environment overrides (not committed) |
Environment Variables
Configuration precedence (highest to lowest):
- Environment variables
.env file
- Default values in code
Server Settings
| Variable |
Default |
Description |
HOST |
0.0.0.0 |
Host to bind to |
PORT |
8000 |
Port to listen on |
DEBUG |
false |
Enable debug mode |
LOG_LEVEL |
INFO |
Logging level |
DATABASE_URL |
sqlite:///games.db |
Database connection |
Room Settings
| Variable |
Default |
Description |
MAX_PLAYERS_PER_ROOM |
6 |
Max players per game |
ROOM_TIMEOUT_MINUTES |
60 |
Inactive room cleanup |
ROOM_CODE_LENGTH |
4 |
Room code length |
Game Defaults
| Variable |
Default |
Description |
DEFAULT_ROUNDS |
9 |
Rounds per game |
DEFAULT_INITIAL_FLIPS |
2 |
Cards to flip at start |
DEFAULT_USE_JOKERS |
false |
Enable jokers |
DEFAULT_FLIP_MODE |
never |
Flip mode: never, always, or endgame |
Security
| Variable |
Default |
Description |
SECRET_KEY |
(empty) |
Secret key for sessions |
INVITE_ONLY |
false |
Require invitation to register |
ADMIN_EMAILS |
(empty) |
Comma-separated admin emails |
Running the Server
# Development (with auto-reload)
DEBUG=true python server/main.py
# Production
PORT=8080 LOG_LEVEL=WARNING python server/main.py
# With .env file
cp .env.example .env
# Edit .env as needed
python server/main.py
# Using uvicorn directly
uvicorn server.main:app --host 0.0.0.0 --port 8000
Part 8: Authentication
Overview
The authentication system supports:
- User accounts stored in SQLite (
users table)
- Admin accounts that can manage other users
- Invite codes (or room codes) for registration
- Session-based authentication with bearer tokens
First-Time Setup
When the server starts with no admin accounts:
- A default
admin account is created (or accounts for each email in ADMIN_EMAILS)
- The admin account has no password initially
- On first login attempt, use
/api/auth/setup-password to set the password
# Check if admin needs setup
curl http://localhost:8000/api/auth/check-setup/admin
# Set admin password (first time only)
curl -X POST http://localhost:8000/api/auth/setup-password \
-H "Content-Type: application/json" \
-d '{"username": "admin", "new_password": "your-secure-password"}'
API Endpoints
Public Endpoints
| Endpoint |
Method |
Description |
/api/auth/check-setup/{username} |
GET |
Check if user needs password setup |
/api/auth/setup-password |
POST |
Set password (first login only) |
/api/auth/login |
POST |
Login with username/password |
/api/auth/register |
POST |
Register with invite code |
/api/auth/logout |
POST |
Logout current session |
Authenticated Endpoints
| Endpoint |
Method |
Description |
/api/auth/me |
GET |
Get current user info |
/api/auth/password |
PUT |
Change own password |
Admin Endpoints
| Endpoint |
Method |
Description |
/api/admin/users |
GET |
List all users |
/api/admin/users/{id} |
GET |
Get user by ID |
/api/admin/users/{id} |
PUT |
Update user |
/api/admin/users/{id}/password |
PUT |
Change user password |
/api/admin/users/{id} |
DELETE |
Deactivate user |
/api/admin/invites |
POST |
Create invite code |
/api/admin/invites |
GET |
List invite codes |
/api/admin/invites/{code} |
DELETE |
Deactivate invite code |
Registration Flow
- User obtains an invite code (from admin) or a room code (from active game)
- User calls
/api/auth/register with username, password, and invite code
- If valid, account is created and session token is returned
# Register with room code
curl -X POST http://localhost:8000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "player1", "password": "pass123", "invite_code": "ABCD"}'
After login, include the token in requests:
Authorization: Bearer <token>
Database Schema
-- Users table
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE,
password_hash TEXT NOT NULL, -- SHA-256 with salt
role TEXT DEFAULT 'user', -- 'user' or 'admin'
created_at TIMESTAMP,
last_login TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
invited_by TEXT
);
-- Sessions table
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id),
created_at TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
-- Invite codes table
CREATE TABLE invite_codes (
code TEXT PRIMARY KEY,
created_by TEXT REFERENCES users(id),
created_at TIMESTAMP,
expires_at TIMESTAMP,
max_uses INTEGER DEFAULT 1,
use_count INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT 1
);
| Implementation |
File |
| AuthManager class |
auth.py:87-460 |
| User model |
auth.py:27-50 |
| Password hashing |
auth.py:159-172 |
| Session management |
auth.py:316-360 |
| Tests |
File |
| User creation |
test_auth.py:22-60 |
| Authentication |
test_auth.py:63-120 |
| Invite codes |
test_auth.py:123-175 |
| Admin functions |
test_auth.py:178-220 |
Last updated: Document generated from codebase analysis
Reference implementations: config.py, constants.py, game.py, ai.py, auth.py
Test suites: test_game.py, test_house_rules.py, test_analyzer.py, test_maya_bug.py, test_auth.py