Spaces:
Running
A newer version of the Streamlit SDK is available:
1.54.0
Game Mode Implementation Guide
This guide documents patterns, best practices, and common gotchas when adding new game modes to Devil's Dozen, based on lessons learned from implementing Peasant's Gamble, Alchemist's Ascent, and Knucklebones.
Quick Start Checklist
- Review
docs/NEW_GAME_MODE_TEMPLATE.mdfor structure - Read this guide for common patterns and gotchas
- Write engine tests FIRST (TDD approach)
- Follow die styling conventions (numeric display)
- Test database update methods with None values
- Verify turn advancement timing
- Test multiplayer sync with 2+ players
- Ensure audio triggers correctly
Architecture Overview
Layer Structure
βββββββββββββββββββββββββββββββββββββββββββ
β UI Layer (Streamlit) β
β - src/ui/views/game.py (routing) β
β - src/ui/components/ β
βββββββββββββββββββββββββββββββββββββββββββ€
β Database Layer (Supabase) β
β - src/database/models.py β
β - src/database/*_manager.py β
βββββββββββββββββββββββββββββββββββββββββββ€
β Engine Layer (Pure Python) β
β - src/engine/<mode>.py β
β - Stateless, immutable dataclasses β
βββββββββββββββββββββββββββββββββββββββββββ
Separation of Concerns:
- Engine: Pure game logic, no I/O, 100% tested
- Database: CRUD operations, state persistence
- UI: Rendering, user interaction, polling
Critical Patterns
1. Die Styling - ALWAYS Use Numeric Display
β WRONG:
# Using Unicode die faces
die_face = {"1": "β", "2": "β", ...}[value]
html = f'<div class="die">{die_face}</div>'
β CORRECT:
# Use numeric values (matches D6 standard)
html = f'<div class="die">{value}</div>'
Why: All D6 dice across all game modes display as numbers (1-6), not Unicode characters. This ensures:
- Consistent styling across modes
- Better readability
- Easier to understand at a glance
- Works with existing CSS (
.dieclass)
Standard die HTML:
<div class="die">4</div> <!-- Basic die -->
<div class="die scoring">4</div> <!-- Scoring die (gold) -->
<div class="die held">4</div> <!-- Held die (green) -->
<div class="die d20 tier-red">15</div> <!-- D20 with tier color -->
<div class="die grid-die">6</div> <!-- Grid die (Knucklebones) -->
CSS Classes:
.die- Base die styling (72px Γ 72px by default).scoring- Gold border (scoring dice).held- Green border with lift effect.d20- Circular D20 styling.tier-red,.tier-green,.tier-blue- D20 tier colors.grid-die- Smaller dice for grids (60px Γ 60px)
2. Database Updates - Handle None Values Correctly
β WRONG:
def update_game_state(self, lobby_id: str, field: int | None = None):
updates = {}
if field is not None: # BUG: None values are skipped!
updates["field"] = field
Problem: When you pass field=None to clear a value, it won't update the database because if field is not None evaluates to False.
β CORRECT:
def update_game_state(self, lobby_id: str, **kwargs):
updates = {}
if "field" in kwargs: # Check if parameter was provided
updates["field"] = kwargs["field"] # Can be None!
Why: Using **kwargs lets you distinguish between:
- "Parameter not provided" (don't update)
- "Parameter provided as None" (clear the field)
Example from Knucklebones:
# Clear current_die_value after placement
gs_mgr.update_knucklebones(
lobby_id,
player1_grid=new_grid,
current_die_value=None, # This MUST update to NULL in DB
)
3. Turn Advancement - Order Matters!
β WRONG:
# Update game state (clear die)
gs_mgr.update(lobby_id, current_die=None)
# Advance turn
lobby_mgr.advance_turn(lobby_id, next_player)
# BUG: Race condition! Other player might see cleared die
# but old turn index during polling.
β CORRECT:
# Advance turn FIRST
lobby_mgr.advance_turn(lobby_id, next_player)
# THEN clear die
gs_mgr.update(lobby_id, current_die=None)
# Now when next player polls, they see:
# - current_turn_index points to them
# - current_die is None
# - Result: "Roll Die" button appears
Why: Database updates happen in separate transactions. If you clear the die before advancing the turn, there's a window where the next player might poll and see:
- Old turn index (still not their turn)
- Cleared die value (None)
- Result: Confusing UI state
Best Practice: Order operations so that each intermediate state is valid:
- Change turn index
- Clear/update related state
- Rerun UI
4. Immutable Data Structures
Always use frozen dataclasses in the engine layer:
from dataclasses import dataclass
@dataclass(frozen=True)
class GameState:
"""Immutable game state."""
dice: tuple[int, ...] # Use tuples, not lists
score: int
# β WRONG: Mutable default
# held: list[int] = []
# β
CORRECT: Immutable default
held: frozenset[int] = field(default_factory=frozenset)
Why:
- Thread-safe
- No accidental mutations
- Easier to reason about
- Better for testing
Converting to/from database:
# From DB (lists) -> Engine (tuples)
grid_state = GridState(
columns=(tuple(col1), tuple(col2), tuple(col3))
)
# From Engine (tuples) -> DB (lists)
db_data = {
"columns": [list(col) for col in grid_state.columns]
}
5. Stateless Engine Methods
All engine methods should be @classmethod and stateless:
class MyGameEngine:
@classmethod
def calculate_score(cls, dice: tuple[int, ...]) -> int:
"""Pure function - no side effects."""
return sum(dice)
@classmethod
def roll_dice(cls, count: int) -> tuple[int, ...]:
"""Returns new dice, doesn't modify state."""
return tuple(random.randint(1, 6) for _ in range(count))
Why:
- Easy to test
- No hidden state
- Deterministic (given same inputs, same outputs)
- Can be called in any order
Common Gotchas
Gotcha 1: Streamlit Reruns in Callbacks
β WRONG:
if st.button("Roll", on_click=lambda: handle_roll()):
pass
def handle_roll():
# Do stuff
st.rerun() # ERROR: "st.rerun() within a callback is a no-op"
β CORRECT:
clicked = st.button("Roll")
if clicked:
handle_roll() # Call after button render
def handle_roll():
# Do stuff
st.rerun() # Now it works!
Why: Streamlit doesn't allow st.rerun() inside on_click callbacks. Detect clicks AFTER rendering, then call handlers.
Gotcha 2: Widget Keys and Roll Count
Always include roll count in widget keys:
# β WRONG: Same key across rolls
st.button("Hold", key=f"hold_{die_index}")
# β
CORRECT: Unique key per roll
st.button("Hold", key=f"hold_{die_index}_r{roll_count}")
Why: Streamlit caches widget state by key. If you don't change keys between rolls, old button states persist, causing clicks to be ignored.
Gotcha 3: Session State Cleanup
Use non-widget keys for persistent data:
# β WRONG: Widget keys get cleared on page change
st.session_state["_sfx_widget"] = True
# β
CORRECT: Use non-widget keys for persistence
st.session_state["_sfx_pref"] = True # Survives page changes
# Sync widget -> preference
def sync_sfx():
st.session_state["_sfx_pref"] = st.session_state["_sfx_widget"]
st.checkbox("SFX", key="_sfx_widget", on_change=sync_sfx)
Pattern: Store preferences in _name_pref keys, sync from _name_widget keys.
Gotcha 4: LobbyManager Method Names
Use specific methods, not generic update():
# β WRONG: LobbyManager has no update() method
lobby_mgr.update(lobby_id, current_turn_index=1)
# β
CORRECT: Use specific methods
lobby_mgr.advance_turn(lobby_id, 1)
lobby_mgr.update_status(lobby_id, "playing")
lobby_mgr.set_winner(lobby_id, winner_id)
Available methods:
advance_turn(lobby_id, turn_index)update_status(lobby_id, status)set_winner(lobby_id, winner_id)
Testing Patterns
1. TDD Approach - Tests First
Write tests BEFORE implementing the engine:
def test_calculate_score_pair():
"""Test pair scoring."""
result = Engine.calculate_score((4, 4))
assert result == 16 # (4 + 4) Γ 2
Benefits:
- Forces you to think about edge cases
- Prevents regressions
- Documents expected behavior
- Enables refactoring
Target: 100% engine coverage, 40+ tests per mode
2. Test Categories
Organize tests by concern:
class TestDataStructures:
"""Test GridState, validation, conversions."""
class TestScoring:
"""Test score calculation logic."""
class TestGameRules:
"""Test placement, win conditions, etc."""
class TestIntegration:
"""Test full game flows."""
3. Manual Testing Checklist
After implementation, test end-to-end:
- Create lobby (correct player count shown)
- Join with 2nd player
- Player 1: Full turn cycle
- Player 2: Full turn cycle (polling works)
- Turn advancement works correctly
- Scoring updates in real-time
- Fill win condition
- Winner announced correctly
- Audio plays (roll, bank, bust, victory)
- Rules display in sidebar
- Mobile responsive (if applicable)
UI Patterns
1. Layout Structure
Standard layout: game area (3) | controls (1)
game_col, controls_col = st.columns([3, 1])
with controls_col:
st.caption(f"Lobby: **{lobby.code}**")
render_scoreboard(...)
st.divider()
# Mode-specific controls (roll, bank, placement, etc.)
with game_col:
st.subheader(f"{active_player.username}'s Turn")
# Mode-specific display (dice, grids, etc.)
Why: Keeps controls always visible on the right, game area has space to breathe.
2. Polling Fragment
Use for multiplayer sync:
@st.fragment(run_every=2)
def _poll_game_state():
"""Check for updates from other players."""
lobby = lobby_mgr.get_by_id(lobby_id)
# Detect turn changes
if lobby.current_turn_index != prev_turn:
st.rerun(scope="app")
# Detect game end
if lobby.status == "finished":
st.session_state["page"] = "results"
st.rerun(scope="app")
Call at end of game page:
def render_game_page():
# ... render game ...
_poll_game_state() # Auto-polls every 2 seconds
3. Mode Routing
Add mode routing early in game.py:
def render_game_page():
# ... setup ...
game_mode = lobby.game_mode
# Route to mode-specific logic
if game_mode == "my_new_mode":
_render_my_new_mode(lobby, players, game_state, player_id)
return
# Existing D6/D20 logic below...
Keep mode logic isolated in separate functions.
Audio Integration
1. Add Audio Files
Add to dictionaries in src/ui/themes/sounds.py:
_SFX_FILES = {
# ... existing ...
"my_sfx": "my_sfx.mp3",
}
_MUSIC_FILES = {
# ... existing ...
"my_mode": "my_mode_theme.mp3",
}
2. Play Sounds
Use play_sfx() from action handlers:
def _handle_roll():
dice = engine.roll_dice(6)
play_sfx("dice_roll") # Queues sound for next render
# ... update database ...
st.rerun()
Available SFX:
dice_roll- Rolling soundbank- Banking pointsbust- Bustinghot_dice- Hot dice triggervictory- Game wontier_advance- Tier up (D20)die_destroy- Destruction (Knucklebones)place_die- Placement (Knucklebones)
3. Background Music
Automatically plays based on page + game mode:
# In render_audio_system()
if page == "game" and game_mode in _MUSIC_FILES:
track_key = game_mode # Plays mode-specific music
else:
track_key = "menu" # Menu theme
Note: Browsers block autoplay until user interaction. Music starts on first click/interaction (standard web behavior).
Deployment Checklist
Before pushing to production:
Run Tests
pytest tests/engine/test_<mode>.py -v pytest tests/engine/ --cov=src/engineDatabase Migration
- Create SQL migration in
database/migrations/ - Test locally first
- Run in Supabase SQL Editor
- Verify columns exist
- Create SQL migration in
Audio Files
- Confirm all audio files exist in
assets/sounds/ - Test playback locally
- Check file sizes (use Git LFS for >1MB)
- Confirm all audio files exist in
Manual E2E Test
- Full game playthrough with 2+ players
- Test all edge cases (full columns, ties, etc.)
- Verify winner determination
Commit & Push
git add . git commit -m "feat: Add <Mode> game mode" git push origin master git push hf master:mainVerify Deployment
- Check Hugging Face Spaces rebuild
- Test live version
- Confirm audio files loaded (check Network tab)
Common Issues & Solutions
Issue: Die styling inconsistent
Solution: Always use numeric display, never Unicode characters.
Issue: Turn not advancing
Solution: Advance turn BEFORE clearing die value.
Issue: Database field not clearing
Solution: Use **kwargs in manager methods to handle None values.
Issue: Music not playing
Solution: Browser autoplay policy - music starts on first interaction (normal).
Issue: Widget clicks ignored
Solution: Include roll count in widget keys.
Issue: "st.rerun() in callback" error
Solution: Call handlers AFTER button render, not in on_click.
Issue: Polling not detecting changes
Solution: Ensure st.rerun(scope="app") is called when changes detected.
Resources
- Template:
docs/NEW_GAME_MODE_TEMPLATE.md - Architecture:
docs/CONTEXT_*.mdfiles - Existing Engines:
src/engine/peasants_gamble.py,src/engine/knucklebones.py - Existing Tests:
tests/engine/test_*.py - Audio System:
src/ui/themes/sounds.py
Questions?
If you encounter issues not covered here, document them as you solve them and add to this guide for future implementations!