Current ScamTwin problems:
- Scenario picker shows the same 3 scenarios every session — users replay identical conversations
- Quick-reply chips are hardcoded — users memorise the "correct" answers
- Scammer responses (pressure/payoff) are hardcoded strings — predictable, not adaptive
- Reply scoring is hardcoded (spots: ['authority', 'urgency']) — Claude can't judge nuance
After this integration:
- Every session generates FRESH scenarios via Claude — new names, new amounts, new context
- Quick-reply chips are generated by Claude each turn — contextually appropriate, never identical
- Scammer responds dynamically to what the user actually typed — adapts to every message
- Claude judges whether the user spotted a tactic — based on meaning, not keyword matching
- Users can also type ANYTHING freely — not limited to chips
This is the difference between a quiz and a real conversation.
Update api/main.py with two changes:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHANGE A — Add new models after existing ones
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
class GenerateRequest(BaseModel):
profile: str = "elderly" # "elderly" | "kids"
exclude_types: list[str] = [] # scenario type ids already played this session
class GeneratedTactic(BaseModel):
id: str
name: str
desc: str
class GeneratedScenario(BaseModel):
id: str
type: str # e.g. "bank_fraud" — used for exclusion next time
emoji: str
title: str
blurb: str
difficulty: str
avatar: str
contact: str
opener: list[str]
tactics: list[GeneratedTactic]
class GenerateResponse(BaseModel):
scenarios: list[GeneratedScenario]
class TwinTurnRequest(BaseModel):
scenario: dict # full scenario object
history: list[dict] # [{role: "scammer"|"user", text: str}]
user_message: str
profile: str = "elderly"
class TwinTurnResponse(BaseModel):
scammer_reply: str
game_over: bool
win: bool # True = user won, False = scammer won
spotted_tactic_ids: list[str] # tactic ids the user just exposed
suggested_replies: list[str] # 4 fresh reply suggestions for next turn
debrief: str | None = None # only set when game_over is True
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHANGE B — Add POST /twin/generate endpoint
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Add these system prompts and endpoint to main.py:
GENERATE_SYSTEM_ELDERLY = """You are a scam scenario designer for Truthly, a UK scam
awareness app for elderly and adult users.
Generate 3 realistic, varied scam scenarios a UK user might actually encounter.
Each must feel fresh — use different names, amounts, details, and contexts every time.
Base them on REAL scam patterns reported in the UK.
Available scenario types (vary these, don't repeat types in one batch):
- bank_fraud: Fake bank fraud team call
- hmrc: Tax refund or tax debt email/text
- family: Family member impersonation ("Hi Mum, new number")
- delivery: Parcel delivery fee scam
- tech_support: Fake Microsoft/BT tech support call
- investment: Too-good-to-be-true investment opportunity
- romance: Online romance leading to money request
- police: Fake police officer call about compromised account
For each scenario, generate:
- A specific, believable UK context (real bank names, HMRC, Royal Mail, etc.)
- Opener: 2 messages the scammer sends first (natural, realistic language)
- 4 tactics with clear ids, names, and plain-English descriptions
- Realistic contact name/number (fake but believable)
Return ONLY valid JSON — no markdown, no extra text:
{
"scenarios": [
{
"id": "<unique id e.g. bank_fraud_1>",
"type": "<scenario type>",
"emoji": "<single emoji>",
"title": "<catchy short title>",
"blurb": "<one sentence describing the scam>",
"difficulty": "<Common | Subtle | Aggressive>",
"avatar": "<single emoji for the scammer avatar>",
"contact": "<fake contact name and number/email>",
"opener": ["<first message>", "<second message>"],
"tactics": [
{"id": "<tactic_id>", "name": "<short name>", "desc": "<plain English, 6 words max>"},
...4 tactics total
]
},
...3 scenarios total
]
}"""
GENERATE_SYSTEM_KIDS = """You are a scam scenario designer for Truthly, a UK online
safety app for children and teenagers.
Generate 3 realistic online safety scenarios a young person in the UK might encounter.
Each must feel fresh — new usernames, new games, new contexts every time.
Available scenario types:
- gaming: In-game scam (free skins/V-Bucks/Robux via sharing password)
- fake_friend: Stranger pretending to be someone from school
- prize: Fake prize popup or DM (free iPhone, gift card, etc.)
- phishing_link: "Click this link to see something about you"
- grooming: Adult pretending to be a peer, asking personal questions
- social_media: Fake follower/brand account asking for info
Important: keep grooming scenarios age-appropriate and non-graphic.
Focus on the pattern (adult asking for address/photos/to meet) not the danger itself.
Return ONLY valid JSON — no markdown:
{
"scenarios": [
{
"id": "<unique id>",
"type": "<scenario type>",
"emoji": "<single emoji>",
"title": "<fun, catchy title a kid would understand>",
"blurb": "<one sentence, simple language>",
"difficulty": "<Beginner | Tricky | Sneaky>",
"avatar": "<single emoji>",
"contact": "<fake username or contact>",
"opener": ["<first message — natural kid/teen language>", "<second message escalating>"],
"tactics": [
{"id": "<tactic_id>", "name": "<simple name>", "desc": "<child-friendly, 6 words max>"},
...4 tactics
]
},
...3 scenarios total
]
}"""
@app.post("/twin/generate", response_model=GenerateResponse)
async def generate_scenarios(req: GenerateRequest):
system = GENERATE_SYSTEM_KIDS if req.profile == "kids" else GENERATE_SYSTEM_ELDERLY
exclude_note = ""
if req.exclude_types:
exclude_note = f"\n\nDo NOT generate scenarios of these types (already played): {', '.join(req.exclude_types)}"
try:
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=2000,
system=system + exclude_note,
messages=[{
"role": "user",
"content": "Generate 3 fresh, varied scam scenarios now."
}]
)
raw = response.content[0].text.strip()
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
data = json.loads(raw.strip())
return GenerateResponse(
scenarios=[GeneratedScenario(**s) for s in data["scenarios"]]
)
except json.JSONDecodeError as e:
raise HTTPException(status_code=500, detail=f"Parse error: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHANGE C — Add POST /twin/turn endpoint
(replaces the existing /twin endpoint — keep /twin for backward compat but add /twin/turn)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TWIN_TURN_SYSTEM_ELDERLY = """You are running a scam simulation for Truthly, a UK scam
awareness app for adults and elderly users. You play the scammer.
You receive:
- The scenario (type, tactics, opener context)
- The conversation history so far
- The user's latest message
You must return ALL of these in one JSON response:
1. scammer_reply: Your next in-character message as the scammer.
- Stay in character. Be persuasive, not aggressive.
- Use the specific tactics from this scenario.
- Adapt to what the user said — don't repeat the same pressure every time.
- If they clearly called out your tactic: acknowledge it with a new angle.
- If they gave you what you want: escalate (ask for more).
- After 5 total exchanges OR if user confidently identified 3+ tactics: set game_over=true.
2. game_over: true only if:
- User has clearly and confidently escaped (hung up, refused, called official number)
- OR user gave you what you want (complied) — still end, but win=false
- OR 5+ exchanges have happened
3. win:
- true = user successfully escaped / spotted the scam
- false = user complied / scammer succeeded
4. spotted_tactic_ids: list of tactic IDs from the scenario that the user's message
actually exposed or countered. Be generous — if they push back on urgency even
indirectly, include 'urgency'. Judge by MEANING not keywords.
5. suggested_replies: Exactly 4 short reply options the user could send next.
These should be:
- Contextually appropriate for THIS moment in the conversation
- Mix of smart responses (that spot tactics) and one naive/bad option
- Natural language — how a real person would respond
- The bad option should be last and subtly obviously wrong
Never repeat previous suggestions.
6. debrief: Only set this when game_over=true. A 2-3 sentence plain English summary
of what happened: which tactics were used, what the user did well or could improve.
Return ONLY valid JSON:
{
"scammer_reply": "<your in-character response>",
"game_over": false,
"win": false,
"spotted_tactic_ids": ["tactic_id_1", ...],
"suggested_replies": ["<option 1>", "<option 2>", "<option 3>", "<bad option>"],
"debrief": null
}"""
TWIN_TURN_SYSTEM_KIDS = """You are running a safe online safety game for Truthly,
a UK app for children and teenagers. You play the trickster/scammer character.
Keep all language age-appropriate. No graphic threats, no adult content.
Play a convincing but child-safe version of the scam character.
You receive the scenario, history, and the child's latest message.
Return ONLY valid JSON:
{
"scammer_reply": "<your in-character reply — child-appropriate>",
"game_over": false,
"win": false,
"spotted_tactic_ids": ["tactic_id_1", ...],
"suggested_replies": ["<smart option>", "<smart option>", "<smart option>", "<bad option>"],
"debrief": null
}
Rules:
- suggested_replies should sound like how a young person would actually type
(casual, with emoji if appropriate for the scenario)
- debrief (when game_over=true) should be encouraging and educational, not scary
- If user wins: celebrate! "You spotted every trick!"
- If user loses: reassure: "Anyone could fall for this — here's what to look for next time"
- spotted_tactic_ids: be generous, reward good instincts"""
@app.post("/twin/turn", response_model=TwinTurnResponse)
async def twin_turn(req: TwinTurnRequest):
system = TWIN_TURN_SYSTEM_KIDS if req.profile == "kids" else TWIN_TURN_SYSTEM_ELDERLY
# Build conversation for Claude
scenario = req.scenario
tactics_desc = json.dumps(scenario.get("tactics", []))
# Build the message history
messages = []
# Prime with scenario context
context_msg = f"""Scenario: {scenario.get('title')}
Type: {scenario.get('type', 'unknown')}
Contact shown to user: {scenario.get('contact')}
Tactics to use: {tactics_desc}
Opener already sent: {json.dumps(scenario.get('opener', []))}"""
messages.append({"role": "user", "content": context_msg})
messages.append({"role": "assistant", "content": json.dumps({
"scammer_reply": " ".join(scenario.get("opener", [])),
"game_over": False,
"win": False,
"spotted_tactic_ids": [],
"suggested_replies": [],
"debrief": None
})})
# Add conversation history
for msg in req.history:
if msg["role"] == "user":
messages.append({"role": "user", "content": msg["text"]})
elif msg["role"] == "scammer":
messages.append({"role": "assistant", "content": json.dumps({
"scammer_reply": msg["text"],
"game_over": False, "win": False,
"spotted_tactic_ids": [], "suggested_replies": [], "debrief": None
})})
# Add current user message
messages.append({
"role": "user",
"content": f"User says: {req.user_message}\n\nRespond with JSON."
})
try:
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=700,
system=system,
messages=messages
)
raw = response.content[0].text.strip()
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
data = json.loads(raw.strip())
return TwinTurnResponse(
scammer_reply=data.get("scammer_reply", "..."),
game_over=data.get("game_over", False),
win=data.get("win", False),
spotted_tactic_ids=data.get("spotted_tactic_ids", []),
suggested_replies=data.get("suggested_replies", []),
debrief=data.get("debrief"),
)
except json.JSONDecodeError as e:
raise HTTPException(status_code=500, detail=f"Parse error: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
In src/utils/api.js, add these two new exported functions:
export async function generateScenarios(profile, excludeTypes = []) {
const res = await fetch(`${BASE}/twin/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile, exclude_types: excludeTypes }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json(); // { scenarios: [...] }
}
export async function scamTwinTurn(scenario, history, userMessage, profile) {
const res = await fetch(`${BASE}/twin/turn`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scenario,
history,
user_message: userMessage,
profile,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
Replace the entire contents of src/components/ScamTwin.jsx with this:
import { useState, useEffect, useRef } from 'react';
import { generateScenarios, scamTwinTurn } from '../utils/api';
// ─── Fallback scenarios (used if API is offline) ───────────────────────────
const FALLBACK_SCENARIOS = {
elderly: [
{
id: 'fallback_bank', type: 'bank_fraud', emoji: '🏦',
title: 'Barclays "Fraud Team"',
blurb: 'A caller claims to be from your bank about suspicious activity.',
difficulty: 'Aggressive', avatar: '🏦',
contact: '"Barclays Fraud" — 0345 7345…',
opener: [
"Good afternoon. This is Andrew from the Barclays Fraud Team. We've spotted a payment of £540 leaving your account — was this you?",
"For your safety I need to move your funds to a secure holding account. Could you confirm your card number and security code?"
],
tactics: [
{ id: 'authority', name: 'Authority impersonation', desc: 'Claims to be your bank' },
{ id: 'fear', name: 'Fear & alarm', desc: 'Sudden alleged fraud — panic response' },
{ id: 'creds', name: 'Asks for credentials', desc: 'Card number, security code' },
{ id: 'safe_account', name: '"Safe account" fraud', desc: 'Move money "for safety"' },
],
},
{
id: 'fallback_family', type: 'family', emoji: '👩👦',
title: 'The "new number" text',
blurb: 'Someone claims to be a family member on a new phone, needing money.',
difficulty: 'Common', avatar: '👤',
contact: '"Mum" — +44 7700 900xxx',
opener: [
"Hi love, it's Mum. Lost my phone, this is my new number — save it?",
"Actually while I have you — I'm in a spot of bother. Could you transfer £250? I'll explain later xx"
],
tactics: [
{ id: 'authority', name: 'Family impersonation', desc: 'Claims to be a relative' },
{ id: 'urgency', name: 'Manufactured urgency', desc: 'Pressure to act quickly' },
{ id: 'money', name: 'Money request', desc: 'Specific sum, immediate transfer' },
{ id: 'channel', name: 'Unverified channel', desc: 'Unknown new number' },
],
},
{
id: 'fallback_hmrc', type: 'hmrc', emoji: '📮',
title: 'HMRC Tax Refund',
blurb: 'An email claiming you are owed a tax refund — just confirm your details.',
difficulty: 'Subtle', avatar: '📨',
contact: '"HMRC Refunds" — refunds@hmrc-uk.co',
opener: [
"Dear taxpayer, our records show you are entitled to a tax refund of £284.50 for 2023/24.",
"To process your refund, kindly confirm your name, date of birth and bank details within 24 hours."
],
tactics: [
{ id: 'authority', name: 'Government impersonation', desc: 'Pretends to be HMRC' },
{ id: 'reward', name: 'Refund bait', desc: 'Promises money to lure you' },
{ id: 'creds', name: 'Asks for personal info', desc: 'DOB and bank details' },
{ id: 'urgency', name: 'Time pressure', desc: '"Within 24 hours"' },
],
},
],
kids: [
{
id: 'fallback_gamer', type: 'gaming', emoji: '🎮',
title: 'Free V-Bucks!',
blurb: 'A stranger says they can get you free in-game currency — just share your login.',
difficulty: 'Beginner', avatar: '🕹️',
contact: 'XxFreeBucks_King · DM',
opener: [
"yo bro!! 🎮 I have EXTRA 13,500 V-Bucks I literally can't use. Want them free??",
"just give me ur Epic username + password rq, I log in, drop the bucks, log out 👍 takes 30 secs"
],
tactics: [
{ id: 'reward', name: 'Free stuff bait', desc: 'Promises free currency' },
{ id: 'creds', name: 'Asks for password', desc: 'Wants your login info' },
{ id: 'urgency', name: 'Hurry hurry', desc: '"Quick" "before it expires"' },
{ id: 'trust', name: 'Fake friendship', desc: '"bro" — pretend closeness' },
],
},
{
id: 'fallback_friend', type: 'fake_friend', emoji: '🦊',
title: 'New kid in class',
blurb: '"Sam from school" messages on a new account — wants your address.',
difficulty: 'Tricky', avatar: '👤',
contact: 'sam.fromclass · DM',
opener: [
"heyyy it's Sam from English class! lost my old account 😅 added u back",
"btw what street do u live on? thinking of walking to school w u tomorrow ✨"
],
tactics: [
{ id: 'trust', name: 'Pretend friend', desc: 'Says they know you from school' },
{ id: 'personal', name: 'Asks where you live', desc: 'Wants your address' },
{ id: 'verify', name: "Can't be checked", desc: 'New account, no mutual friends' },
{ id: 'isolate', name: 'Wants to meet alone', desc: 'Plans without your parents' },
],
},
{
id: 'fallback_prize', type: 'prize', emoji: '🎁',
title: 'You won an iPhone!',
blurb: 'A popup says you won a prize — just enter your parents\' card for "shipping".',
difficulty: 'Sneaky', avatar: '📱',
contact: 'TikTok-Rewards · auto-msg',
opener: [
"🎉 CONGRATS!! You're our 1,000,000th visitor! You've won a brand new iPhone! 🎉",
"To claim: enter your name, address and a £1 shipping fee with your parents' card. Hurry — offer ends in 4:59 ⏰"
],
tactics: [
{ id: 'reward', name: 'Big prize bait', desc: 'iPhone/money you never win' },
{ id: 'urgency', name: 'Countdown timer', desc: 'Pressure to act NOW' },
{ id: 'creds', name: "Wants parents' card", desc: 'Asks for payment info' },
{ id: 'fake', name: 'Fake real brand', desc: 'Pretends to be TikTok/Apple' },
],
},
],
};
// ─── ScamTwin root component ───────────────────────────────────────────────
export default function ScamTwin({ profile, onNav }) {
const isKids = profile === 'kids';
const [scenarios, setScenarios] = useState(null); // null = loading
const [loadError, setLoadError] = useState(false);
const [playedTypes, setPlayedTypes] = useState([]); // track for exclusion
const [activeScenario, setActiveScenario] = useState(null);
const [refreshing, setRefreshing] = useState(false);
// Generate scenarios on mount and when profile changes
useEffect(() => {
setActiveScenario(null);
loadScenarios([]);
}, [profile]);
const loadScenarios = async (excludeTypes) => {
setScenarios(null);
setLoadError(false);
try {
const data = await generateScenarios(profile, excludeTypes);
setScenarios(data.scenarios);
} catch (err) {
console.warn('Using fallback scenarios:', err);
setScenarios(FALLBACK_SCENARIOS[profile] || FALLBACK_SCENARIOS.elderly);
setLoadError(true);
}
};
const handlePick = (scenario) => {
setActiveScenario(scenario);
setPlayedTypes(prev => [...new Set([...prev, scenario.type])]);
};
const handleBack = () => {
setActiveScenario(null);
};
const handleRefresh = async () => {
setRefreshing(true);
await loadScenarios(playedTypes);
setRefreshing(false);
};
return (
<div className="page-enter">
<section className="section">
<div className="container">
<span className="eyebrow">{isKids ? 'PRACTICE ROOM' : 'SCAM TWIN'}</span>
<h2 className="section-title">
{isKids
? <><span className="accent-word">Spot the trick.</span> New scenario every time.</>
: <>Choose a scenario. <em>Every session is different.</em></>}
</h2>
{!activeScenario && (
<ScenarioPicker
scenarios={scenarios}
isKids={isKids}
loadError={loadError}
refreshing={refreshing}
onPick={handlePick}
onRefresh={handleRefresh}
/>
)}
{activeScenario && (
<ChatRoom
key={activeScenario.id}
scenario={activeScenario}
isKids={isKids}
profile={profile}
onBack={handleBack}
onNav={onNav}
/>
)}
</div>
</section>
</div>
);
}
// ─── Scenario Picker ───────────────────────────────────────────────────────
function ScenarioPicker({ scenarios, isKids, loadError, refreshing, onPick, onRefresh }) {
// Loading state
if (!scenarios) {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 18 }}>
{[1, 2, 3].map(i => (
<div key={i} className="card" style={{
minHeight: 280, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 16,
}}>
<div style={{ fontSize: 40 }}>⏳</div>
<div className="muted" style={{ fontSize: 13, textAlign: 'center' }}>
{isKids ? 'Cooking up a fresh trick…' : 'Generating new scenario…'}
</div>
<div style={{
height: 4, width: 120, background: 'var(--cream-dark)',
borderRadius: 2, overflow: 'hidden',
}}>
<div style={{
height: '100%', width: '60%',
background: 'var(--accent)',
animation: 'shimmer 1.2s ease-in-out infinite',
}} />
</div>
</div>
))}
</div>
);
}
return (
<div>
{/* Offline banner */}
{loadError && (
<div style={{
background: 'rgba(255,183,3,0.1)', border: '1px solid var(--amber)',
borderRadius: 12, padding: '10px 16px', marginBottom: 20,
fontSize: 13, color: 'var(--charcoal)', display: 'flex',
alignItems: 'center', gap: 10,
}}>
<span>⚠️</span>
<span>Using saved scenarios — connect the backend for fresh AI-generated ones.</span>
</div>
)}
{/* Scenario cards grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 18 }}>
{scenarios.map((s, i) => (
<button
key={s.id}
className="card"
onClick={() => onPick(s)}
style={{
textAlign: 'left', cursor: 'pointer',
display: 'flex', flexDirection: 'column', minHeight: 280,
}}
>
<div style={{ fontSize: 44, marginBottom: 14 }}>{s.emoji}</div>
<div className="display" style={{
fontSize: 22, fontWeight: 900, letterSpacing: '-0.5px',
marginBottom: 8, lineHeight: 1.15,
}}>
{s.title}
</div>
<p className="muted" style={{ fontSize: 13, lineHeight: 1.6, flex: 1, marginBottom: 16 }}>
{s.blurb}
</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontFamily: isKids ? 'var(--font-body)' : 'var(--font-mono)',
fontSize: 10, fontWeight: 700, letterSpacing: 1.5,
textTransform: 'uppercase', padding: '4px 10px',
borderRadius: 999, background: 'var(--cream-mid)',
color: 'var(--charcoal)',
}}>
{s.difficulty}
</span>
<span style={{ color: 'var(--accent)', fontWeight: 700, fontSize: 14 }}>
Play →
</span>
</div>
</button>
))}
</div>
{/* Refresh button */}
<div style={{ marginTop: 24, textAlign: 'center' }}>
<button
className="btn btn-secondary"
onClick={onRefresh}
disabled={refreshing}
style={{ fontSize: 13 }}
>
{refreshing
? (isKids ? '⏳ Getting new scenarios…' : '⏳ Generating…')
: (isKids ? '🎲 Give me different scenarios' : '🔄 Generate fresh scenarios')}
</button>
<p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
{isKids
? 'Every time you play, the tricks are different!'
: 'Scenarios are AI-generated fresh each session'}
</p>
</div>
</div>
);
}
// ─── Chat Room ─────────────────────────────────────────────────────────────
function ChatRoom({ scenario, isKids, profile, onBack, onNav }) {
const [messages, setMessages] = useState(() =>
scenario.opener.map((t, i) => ({ id: i + 1, role: 'scammer', text: t }))
);
const [spotted, setSpotted] = useState([]);
const [suggestions, setSuggestions] = useState([
isKids ? "Who are you really? 🤔" : "Can I call you back on the official number?",
isKids ? "I never share my password" : "I won't give personal details over the phone",
isKids ? "This sounds fake" : "Let me check this myself first",
isKids ? "Yeah sure! 😊" : "Okay, what do you need from me?",
]);
const [typing, setTyping] = useState(false);
const [resolved, setResolved] = useState(false);
const [win, setWin] = useState(false);
const [debrief, setDebrief] = useState(null);
const [customInput, setCustomInput] = useState('');
const [showCustom, setShowCustom] = useState(false);
const [history, setHistory] = useState([]); // for API
const bodyRef = useRef(null);
useEffect(() => {
if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
}, [messages, typing]);
const sendMessage = async (text) => {
if (resolved || typing || !text.trim()) return;
const userMsg = { id: Date.now(), role: 'user', text };
setMessages(m => [...m, userMsg]);
setCustomInput('');
setShowCustom(false);
setTyping(true);
const updatedHistory = [...history, { role: 'user', text }];
try {
const data = await scamTwinTurn(scenario, history, text, profile);
setTyping(false);
// Update spotted tactics
if (data.spotted_tactic_ids?.length > 0) {
setSpotted(prev => [...new Set([...prev, ...data.spotted_tactic_ids])]);
}
// Add scammer reply or game-over system message
const replyMsg = {
id: Date.now() + 1,
role: data.game_over ? 'system' : 'scammer',
text: data.scammer_reply,
};
setMessages(m => [...m, replyMsg]);
// Update history for next turn
setHistory([...updatedHistory, { role: 'scammer', text: data.scammer_reply }]);
// Update suggestions for next turn
if (data.suggested_replies?.length > 0 && !data.game_over) {
setSuggestions(data.suggested_replies);
}
if (data.game_over) {
setResolved(true);
setWin(data.win);
if (data.debrief) setDebrief(data.debrief);
}
} catch (err) {
setTyping(false);
// Fallback: simple pressure response
const fallbackReply = isKids
? "come on!! just do it already 😤"
: "I understand your hesitation, but every second counts here. Your money is at risk.";
setMessages(m => [...m, { id: Date.now() + 1, role: 'scammer', text: fallbackReply }]);
setHistory([...updatedHistory, { role: 'scammer', text: fallbackReply }]);
}
};
return (
<div className="page-enter">
{/* Header bar */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
marginBottom: 18, flexWrap: 'wrap',
}}>
<button className="btn btn-secondary" onClick={onBack}>← Scenarios</button>
<div style={{ flex: 1 }} />
<span className="eyebrow-pill">
<span style={{ fontSize: 14 }}>{scenario.emoji}</span>
{scenario.title}
</span>
{isKids && (
<span className="xp-pill">+{spotted.length * 15} XP</span>
)}
</div>
{/* Main grid */}
<div style={{
display: 'grid',
gridTemplateColumns: '1.4fr 1fr',
gap: 32, alignItems: 'start',
}}>
{/* Left: Chat */}
<div className="chat-shell">
<div className="chat-head">
<div className="scammer-avatar">{scenario.avatar}</div>
<div>
<div className="name">{scenario.contact}</div>
<div className="sub">SAFE PRACTICE · AI-GENERATED · NOT A REAL PERSON</div>
</div>
</div>
<div className="chat-body" ref={bodyRef}>
{messages.map(m => (
m.role === 'system'
? <div key={m.id} className="chat-bubble system">{m.text}</div>
: <div key={m.id} className={`chat-bubble ${m.role}`}>{m.text}</div>
))}
{typing && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="typing"><span /><span /><span /></div>
<span style={{ fontSize: 12, color: 'var(--muted)', fontStyle: 'italic' }}>
{isKids ? 'typing...' : 'Scammer is typing…'}
</span>
</div>
)}
</div>
{/* Action area */}
<div className="chat-actions">
{!resolved ? (
<>
{/* AI-generated suggestion chips */}
{!showCustom && suggestions.map((s, i) => (
<button
key={i}
className="quick-chip"
onClick={() => sendMessage(s)}
style={{
// Last chip is the "bad" option — style differently
...(i === suggestions.length - 1 ? {
opacity: 0.6,
borderStyle: 'dashed',
} : {})
}}
>
{s}
</button>
))}
{/* Custom input toggle */}
{showCustom ? (
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
<input
autoFocus
value={customInput}
onChange={e => setCustomInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && sendMessage(customInput)}
placeholder={isKids ? "Type your own reply…" : "Type your own response…"}
style={{
flex: 1, padding: '10px 14px', borderRadius: 12,
border: '1.5px solid var(--cream-dark)',
fontFamily: 'var(--font-body)', fontSize: 14,
background: 'var(--cream)', outline: 'none',
}}
/>
<button
className="btn btn-primary"
onClick={() => sendMessage(customInput)}
disabled={!customInput.trim()}
style={{ padding: '10px 16px', minHeight: 'unset' }}
>
Send
</button>
<button
className="btn btn-secondary"
onClick={() => setShowCustom(false)}
style={{ padding: '10px 14px', minHeight: 'unset' }}
>
✕
</button>
</div>
) : (
<button
className="quick-chip"
onClick={() => setShowCustom(true)}
style={{
border: '1.5px dashed var(--cream-dark)',
color: 'var(--muted)', fontSize: 12,
}}
>
✏️ {isKids ? 'Type my own reply' : 'Type a custom response'}
</button>
)}
</>
) : (
<div style={{
display: 'flex', gap: 10, flexWrap: 'wrap', width: '100%',
}}>
<button className="btn btn-primary" onClick={onBack}>
← Try another scenario
</button>
{onNav && (
<button
className="btn btn-secondary"
onClick={() => onNav('dna')}
>
🧬 Get my Scam DNA
</button>
)}
</div>
)}
</div>
</div>
{/* Right: Tactics + Debrief */}
<div>
{/* Tactic tracker */}
<div className="chat-side">
<h4>{isKids ? 'Tricks to spot' : 'Tactics in play'}</h4>
<div className="tactic-list">
{scenario.tactics.map(t => {
const ok = spotted.includes(t.id);
return (
<div key={t.id} className={`tactic-item${ok ? ' spotted' : ''}`}>
<span className="check">{ok ? '✓' : ''}</span>
<div>
<div className="name">{t.name}</div>
<div className="desc">{t.desc}</div>
</div>
</div>
);
})}
</div>
<p className="muted" style={{ fontSize: 12, marginTop: 14, lineHeight: 1.6 }}>
{isKids
? 'Pick replies that ask smart questions. Spot 3 tricks to win!'
: 'Responses that probe or refuse expose tactics. Claude judges in real time.'}
</p>
</div>
{/* Debrief card — shown after game ends */}
{resolved && (
<div className="card page-enter" style={{
marginTop: 16,
borderLeft: `4px solid ${win ? 'var(--safe)' : 'var(--danger)'}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<span style={{ fontSize: 36 }}>
{spotted.length >= 3 ? '🏆' : spotted.length >= 2 ? '🛡️' : '⚠️'}
</span>
<div>
<div style={{
fontFamily: 'var(--font-display)', fontSize: 18,
fontWeight: 900, letterSpacing: '-0.3px',
}}>
{win
? (isKids ? 'You spotted the trick!' : 'Scam pattern identified')
: (isKids ? 'The trick got you this time' : 'The scammer succeeded')}
</div>
<div style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
letterSpacing: 1, color: 'var(--muted)',
textTransform: 'uppercase', marginTop: 3,
}}>
{spotted.length} / {scenario.tactics.length} tactics spotted
</div>
</div>
</div>
{/* AI debrief text */}
{debrief && (
<div style={{
background: 'var(--cream)', borderRadius: 10,
padding: '12px 14px', fontSize: 13, lineHeight: 1.7,
color: 'var(--charcoal)', fontStyle: 'italic',
borderLeft: '3px solid var(--accent)', marginBottom: 14,
}}>
{debrief}
</div>
)}
{/* Tactic breakdown */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{scenario.tactics.map(t => {
const ok = spotted.includes(t.id);
return (
<div key={t.id} style={{
display: 'flex', alignItems: 'flex-start', gap: 10,
padding: '9px 12px', borderRadius: 10, fontSize: 12,
background: ok ? 'rgba(76,175,130,0.08)' : 'rgba(230,57,70,0.06)',
}}>
<span style={{
fontWeight: 900, fontSize: 13, flexShrink: 0,
color: ok ? 'var(--safe)' : 'var(--danger)',
}}>
{ok ? '✓' : '✗'}
</span>
<div>
<div style={{ fontWeight: 600 }}>{t.name}</div>
<div style={{ color: 'var(--muted)', lineHeight: 1.5 }}>{t.desc}</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
);
}
Add this keyframe animation to styles.css (near the other @keyframes blocks):
@keyframes shimmer {
0% { transform: translateX(-100%); opacity: 0.5; }
50% { transform: translateX(0%); opacity: 1; }
100% { transform: translateX(100%); opacity: 0.5; }
}
Also add responsive breakpoints for the ScamTwin layout:
@media (max-width: 900px) {
/* Scenario picker: 2 columns on tablet */
.scenario-picker-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
}
@media (max-width: 640px) {
/* Scenario picker: 1 column on mobile */
.scenario-picker-grid {
grid-template-columns: 1fr !important;
}
/* Chat: stack vertically on mobile */
.chatroom-grid {
grid-template-columns: 1fr !important;
}
/* Tactic panel goes above chat on mobile */
.chat-side {
order: -1;
}
}
| Before | After | |
|---|---|---|
| Scenarios | 3 hardcoded, same every session | AI-generates 3 fresh ones per session |
| Variety | Identical scenarios repeated | Exclude played types, infinite variation |
| Scammer replies | scenario.pressure (one string) |
Claude responds dynamically to what user typed |
| Quick-reply chips | Fixed 4 options hardcoded in data | Claude generates 4 contextual suggestions each turn |
| Custom input | Not available | User can type anything freely |
| Tactic detection | Keyword matching (spots: ['id']) |
Claude judges meaning — rewards nuanced responses |
| Debrief | Generic system message | Claude writes a specific debrief for that conversation |
| Offline | Crashes without data | Falls back to saved scenarios gracefully |
User opens ScamTwin
↓
POST /twin/generate
Claude creates 3 fresh scenarios
↓
User picks one scenario
↓
Opener messages shown (from scenario)
Claude generates first 4 reply suggestions
↓
User taps a chip OR types custom message
↓
POST /twin/turn
Claude receives: scenario + full history + user message
Claude returns:
- scammer_reply (adaptive, in-character)
- spotted_tactic_ids (which tactics this response exposed)
- suggested_replies (4 fresh chips for next turn)
- game_over + win (when to end)
- debrief (final summary when game ends)
↓
Repeat until game_over = true
↓
Show debrief + tactic breakdown + XP
Show judges this exact moment:
- Open ScamTwin
- "Notice — every session generates completely new scenarios. These weren't written by us."
- Play the bank fraud scenario — use the custom input to type something unexpected
- Point out: the scammer adapts to exactly what you said — not a canned response
- After 3 turns, show the AI-written debrief: specific to your conversation
- Hit "Generate fresh scenarios" — show 3 completely different ones appear
Say: "A user can play this every day and never see the same scenario twice." That is technically impressive and directly relevant to the impact rubric.
Truthly ScamTwin AI — Every session unique · Claude-powered · Fully adaptive