The original project was called Game Glitch Investigator. It was a deliberately broken number guessing game built in Streamlit. The goal was to identify and fix three hidden bugs: the hint logic had reversed comparisons (< and > flipped), the attempt counter was initialized to 1 instead of 0 causing off-by-one errors throughout, and the guess was compared to the secret as a string instead of an integer, making even-attempt hints randomly wrong. After fixing those bugs and moving the logic into logic_utils.py, the project had a working game backed by a pytest suite.
Number Duel is a competitive number guessing game where a human player races against a personality-driven AI opponent in real time. Both players are given the same secret number and the same attempt limit. The human guesses first each turn, then the AI takes its turn. The first to guess correctly wins.
The AI is not just a chatbot attached to the side — it runs a structured agentic loop on every turn (observe the search range, plan a guess using a Python strategy, check the result, detect the game situation, generate in-character commentary, then update its range). Four distinct AI personalities each play differently: The Strategist always picks the mathematical midpoint, The Gambler swings toward the extremes early and stays chaotic, The Professor uses binary search and explains the math condescendingly, and The Trash-Talker plays near-optimally but locks in when closing and reacts directly to what you just did.
The system is split into five modules with strict separation: the UI layer (app.py) orchestrates the game loop and session state, game logic (logic_utils.py) is pure functions with no dependencies, the agent (agent.py) runs the agentic loop and routes narration, personalities (personalities.py) are pure Python strategy functions plus system prompts, and the logger (logger.py) appends every turn to game_log.jsonl. The OpenAI API is optional — the game runs in fallback mode with canned situation-specific lines when no key is set.
---
id: 4cbd84b0-bc72-4826-81e9-ef145e7bd32d
---
flowchart TD
%% ════════════════════════════════════════════════════════
%% LAYER 1 — INPUT
%% ════════════════════════════════════════════════════════
HUMAN(["👤 Human Player"])
VAL{"parse_guess
logic_utils.py
─────────────────
reject decimals
reject out-of-range
reject duplicates"}
ERR["❌ Error shown
attempt NOT counted"]
HUMAN -->|"types a number"| VAL
VAL -->|"invalid"| ERR
%% ════════════════════════════════════════════════════════
%% LAYER 2 — HUMAN TURN
%% ════════════════════════════════════════════════════════
subgraph HT [" Human Turn — logic_utils.py "]
direction TB
CG["check_guess(guess, secret)
→ Win / Too High / Too Low"]
SC["update_score()"]
CG --> SC
end
VAL -->|"valid + new"| CG
CG -->|"Win"| HWIN(["🏆 VICTORY
Match result + Rematch"])
%% ════════════════════════════════════════════════════════
%% LAYER 3 — AI AGENT TURN
%% ════════════════════════════════════════════════════════
subgraph AG [" AI Agent Turn — agent.py "]
direction TB
OBS["① OBSERVE
read current range · low, high · attempt count"]
subgraph PLAN [" ② PLAN — personalities.py "]
direction LR
PLN["strategy(low, high, history)"]
P1["Strategist
midpoint always"]
P2["Gambler
extremes early
random throughout"]
P3["Professor
midpoint +
math explanation"]
P4["Trash-Talker
near-optimal
locks in when range ≤ 6"]
PLN --> P1 & P2 & P3 & P4
end
ACT["③ ACT
check_guess(agent_guess, secret)"]
SIT["④ DETECT SITUATION
opening · ahead · behind · closing_in · tied"]
NAR["⑤ NARRATE
get_narration(personality, situation, human_last_guess)"]
EVL["⑥ EVALUATE
capture low_before / high_before FIRST
then shrink range based on outcome"]
OBS --> PLAN --> ACT --> SIT --> NAR --> EVL
end
CG -->|"not Win"| OBS
ACT -->|"Win"| AWIN(["💀 DEFEAT
Match result + Rematch"])
%% ════════════════════════════════════════════════════════
%% NARRATION ROUTING — branches out, rejoins at outputs
%% ════════════════════════════════════════════════════════
OAI(["☁️ OpenAI API
gpt-3.5-turbo · max 60 tokens
situation + human_last_guess as context"])
FB["situation_lines
fallback per situation
personalities.py"]
NAR -->|"API key present"| OAI
NAR -->|"no key / error"| FB
%% ════════════════════════════════════════════════════════
%% LAYER 4 — OUTPUTS (all turns collect here)
%% ════════════════════════════════════════════════════════
subgraph OUT [" Outputs "]
direction LR
BL[("battle_log
human + agent entries
personality color · important flag")]
GL[("game_log.jsonl
append-only audit trail
low_before · situation · api_used")]
end
CG --> GL
EVL --> GL
CG --> BL
OAI --> BL
FB --> BL
EVL --> BL
%% ════════════════════════════════════════════════════════
%% LAYER 5 — STREAMLIT UI
%% ════════════════════════════════════════════════════════
subgraph UI [" Streamlit UI — app.py "]
direction LR
U1["Player Cards
YOU · VS · AI
attempts · last guess · status"]
U2["Last-Turn Summary Bar
You: 38 Too High | AI: 56 Too High"]
U3["Battle Log
newest first · colored by personality
narration dimmed on normal turns
bold on important moments"]
end
BL --> U2 & U3
OUT --> U1
%% ════════════════════════════════════════════════════════
%% TEST SUITE — verification only, dotted edges
%% ════════════════════════════════════════════════════════
subgraph TESTS [" Test Suite — tests/ (17 tests · all passing) "]
direction TB
T1["test_game_logic.py
check_guess correctness
Win · Too High · Too Low"]
T2["test_personalities.py
strategy stays in valid range
midpoint accuracy"]
T3["test_agent.py
range shrinks correctly
Strategist solves in 7 guesses max
fallback works without API key"]
end
TESTS -.->|"verifies"| HT
TESTS -.->|"verifies"| PLAN
TESTS -.->|"verifies"| AG
Where humans and testing are involved:
- The human is in the loop every turn — they read the battle log, interpret the AI's narration and last guess, and decide their next number
- pytest verifies that hints are correct, that no strategy ever guesses outside its valid range, and that the agent solves a 1–100 game in at most 7 guesses
- Runtime guardrails protect the loop: input validation blocks bad guesses before they count, API failures fall back to canned lines, and
game_log.jsonlrecords every turn for inspection
1. Install dependencies
pip install -r requirements.txt2. Set your OpenAI API key (optional — the game works without it)
# Mac / Linux
export OPENAI_API_KEY=sk-...
# Windows Command Prompt
set OPENAI_API_KEY=sk-...Without a key, the AI still plays and still narrates — it uses built-in situation-specific fallback lines instead of calling the API. It never crashes.
3. Run the app
streamlit run app.py4. Run the tests
pytestExpected output: 17 passed.
5. Run the evaluation harness (optional — no API key needed)
python eval.pySimulates all 4 AI personalities against 21 fixed secrets and prints a summary table showing average guesses, worst case, solve rate, and within-limit rate.
You: 50 → Too Low
Strategist: 50 → Too Low "Starting at the midpoint. The math is on my side."
You: 75 → Too High
Strategist: 75 → Too High "Anomaly. Recalibrating. Don't get comfortable."
You: 62 → Too Low
Strategist: 62 → Too Low [no narration — normal turn]
You: 68 → Too Low
Strategist: 68 → Too Low [no narration]
You: 73 → WIN 🏆
VICTORY — You found it before The Strategist.
Secret: 73 | Your guesses: 5 | AI guesses: 4
The Strategist was one guess ahead in efficiency but the human hit the number first.
You: 75 → Too High
Trash-Talker: 88 → Too High "First guess. Already more confident than you'll ever be."
You: 50 → Too High
Trash-Talker: 44 → Too High [narration hidden — normal turn]
You: 25 → Too Low
Trash-Talker: 40 → Too High "I've boxed it in. You're running out of room."
You: 37 → WIN — wait, same turn...
Trash-Talker: 37 → WIN
DEFEAT — The Trash-Talker found it first.
Secret: 37 | Your guesses: 4 | AI guesses: 4
Both found it on turn 4, but the human goes first — if the human had guessed 37 on their turn, they would have won. The Trash-Talker's near-optimal strategy kept pace throughout.
You: 10 → Too Low
Gambler: 2 → Too Low "Opening move — pure chaos, maximum confidence."
You: 15 → Too High
Gambler: 19 → Too High [narration hidden]
You: 12 → Too High
Gambler: 4 → Too Low [narration hidden]
You: 11 → WIN 🏆
VICTORY — You found it before The Gambler.
Secret: 11 | Your guesses: 4 | AI guesses: 3
The Gambler's extreme-biased early guesses (2, 19) wasted moves the Strategist would never have made. The human's methodical narrowing won easily.
Why is the LLM only the voice, not the decision-maker? Letting the LLM choose the number would make strategy unpredictable and untestable. By keeping strategy as pure Python functions, every personality's behavior can be verified with pytest — the Strategist is guaranteed to solve in at most 7 guesses, and no strategy can ever guess outside its valid range. The LLM handles character voice only.
Why is the OpenAI key optional?
Requiring an API key to run creates a hard dependency that breaks demos, classrooms, and offline environments. The fallback situation_lines dict on each personality gives context-aware responses (opening, ahead, behind, closing_in) without any API call. The game is fully playable either way.
Why capture low_before / high_before before EVALUATE, not after?
The log should record the state the agent was in when it made the guess, not the state after narrowing. Logging post-mutation would make analytics misleading — a guess of 50 into range [1, 100] should not appear in the log as low_before=51.
Why reject floats instead of silently truncating?
Silently converting 60.9 to 60 breaks the fairness contract — the player typed something that isn't a valid integer and the game should say so. Guessing 5659 in a 1–100 game should tell the player immediately rather than giving a hint that wastes their attempt.
Why show narration on every turn but style it differently? Showing narration only on "important" turns made it feel absent — most turns showed nothing. Showing it on every turn but dimming normal turns (70% opacity, normal weight) and highlighting important ones (100% opacity, bold) lets the AI feel present without being noisy.
Trade-off: personality color vs. uniform styling Each personality has a stored hex color that drives its card border, attempt number, log tag, and narration text. This makes visual identification instant — you don't need to read the name to know if it's the Strategist or the Trash-Talker. The cost is that the CSS is more dynamic (inline styles per entry) but the UX benefit is worth it.
Results: 17 out of 17 tests passed across three test files. The agent reliably solves any 1–100 secret in 7 or fewer guesses. Fallback narration works correctly without an API key — zero crashes in offline mode. The one reliability gap found: parse_guess silently truncated floats before an explicit rejection rule was added, and this was caught by code review rather than tests.
What worked:
The test suite gave a clear contract before writing any code. test_strategist_solves_normal_within_7 is the strongest test — it verifies not just that the algorithm is correct but that it terminates efficiently for any secret in a 1–100 range. test_guess_always_in_range running 20 iterations of TrashTalker (the only strategy with jitter) catches any clamping bug reliably.
test_fallback_narration_when_no_api_key temporarily removes OPENAI_API_KEY from the environment and confirms the game continues — this is the kind of test that prevents silent regressions where someone adds an API call path that crashes in offline environments.
What didn't work initially:
The agent log was recording low_before and high_before after the EVALUATE step had already mutated the state. Tests didn't catch this because they verify game outcomes (range shrinks, win condition) not log accuracy. It was caught by code review — a reminder that tests verify behavior, not internal bookkeeping.
The parse_guess function silently truncated floats (60.9 → 60) and accepted numbers far outside the game range. Tests never called parse_guess directly so this went undetected until the review flagged it as a fairness bug.
What I learned:
Separating logic into pure functions (logic_utils.py, personalities.py) makes testing trivial — no mocking, no Streamlit state, just inputs and outputs. The parts that are hardest to test (UI, session state, API calls) are also the parts most likely to have subtle bugs. Keeping them thin and pushing logic into pure modules is the most effective guardrail.
Run python eval.py to reproduce. Simulates each personality against 21 fixed
secrets in the Normal range (1–100, 8-guess limit). Reproducible via random.seed(42).
Number Duel — AI Evaluation Harness
Secrets: 21 fixed values in [1, 100] Guess limit: 8
Personality Avg Guesses Max Guesses Solved Within Limit
------------------------------------------------------------------
Strategist 5.7 7 21/21 21/21
Gambler 8.5 17 21/21 12/21
Professor 5.7 7 21/21 21/21
TrashTalker 6.4 11 21/21 18/21
The Strategist and Professor share identical strategies (binary search) and produce identical numbers. The Gambler's random walk solves every game eventually (21/21) but exceeds the 8-guess limit more than half the time — it is measurably the weakest opponent. The Trash-Talker's near-optimal strategy finishes within the limit on 18/21 secrets; the 3 failures occur when its upward jitter overshoots on edge secrets.
Each personality's OpenAI narration call now includes 3 few-shot (user, assistant) example pairs that demonstrate the target voice before the live game context is appended. This gives the model concrete anchors for each personality's register.
Same prompt, same situation (closing_in, agent ahead) — Strategist:
| Version | Output |
|---|---|
| Baseline (no few-shot) | "Getting close. I think I have this one." |
| Few-shot enhanced | "Three values remain. I've already eliminated your likely range." |
Same prompt, same situation (behind, opening over) — Gambler:
| Version | Output |
|---|---|
| Baseline (no few-shot) | "I might be behind but I'm feeling good about this." |
| Few-shot enhanced | "I'm behind. That's fine. I'm just warming up. My gut is recalibrating." |
Without few-shot examples the model drifts toward neutral hedging under pressure. With examples it reliably mirrors the declared personality register.
Building this project clarified something important about agentic AI systems: the LLM is a voice, not a brain. The decisions — which number to guess, when to narrow the range, how to detect if the AI is ahead or behind — are all deterministic Python. The LLM makes the output feel alive, but it cannot be trusted with correctness. That separation is what makes the system testable and reliable.
The experience of designing situation-aware commentary (opening, ahead, behind, closing_in) also showed that context matters more than personality. A Trash-Talker line that says "Fewer guesses than you. Let that sink in." only works because the system detected who's actually ahead — without that state signal, the commentary is generic and breaks immersion instantly.
The biggest shift in how I think about problem-solving is: define failure before you define success. Writing tests first (test_strategist_solves_normal_within_7, test_fallback_narration_when_no_api_key) made every implementation decision clearer because there was a concrete pass/fail target. That habit — turning requirements into verifiable assertions before touching implementation — is the most transferable thing this project taught me.