How I built my AI Rock Paper Scissor Game
Synopsis:
In this project I demonstrate an interactive rock–paper–scissors game with configurable limits, offline and AI-driven strategies, and session-based tracking. It integrates budget controls, counters, and cooldowns while providing a responsive interface that combines gameplay, feedback, and usage monitoring in a single streamlined experience.
This post explains every part of the attached app.py, block by block, and clarifies how configuration, limits, offline and OpenAI logic, and UI all fit together.
Imports and Page Setup
import os, time, json, random, datetime as dt, types, urllib.request
from dataclasses import dataclass
from typing import List
import streamlit as st
try:
from openai import OpenAI
except Exception:
OpenAI=None
st.set_page_config(page_title="Rock–Paper–Scissors", layout="wide")
What this does
- Standard libraries:
osandtimefor environment and timing,jsonfor parsing model output,randomfor fallback moves,datetime as dtfor hourly bucket keys,typesfor creating a temporary module, andurllib.requestto fetch a remote config. - Type hints:
dataclassis imported but unused;Listis used for history typing. streamlitbuilds the UI.openaiimport is optional; if not available,OpenAIis set toNoneso the app can still run offline.st.set_page_config(...)sets a wide layout and browser tab attributes.
Remote Budget Configuration With Local Fallbacks
BUDGET_URL=os.getenv("BUDGET_URL","https://raw.githubusercontent.com/RahulBhattacharya1/shared_config/main/budget.py")
DEF={"COOLDOWN_SECONDS":30,"DAILY_LIMIT":40,"HOURLY_SHARED_CAP":250,"DAILY_BUDGET":1.00,"EST_COST_PER_GEN":1.00,"VERSION":"fallback-local"}
def _fetch(u: str) -> dict:
"""Fetch remote budget.py and extract expected keys with fallbacks."""
mod = types.ModuleType("budget_remote")
with urllib.request.urlopen(u, timeout=5) as r:
code = r.read().decode()
exec(compile(code, "budget_remote", "exec"), mod.__dict__)
return {k: getattr(mod, k, DEF[k]) for k in DEF}
What this does
BUDGET_URLcan be overridden via environment variable; defaults to a GitHub file.DEFholds safe local defaults if remote fetch fails or keys are missing._fetchdownloads the Python file, compiles and executes it in a temporary module object, then returns only the expected keys, falling back toDEFfor any missing values.
Config Cache and Environment Overrides
def _cfg(ttl=300):
now=time.time(); c=st.session_state.get("_b"); ts=st.session_state.get("_bts",0)
if c and (now-ts)<ttl: return c
try: cfg=_fetch(BUDGET_URL)
except Exception: cfg=DEF.copy()
cfg["DAILY_BUDGET"]=float(os.getenv("DAILY_BUDGET",cfg["DAILY_BUDGET"]))
cfg["EST_COST_PER_GEN"]=float(os.getenv("EST_COST_PER_GEN",cfg["EST_COST_PER_GEN"]))
st.session_state["_b"]=cfg; st.session_state["_bts"]=now; return cfg
_cfg=_cfg(); COOLDOWN_SECONDS=int(_cfg["COOLDOWN_SECONDS"]); DAILY_LIMIT=int(_cfg["DAILY_LIMIT"])
HOURLY_SHARED_CAP=int(_cfg["HOURLY_SHARED_CAP"]); DAILY_BUDGET=float(_cfg["DAILY_BUDGET"])
EST_COST_PER_GEN=float(_cfg["EST_COST_PER_GEN"]); CONFIG_VERSION=str(_cfg["VERSION"])
What this does
_cfgcaches the configuration inst.session_stateforttlseconds (default 300), avoiding frequent network fetches.- On failure, it copies
DEF. Then it allows two keys (DAILY_BUDGET,EST_COST_PER_GEN) to be overridden by environment variables. - The last lines materialize final constants used throughout the app for rate‑limiting and display.
Hour Bucket and Shared Counter Store
def _hour(): return dt.datetime.utcnow().strftime("%Y-%m-%d-%H")
@st.cache_resource
def _counters(): return {}
What this does
_hourreturns a UTC hour string to group requests into hourly buckets for shared caps._countersis a cached resource that persists a process‑local dictionary across Streamlit reruns to track usage counts per hour.
Daily and Cooldown Tracking
def _init():
ss=st.session_state; today=dt.date.today().isoformat()
if ss.get("rl_date")!=today: ss["rl_date"]=today; ss["rl_calls_today"]=0; ss["rl_last_ts"]=0.0
ss.setdefault("rl_last_ts",0.0); ss.setdefault("rl_calls_today",0)
def _can():
_init(); ss=st.session_state; now=time.time()
rem=int(max(0, ss["rl_last_ts"]+COOLDOWN_SECONDS-now))
if rem>0: return False,f"Wait {rem}s.",rem
if ss["rl_calls_today"]*EST_COST_PER_GEN>=DAILY_BUDGET: return False,f"Budget reached (${DAILY_BUDGET:.2f}).",0
if ss["rl_calls_today"]>=DAILY_LIMIT: return False,f"Daily limit {DAILY_LIMIT}.",0
if HOURLY_SHARED_CAP>0:
b=_hour(); c=_counters()
if c.get(b,0)>=HOURLY_SHARED_CAP: return False,"Hourly cap reached.",0
return True,"",0
def _rec():
ss=st.session_state; ss["rl_last_ts"]=time.time(); ss["rl_calls_today"]+=1
if HOURLY_SHARED_CAP>0:
b=_hour(); c=_counters(); c[b]=c.get(b,0)+1
What this does
_initresets daily counters when the date changes and ensures keys exist._canenforces cooldown seconds, daily budget (estimated byEST_COST_PER_GEN), daily limit, and hourly shared cap. Returns a tuple(allowed, message, seconds_remaining)._recrecords a successful counted call: updates last timestamp, increments daily counter, and increments the current hour bucket if enabled.
Game Choices and Offline Strategy
CHOICES=["rock","paper","scissors"]
def offline_move(hist:List[str])->str:
if not hist: return random.choice(CHOICES)
most=max(CHOICES, key=lambda x: hist.count(x))
return {"rock":"paper","paper":"scissors","scissors":"rock"}[most]
What this does
CHOICESdefines the allowed tokens.offline_moveis a very simple heuristic: it predicts the user will repeat the most frequent recent choice and selects the counter to that choice.
OpenAI‑Backed Move (JSON Contract)
def call_openai(model, hist, temp, max_tok)->str:
key=st.secrets.get("OPENAI_API_KEY","")
if not key: raise RuntimeError("OPENAI_API_KEY missing")
if OpenAI is None: raise RuntimeError("openai package not available")
client=OpenAI(api_key=key)
sys="Play RPS. Return JSON with key 'choice' in ['rock','paper','scissors']."
usr=f"User history (most recent last): {hist}"
r=client.chat.completions.create(model=model,temperature=float(temp),max_tokens=int(max_tok),
messages=[{"role":"system","content":sys},{"role":"user","content":usr}])
t=r.choices[0].message.content.strip()
if t.startswith("```"): t=t.strip("`").split("\n",1)[-1].strip()
c=str(json.loads(t).get("choice","")).lower()
return c if c in CHOICES else offline_move(hist)
What this does
- Reads
OPENAI_API_KEYfromst.secrets. If missing or theopenaiclient is unavailable, it raises an error handled elsewhere. - Sends system and user messages asking for strict JSON with a
choicekey. - Strips Markdown code fences if present, parses JSON, and validates the choice. If anything is off, it falls back to the offline heuristic.
Round Outcome Logic
def result(u,a):
if u==a: return "draw"
wins={("rock","scissors"),("paper","rock"),("scissors","paper")}
return "win" if (u,a) in wins else "lose"
What this does
- Computes
win,lose, ordrawvia a set membership check for winning pairs.
Small UI Helpers
def h2(t,c): st.markdown(f"<h2 style='margin:.25rem 0 .75rem 0; color:{c}'>"+t+"</h2>", unsafe_allow_html=True)
def card(t,sub=""): st.markdown(f"<div style='border:1px solid #e5e7eb;padding:.75rem 1rem;border-radius:10px;margin:.75rem 0;'><div style='font-weight:600'>{t}</div>{f'<div style=margin-top:.25rem>{sub}</div>' if sub else ''}</div>", unsafe_allow_html=True)
What this does
h2prints a styled<h2>with custom color.cardrenders a simple bordered box with a title and an optional subtitle HTML snippet.
Main Title and Sidebar Controls
st.title("Rock–Paper–Scissors")
with st.sidebar:
st.subheader("Generator")
provider=st.selectbox("Provider",["OpenAI","Offline (rule-based)"])
model=st.selectbox("Model (OpenAI)",["gpt-4o-mini","gpt-4o","gpt-4.1-mini"])
brand="#0F62FE"
temp=st.slider("Creativity (OpenAI)",0.0,1.0,0.4,0.05)
max_tok=st.slider("Max tokens (OpenAI)",256,2048,600,32)
_init(); ss=st.session_state
st.markdown("**Usage limits**")
st.write(f"<span style='font-size:.9rem'>Today: {ss['rl_calls_today']} / {DAILY_LIMIT}</span>", unsafe_allow_html=True)
if HOURLY_SHARED_CAP>0:
used=_counters().get(_hour(),0)
st.write(f"<span style='font-size:.9rem'>Hour: {used} / {HOURLY_SHARED_CAP}</span>", unsafe_allow_html=True)
est=ss['rl_calls_today']*EST_COST_PER_GEN
st.markdown(f"<span style='font-size:.9rem'>Budget: ${est:.2f} / ${DAILY_BUDGET:.2f}</span><br/>"
f"<span style='font-size:.8rem;opacity:.8'>Version: {CONFIG_VERSION}</span>", unsafe_allow_html=True)
What this does
- Title appears in the main area.
- Sidebar contains: provider and model selectors, aesthetic color
brand, temperature and max token sliders, and a usage panel drawing from rate‑limit state and config to show daily/hour counters, estimated spend, and config version. _init()ensures counters are ready before rendering current usage.
Session State for Game and Score
st.session_state.setdefault("hist", [])
st.session_state.setdefault("score", {"wins":0,"losses":0,"draws":0})
What this does
- Initializes a per‑session move history and a score dictionary so repeated reruns keep state.
Action Buttons and Layout
h2("Play", brand)
c1,c2,c3=st.columns(3)
choice=None
with c1:
if st.button("Rock"): choice="rock"
with c2:
if st.button("Paper"): choice="paper"
with c3:
if st.button("Scissors"): choice="scissors"
What this does
- Renders a colored section header and three columns, each with a move button.
- When a button is pressed,
choicestores the user’s selection for this rerun.
Turn Resolution: AI Move, Limits, and Errors
if choice:
st.session_state.hist.append(choice)
ok,msg,_=_can()
try:
if provider=="OpenAI" and ok:
ai=call_openai(model, st.session_state.hist[-10:], temp, max_tok); _rec()
else:
if provider=="OpenAI" and not ok: st.warning(msg)
ai=offline_move(st.session_state.hist[-10:])
except Exception as e:
st.error(f"AI move error: {e}. Using offline."); ai=offline_move(st.session_state.hist[-10:])
out=result(choice, ai)
if out=="win": st.session_state.score["wins"]+=1
elif out=="lose": st.session_state.score["losses"]+=1
else: st.session_state.score["draws"]+=1
card("Round", f"You: {choice} · AI: {ai}<br/>Outcome: {out.capitalize()}")
What this does
- Appends the user’s move to history.
- Calls
_can()to verify cooldown, budget, daily, and hourly limits. - If provider is OpenAI and allowed, calls
call_openai(...)and records usage with_rec(). Otherwise shows a warning and usesoffline_move. - Any exception in the OpenAI path triggers a visible error and falls back to offline logic.
- Computes outcome, updates the score dictionary, and shows a round summary card.
Score Display
s=st.session_state.score
card("Score", f"Wins: {s['wins']} · Losses: {s['losses']} · Draws: {s['draws']}")
What this does
- Reads the score from session state and displays a summary card with totals.