bridge-game / app.py
gfichetdc's picture
Move leaderboard to right column, always visible, no expander
bb71b7a
Raw
History Blame Contribute Delete
20.8 kB
import streamlit as st
import gensim.downloader as api
import random
import json
import hashlib
import math
import os
from datetime import date, datetime
st.set_page_config(page_title="Bridge", page_icon="🌉", layout="wide")
# ==========================================
# Model loading
# ==========================================
@st.cache_resource
def load_model():
return api.load('word2vec-google-news-300')
model = load_model()
# ==========================================
# Scoring: logit transform (0-1) → (0-+inf)
# ==========================================
def to_display_score(raw):
clamped = max(0.01, min(raw, 0.99))
return max(0, round(100 * math.log(clamped / (1 - clamped))))
# ==========================================
# User identification & score persistence
# ==========================================
SCORES_FILE = "/tmp/bridge_scores.json"
SECRET_SEED = "bridge_7f3a9e2b"
def get_user_id():
if 'user_id' not in st.session_state:
try:
headers = st.context.headers
ip = headers.get("x-forwarded-for", headers.get("x-real-ip", ""))
if ip:
ip = ip.split(",")[0].strip()
raw = f"{SECRET_SEED}:{ip}"
h = hashlib.sha256(raw.encode()).hexdigest()[:6]
st.session_state.user_id = f"P-{h.upper()}"
else:
st.session_state.user_id = f"P-{random.randint(100000, 999999)}"
except Exception:
st.session_state.user_id = f"P-{random.randint(100000, 999999)}"
return st.session_state.user_id
def today_str():
return date.today().strftime('%Y-%m-%d')
def load_scores():
if os.path.exists(SCORES_FILE):
try:
with open(SCORES_FILE, "r") as f:
data = json.load(f)
if data.get("_day") != today_str():
return {"_day": today_str()}
return data
except Exception:
return {"_day": today_str()}
return {"_day": today_str()}
def save_score(user_id, raw_score, display_score, steps, chain, start, target):
scores = load_scores()
is_new_best = False
existing = scores.get(user_id)
if existing is None or display_score > existing["display_score"]:
scores[user_id] = {
"raw_score": round(raw_score, 4),
"display_score": display_score,
"steps": steps,
"chain": chain,
"start": start,
"target": target,
"time": datetime.now().strftime("%H:%M"),
}
scores["_day"] = today_str()
with open(SCORES_FILE, "w") as f:
json.dump(scores, f)
is_new_best = True
return is_new_best
def get_leaderboard(limit=10):
scores = load_scores()
entries = {k: v for k, v in scores.items() if not k.startswith("_")}
ranked = sorted(entries.items(), key=lambda x: x[1]["display_score"], reverse=True)
return ranked[:limit]
# ==========================================
# Word list & pair generation
# ==========================================
COMMON_WORDS = [
'ocean', 'mountain', 'guitar', 'hospital', 'diamond', 'library',
'kitchen', 'soldier', 'painting', 'engine', 'garden', 'prison',
'camera', 'forest', 'bridge', 'castle', 'planet', 'dragon',
'market', 'island', 'thunder', 'silver', 'robot', 'circus',
'desert', 'rocket', 'mirror', 'shadow', 'candle', 'sword',
'winter', 'honey', 'storm', 'angel', 'pirate', 'jungle',
'volcano', 'trophy', 'ghost', 'crown', 'eagle', 'flame',
'harvest', 'crystal', 'anchor', 'bullet', 'compass', 'lantern'
]
def daily_pair():
seed = int(date.today().strftime('%Y%m%d'))
rng = random.Random(seed)
while True:
start, target = rng.sample(COMMON_WORDS, 2)
if start in model and target in model:
sim = model.similarity(start, target)
if sim < 0.20:
return start, target
def get_proximity(sim_to_target):
if sim_to_target >= 0.50:
return "very close"
elif sim_to_target >= 0.35:
return "close"
elif sim_to_target >= 0.25:
return "getting closer"
elif sim_to_target >= 0.15:
return "far"
else:
return "very far"
# ==========================================
# Session state
# ==========================================
def init_game(start, target):
st.session_state.chain = [start]
st.session_state.target = target
st.session_state.similarities = []
st.session_state.sim_to_target = []
st.session_state.lives = 3
st.session_state.game_over = False
st.session_state.won = False
st.session_state.max_steps = 5
st.session_state.message = None
st.session_state.result_saved = False
st.session_state.is_new_best = False
if 'chain' not in st.session_state:
st.session_state.history = []
start, target = daily_pair()
init_game(start, target)
user_id = get_user_id()
# ==========================================
# CSS
# ==========================================
st.markdown("""
<style>
#MainMenu, footer, header { visibility: hidden; }
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-5px); }
80% { transform: translateX(5px); }
}
@keyframes glow {
0% { box-shadow: 0 0 5px #6366f1; }
50% { box-shadow: 0 0 30px #6366f1, 0 0 60px #818cf8; }
100% { box-shadow: 0 0 5px #6366f1; }
}
@keyframes pop {
0% { transform: scale(1); }
50% { transform: scale(1.08); }
100% { transform: scale(1); }
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.shake { animation: shake 0.4s ease-in-out; }
.glow { animation: glow 1.2s ease-in-out 2; border-radius: 12px; }
.pop { animation: pop 0.3s ease-out; }
.game-board {
background: linear-gradient(135deg, rgba(99,102,241,0.08), rgba(168,85,247,0.08));
border: 1px solid rgba(99,102,241,0.2);
border-radius: 16px;
padding: 24px;
margin: 16px 0;
}
.endpoints {
display: flex;
justify-content: center;
align-items: center;
gap: 32px;
margin-bottom: 20px;
}
.endpoint { text-align: center; }
.endpoint-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 2px;
color: #94a3b8;
margin-bottom: 4px;
}
.endpoint-word {
font-size: 1.8rem;
font-weight: 800;
letter-spacing: -0.5px;
}
.endpoint-start { color: #818cf8; }
.endpoint-target { color: #f472b6; }
.endpoint-dots {
color: #475569;
font-size: 1.5rem;
letter-spacing: 4px;
}
.chain-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 4px;
margin: 16px 0;
}
.chain-word {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: 24px;
font-weight: 700;
font-size: 0.95rem;
animation: fadeSlideIn 0.3s ease-out;
letter-spacing: 0.3px;
}
.chain-start {
background: linear-gradient(135deg, #6366f1, #818cf8);
color: white;
box-shadow: 0 2px 8px rgba(99,102,241,0.3);
}
.chain-step {
background: linear-gradient(135deg, #334155, #475569);
color: #e2e8f0;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
.chain-target {
background: linear-gradient(135deg, #ec4899, #f472b6);
color: white;
box-shadow: 0 2px 8px rgba(236,72,153,0.3);
}
.chain-arrow {
color: #64748b;
font-size: 1rem;
margin: 0 2px;
}
.chain-sim {
font-size: 0.7rem;
color: #94a3b8;
margin: 0 2px;
}
.stats-bar {
display: flex;
justify-content: center;
gap: 32px;
margin: 12px 0 4px 0;
}
.stat { text-align: center; }
.stat-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #94a3b8;
}
.stat-value {
font-size: 1.3rem;
font-weight: 700;
color: #e2e8f0;
}
.lives-full { color: #f472b6; }
.lives-empty { color: #334155; }
.msg {
padding: 12px 16px;
border-radius: 12px;
text-align: center;
font-weight: 600;
font-size: 0.95rem;
margin: 8px 0;
}
.msg-error {
background: rgba(239,68,68,0.12);
color: #fca5a5;
border: 1px solid rgba(239,68,68,0.25);
}
.msg-great {
background: rgba(99,102,241,0.12);
color: #a5b4fc;
border: 1px solid rgba(99,102,241,0.25);
}
.msg-good {
background: rgba(34,197,94,0.1);
color: #86efac;
border: 1px solid rgba(34,197,94,0.2);
}
.result-box {
text-align: center;
padding: 24px;
border-radius: 16px;
margin: 16px 0;
}
.result-win {
background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(236,72,153,0.15));
border: 1px solid rgba(99,102,241,0.3);
}
.result-lose {
background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.2);
}
.result-title {
font-size: 1.8rem;
font-weight: 800;
margin-bottom: 12px;
}
.result-stats {
display: flex;
justify-content: center;
gap: 40px;
margin-top: 12px;
}
.result-stat-value {
font-size: 1.6rem;
font-weight: 800;
color: #818cf8;
}
.result-stat-label {
font-size: 0.75rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 1px;
}
.speed-bonus {
display: inline-block;
margin-top: 12px;
padding: 6px 16px;
border-radius: 20px;
background: linear-gradient(135deg, #6366f1, #ec4899);
color: white;
font-weight: 700;
font-size: 0.9rem;
}
.stTextInput > div > div > input {
border-radius: 12px !important;
border: 2px solid rgba(99,102,241,0.3) !important;
padding: 12px 16px !important;
font-size: 1rem !important;
transition: border-color 0.2s !important;
}
.stTextInput > div > div > input:focus {
border-color: #6366f1 !important;
box-shadow: 0 0 0 3px rgba(99,102,241,0.15) !important;
}
</style>
""", unsafe_allow_html=True)
# ==========================================
# Layout: game (left) + leaderboard (right)
# ==========================================
game_col, lb_col = st.columns([3, 1], gap="large")
with game_col:
# ── Header ──
st.markdown(f"""
<div style="text-align:center; margin-bottom: 4px;">
<span style="font-size: 2.5rem;">🌉</span>
<span style="font-size: 2.2rem; font-weight: 800; letter-spacing: -1px;
background: linear-gradient(135deg, #818cf8, #f472b6);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;">
Bridge
</span>
</div>
<p style="text-align:center; color:#64748b; margin-top:0; font-size:0.9rem;">
Daily word chain · <span style="color:#818cf8; font-weight:600;">{user_id}</span>
</p>
""", unsafe_allow_html=True)
# ── Game board ──
target = st.session_state.target
steps_used = len(st.session_state.chain) - 1
steps_left = st.session_state.max_steps - steps_used
lives_html = '<span class="lives-full">♥</span> ' * st.session_state.lives + '<span class="lives-empty">♥</span> ' * (3 - st.session_state.lives)
st.markdown(f"""
<div class="game-board">
<div class="endpoints">
<div class="endpoint">
<div class="endpoint-label">Start</div>
<div class="endpoint-word endpoint-start">{st.session_state.chain[0]}</div>
</div>
<div class="endpoint-dots">· · · · ·</div>
<div class="endpoint">
<div class="endpoint-label">Target</div>
<div class="endpoint-word endpoint-target">{target}</div>
</div>
</div>
<div class="stats-bar">
<div class="stat">
<div class="stat-value">{steps_left}</div>
<div class="stat-label">Steps left</div>
</div>
<div class="stat">
<div class="stat-value">{lives_html}</div>
<div class="stat-label">Lives</div>
</div>
</div>
</div>
""", unsafe_allow_html=True)
# ── Chain display ──
if len(st.session_state.chain) > 1 or st.session_state.game_over or st.session_state.won:
chain_html = ""
for i, word in enumerate(st.session_state.chain):
if i == 0:
css = "chain-start"
elif word == target:
css = "chain-target"
else:
css = "chain-step"
chain_html += f'<span class="chain-word {css}">{word}</span>'
if i < len(st.session_state.chain) - 1:
sim_val = st.session_state.similarities[i] if i < len(st.session_state.similarities) else 0
chain_html += f'<span class="chain-sim">{sim_val:.2f}</span>'
chain_html += '<span class="chain-arrow">→</span>'
st.markdown(f'<div class="chain-container">{chain_html}</div>', unsafe_allow_html=True)
# ── Message ──
if st.session_state.message:
msg_type, msg_text = st.session_state.message
css_map = {"error": "msg-error shake", "great": "msg-great glow", "good": "msg-good pop"}
css = css_map.get(msg_type, "msg-good")
st.markdown(f'<div class="msg {css}">{msg_text}</div>', unsafe_allow_html=True)
st.session_state.message = None
# ── Game logic ──
min_sim = 0.15
def submit_guess():
guess = st.session_state.guess_input.strip().lower()
st.session_state.guess_input = ""
target = st.session_state.target
if not guess:
return
if guess in st.session_state.chain:
st.session_state.message = ("error", "Already used that word")
return
if guess not in model:
st.session_state.message = ("error", "Word not in vocabulary")
return
prev = st.session_state.chain[-1]
sim = float(model.similarity(prev, guess))
sim_target = float(model.similarity(guess, target))
proximity = get_proximity(sim_target)
if sim < min_sim:
st.session_state.lives -= 1
if st.session_state.lives == 0:
st.session_state.game_over = True
st.session_state.message = ("error", f"{prev}{guess}: similarity {sim:.3f} — too weak, no lives left")
else:
st.session_state.message = ("error", f"{prev}{guess}: similarity {sim:.3f} — too weak (min {min_sim})")
return
st.session_state.chain.append(guess)
st.session_state.similarities.append(sim)
st.session_state.sim_to_target.append(sim_target)
if sim >= 0.45:
st.session_state.message = ("great", f"{prev}{guess}: {sim:.3f} — strong link, {proximity} to target")
elif sim >= 0.30:
st.session_state.message = ("good", f"{prev}{guess}: {sim:.3f} — good link, {proximity} to target")
else:
st.session_state.message = ("good", f"{prev}{guess}: {sim:.3f} — weak link, {proximity} to target")
if guess == target:
st.session_state.won = True
elif len(st.session_state.chain) - 1 >= st.session_state.max_steps:
st.session_state.game_over = True
st.session_state.message = ("error", f"Didn't reach '{target}' in {st.session_state.max_steps} steps")
if not st.session_state.game_over and not st.session_state.won:
st.text_input("guess", key="guess_input", placeholder="Type a word and press Enter...", on_change=submit_guess, label_visibility="collapsed")
# ── Win screen ──
if st.session_state.won:
steps_used = len(st.session_state.chain) - 1
steps_saved = st.session_state.max_steps - steps_used
avg_sim = sum(st.session_state.similarities) / len(st.session_state.similarities)
bonus = 0.15 * steps_saved
raw_score = avg_sim + bonus
display_score = to_display_score(raw_score)
bonus_html = ""
if steps_saved > 0:
bonus_html = f'<div class="speed-bonus">+{to_display_score(avg_sim + bonus) - to_display_score(avg_sim)} pts speed bonus — {steps_saved} step{"s" if steps_saved > 1 else ""} saved</div>'
st.markdown(f"""
<div class="result-box result-win">
<div class="result-title">Bridge Complete</div>
<div class="result-stats">
<div>
<div class="result-stat-value">{steps_used}/{st.session_state.max_steps}</div>
<div class="result-stat-label">Steps</div>
</div>
<div>
<div class="result-stat-value">{avg_sim:.3f}</div>
<div class="result-stat-label">Avg Similarity</div>
</div>
<div>
<div class="result-stat-value">{display_score}</div>
<div class="result-stat-label">Score</div>
</div>
</div>
{bonus_html}
</div>
""", unsafe_allow_html=True)
if not st.session_state.result_saved:
st.session_state.history.append({
'start': st.session_state.chain[0],
'target': target,
'chain': list(st.session_state.chain),
'display_score': display_score,
'steps': steps_used
})
is_new_best = save_score(
user_id, raw_score, display_score, steps_used,
list(st.session_state.chain),
st.session_state.chain[0], target
)
st.session_state.is_new_best = is_new_best
st.session_state.result_saved = True
if st.session_state.get('is_new_best'):
st.markdown(f"""
<div class="msg msg-great glow" style="margin-top:8px;">
New personal best! Score saved as <strong>{user_id}</strong>
</div>
""", unsafe_allow_html=True)
else:
best = load_scores().get(user_id)
if best:
st.markdown(f"""
<div style="text-align:center; margin-top:8px; font-size:0.85rem; color:#94a3b8;">
Your best today: <strong style="color:#818cf8;">{best['display_score']}</strong> — play again to beat it
</div>
""", unsafe_allow_html=True)
# ── Game over screen ──
if st.session_state.game_over and not st.session_state.won:
st.markdown("""
<div class="result-box result-lose">
<div class="result-title">Game Over</div>
</div>
""", unsafe_allow_html=True)
if not st.session_state.result_saved:
st.session_state.history.append({
'start': st.session_state.chain[0],
'target': target,
'chain': list(st.session_state.chain),
'display_score': 0,
'steps': len(st.session_state.chain) - 1
})
st.session_state.result_saved = True
best = load_scores().get(user_id)
if best:
st.markdown(f"""
<div style="text-align:center; margin-top:8px; font-size:0.85rem; color:#94a3b8;">
Your best today: <strong style="color:#818cf8;">{best['display_score']}</strong> — try again
</div>
""", unsafe_allow_html=True)
# ── Play again ──
if st.session_state.game_over or st.session_state.won:
if st.button("Play again"):
start, target = daily_pair()
init_game(start, target)
st.rerun()
# ── Past rounds ──
if st.session_state.history:
with st.expander(f"Past rounds ({len(st.session_state.history)})", expanded=False):
for h in reversed(st.session_state.history):
chain_str = " → ".join(h['chain'])
result = "won" if h['display_score'] > 0 else "lost"
st.markdown(f"**{h['start']}{h['target']}**: {chain_str}{result}, score {h['display_score']} ({h['steps']} steps)")
# ==========================================
# Leaderboard (right column, always visible)
# ==========================================
with lb_col:
st.markdown(f"**Leaderboard** · {date.today().strftime('%d %b %Y')}")
st.markdown("---")
leaderboard = get_leaderboard(10)
if leaderboard:
for i, (uid, data) in enumerate(leaderboard):
rank = i + 1
if uid == user_id:
st.markdown(f"**#{rank} {uid} (you) · {data['display_score']} pts**")
else:
st.markdown(f"#{rank} {uid} · {data['display_score']} pts")
else:
st.caption("No scores yet — be the first!")