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("""
""", 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"""
๐
Bridge
Daily word chain ยท {user_id}
""", 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 = 'โฅ ' * st.session_state.lives + 'โฅ ' * (3 - st.session_state.lives)
st.markdown(f"""
Start
{st.session_state.chain[0]}
ยท ยท ยท ยท ยท
""", 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'{word}'
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'{sim_val:.2f}'
chain_html += 'โ'
st.markdown(f'{chain_html}
', 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'{msg_text}
', 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'+{to_display_score(avg_sim + bonus) - to_display_score(avg_sim)} pts speed bonus โ {steps_saved} step{"s" if steps_saved > 1 else ""} saved
'
st.markdown(f"""
Bridge Complete
{steps_used}/{st.session_state.max_steps}
Steps
{avg_sim:.3f}
Avg Similarity
{bonus_html}
""", 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"""
New personal best! Score saved as {user_id}
""", unsafe_allow_html=True)
else:
best = load_scores().get(user_id)
if best:
st.markdown(f"""
Your best today: {best['display_score']} โ play again to beat it
""", unsafe_allow_html=True)
# โโ Game over screen โโ
if st.session_state.game_over and not st.session_state.won:
st.markdown("""
""", 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"""
Your best today: {best['display_score']} โ try again
""", 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!")