How I built my AI Rock Paper Scissor Game
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:
os
andtime
for environment and timing,json
for parsing model output,random
for fallback moves,datetime as dt
for hourly bucket keys,types
for creating a temporary module, andurllib.request
to fetch a remote config. - Type hints:
dataclass
is imported but unused;List
is used for history typing. streamlit
builds the UI.openai
import is optional; if not available,OpenAI
is set toNone
so 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_URL
can be overridden via environment variable; defaults to a GitHub file.DEF
holds safe local defaults if remote fetch fails or keys are missing._fetch
downloads the Python file, compiles and executes it in a temporary module object, then returns only the expected keys, falling back toDEF
for 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
_cfg
caches the configuration inst.session_state
forttl
seconds (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
_hour
returns a UTC hour string to group requests into hourly buckets for shared caps._counters
is 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
_init
resets daily counters when the date changes and ensures keys exist._can
enforces cooldown seconds, daily budget (estimated byEST_COST_PER_GEN
), daily limit, and hourly shared cap. Returns a tuple(allowed, message, seconds_remaining)
._rec
records 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
CHOICES
defines the allowed tokens.offline_move
is 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_KEY
fromst.secrets
. If missing or theopenai
client is unavailable, it raises an error handled elsewhere. - Sends system and user messages asking for strict JSON with a
choice
key. - 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
, ordraw
via 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
h2
prints a styled<h2>
with custom color.card
renders 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,
choice
stores 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.