runrate-lab / app.py
ramanaprabhusana's picture
Improve RunRate Lab bat rig and swing posture
b1c04e2 verified
import base64
import json
import random
from typing import Any
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
APP_TITLE = "🏏 RunRate Lab"
APP_SUBTITLE = "Play. Analyze. Improve."
SHORT_DESCRIPTION = "Learn cricket analytics by playing a T20 chase and studying every shot's expected runs and risk."
TOTAL_BALLS = 120
TOTAL_WICKETS = 10
BALLS_PER_OVER = 6
DEFAULT_SIMULATION_SAMPLES = 7000
TIMING_ASSIST_MULTIPLIER = 2.35
POWER_TOLERANCE = 60
DECISION_THRESHOLDS = {
"excellent_xruns": 4.0,
"excellent_risk": 0.18,
"good_xruns": 2.5,
"good_risk": 0.25,
"risky_xruns": 3.5,
"low_value_xruns": 2.0,
"poor_xruns": 2.5,
"luck_run_gap": 2.0,
"low_risk_wicket": 0.18,
"low_xruns_boundary": 2.5,
}
PITCH_MODEL = pd.DataFrame(
[
{"Delivery": "Half Volley", "Speed": 1260, "Window": 130, "Risk": 0.06, "Ideal Power": 78},
{"Delivery": "Length Ball", "Speed": 1120, "Window": 112, "Risk": 0.10, "Ideal Power": 66},
{"Delivery": "Cutter", "Speed": 1040, "Window": 92, "Risk": 0.15, "Ideal Power": 58},
{"Delivery": "Bouncer", "Speed": 900, "Window": 82, "Risk": 0.20, "Ideal Power": 72},
{"Delivery": "Yorker", "Speed": 820, "Window": 70, "Risk": 0.25, "Ideal Power": 47},
]
)
SHOT_MODEL = pd.DataFrame(
[
{
"Shot": "Defend",
"Contact Bonus": 0.14,
"Risk Multiplier": 0.58,
"Upside": 0.54,
"Power Bias": -18,
"Best Deliveries": "Yorker,Cutter",
"Risky Deliveries": "Bouncer",
"Keyboard": "1",
},
{
"Shot": "Drive",
"Contact Bonus": 0.05,
"Risk Multiplier": 1.00,
"Upside": 1.00,
"Power Bias": 0,
"Best Deliveries": "Half Volley,Length Ball",
"Risky Deliveries": "Bouncer",
"Keyboard": "2",
},
{
"Shot": "Cut / Pull",
"Contact Bonus": -0.02,
"Risk Multiplier": 1.12,
"Upside": 1.10,
"Power Bias": 8,
"Best Deliveries": "Bouncer,Cutter",
"Risky Deliveries": "Yorker",
"Keyboard": "3",
},
{
"Shot": "Loft",
"Contact Bonus": -0.10,
"Risk Multiplier": 1.58,
"Upside": 1.38,
"Power Bias": 16,
"Best Deliveries": "Half Volley,Length Ball",
"Risky Deliveries": "Yorker,Cutter",
"Keyboard": "4",
},
]
)
AIM_MODEL = pd.DataFrame(
[
{"Aim": "Leg Side", "Contact Bonus": -0.02, "Risk Multiplier": 1.08, "Run Multiplier": 1.05, "Angle": -36},
{"Aim": "Straight", "Contact Bonus": 0.04, "Risk Multiplier": 0.95, "Run Multiplier": 1.00, "Angle": 0},
{"Aim": "Off Side", "Contact Bonus": -0.01, "Risk Multiplier": 1.05, "Run Multiplier": 1.04, "Angle": 36},
]
)
st.set_page_config(page_title=APP_TITLE, layout="wide", initial_sidebar_state="collapsed")
def initialize_session_state() -> None:
"""Create stable values for the Streamlit shell around the browser game."""
if "target_score" not in st.session_state:
st.session_state.target_score = random.randint(150, 220)
def get_pitch(delivery: str) -> pd.Series:
"""Return one delivery row from the pitch model."""
matches = PITCH_MODEL[PITCH_MODEL["Delivery"] == delivery]
if matches.empty:
raise ValueError(f"Unknown delivery type: {delivery}")
return matches.iloc[0]
def get_shot(shot_intent: str) -> pd.Series:
"""Return one shot row from the shot model."""
matches = SHOT_MODEL[SHOT_MODEL["Shot"] == shot_intent]
if matches.empty:
raise ValueError(f"Unknown shot intent: {shot_intent}")
return matches.iloc[0]
def get_aim(aim_lane: str) -> pd.Series:
"""Return one aim row from the aim model."""
matches = AIM_MODEL[AIM_MODEL["Aim"] == aim_lane]
if matches.empty:
raise ValueError(f"Unknown aim lane: {aim_lane}")
return matches.iloc[0]
def csv_contains(csv_value: str, value: str) -> bool:
"""Check whether a comma-separated model field contains a value."""
return value in {item.strip() for item in str(csv_value).split(",") if item.strip()}
def calculate_contact(
timing_error_ms: float,
power: float,
pitch: pd.Series,
shot_intent: str = "Drive",
aim_lane: str = "Straight",
) -> float:
"""Calculate deterministic contact quality before random variation."""
shot = get_shot(shot_intent)
aim = get_aim(aim_lane)
assisted_window = float(pitch["Window"]) * TIMING_ASSIST_MULTIPLIER
timing_score = np.clip(1 - abs(timing_error_ms) / assisted_window, 0, 1)
ideal_power = float(pitch["Ideal Power"]) + float(shot["Power Bias"])
power_score = np.clip(1 - abs(power - ideal_power) / POWER_TOLERANCE, 0, 1)
matchup = 0.0
if csv_contains(str(shot["Best Deliveries"]), str(pitch["Delivery"])):
matchup += 0.08
if csv_contains(str(shot["Risky Deliveries"]), str(pitch["Delivery"])):
matchup -= 0.10
contact = timing_score * 0.58 + power_score * 0.34 + float(shot["Contact Bonus"]) + float(aim["Contact Bonus"]) + matchup
return float(np.clip(contact, 0, 1))
def calculate_wicket_risk(
contact: float | np.ndarray,
power: float,
timing_error_ms: float,
required_rate: float,
pitch: pd.Series,
shot_intent: str = "Drive",
aim_lane: str = "Straight",
) -> float | np.ndarray:
"""Estimate wicket probability from delivery, contact, power, timing, and chase pressure."""
shot = get_shot(shot_intent)
aim = get_aim(aim_lane)
pressure_risk = 0.05 if required_rate > 10 else 0
overhit_risk = 0.07 if power > 92 else 0
mistimed_risk = 0.10 if abs(timing_error_ms) > float(pitch["Window"]) * 2.1 else 0
matchup_risk = 0.05 if csv_contains(str(shot["Risky Deliveries"]), str(pitch["Delivery"])) else 0
risk = (
float(pitch["Risk"])
* float(shot["Risk Multiplier"])
* float(aim["Risk Multiplier"])
+ pressure_risk
+ overhit_risk
+ mistimed_risk
+ matchup_risk
+ np.where(np.asarray(contact) < 0.22, 0.18, 0)
)
return np.clip(risk, 0.02, 0.62)
def calculate_expected_runs(
contact: float | np.ndarray,
power: float,
wicket_risk: float | np.ndarray,
shot_intent: str = "Drive",
aim_lane: str = "Straight",
) -> float | np.ndarray:
"""Estimate expected runs for one ball before the stochastic outcome is sampled."""
shot = get_shot(shot_intent)
aim = get_aim(aim_lane)
value = (np.asarray(contact) * 4.6 + (power / 100) * 1.25) * float(shot["Upside"]) * float(aim["Run Multiplier"])
return np.clip(value - np.asarray(wicket_risk) * 2.15, 0, 6)
def classify_decision_quality(expected_runs: float, wicket_risk: float) -> str:
"""Label the process quality of a batting decision."""
if expected_runs >= DECISION_THRESHOLDS["excellent_xruns"] and wicket_risk <= DECISION_THRESHOLDS["excellent_risk"]:
return "Excellent Process"
if expected_runs >= DECISION_THRESHOLDS["good_xruns"] and wicket_risk <= DECISION_THRESHOLDS["good_risk"]:
return "Good Process"
if expected_runs >= DECISION_THRESHOLDS["risky_xruns"] and wicket_risk > DECISION_THRESHOLDS["good_risk"]:
return "Risky But Rational"
if expected_runs < DECISION_THRESHOLDS["low_value_xruns"] and wicket_risk <= DECISION_THRESHOLDS["good_risk"]:
return "Low-Value Shot"
if expected_runs < DECISION_THRESHOLDS["poor_xruns"] and wicket_risk > DECISION_THRESHOLDS["good_risk"]:
return "Poor Risk Tradeoff"
return "Balanced Tradeoff"
def classify_luck(actual_runs: int, expected_runs: float, wicket: bool, wicket_risk: float) -> str:
"""Separate outcome luck from decision process."""
if wicket and wicket_risk < DECISION_THRESHOLDS["low_risk_wicket"]:
return "Unlucky Result"
if (not wicket) and actual_runs - expected_runs >= DECISION_THRESHOLDS["luck_run_gap"]:
return "Lucky Result"
if (not wicket) and actual_runs >= 4 and expected_runs < DECISION_THRESHOLDS["low_xruns_boundary"]:
return "Lucky Result"
if wicket and wicket_risk >= DECISION_THRESHOLDS["good_risk"]:
return "Expected Result"
if (not wicket) and expected_runs - actual_runs >= DECISION_THRESHOLDS["luck_run_gap"]:
return "Unlucky Result"
return "Expected Result"
def build_lesson(decision_quality: str, luck_label: str, actual_runs: int, expected_runs: float, wicket_risk: float, wicket: bool) -> str:
"""Create one concise coaching sentence."""
result = "a wicket" if wicket else f"{actual_runs} run{'s' if actual_runs != 1 else ''}"
risk = f"{wicket_risk * 100:.0f}%"
if luck_label == "Lucky Result":
return f"{decision_quality}, lucky result. The shot was worth {expected_runs:.2f} expected runs with {risk} wicket risk, but the roll produced {result}."
if luck_label == "Unlucky Result":
return f"{decision_quality}, unlucky result. The process created {expected_runs:.2f} expected runs with {risk} wicket risk, but variance produced {result}."
return f"{decision_quality}. The result, {result}, broadly matched {expected_runs:.2f} expected runs and {risk} wicket risk."
def simulate_outcome_distribution(
delivery: str,
timing_error_ms: int,
power: int,
required_rate: float,
samples: int = DEFAULT_SIMULATION_SAMPLES,
shot_intent: str = "Drive",
aim_lane: str = "Straight",
) -> tuple[pd.DataFrame, dict[str, float]]:
"""Monte Carlo estimate of the same outcome logic used by the browser game."""
pitch = get_pitch(delivery)
shot = get_shot(shot_intent)
rng = np.random.default_rng(23)
base_contact = calculate_contact(timing_error_ms, power, pitch, shot_intent, aim_lane)
contact = np.clip(base_contact + rng.uniform(-0.07, 0.07, samples), 0, 1)
wicket_chance = calculate_wicket_risk(contact, power, timing_error_ms, required_rate, pitch, shot_intent, aim_lane)
expected_runs = calculate_expected_runs(contact, power, wicket_chance, shot_intent, aim_lane)
wicket_roll = rng.random(samples)
outcome_roll = rng.random(samples)
wicket_modifier = np.clip(1.08 - contact * 0.55, 0.34, 1.05)
wickets = wicket_roll < wicket_chance * wicket_modifier
runs = np.zeros(samples, dtype=int)
if shot_intent == "Defend":
mask = (~wickets) & (contact > 0.76)
runs[mask] = np.where(outcome_roll[mask] < 0.62, 1, np.where(power > 62, 2, 0))
mask = (~wickets) & (runs == 0) & (contact > 0.52)
runs[mask] = np.where(outcome_roll[mask] < 0.55, 1, 0)
elif shot_intent == "Loft":
mask = (~wickets) & (contact > 0.82) & (power > 70)
runs[mask] = np.where(outcome_roll[mask] < 0.70, 6, 4)
mask = (~wickets) & (runs == 0) & (contact > 0.64)
runs[mask] = np.where(outcome_roll[mask] < 0.52, 4, np.where(power > 82, 6, 2))
mask = (~wickets) & (runs == 0) & (contact > 0.35)
runs[mask] = np.where(outcome_roll[mask] < 0.62, 1, 2)
elif shot_intent == "Cut / Pull":
mask = (~wickets) & (contact > 0.78) & (power > 64)
runs[mask] = np.where(outcome_roll[mask] < 0.56, 4, 6)
mask = (~wickets) & (runs == 0) & (contact > 0.58)
runs[mask] = np.where(outcome_roll[mask] < 0.58, 2, 4)
mask = (~wickets) & (runs == 0) & (contact > 0.32)
runs[mask] = np.where(outcome_roll[mask] < 0.66, 1, 2)
else:
mask = (~wickets) & (contact > 0.88) & (power > 68)
runs[mask] = np.where(outcome_roll[mask] < 0.72, 6, 4)
mask = (~wickets) & (runs == 0) & (contact > 0.74)
runs[mask] = np.where(power > 70, np.where(outcome_roll[mask] < 0.58, 4, 6), 3)
mask = (~wickets) & (runs == 0) & (contact > 0.52)
runs[mask] = np.where(power > 66, 4, np.where(outcome_roll[mask] < 0.55, 2, 3))
mask = (~wickets) & (runs == 0) & (contact > 0.30)
runs[mask] = np.where(outcome_roll[mask] < 0.70, 1, 2)
if float(shot["Upside"]) > 1.2:
runs = np.where((~wickets) & (runs == 4) & (outcome_roll > 0.86) & (power > 86), 6, runs)
outcomes = np.where(wickets, "W", runs.astype(str))
order = ["0", "1", "2", "3", "4", "6", "W"]
probability_df = (
pd.Series(outcomes)
.value_counts(normalize=True)
.reindex(order, fill_value=0)
.rename_axis("Outcome")
.reset_index(name="Probability")
)
probability_df["Probability"] = probability_df["Probability"] * 100
wicket_risk_mean = float(np.mean(wicket_chance))
expected_runs_mean = float(np.mean(expected_runs))
metrics = {
"contact": float(np.mean(contact) * 100),
"expected_runs": expected_runs_mean,
"wicket_risk": wicket_risk_mean * 100,
"shot_intent": shot_intent,
"aim_lane": aim_lane,
"timing_score": float(
np.clip(1 - abs(timing_error_ms) / (float(pitch["Window"]) * TIMING_ASSIST_MULTIPLIER), 0, 1) * 100
),
"power_score": float(
np.clip(1 - abs(power - (float(pitch["Ideal Power"]) + float(shot["Power Bias"]))) / POWER_TOLERANCE, 0, 1)
* 100
),
"decision_quality": classify_decision_quality(expected_runs_mean, wicket_risk_mean),
}
return probability_df, metrics
def simulate_sample_table(
delivery: str,
timing_error_ms: int,
power: int,
required_rate: float,
samples: int = 10,
shot_intent: str = "Drive",
aim_lane: str = "Straight",
) -> pd.DataFrame:
"""Show a small set of possible outcomes from the same shot setup."""
pitch = get_pitch(delivery)
rng = np.random.default_rng(41)
rows: list[dict[str, Any]] = []
for trial in range(1, samples + 1):
contact = float(
np.clip(calculate_contact(timing_error_ms, power, pitch, shot_intent, aim_lane) + rng.uniform(-0.07, 0.07), 0, 1)
)
wicket_risk = float(calculate_wicket_risk(contact, power, timing_error_ms, required_rate, pitch, shot_intent, aim_lane))
expected_runs = float(calculate_expected_runs(contact, power, wicket_risk, shot_intent, aim_lane))
wicket_roll = float(rng.random())
outcome_roll = float(rng.random())
wicket = wicket_roll < wicket_risk * float(np.clip(1.08 - contact * 0.55, 0.34, 1.05))
runs = 0
if not wicket:
if shot_intent == "Defend":
if contact > 0.76:
runs = 1 if outcome_roll < 0.62 else 2 if power > 62 else 0
elif contact > 0.52:
runs = 1 if outcome_roll < 0.55 else 0
elif shot_intent == "Loft":
if contact > 0.82 and power > 70:
runs = 6 if outcome_roll < 0.70 else 4
elif contact > 0.64:
runs = 4 if outcome_roll < 0.52 else 6 if power > 82 else 2
elif contact > 0.35:
runs = 1 if outcome_roll < 0.62 else 2
elif shot_intent == "Cut / Pull":
if contact > 0.78 and power > 64:
runs = 4 if outcome_roll < 0.56 else 6
elif contact > 0.58:
runs = 2 if outcome_roll < 0.58 else 4
elif contact > 0.32:
runs = 1 if outcome_roll < 0.66 else 2
else:
if contact > 0.88 and power > 68:
runs = 6 if outcome_roll < 0.72 else 4
elif contact > 0.74:
runs = 4 if power > 70 and outcome_roll < 0.58 else 6 if power > 70 else 3
elif contact > 0.52:
runs = 4 if power > 66 else 2 if outcome_roll < 0.55 else 3
elif contact > 0.30:
runs = 1 if outcome_roll < 0.70 else 2
rows.append(
{
"Trial": trial,
"Shot": shot_intent,
"Aim": aim_lane,
"Outcome": "W" if wicket else str(runs),
"Contact": f"{contact * 100:.0f}%",
"xRuns": round(expected_runs, 2),
"Wicket Risk": f"{wicket_risk * 100:.0f}%",
"Decision": classify_decision_quality(expected_runs, wicket_risk),
"Luck": classify_luck(runs, expected_runs, wicket, wicket_risk),
}
)
return pd.DataFrame(rows)
def build_learning_summary(ball_log: list[dict[str, Any]]) -> dict[str, float]:
"""Summarize a list of ball records for learning modules."""
if not ball_log:
return {"balls": 0, "avg_actual_runs": 0.0, "avg_xruns": 0.0, "avg_wicket_risk": 0.0}
frame = pd.DataFrame(ball_log)
def safe_mean(column: str) -> float:
if column not in frame:
return 0.0
mean_value = pd.to_numeric(frame[column], errors="coerce").mean()
if pd.isna(mean_value):
return 0.0
return float(mean_value)
return {
"balls": float(len(frame)),
"avg_actual_runs": safe_mean("actual_runs"),
"avg_xruns": safe_mean("expected_runs"),
"avg_wicket_risk": safe_mean("wicket_risk"),
}
def build_pitch_analytics() -> pd.DataFrame:
"""Return model hints used by the sidebar chart."""
model_df = PITCH_MODEL.copy()
model_df["Timing Window"] = (model_df["Window"] * TIMING_ASSIST_MULTIPLIER / 1.48).round(0).astype(int)
model_df["Wicket Risk"] = (model_df["Risk"] * 100).round(0).astype(int)
model_df["Boundary Upside"] = np.clip(
105 - model_df["Risk"] * 170 - np.abs(model_df["Ideal Power"] - 76) * 1.2,
40,
96,
).round(0).astype(int)
return model_df[["Delivery", "Timing Window", "Wicket Risk", "Boundary Upside"]]
def build_interpretation(
delivery: str,
shot_intent: str,
aim_lane: str,
timing_error_ms: int,
power: int,
required_rate: float,
risk_budget: int,
metrics: dict[str, float],
) -> dict[str, str]:
"""Explain the analytics lab setup in plain language."""
timing_text = "well-timed" if abs(timing_error_ms) <= 40 else "early" if timing_error_ms < 0 else "late"
risk_text = "inside" if metrics["wicket_risk"] <= risk_budget else "above"
best_use = "Use this when you need boundaries." if metrics["expected_runs"] >= 3.5 else "Use this when protecting wickets matters."
if required_rate >= 12 and metrics["expected_runs"] < 3:
best_use = "This is too conservative for the current chase pressure."
if shot_intent == "Defend" and required_rate >= 10:
best_use = "Defend is mainly for survival. At this required rate, compare it with Drive or Loft."
if shot_intent == "Loft" and metrics["wicket_risk"] <= risk_budget:
best_use = "This is a rational attacking option because the upside is high and risk stays within budget."
return {
"What changed?": (
f"{shot_intent} to {aim_lane} against {delivery} is {timing_text} at {power}% power, "
f"creating {metrics['expected_runs']:.2f} expected runs."
),
"Best use case": best_use,
"Risk warning": f"Wicket risk is {metrics['wicket_risk']:.0f}%, which is {risk_text} your {risk_budget}% risk budget.",
"Analytics lesson": (
"Shot choice changes the model. A Drive, Loft, or Defend with the same timing can be a different decision "
"because upside and dismissal risk move together."
),
}
def render_sidebar_model() -> None:
"""Show lightweight analytics without turning the game into a spreadsheet."""
model_df = build_pitch_analytics()
with st.sidebar:
st.header("Model Guide")
st.write(
"RunRate Lab treats every ball as a decision under uncertainty. "
"Timing, power, delivery type, and match pressure become measurable inputs."
)
radar_fig = go.Figure()
radar_fig.add_trace(
go.Scatterpolar(
r=[82, 74, 69, 58, 77],
theta=["Timing", "Power", "Contact", "Risk Control", "Pressure"],
fill="toself",
name="Analytics Skill",
line_color="#facc15",
fillcolor="rgba(250, 204, 21, 0.22)",
)
)
radar_fig.update_layout(
height=270,
margin={"l": 20, "r": 20, "t": 35, "b": 20},
paper_bgcolor="rgba(0,0,0,0)",
polar={"bgcolor": "rgba(0,0,0,0)", "radialaxis": {"visible": True, "range": [0, 100]}},
showlegend=False,
title="Learning Skills",
)
st.plotly_chart(radar_fig, width="stretch")
risk_fig = px.bar(
model_df,
x="Delivery",
y=["Boundary Upside", "Wicket Risk"],
barmode="group",
title="Delivery Tradeoffs",
color_discrete_map={"Boundary Upside": "#22c55e", "Wicket Risk": "#ef4444"},
)
risk_fig.update_layout(
height=290,
margin={"l": 15, "r": 15, "t": 45, "b": 20},
paper_bgcolor="rgba(0,0,0,0)",
legend_title_text="",
)
st.plotly_chart(risk_fig, width="stretch")
st.dataframe(model_df, hide_index=True)
GAME_HTML = r"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
--bg: #06110c;
--panel: rgba(5, 14, 10, 0.90);
--panel-strong: rgba(3, 9, 7, 0.95);
--soft: rgba(255, 255, 255, 0.08);
--line: rgba(255, 255, 255, 0.18);
--text: #f8fafc;
--muted: #b8c7bf;
--gold: #facc15;
--green: #22c55e;
--red: #ef4444;
--blue: #38bdf8;
--grass: #16793a;
--pitch: #c9aa67;
}
* { box-sizing: border-box; }
body {
margin: 0;
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.shell {
position: relative;
width: 100%;
height: 100vh;
min-height: 670px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 18px;
background: #082013;
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.36);
}
.shell.color-safe {
--gold: #fde047;
--green: #14b8a6;
--red: #f97316;
--blue: #60a5fa;
}
#gameRoot {
position: absolute;
inset: 0;
outline: none;
touch-action: none;
user-select: none;
}
canvas { width: 100%; height: 100%; display: block; }
.topbar {
position: absolute;
z-index: 5;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: flex-end;
gap: 14px;
align-items: flex-start;
padding: 10px 14px 48px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.58), rgba(0, 0, 0, 0));
pointer-events: none;
}
.brand { display: none; }
.brand small {
display: block;
color: var(--gold);
font-weight: 1000;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.brand strong {
display: block;
color: var(--text);
font-size: clamp(1.08rem, 2.2vw, 1.65rem);
line-height: 1;
}
.brand span {
display: none;
margin-top: 5px;
color: var(--muted);
font-weight: 800;
}
.pill-row { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
.pill {
min-width: 68px;
padding: 6px 8px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(3, 9, 7, 0.62);
text-align: center;
backdrop-filter: blur(10px);
}
.process-pill { display: none; }
.pill span, .insight span, .process-grid span, .mission-card span {
display: block;
color: var(--muted);
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.pill b { display: block; margin-top: 1px; color: var(--text); font-size: 0.98rem; }
.tutorial-prompt {
position: absolute;
z-index: 4;
left: 50%;
top: 42%;
transform: translate(-50%, -50%);
padding: 12px 16px;
border-radius: 14px;
background: rgba(3, 9, 7, 0.58);
border: 1px solid rgba(255, 255, 255, 0.18);
color: var(--text);
text-align: center;
font-weight: 1000;
letter-spacing: 0.10em;
text-transform: uppercase;
pointer-events: none;
backdrop-filter: blur(10px);
}
.tutorial-prompt span {
display: block;
margin-top: 5px;
color: var(--gold);
font-size: 1.35rem;
line-height: 1;
}
.control-strip {
position: absolute;
z-index: 6;
left: 16px;
right: auto;
bottom: 16px;
display: grid;
gap: 8px;
width: min(610px, calc(100% - 440px));
pointer-events: auto;
}
.control-strip.hidden-for-review { display: none; }
.control-card {
border: 1px solid var(--line);
border-radius: 12px;
padding: 9px;
background: rgba(3, 9, 7, 0.72);
backdrop-filter: blur(14px);
}
.control-card h3, .coach-panel h3, .mission-card h3 {
margin: 0 0 6px;
color: var(--gold);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.70rem;
}
.button-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
}
.aim-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
button {
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 10px;
min-height: 38px;
padding: 8px 9px;
background: rgba(255, 255, 255, 0.10);
color: var(--text);
font-weight: 1000;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
box-shadow: none;
}
button.active {
background: var(--gold);
color: #1f1600;
border-color: rgba(250, 204, 21, 0.95);
box-shadow: 0 10px 28px rgba(250, 204, 21, 0.22);
}
button.primary {
background: var(--gold);
color: #1f1600;
border-color: rgba(250, 204, 21, 0.95);
}
button.secondary { background: rgba(255, 255, 255, 0.12); color: var(--text); }
.charge-meter {
position: relative;
height: 13px;
overflow: hidden;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.08);
}
.charge-fill {
width: 0%;
height: 100%;
background: linear-gradient(90deg, var(--green), var(--gold), var(--red));
border-radius: inherit;
transition: width 70ms linear;
}
.control-hint {
margin-top: 8px;
color: var(--muted);
font-size: 0.70rem;
line-height: 1.35;
}
.control-card .control-hint { display: none; }
#statusHint {
display: block;
margin-top: 7px;
font-size: 0.68rem;
}
.coach-panel {
position: absolute;
z-index: 6;
right: 16px;
top: 128px;
bottom: auto;
width: min(360px, calc(100% - 32px));
max-height: min(430px, calc(100% - 154px));
overflow: auto;
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: rgba(3, 9, 7, 0.86);
backdrop-filter: blur(14px);
transform: translateY(0);
transition: opacity 160ms ease, transform 160ms ease;
}
.coach-panel.hidden-during-play {
opacity: 0;
pointer-events: none;
transform: translateY(12px);
}
.coach-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.coach-head h3 { margin: 0; }
.small-button {
min-height: 32px;
padding: 6px 9px;
border-radius: 9px;
font-size: 0.62rem;
}
.hidden { display: none !important; }
.insight-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
margin-bottom: 8px;
}
.insight, .process-grid div {
min-height: 48px;
padding: 7px 8px;
border-radius: 10px;
background: var(--soft);
border: 1px solid rgba(255, 255, 255, 0.10);
}
.insight b, .process-grid b {
display: block;
margin-top: 2px;
color: var(--text);
font-size: 0.88rem;
overflow-wrap: anywhere;
}
.coach-copy {
margin: 0 0 10px;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.35;
}
.coach-copy.compact-copy { margin-bottom: 0; }
.process-card {
margin-bottom: 10px;
padding-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.process-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.log {
display: grid;
gap: 7px;
max-height: 142px;
overflow: hidden;
}
.detail-only.hidden { display: none !important; }
.log-row {
display: grid;
grid-template-columns: 50px 1fr 42px;
align-items: center;
gap: 8px;
padding: 7px 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.07);
color: var(--text);
font-size: 0.81rem;
}
.log-row small { color: var(--muted); display: block; margin-top: 2px; }
.log-row b { color: var(--gold); text-align: right; }
.mission-card {
position: absolute;
z-index: 6;
left: 18px;
top: 116px;
width: min(318px, calc(100% - 36px));
padding: 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(3, 9, 7, 0.84);
backdrop-filter: blur(14px);
transition: opacity 160ms ease, transform 160ms ease;
}
.mission-card.hidden {
opacity: 0;
pointer-events: none;
transform: translateX(-16px);
}
.mission-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.mission-top h3 { margin: 0; }
.mission-card strong { display: block; margin: 4px 0 7px; }
.mission-card p { margin: 6px 0; color: var(--muted); line-height: 1.35; font-size: 0.84rem; }
.mission-actions { display: flex; gap: 8px; margin-top: 10px; }
.mission-actions button { flex: 1; min-height: 34px; font-size: 0.68rem; }
.icon-button {
width: 32px;
min-height: 32px;
height: 32px;
padding: 0;
display: inline-grid;
place-items: center;
line-height: 1;
}
.mission-chip {
position: absolute;
z-index: 6;
left: 18px;
top: 116px;
display: none;
min-height: 38px;
padding: 8px 11px;
background: rgba(3, 9, 7, 0.84);
color: var(--text);
backdrop-filter: blur(14px);
}
.mission-chip.visible { display: none; }
.toast {
position: absolute;
z-index: 7;
left: 50%;
top: 45%;
min-width: 230px;
transform: translate(-50%, -50%) scale(0.96);
opacity: 0;
pointer-events: none;
padding: 13px 18px;
border: 1px solid rgba(255, 255, 255, 0.20);
border-radius: 16px;
background: rgba(3, 9, 7, 0.78);
text-align: center;
backdrop-filter: blur(14px);
transition: opacity 160ms ease, transform 160ms ease;
}
.toast.visible { opacity: 1; transform: translate(-50%, -58%) scale(1); }
.toast b { display: block; color: var(--gold); font-size: 2.5rem; line-height: 1; }
.toast em {
display: block;
margin-top: 5px;
color: var(--text);
font-style: normal;
font-weight: 1000;
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.toast span {
display: block;
margin-top: 4px;
color: var(--muted);
font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.74rem;
}
.overlay {
position: absolute;
z-index: 8;
inset: 0;
display: grid;
place-items: center;
padding: 18px;
background: rgba(2, 8, 6, 0.62);
}
.modal {
width: min(660px, 94vw);
padding: 25px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 18px;
background: rgba(5, 15, 10, 0.94);
text-align: center;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.50);
backdrop-filter: blur(16px);
}
.modal h1 {
margin: 0;
color: var(--text);
font-size: clamp(2.35rem, 8vw, 5rem);
line-height: 0.93;
}
.modal h2 {
margin: 0 0 8px;
color: var(--gold);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.modal p {
margin: 13px auto 0;
color: var(--muted);
max-width: 540px;
line-height: 1.45;
}
.start-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 19px;
}
.start-stats div {
padding: 12px 10px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--soft);
}
.start-stats span {
display: block;
color: var(--muted);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.start-stats b { display: block; margin-top: 4px; font-size: 1.35rem; }
.button-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-top: 21px;
}
.settings-modal {
position: absolute;
z-index: 9;
inset: 0;
display: grid;
place-items: center;
padding: 18px;
background: rgba(0, 0, 0, 0.54);
}
.settings-card {
width: min(460px, 94vw);
padding: 18px;
border: 1px solid rgba(255, 255, 255, 0.20);
border-radius: 16px;
background: var(--panel-strong);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48);
backdrop-filter: blur(16px);
}
.settings-card h2 {
margin: 0 0 12px;
color: var(--gold);
text-transform: uppercase;
letter-spacing: 0.10em;
font-size: 1.1rem;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
padding: 12px 0;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.setting-row:first-of-type { border-top: 0; }
.setting-row strong { display: block; color: var(--text); }
.setting-row span { display: block; margin-top: 3px; color: var(--muted); font-size: 0.78rem; line-height: 1.3; }
.toggle {
position: relative;
width: 48px;
height: 28px;
flex: 0 0 auto;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.20);
transition: background 160ms ease;
}
.toggle-slider::after {
content: "";
position: absolute;
width: 20px;
height: 20px;
left: 3px;
top: 3px;
border-radius: 50%;
background: var(--text);
transition: transform 160ms ease;
}
.toggle input:checked + .toggle-slider { background: rgba(250, 204, 21, 0.90); }
.toggle input:checked + .toggle-slider::after { transform: translateX(20px); background: #1f1600; }
.game-message {
position: absolute;
z-index: 4;
left: 50%;
top: 108px;
transform: translateX(-50%);
width: min(560px, calc(100% - 32px));
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 11px;
background: rgba(3, 9, 7, 0.46);
color: rgba(248, 250, 252, 0.84);
text-align: center;
font-size: 0.88rem;
font-weight: 900;
pointer-events: none;
backdrop-filter: blur(8px);
}
.game-message.review-message {
top: 86px;
width: auto;
max-width: min(420px, calc(100% - 32px));
padding: 7px 11px;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.07em;
}
@media (max-width: 700px) {
.shell { min-height: 670px; }
.topbar { flex-direction: column; }
.pill-row { justify-content: flex-start; }
.control-strip { left: 14px; right: 14px; bottom: 14px; width: auto; max-width: none; }
.coach-panel {
left: 14px;
right: 14px;
top: auto;
bottom: 14px;
width: auto;
max-height: 328px;
overflow: auto;
}
.coach-panel.hidden-during-play { display: none; }
.mission-card, .mission-chip { display: none; }
.button-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.aim-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.insight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.log { display: none; }
}
</style>
</head>
<body>
<div id="gameShell" class="shell">
<div id="gameRoot" tabindex="0" aria-label="RunRate Lab 2.5D cricket arcade game">
<canvas id="gameCanvas"></canvas>
</div>
<div id="tutorialPrompt" class="tutorial-prompt">Hold and release<span>to swing</span></div>
<div id="gameMessage" class="game-message">Choose a shot, pick a lane, hold, then release near impact.</div>
<div class="topbar">
<div class="brand">
<small>RunRate Lab</small>
<strong>Play. Analyze. Improve.</strong>
<span>Choose shot intent, pick a lane, then judge the process before the result.</span>
</div>
<div class="pill-row">
<div class="pill"><span>Target</span><b id="targetPill">0</b></div>
<div class="pill"><span>Score</span><b id="scorePill">0/0</b></div>
<div class="pill"><span>Overs</span><b id="oversPill">0.0</b></div>
<div class="pill"><span>Need</span><b id="needPill">0</b></div>
<div class="pill"><span>RRR</span><b id="rrrPill">0.0</b></div>
<div class="pill process-pill"><span>Process</span><b id="processPill">0</b></div>
</div>
</div>
<div id="missionCard" class="mission-card hidden">
<div class="mission-top"><h3>Learning Mission</h3><button id="hideMission" class="icon-button" title="Hide mission">x</button></div>
<span id="missionNumber">Module 1 of 5</span>
<strong id="missionTitle">Expected Runs</strong>
<p id="missionProgress">Compare two shot intents for the same delivery.</p>
<p id="missionLesson">Same ball, same timing, different shot. Expected value reveals the tradeoff.</p>
<div class="mission-actions"><button id="prevMission" class="secondary">Previous</button><button id="nextMission" class="secondary">Next</button></div>
</div>
<button id="missionChip" class="mission-chip">Learning Mission</button>
<div id="coachPanel" class="coach-panel">
<div class="coach-head">
<h3>Analytics Coach</h3>
<div>
<button id="nextBallBtn" class="small-button primary hidden">Next Ball</button>
<button id="detailsBtn" class="small-button secondary hidden">Details</button>
<button id="settingsBtn" class="small-button secondary">Settings</button>
</div>
</div>
<div id="insightGrid" class="insight-grid"></div>
<p id="coachCopy" class="coach-copy">Select a shot and lane. Hold to charge as the bowler releases, then let go near the impact band.</p>
<div id="processCard" class="process-card detail-only hidden">
<h3>Process Score</h3>
<div id="processGrid" class="process-grid"></div>
</div>
<div id="logBlock" class="detail-only hidden">
<h3>Last Six Balls</h3>
<div id="log" class="log"></div>
</div>
</div>
<div id="controlStrip" class="control-strip">
<div class="control-card">
<h3>Shot Intent</h3>
<div id="shotButtons" class="button-grid"></div>
<div class="control-hint">1 Defend, 2 Drive, 3 Cut or Pull, 4 Loft.</div>
</div>
<div class="control-card">
<h3>Aim Lane</h3>
<div id="aimButtons" class="button-grid aim-grid"></div>
<div class="charge-meter"><div id="chargeFill" class="charge-fill"></div></div>
<div id="statusHint" class="control-hint">Hold to start. Release near the impact band.</div>
</div>
</div>
<div id="resultToast" class="toast"><b id="resultScore">+0</b><em id="resultDecision">Ready</em><span id="resultCopy">Ready</span></div>
<div id="overlay" class="overlay">
<div class="modal">
<h1 id="overlayTitle">RunRate Lab</h1>
<h2 id="overlaySubtitle">Cricket Analytics Arcade</h2>
<p id="overlayCopy">Play a T20 chase and learn expected runs, wicket risk, variance, and decision quality from every shot.</p>
<div class="start-stats">
<div><span>Target</span><b id="startTarget">0</b></div>
<div><span>Controls</span><b>Hold</b></div>
<div><span>Review</span><b>Every Ball</b></div>
</div>
<div class="button-row">
<button id="startBtn" class="primary">Start T20 Chase</button>
<button id="practiceBtn" class="secondary">Practice Analytics</button>
<button id="learnBtn" class="secondary">Open Learning Path</button>
</div>
<p>Mouse, touch, or Space. Choose shot intent and lane, hold to charge, release near the green impact band, then read the coach panel.</p>
</div>
</div>
<div id="settingsModal" class="settings-modal hidden">
<div class="settings-card">
<h2>Settings</h2>
<div class="setting-row">
<div><strong>Reduce Flashing</strong><span>Softens boundary flash and impact effects.</span></div>
<label class="toggle"><input id="reduceFlashToggle" type="checkbox"><span class="toggle-slider"></span></label>
</div>
<div class="setting-row">
<div><strong>Color-Safe Mode</strong><span>Uses a wider contrast palette for feedback colors.</span></div>
<label class="toggle"><input id="colorSafeToggle" type="checkbox"><span class="toggle-slider"></span></label>
</div>
<div class="setting-row">
<div><strong>First-Ball Prompt</strong><span>Shows the hold-and-release reminder before your first shot.</span></div>
<label class="toggle"><input id="tutorialToggle" type="checkbox" checked><span class="toggle-slider"></span></label>
</div>
<div class="button-row">
<button id="restartBtn" class="secondary">Restart Match</button>
<button id="closeSettingsBtn" class="primary">Close</button>
</div>
</div>
</div>
</div>
<script>
(() => {
const initialTarget = Number("__INITIAL_TARGET__");
const deliveries = __DELIVERIES_JSON__;
const shots = __SHOTS_JSON__;
const aims = __AIMS_JSON__;
const thresholds = __DECISION_THRESHOLDS__;
const timingAssistMultiplier = Number("__TIMING_ASSIST_MULTIPLIER__");
const powerTolerance = Number("__POWER_TOLERANCE__");
const totalBalls = 120;
const totalWickets = 10;
const shell = document.getElementById("gameShell");
const root = document.getElementById("gameRoot");
const tutorialPrompt = document.getElementById("tutorialPrompt");
const gameMessage = document.getElementById("gameMessage");
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const overlay = document.getElementById("overlay");
const overlayTitle = document.getElementById("overlayTitle");
const overlaySubtitle = document.getElementById("overlaySubtitle");
const overlayCopy = document.getElementById("overlayCopy");
const startBtn = document.getElementById("startBtn");
const practiceBtn = document.getElementById("practiceBtn");
const learnBtn = document.getElementById("learnBtn");
const targetPill = document.getElementById("targetPill");
const scorePill = document.getElementById("scorePill");
const oversPill = document.getElementById("oversPill");
const needPill = document.getElementById("needPill");
const rrrPill = document.getElementById("rrrPill");
const processPill = document.getElementById("processPill");
const startTarget = document.getElementById("startTarget");
const coachPanel = document.getElementById("coachPanel");
const insightGrid = document.getElementById("insightGrid");
const coachCopy = document.getElementById("coachCopy");
const processCard = document.getElementById("processCard");
const processGrid = document.getElementById("processGrid");
const logBlock = document.getElementById("logBlock");
const logNode = document.getElementById("log");
const nextBallBtn = document.getElementById("nextBallBtn");
const detailsBtn = document.getElementById("detailsBtn");
const settingsBtn = document.getElementById("settingsBtn");
const restartBtn = document.getElementById("restartBtn");
const controlStrip = document.getElementById("controlStrip");
const shotButtons = document.getElementById("shotButtons");
const aimButtons = document.getElementById("aimButtons");
const chargeFill = document.getElementById("chargeFill");
const statusHint = document.getElementById("statusHint");
const resultToast = document.getElementById("resultToast");
const resultScore = document.getElementById("resultScore");
const resultDecision = document.getElementById("resultDecision");
const resultCopy = document.getElementById("resultCopy");
const settingsModal = document.getElementById("settingsModal");
const closeSettingsBtn = document.getElementById("closeSettingsBtn");
const reduceFlashToggle = document.getElementById("reduceFlashToggle");
const colorSafeToggle = document.getElementById("colorSafeToggle");
const tutorialToggle = document.getElementById("tutorialToggle");
const missionCard = document.getElementById("missionCard");
const missionChip = document.getElementById("missionChip");
const missionNumber = document.getElementById("missionNumber");
const missionTitle = document.getElementById("missionTitle");
const missionProgress = document.getElementById("missionProgress");
const missionLesson = document.getElementById("missionLesson");
const prevMission = document.getElementById("prevMission");
const nextMission = document.getElementById("nextMission");
const hideMission = document.getElementById("hideMission");
let width = 1280;
let height = 820;
let dpr = 1;
const missions = [
{
title: "Expected Runs",
mission: "Compare two shot intents for the same delivery.",
lesson: "Same ball, same timing, different shot. Expected value reveals the tradeoff."
},
{
title: "Wicket Risk",
mission: "Complete an over using only shots under 20% wicket risk.",
lesson: "A shot can score quickly and still be a bad decision if dismissal risk is too high."
},
{
title: "Variance And Luck",
mission: "Find one lucky and one unlucky result in the ball log.",
lesson: "Analytics separates process quality from scoreboard noise."
},
{
title: "Timing Sensitivity",
mission: "Replay the same shot with early, good, and late release timing.",
lesson: "Changing one input at a time isolates cause and effect."
},
{
title: "Match Pressure",
mission: "Compare Drive and Loft when the required rate is 6, 10, and 14.",
lesson: "Pressure changes which risk is rational."
}
];
const state = {
mode: "title",
score: 0,
wickets: 0,
balls: 0,
target: initialTarget,
selectedShot: "Drive",
selectedAim: "Straight",
delivery: null,
deliveryLine: 0,
ballProgress: 0,
deliveryStart: 0,
idealContactTime: 0,
charging: false,
chargeStart: 0,
power: 0,
swingStart: 0,
swingState: "ready",
lastFrame: performance.now(),
log: [],
particles: [],
trail: [],
lastBallVisual: null,
resultFlash: 0,
wicketShake: 0,
processStreak: 0,
bestProcessStreak: 0,
missionIndex: 0,
lastInsight: null,
processSummary: null,
detailsOpen: false,
settings: {
reduceFlash: false,
colorSafe: false,
showTutorial: true
},
message: "Choose intent and lane. Hold to start the delivery."
};
function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); }
function rand(min, max) { return min + Math.random() * (max - min); }
function rowBy(table, field, value) { return table.find((row) => row[field] === value) || table[0]; }
function csvContains(text, value) {
return String(text || "").split(",").map((item) => item.trim()).includes(value);
}
function oversText(balls) { return `${Math.floor(balls / 6)}.${balls % 6}`; }
function ballsRemaining() { return Math.max(totalBalls - state.balls, 0); }
function runsNeeded() { return Math.max(state.target - state.score, 0); }
function requiredRate() {
const remaining = ballsRemaining();
return remaining > 0 ? (runsNeeded() / remaining) * 6 : 0;
}
function loadSettings() {
try {
const saved = JSON.parse(window.localStorage.getItem("runRateSettings") || "{}");
state.settings.reduceFlash = Boolean(saved.reduceFlash);
state.settings.colorSafe = Boolean(saved.colorSafe);
state.settings.showTutorial = saved.showTutorial !== false;
} catch {
state.settings.reduceFlash = false;
state.settings.colorSafe = false;
state.settings.showTutorial = true;
}
}
function saveSettings() {
try { window.localStorage.setItem("runRateSettings", JSON.stringify(state.settings)); } catch { return; }
}
function applySettings() {
shell.classList.toggle("color-safe", state.settings.colorSafe);
reduceFlashToggle.checked = state.settings.reduceFlash;
colorSafeToggle.checked = state.settings.colorSafe;
tutorialToggle.checked = state.settings.showTutorial;
updateHud();
}
function fxColor(kind) {
if (state.settings.colorSafe) {
if (kind === "risk") return "#f97316";
if (kind === "boundary") return "#fde047";
if (kind === "single") return "#60a5fa";
return "#14b8a6";
}
if (kind === "risk") return "#ef4444";
if (kind === "boundary") return "#facc15";
if (kind === "single") return "#38bdf8";
return "#22c55e";
}
function calculateContact(timingError, power, pitch, shotName, aimName) {
const shot = rowBy(shots, "Shot", shotName);
const aim = rowBy(aims, "Aim", aimName);
const assistedWindow = Number(pitch.Window) * timingAssistMultiplier;
const timingScore = clamp(1 - Math.abs(timingError) / assistedWindow, 0, 1);
const idealPower = Number(pitch["Ideal Power"]) + Number(shot["Power Bias"]);
const powerScore = clamp(1 - Math.abs(power - idealPower) / powerTolerance, 0, 1);
let matchup = 0;
if (csvContains(shot["Best Deliveries"], pitch.Delivery)) matchup += 0.08;
if (csvContains(shot["Risky Deliveries"], pitch.Delivery)) matchup -= 0.10;
return clamp(
timingScore * 0.58 + powerScore * 0.34 + Number(shot["Contact Bonus"]) + Number(aim["Contact Bonus"]) + matchup,
0,
1
);
}
function calculateRisk(contact, power, timingError, rate, pitch, shotName, aimName) {
const shot = rowBy(shots, "Shot", shotName);
const aim = rowBy(aims, "Aim", aimName);
const pressureRisk = rate > 10 ? 0.05 : 0;
const overhitRisk = power > 92 ? 0.07 : 0;
const mistimedRisk = Math.abs(timingError) > Number(pitch.Window) * 2.1 ? 0.10 : 0;
const matchupRisk = csvContains(shot["Risky Deliveries"], pitch.Delivery) ? 0.05 : 0;
const poorContactRisk = contact < 0.22 ? 0.18 : 0;
const risk = Number(pitch.Risk) * Number(shot["Risk Multiplier"]) * Number(aim["Risk Multiplier"])
+ pressureRisk + overhitRisk + mistimedRisk + matchupRisk + poorContactRisk;
return clamp(risk, 0.02, 0.62);
}
function calculateXRuns(contact, power, risk, shotName, aimName) {
const shot = rowBy(shots, "Shot", shotName);
const aim = rowBy(aims, "Aim", aimName);
const value = (contact * 4.6 + (power / 100) * 1.25) * Number(shot.Upside) * Number(aim["Run Multiplier"]);
return clamp(value - risk * 2.15, 0, 6);
}
function classifyDecision(xRuns, risk) {
if (xRuns >= thresholds.excellent_xruns && risk <= thresholds.excellent_risk) return "Excellent Process";
if (xRuns >= thresholds.good_xruns && risk <= thresholds.good_risk) return "Good Process";
if (xRuns >= thresholds.risky_xruns && risk > thresholds.good_risk) return "Risky But Rational";
if (xRuns < thresholds.low_value_xruns && risk <= thresholds.good_risk) return "Low-Value Shot";
if (xRuns < thresholds.poor_xruns && risk > thresholds.good_risk) return "Poor Risk Tradeoff";
return "Balanced Tradeoff";
}
function classifyLuck(runs, xRuns, wicket, risk) {
if (wicket && risk < thresholds.low_risk_wicket) return "Unlucky Result";
if (!wicket && runs - xRuns >= thresholds.luck_run_gap) return "Lucky Result";
if (!wicket && runs >= 4 && xRuns < thresholds.low_xruns_boundary) return "Lucky Result";
if (wicket && risk >= thresholds.good_risk) return "Expected Result";
if (!wicket && xRuns - runs >= thresholds.luck_run_gap) return "Unlucky Result";
return "Expected Result";
}
function nextSuggestion(insight) {
if (!insight) return "Pick a shot and lane, then compare the coach label with the outcome.";
if (insight.wicketRisk > 0.30 && insight.xRuns < 3.5) return "Lower risk next ball. Try Drive or Defend with cleaner timing.";
if (insight.contact < 0.45) return "Improve the input first. Keep the same shot and release closer to impact.";
if (requiredRate() > 11 && insight.xRuns < 3.0) return "The chase needs more upside. Compare Drive and Loft before the next ball.";
if (insight.luck !== "Expected Result") return "Repeat the setup once. One result can mislead, but repeated trials reveal process.";
return "Good learning rep. Change only one input next ball so the effect is visible.";
}
function sampleOutcome(contact, power, risk, shotName) {
const wicketRoll = Math.random();
const outcomeRoll = Math.random();
const wicketChance = risk * clamp(1.08 - contact * 0.55, 0.34, 1.05);
const wicket = wicketRoll < wicketChance;
let runs = 0;
if (!wicket) {
if (shotName === "Defend") {
if (contact > 0.76) runs = outcomeRoll < 0.62 ? 1 : power > 62 ? 2 : 0;
else if (contact > 0.52) runs = outcomeRoll < 0.55 ? 1 : 0;
} else if (shotName === "Loft") {
if (contact > 0.82 && power > 70) runs = outcomeRoll < 0.70 ? 6 : 4;
else if (contact > 0.64) runs = outcomeRoll < 0.52 ? 4 : power > 82 ? 6 : 2;
else if (contact > 0.35) runs = outcomeRoll < 0.62 ? 1 : 2;
} else if (shotName === "Cut / Pull") {
if (contact > 0.78 && power > 64) runs = outcomeRoll < 0.56 ? 4 : 6;
else if (contact > 0.58) runs = outcomeRoll < 0.58 ? 2 : 4;
else if (contact > 0.32) runs = outcomeRoll < 0.66 ? 1 : 2;
} else {
if (contact > 0.88 && power > 68) runs = outcomeRoll < 0.72 ? 6 : 4;
else if (contact > 0.74) runs = power > 70 ? (outcomeRoll < 0.58 ? 4 : 6) : 3;
else if (contact > 0.52) runs = power > 66 ? 4 : outcomeRoll < 0.55 ? 2 : 3;
else if (contact > 0.30) runs = outcomeRoll < 0.70 ? 1 : 2;
}
}
return { wicket, runs, roll: outcomeRoll, wicketRoll };
}
function commentaryFor(insight) {
if (insight.wicket) return `Wicket. ${insight.decision} with ${Math.round(insight.wicketRisk * 100)}% risk.`;
if (insight.runs === 6) return `Clean ${insight.shot.toLowerCase()} into the ${insight.aim.toLowerCase()} lane.`;
if (insight.runs === 4) return `${insight.shot} finds value. Four through ${insight.aim.toLowerCase()}.`;
if (insight.runs > 0) return `${insight.shot} earns ${insight.runs}. Process label: ${insight.decision}.`;
return `Dot ball. ${insight.decision}. Check contact and risk before judging the result.`;
}
function lessonFor(insight) {
const result = insight.wicket ? "a wicket" : `${insight.runs} run${insight.runs === 1 ? "" : "s"}`;
if (insight.luck === "Lucky Result") {
return `${insight.decision}, lucky result. You created ${insight.xRuns.toFixed(2)} xRuns with ${Math.round(insight.wicketRisk * 100)}% wicket risk, but the roll produced ${result}.`;
}
if (insight.luck === "Unlucky Result") {
return `${insight.decision}, unlucky result. The shot was worth ${insight.xRuns.toFixed(2)} xRuns, but variance produced ${result}.`;
}
return `${insight.decision}. The result, ${result}, broadly matched ${insight.xRuns.toFixed(2)} xRuns and ${Math.round(insight.wicketRisk * 100)}% wicket risk.`;
}
function chooseDelivery() {
const pitch = deliveries[Math.floor(Math.random() * deliveries.length)];
state.delivery = pitch;
state.deliveryLine = rand(-0.28, 0.28);
state.ballProgress = 0;
state.trail = [];
state.lastBallVisual = null;
state.message = `${pitch.Delivery}. Hold to charge. Release at impact.`;
}
function resetMatch() {
state.mode = "ready";
state.score = 0;
state.wickets = 0;
state.balls = 0;
state.log = [];
state.particles = [];
state.trail = [];
state.lastBallVisual = null;
state.lastInsight = null;
state.processSummary = null;
state.processStreak = 0;
state.bestProcessStreak = 0;
state.power = 0;
state.swingState = "ready";
state.resultFlash = 0;
state.wicketShake = 0;
chooseDelivery();
overlay.classList.add("hidden");
nextBallBtn.classList.add("hidden");
detailsBtn.classList.add("hidden");
processCard.classList.add("hidden");
logBlock.classList.add("hidden");
state.detailsOpen = false;
statusHint.textContent = "Hold mouse, touch, or Space to start the delivery.";
updateHud();
updateCoach();
root.focus();
}
function beginDelivery() {
if (state.mode !== "ready") return;
state.mode = "bowling";
state.charging = true;
state.chargeStart = performance.now();
state.deliveryStart = state.chargeStart;
state.idealContactTime = state.deliveryStart + Number(state.delivery.Speed);
state.ballProgress = 0;
state.power = 0;
state.swingState = "backlift";
state.swingStart = performance.now();
state.message = "Charging. Release as the ball reaches the green impact band.";
statusHint.textContent = "Release near impact. Holding too long can overhit.";
root.focus();
}
function releaseSwing() {
if (state.mode !== "bowling" || !state.charging) return;
const now = performance.now();
const power = clamp((now - state.chargeStart) / 9, 0, 100);
const timingError = now - state.idealContactTime;
state.charging = false;
state.power = power;
state.swingState = Math.abs(timingError) < Number(state.delivery.Window) * 0.75 ? "contact" : "miss";
state.swingStart = now;
resolveDelivery(timingError, power);
}
function resolveDelivery(timingError, power) {
const pitch = state.delivery;
const contactBase = calculateContact(timingError, power, pitch, state.selectedShot, state.selectedAim);
const contact = clamp(contactBase + rand(-0.07, 0.07), 0, 1);
const risk = calculateRisk(contact, power, timingError, requiredRate(), pitch, state.selectedShot, state.selectedAim);
const xRuns = calculateXRuns(contact, power, risk, state.selectedShot, state.selectedAim);
const sampled = sampleOutcome(contact, power, risk, state.selectedShot);
const decision = classifyDecision(xRuns, risk);
const luck = classifyLuck(sampled.runs, xRuns, sampled.wicket, risk);
const outcomeText = sampled.wicket ? "W" : String(sampled.runs);
if (sampled.wicket) state.wickets += 1;
else state.score += sampled.runs;
state.balls += 1;
const insight = {
ball: state.balls,
over: oversText(state.balls),
delivery: pitch.Delivery,
shot: state.selectedShot,
aim: state.selectedAim,
timingError,
power,
contact,
xRuns,
wicketRisk: risk,
roll: sampled.wicket ? sampled.wicketRoll : sampled.roll,
runs: sampled.runs,
wicket: sampled.wicket,
outcome: outcomeText,
decision,
luck,
lesson: "",
suggestion: ""
};
insight.lesson = lessonFor(insight);
insight.suggestion = nextSuggestion(insight);
if (decision === "Excellent Process" || decision === "Good Process") {
state.processStreak += 1;
state.bestProcessStreak = Math.max(state.bestProcessStreak, state.processStreak);
} else {
state.processStreak = 0;
}
state.lastInsight = insight;
state.log.unshift(insight);
state.log = state.log.slice(0, 24);
state.mode = "between";
nextBallBtn.classList.remove("hidden");
detailsBtn.classList.remove("hidden");
state.detailsOpen = false;
state.message = "Review the analytics coach, then press Next Ball.";
statusHint.textContent = "Review state. Press Next Ball or Enter when ready.";
if (sampled.wicket) addWicketBallVisual();
if (!sampled.wicket && sampled.runs > 0) addShotTrail(sampled.runs);
if (!sampled.wicket && sampled.runs === 0) addDotBallVisual();
addImpactParticles(sampled.wicket ? fxColor("risk") : sampled.runs >= 4 ? fxColor("boundary") : fxColor("single"));
if (sampled.runs >= 4) state.resultFlash = state.settings.reduceFlash ? 0.35 : 1;
if (sampled.wicket) state.wicketShake = 1;
updateOverSummary();
showToast(sampled.wicket ? "W" : `+${sampled.runs}`, commentaryFor(insight), decision);
updateHud();
updateCoach();
checkGameOver();
}
function noSwingIfNeeded(now) {
if (state.mode === "bowling" && state.charging && now > state.idealContactTime + 240) {
state.charging = false;
state.swingState = "miss";
state.swingStart = now;
resolveDelivery(260, 0);
}
}
function nextBall() {
if (state.mode !== "between") return;
chooseDelivery();
state.mode = "ready";
state.power = 0;
state.ballProgress = 0;
state.swingState = "ready";
state.trail = [];
state.lastBallVisual = null;
nextBallBtn.classList.add("hidden");
detailsBtn.classList.add("hidden");
state.detailsOpen = false;
statusHint.textContent = "Next delivery set. Hold to start.";
updateHud();
updateCoach();
root.focus();
}
function checkGameOver() {
if (state.score >= state.target) {
state.mode = "gameover";
nextBallBtn.classList.add("hidden");
detailsBtn.classList.add("hidden");
showEndOverlay("Chase Complete", "Win by analytics", `You reached ${state.score}/${state.wickets} in ${oversText(state.balls)}. Review the last over process score, then restart to test a new strategy.`);
updateHud();
updateCoach();
} else if (state.wickets >= totalWickets || state.balls >= totalBalls) {
state.mode = "gameover";
nextBallBtn.classList.add("hidden");
detailsBtn.classList.add("hidden");
showEndOverlay("Innings Complete", "Target not reached", `You finished ${state.score}/${state.wickets}. The useful question is which decisions had strong xRuns and controlled risk.`);
updateHud();
updateCoach();
}
}
function showEndOverlay(title, subtitle, copy) {
overlayTitle.textContent = title;
overlaySubtitle.textContent = subtitle;
overlayCopy.textContent = copy;
startBtn.textContent = "Restart Match";
overlay.classList.remove("hidden");
}
function showToast(score, copy, decision = "Ready") {
resultScore.textContent = score;
resultDecision.textContent = decision;
resultCopy.textContent = copy;
resultToast.classList.add("visible");
window.setTimeout(() => resultToast.classList.remove("visible"), 1700);
}
function updateOverSummary() {
if (state.balls === 0 || state.balls % 6 !== 0) return;
const overBalls = state.log.slice(0, 6);
const avgXRuns = overBalls.reduce((sum, item) => sum + item.xRuns, 0) / overBalls.length;
const actual = overBalls.reduce((sum, item) => sum + item.runs, 0);
const avgRisk = overBalls.reduce((sum, item) => sum + item.wicketRisk, 0) / overBalls.length;
const best = [...overBalls].sort((a, b) => b.xRuns - a.xRuns)[0];
const biggestSwing = [...overBalls].sort((a, b) => Math.abs((b.wicket ? 0 : b.runs) - b.xRuns) - Math.abs((a.wicket ? 0 : a.runs) - a.xRuns))[0];
state.processSummary = {
avgXRuns,
actual,
avgRisk,
best: `${best.shot}, ${best.xRuns.toFixed(1)} xR`,
swing: `${biggestSwing.luck}, ball ${biggestSwing.over}`
};
}
function updateHud() {
targetPill.textContent = String(state.target);
scorePill.textContent = `${state.score}/${state.wickets}`;
oversPill.textContent = oversText(state.balls);
needPill.textContent = String(runsNeeded());
rrrPill.textContent = requiredRate().toFixed(1);
processPill.textContent = `${state.processStreak}`;
startTarget.textContent = String(state.target);
chargeFill.style.width = `${Math.round(state.power)}%`;
tutorialPrompt.classList.toggle("hidden", !(state.settings.showTutorial && state.mode === "ready" && state.log.length === 0));
const inReview = state.mode === "between";
controlStrip.classList.toggle("hidden-for-review", inReview || state.mode === "gameover");
coachPanel.classList.toggle("hidden-during-play", !inReview);
gameMessage.textContent = inReview ? "Coach ready. Press Next Ball when done." : state.message;
gameMessage.classList.toggle("review-message", inReview);
gameMessage.classList.toggle("hidden", state.mode === "title" || state.mode === "gameover");
}
function updateCoach() {
const insight = state.lastInsight;
const items = insight ? (
state.detailsOpen ? [
["Delivery", insight.delivery],
["Shot", insight.shot],
["Aim", insight.aim],
["Timing", `${Math.round(insight.timingError)} ms`],
["Power", `${Math.round(insight.power)}%`],
["Contact", `${Math.round(insight.contact * 100)}%`],
["xRuns", insight.xRuns.toFixed(2)],
["Risk", `${Math.round(insight.wicketRisk * 100)}%`],
["Roll", insight.roll.toFixed(2)],
["Decision", insight.decision],
["Luck", insight.luck],
["Next", insight.suggestion]
] : [
["Outcome", insight.wicket ? "Wicket" : `+${insight.runs}`],
["Decision", insight.decision],
["xRuns", insight.xRuns.toFixed(2)],
["Risk", `${Math.round(insight.wicketRisk * 100)}%`]
]
) : [
["Delivery", state.delivery ? state.delivery.Delivery : "Set soon"],
["Shot", state.selectedShot],
["Aim", state.selectedAim],
["Goal", "Good process"]
];
insightGrid.innerHTML = items.map(([label, value]) => `<div class="insight"><span>${label}</span><b>${value}</b></div>`).join("");
coachCopy.textContent = insight
? (state.detailsOpen ? `${insight.lesson} Next: ${insight.suggestion}` : insight.lesson)
: "Every shot is judged before the result: expected runs, wicket risk, decision quality, then luck.";
coachCopy.classList.toggle("compact-copy", Boolean(insight && !state.detailsOpen));
coachPanel.classList.toggle("details-open", state.detailsOpen);
detailsBtn.textContent = state.detailsOpen ? "Hide" : "Details";
processCard.classList.toggle("hidden", !(state.detailsOpen && state.processSummary));
logBlock.classList.toggle("hidden", !state.detailsOpen);
if (state.processSummary && state.detailsOpen) {
processCard.classList.remove("hidden");
processGrid.innerHTML = [
["Avg xRuns", state.processSummary.avgXRuns.toFixed(2)],
["Actual Runs", String(state.processSummary.actual)],
["Avg Risk", `${Math.round(state.processSummary.avgRisk * 100)}%`],
["Best Decision", state.processSummary.best],
["Variance Flag", state.processSummary.swing],
["Best Streak", `${state.bestProcessStreak} balls`]
].map(([label, value]) => `<div><span>${label}</span><b>${value}</b></div>`).join("");
}
logNode.innerHTML = state.log.slice(0, 6).map((item) => {
const outcome = item.wicket ? "W" : `+${item.runs}`;
return `<div class="log-row"><span>${item.over}</span><div>${item.shot} to ${item.aim}<small>${item.decision}, ${item.luck}</small></div><b>${outcome}</b></div>`;
}).join("");
}
function updateMission() {
const mission = missions[state.missionIndex];
missionNumber.textContent = `Module ${state.missionIndex + 1} of ${missions.length}`;
missionTitle.textContent = mission.title;
missionProgress.textContent = mission.mission;
missionLesson.textContent = mission.lesson;
}
function setMissionHidden(hidden) {
missionCard.classList.toggle("hidden", hidden);
missionChip.classList.toggle("visible", hidden);
try { window.localStorage.setItem("runRateMissionHidden", hidden ? "1" : "0"); } catch { return; }
}
function restoreMissionVisibility() {
let hidden = true;
try {
const saved = window.localStorage.getItem("runRateMissionHidden");
hidden = saved === null ? true : saved === "1";
} catch {
hidden = true;
}
setMissionHidden(hidden);
}
function renderControls() {
shotButtons.innerHTML = shots.map((shot) => `<button type="button" data-shot="${shot.Shot}">${shot.Keyboard}. ${shot.Shot}</button>`).join("");
aimButtons.innerHTML = aims.map((aim) => `<button type="button" data-aim="${aim.Aim}">${aim.Aim}</button>`).join("");
shotButtons.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", (event) => {
event.stopPropagation();
state.selectedShot = button.dataset.shot;
updateActiveControls();
updateCoach();
root.focus();
});
});
aimButtons.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", (event) => {
event.stopPropagation();
state.selectedAim = button.dataset.aim;
updateActiveControls();
updateCoach();
root.focus();
});
});
updateActiveControls();
}
function updateActiveControls() {
shotButtons.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.shot === state.selectedShot));
aimButtons.querySelectorAll("button").forEach((button) => button.classList.toggle("active", button.dataset.aim === state.selectedAim));
}
function cycleAim(delta) {
const names = aims.map((aim) => aim.Aim);
const index = names.indexOf(state.selectedAim);
state.selectedAim = names[(index + delta + names.length) % names.length];
updateActiveControls();
updateCoach();
}
function resize() {
const rect = root.getBoundingClientRect();
width = Math.max(720, rect.width);
height = Math.max(760, rect.height);
dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function pitchPoint(t, line = 0) {
const topY = height * 0.18;
const bottomY = height * 0.82;
const y = topY + (bottomY - topY) * t;
const halfWidth = 30 + 98 * t;
return { x: width / 2 + line * halfWidth, y, halfWidth };
}
function ballPosition() {
const t = clamp(state.ballProgress, 0, 1);
const lane = state.deliveryLine * (1 - t * 0.35);
const p = pitchPoint(t, lane);
const bob = Math.sin(t * Math.PI * 3) * 5 * (1 - t);
return { x: p.x, y: p.y + bob, r: 5 + t * 11, t };
}
function drawField() {
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, "#176f35");
gradient.addColorStop(0.55, "#16793a");
gradient.addColorStop(1, "#0d4f29");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
ctx.save();
ctx.translate(width / 2, height * 0.56);
ctx.strokeStyle = "rgba(255,255,255,0.18)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.ellipse(0, 0, width * 0.44, height * 0.39, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = "rgba(255,255,255,0.34)";
ctx.lineWidth = 6;
ctx.beginPath();
ctx.ellipse(0, 0, width * 0.56, height * 0.51, 0, Math.PI * 0.08, Math.PI * 0.92);
ctx.stroke();
ctx.restore();
const topLeft = pitchPoint(0, -1);
const topRight = pitchPoint(0, 1);
const botLeft = pitchPoint(1, -1);
const botRight = pitchPoint(1, 1);
const pitchGrad = ctx.createLinearGradient(0, topLeft.y, 0, botLeft.y);
pitchGrad.addColorStop(0, "#b99553");
pitchGrad.addColorStop(0.5, "#d3b977");
pitchGrad.addColorStop(1, "#aa8246");
ctx.fillStyle = pitchGrad;
ctx.beginPath();
ctx.moveTo(topLeft.x, topLeft.y);
ctx.lineTo(topRight.x, topRight.y);
ctx.lineTo(botRight.x, botRight.y);
ctx.quadraticCurveTo(width / 2, botRight.y + 36, botLeft.x, botLeft.y);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.55)";
ctx.lineWidth = 3;
const creaseTop = pitchPoint(0.18, 0);
const creaseBottom = pitchPoint(0.91, 0);
ctx.beginPath();
ctx.moveTo(creaseTop.x - creaseTop.halfWidth * 1.15, creaseTop.y);
ctx.lineTo(creaseTop.x + creaseTop.halfWidth * 1.15, creaseTop.y);
ctx.moveTo(creaseBottom.x - creaseBottom.halfWidth * 1.2, creaseBottom.y);
ctx.lineTo(creaseBottom.x + creaseBottom.halfWidth * 1.2, creaseBottom.y);
ctx.stroke();
const impact = pitchPoint(0.88, 0);
const pulse = 0.5 + Math.sin(performance.now() / 170) * 0.5;
ctx.fillStyle = `rgba(34, 197, 94, ${0.16 + pulse * 0.11})`;
ctx.beginPath();
ctx.ellipse(impact.x, impact.y, impact.halfWidth * 0.78, 18, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.86)";
ctx.font = "800 12px Inter, sans-serif";
ctx.textAlign = "center";
ctx.fillText("IMPACT", impact.x, impact.y + 4);
}
function drawStumps(x, y, scale) {
ctx.save();
ctx.strokeStyle = "#f8fafc";
ctx.lineWidth = 3 * scale;
for (let i = -1; i <= 1; i += 1) {
ctx.beginPath();
ctx.moveTo(x + i * 8 * scale, y);
ctx.lineTo(x + i * 8 * scale, y - 42 * scale);
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(x - 18 * scale, y - 42 * scale);
ctx.lineTo(x + 18 * scale, y - 42 * scale);
ctx.stroke();
ctx.restore();
}
function drawBowler() {
const p = pitchPoint(0.06, 0);
ctx.save();
ctx.translate(p.x, p.y + 4);
ctx.scale(0.58, 0.58);
ctx.fillStyle = "#0f172a";
ctx.fillRect(-14, -2, 28, 38);
ctx.fillStyle = "#f8fafc";
ctx.beginPath();
ctx.arc(0, -14, 12, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.45)";
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(-22, 8);
ctx.lineTo(20, -26);
ctx.stroke();
ctx.restore();
}
function drawBallPath() {
if (!state.delivery) return;
if (state.mode === "between" || state.mode === "gameover") return;
const start = pitchPoint(0.08, state.deliveryLine);
const end = pitchPoint(0.88, state.deliveryLine * 0.25);
ctx.save();
ctx.strokeStyle = "rgba(248,250,252,0.38)";
ctx.setLineDash([7, 9]);
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.quadraticCurveTo(width / 2 + state.deliveryLine * 46, height * 0.48, end.x, end.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "rgba(250,204,21,0.78)";
ctx.beginPath();
ctx.arc(start.x, start.y, 8, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawCricketBall(x, y, r, rotation = 0, alpha = 1) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = "rgba(0,0,0,0.26)";
ctx.beginPath();
ctx.ellipse(x + r * 0.18, y + r * 0.78, r * 0.92, r * 0.30, 0, 0, Math.PI * 2);
ctx.fill();
const gradient = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.1, x, y, r);
gradient.addColorStop(0, "#ff7a7a");
gradient.addColorStop(0.58, "#ef4444");
gradient.addColorStop(1, "#8f1f1f");
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
ctx.translate(x, y);
ctx.rotate(rotation);
ctx.strokeStyle = "rgba(255,255,255,0.72)";
ctx.lineWidth = Math.max(1.2, r * 0.15);
ctx.lineCap = "round";
ctx.beginPath();
ctx.arc(-r * 0.18, -r * 0.02, r * 0.62, -0.95, 0.95);
ctx.stroke();
ctx.strokeStyle = "rgba(255,255,255,0.42)";
ctx.lineWidth = Math.max(1, r * 0.08);
for (let i = -2; i <= 2; i += 1) {
ctx.beginPath();
ctx.moveTo(-r * 0.24, i * r * 0.18);
ctx.lineTo(-r * 0.08, i * r * 0.18 + r * 0.05);
ctx.stroke();
}
ctx.restore();
}
function drawBall() {
if (state.mode !== "bowling") return;
const p = ballPosition();
drawCricketBall(p.x, p.y, p.r, p.t * Math.PI * 4, 1);
}
function drawReviewBall(now) {
if (state.mode !== "between" || !state.lastBallVisual) return;
const visual = state.lastBallVisual;
const elapsed = clamp((now - visual.createdAt) / 860, 0, 1);
const ease = 1 - Math.pow(1 - elapsed, 2);
const x = (1 - ease) * visual.x0 + ease * visual.x1;
const curveY = (1 - ease) * (1 - ease) * visual.y0 + 2 * (1 - ease) * ease * visual.cy + ease * ease * visual.y1;
const r = visual.wicket ? 9 : visual.runs >= 4 ? 8 : 7;
drawCricketBall(x, curveY, r, now / 95, 0.94);
}
function drawShotTrail() {
state.trail.forEach((trail) => {
trail.life -= 0.018;
if (trail.life <= 0) return;
ctx.save();
ctx.globalAlpha = clamp(trail.life, 0, 1);
ctx.strokeStyle = trail.runs >= 6 ? "#facc15" : trail.runs === 4 ? "#38bdf8" : "#e2e8f0";
ctx.lineWidth = trail.runs >= 4 ? 5 : 3;
ctx.beginPath();
ctx.moveTo(trail.x0, trail.y0);
ctx.quadraticCurveTo(trail.cx, trail.cy, trail.x1, trail.y1);
ctx.stroke();
ctx.restore();
});
state.trail = state.trail.filter((trail) => trail.life > 0);
}
function shotPathFor(runs) {
const aim = rowBy(aims, "Aim", state.selectedAim);
const lane = Number(aim.Angle) / 45;
const start = pitchPoint(0.89, 0);
if (runs < 4) {
const length = height * (runs >= 2 ? 0.24 : 0.18);
return {
x0: start.x,
y0: start.y,
cx: start.x + lane * length * 0.34,
cy: start.y - length * 0.14,
x1: start.x + lane * length * 0.72,
y1: start.y - length * 0.20,
runs
};
}
const angle = (Number(aim.Angle) - 90 + (state.selectedShot === "Cut / Pull" ? 14 : 0)) * Math.PI / 180;
const length = runs >= 6 ? height * 0.60 : runs === 4 ? height * 0.48 : height * 0.26;
return {
x0: start.x,
y0: start.y,
cx: start.x + Math.cos(angle) * length * 0.38,
cy: start.y + Math.sin(angle) * length * 0.36,
x1: start.x + Math.cos(angle) * length,
y1: start.y + Math.sin(angle) * length,
runs
};
}
function addShotTrail(runs) {
const path = shotPathFor(runs);
state.trail.push({
...path,
life: 1
});
state.lastBallVisual = {
...path,
wicket: false,
createdAt: performance.now()
};
}
function addWicketBallVisual() {
const start = pitchPoint(0.89, 0);
const side = state.deliveryLine < 0 ? -1 : 1;
state.lastBallVisual = {
x0: start.x + side * 10,
y0: start.y - 16,
cx: start.x + side * rand(26, 40),
cy: start.y - rand(24, 42),
x1: start.x + side * rand(48, 72),
y1: start.y + rand(0, 12),
runs: 0,
wicket: true,
createdAt: performance.now()
};
}
function addDotBallVisual() {
const start = pitchPoint(0.89, state.deliveryLine * 0.15);
const side = state.selectedAim === "Leg Side" ? -1 : 1;
state.lastBallVisual = {
x0: start.x + side * 12,
y0: start.y - 14,
cx: start.x + side * rand(26, 42),
cy: start.y - rand(18, 34),
x1: start.x + side * rand(52, 82),
y1: start.y + rand(2, 16),
runs: 0,
wicket: false,
createdAt: performance.now()
};
}
function addImpactParticles(color) {
const p = pitchPoint(0.88, 0);
for (let i = 0; i < 18; i += 1) {
state.particles.push({
x: p.x + rand(-20, 20),
y: p.y + rand(-10, 10),
vx: rand(-2.6, 2.6),
vy: rand(-3.2, 1.8),
r: rand(2, 5),
color,
life: 1
});
}
}
function drawParticles() {
state.particles.forEach((particle) => {
particle.x += particle.vx;
particle.y += particle.vy;
particle.vy += 0.05;
particle.life -= 0.025;
ctx.save();
ctx.globalAlpha = clamp(particle.life, 0, 1);
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
state.particles = state.particles.filter((particle) => particle.life > 0);
}
function roundedRect(x, y, w, h, r) {
const radius = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + w - radius, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
ctx.lineTo(x + w, y + h - radius);
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
ctx.lineTo(x + radius, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
function drawLimb(x1, y1, x2, y2, widthPx, color) {
ctx.strokeStyle = color;
ctx.lineWidth = widthPx;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
function drawGlove(x, y, angle, scale, flip = 1) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle + flip * 0.10);
ctx.fillStyle = "#f8fafc";
roundedRect(-12 * scale, -8 * scale, 24 * scale, 16 * scale, 5 * scale);
ctx.fill();
ctx.fillStyle = "#d8ae55";
roundedRect(-13 * scale, 3 * scale, 12 * scale, 9 * scale, 4 * scale);
ctx.fill();
ctx.fillStyle = "rgba(15,23,42,0.18)";
ctx.fillRect(-4 * scale, -4 * scale, 12 * scale, 2 * scale);
ctx.fillRect(-4 * scale, 0, 12 * scale, 2 * scale);
ctx.restore();
}
function drawBat(topHand, bottomHand, angle, scale) {
const anchor = topHand;
const handSpacing = 16 * scale;
const handleLen = 56 * scale;
const bladeLen = 126 * scale;
const handleW = 6 * scale;
const bladeTopW = 18 * scale;
const bladeMidW = 28 * scale;
const bladeBotW = 40 * scale;
ctx.save();
ctx.translate(anchor.x, anchor.y);
ctx.rotate(angle);
ctx.fillStyle = "rgba(0,0,0,0.22)";
ctx.beginPath();
ctx.ellipse(handleLen + bladeLen * 0.52, 14 * scale, bladeBotW * 0.55, 9 * scale, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#4a3417";
ctx.lineWidth = handleW;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(handleLen, 0);
ctx.stroke();
ctx.strokeStyle = "#f8fafc";
ctx.lineWidth = 2 * scale;
for (let i = 0; i < 3; i += 1) {
const gx = (11 + i * 11) * scale;
ctx.beginPath();
ctx.moveTo(gx, -6 * scale);
ctx.lineTo(gx, 6 * scale);
ctx.stroke();
}
ctx.fillStyle = "#d9b368";
ctx.beginPath();
ctx.moveTo(handleLen - 2 * scale, -bladeTopW * 0.50);
ctx.quadraticCurveTo(handleLen + 10 * scale, -bladeTopW * 0.78, handleLen + 22 * scale, -bladeMidW * 0.48);
ctx.lineTo(handleLen + bladeLen * 0.68, -bladeBotW * 0.44);
ctx.quadraticCurveTo(handleLen + bladeLen + 8 * scale, -bladeBotW * 0.12, handleLen + bladeLen, 0);
ctx.quadraticCurveTo(handleLen + bladeLen + 8 * scale, bladeBotW * 0.12, handleLen + bladeLen * 0.68, bladeBotW * 0.44);
ctx.lineTo(handleLen + 22 * scale, bladeMidW * 0.48);
ctx.quadraticCurveTo(handleLen + 10 * scale, bladeTopW * 0.78, handleLen - 2 * scale, bladeTopW * 0.50);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "#76551e";
ctx.lineWidth = 3 * scale;
ctx.beginPath();
ctx.moveTo(handleLen - 2 * scale, -bladeTopW * 0.50);
ctx.quadraticCurveTo(handleLen + 10 * scale, -bladeTopW * 0.78, handleLen + 22 * scale, -bladeMidW * 0.48);
ctx.lineTo(handleLen + bladeLen * 0.68, -bladeBotW * 0.44);
ctx.quadraticCurveTo(handleLen + bladeLen + 8 * scale, -bladeBotW * 0.12, handleLen + bladeLen, 0);
ctx.quadraticCurveTo(handleLen + bladeLen + 8 * scale, bladeBotW * 0.12, handleLen + bladeLen * 0.68, bladeBotW * 0.44);
ctx.lineTo(handleLen + 22 * scale, bladeMidW * 0.48);
ctx.quadraticCurveTo(handleLen + 10 * scale, bladeTopW * 0.78, handleLen - 2 * scale, bladeTopW * 0.50);
ctx.closePath();
ctx.stroke();
ctx.strokeStyle = "rgba(255,255,255,0.32)";
ctx.lineWidth = 2 * scale;
ctx.beginPath();
ctx.moveTo(handleLen + 18 * scale, -4 * scale);
ctx.lineTo(handleLen + bladeLen * 0.74, -11 * scale);
ctx.stroke();
ctx.strokeStyle = "rgba(118,85,30,0.28)";
ctx.lineWidth = 1.5 * scale;
ctx.beginPath();
ctx.moveTo(handleLen + 14 * scale, 0);
ctx.lineTo(handleLen + bladeLen * 0.76, 0);
ctx.stroke();
ctx.fillStyle = "#1f1600";
ctx.beginPath();
ctx.arc(handSpacing * 0.22, 0, 2.2 * scale, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(handSpacing, 0, 2.2 * scale, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawBatter(now) {
const base = pitchPoint(0.95, 0);
const scale = clamp(width / 1180, 0.72, 1.08);
const shake = state.wicketShake > 0 ? Math.sin(now / 18) * 7 * state.wicketShake : 0;
const x = base.x + shake;
const y = base.y + 18 * scale;
const progress = clamp((now - state.swingStart) / 420, 0, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
let batAngle = -1.18;
let handReach = 0;
let handLift = 0;
let torsoLean = 0;
if (state.mode === "bowling" && state.charging) {
const charge = clamp(state.power / 100, 0, 1);
batAngle = -1.58 - charge * 0.34;
handReach = -8 * charge;
handLift = -18 * charge;
torsoLean = -0.05;
} else if (state.swingState === "contact") {
if (state.selectedShot === "Loft") {
batAngle = -1.72 + easeOut * 1.46;
handReach = 26 * easeOut;
handLift = -16 * easeOut;
torsoLean = 0.14 * easeOut;
} else if (state.selectedShot === "Cut / Pull") {
batAngle = -1.66 + easeOut * 1.12;
handReach = 22 * easeOut;
handLift = -6 * easeOut;
torsoLean = 0.08 * easeOut;
} else if (state.selectedShot === "Defend") {
batAngle = -1.46 + easeOut * 0.62;
handReach = 8 * easeOut;
handLift = 2 * easeOut;
torsoLean = 0.03 * easeOut;
} else {
batAngle = -1.64 + easeOut * 0.96;
handReach = 18 * easeOut;
handLift = -2 * easeOut;
torsoLean = 0.08 * easeOut;
}
} else if (state.swingState === "miss") {
batAngle = -1.56 + easeOut * 0.72;
handReach = 10 * easeOut;
handLift = -4 * easeOut;
torsoLean = 0.04 * easeOut;
} else if (state.mode === "between" && state.lastInsight) {
if (state.lastInsight.shot === "Loft") {
batAngle = -0.32;
handReach = 24;
handLift = -12;
torsoLean = 0.10;
} else if (state.lastInsight.shot === "Defend") {
batAngle = -0.86;
handReach = 8;
handLift = 2;
torsoLean = 0.02;
} else if (state.lastInsight.shot === "Cut / Pull") {
batAngle = -0.58;
handReach = 18;
handLift = -2;
torsoLean = 0.06;
} else {
batAngle = -0.70;
handReach = 16;
handLift = -2;
torsoLean = 0.06;
}
}
const hipY = y - 82 * scale;
const shoulderY = y - 178 * scale;
const headY = y - 232 * scale;
const footY = y + 18 * scale;
const torsoShift = torsoLean * 34 * scale;
const leftShoulder = { x: x - 48 * scale + torsoShift, y: shoulderY + 28 * scale };
const rightShoulder = { x: x + 44 * scale + torsoShift, y: shoulderY + 28 * scale };
const topHand = { x: x + (26 + handReach) * scale, y: shoulderY + (32 + handLift) * scale };
const bottomHand = {
x: topHand.x + Math.cos(batAngle) * 18 * scale,
y: topHand.y + Math.sin(batAngle) * 18 * scale
};
const leftElbow = {
x: (leftShoulder.x + topHand.x) / 2 + 4 * scale,
y: (leftShoulder.y + topHand.y) / 2 + 7 * scale
};
const rightElbow = {
x: (rightShoulder.x + bottomHand.x) / 2 + 16 * scale,
y: (rightShoulder.y + bottomHand.y) / 2 + 10 * scale
};
ctx.save();
ctx.fillStyle = "rgba(0,0,0,0.26)";
ctx.beginPath();
ctx.ellipse(x, footY + 16 * scale, 118 * scale, 22 * scale, 0, 0, Math.PI * 2);
ctx.fill();
drawLimb(x - 34 * scale, hipY + 2 * scale, x - 62 * scale, footY, 24 * scale, "#f8fafc");
drawLimb(x + 34 * scale, hipY + 2 * scale, x + 66 * scale, footY, 24 * scale, "#f8fafc");
ctx.fillStyle = "#f8fafc";
roundedRect(x - 84 * scale, footY - 2 * scale, 42 * scale, 64 * scale, 12 * scale);
ctx.fill();
roundedRect(x + 44 * scale, footY - 2 * scale, 42 * scale, 64 * scale, 12 * scale);
ctx.fill();
ctx.fillStyle = "#f8fafc";
roundedRect(x - 54 * scale + torsoShift, shoulderY, 108 * scale, 124 * scale, 26 * scale);
ctx.fill();
ctx.fillStyle = "#2563eb";
roundedRect(x - 68 * scale + torsoShift, shoulderY + 16 * scale, 136 * scale, 36 * scale, 15 * scale);
ctx.fill();
ctx.fillStyle = "#0f172a";
roundedRect(x - 42 * scale + torsoShift, shoulderY + 40 * scale, 84 * scale, 80 * scale, 13 * scale);
ctx.fill();
drawLimb(leftShoulder.x, leftShoulder.y, leftElbow.x, leftElbow.y, 15 * scale, "#f8fafc");
drawLimb(leftElbow.x, leftElbow.y, topHand.x, topHand.y, 15 * scale, "#f8fafc");
drawLimb(rightShoulder.x, rightShoulder.y, rightElbow.x, rightElbow.y, 15 * scale, "#f8fafc");
drawLimb(rightElbow.x, rightElbow.y, bottomHand.x, bottomHand.y, 15 * scale, "#f8fafc");
drawBat(topHand, bottomHand, batAngle, scale);
drawGlove(topHand.x, topHand.y, batAngle - 0.08, scale, -1);
drawGlove(bottomHand.x, bottomHand.y, batAngle + 0.02, scale, 1);
ctx.fillStyle = "#f8fafc";
ctx.beginPath();
ctx.arc(x + torsoShift, headY, 32 * scale, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#0f172a";
ctx.beginPath();
ctx.arc(x + torsoShift, headY - 9 * scale, 34 * scale, Math.PI, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(15,23,42,0.95)";
ctx.fillRect(x - 36 * scale + torsoShift, headY - 9 * scale, 72 * scale, 9 * scale);
ctx.fillStyle = "rgba(56,189,248,0.58)";
ctx.fillRect(x - 28 * scale + torsoShift, headY + 10 * scale, 56 * scale, 6 * scale);
ctx.restore();
}
function drawScoreState() {
if (state.mode === "title" || state.mode === "gameover") return;
ctx.save();
ctx.fillStyle = "rgba(3,9,7,0.34)";
roundedRect(width / 2 - 84, height * 0.13, 168, 34, 11);
ctx.fill();
ctx.fillStyle = "rgba(248,250,252,0.82)";
ctx.font = "900 13px Inter, sans-serif";
ctx.textAlign = "center";
const modeLabel = state.mode === "between" ? "Review" : state.mode === "ready" ? "Ready" : "Live";
ctx.fillText(modeLabel, width / 2, height * 0.13 + 22);
ctx.restore();
}
function drawFlash() {
if (state.resultFlash <= 0) return;
ctx.save();
ctx.globalAlpha = state.resultFlash * (state.settings.reduceFlash ? 0.08 : 0.26);
ctx.fillStyle = fxColor("boundary");
ctx.fillRect(0, 0, width, height);
ctx.restore();
state.resultFlash *= state.settings.reduceFlash ? 0.84 : 0.91;
}
function drawScene(now) {
drawField();
drawBallPath();
drawBowler();
drawShotTrail();
drawBall();
drawParticles();
drawStumps(width / 2, pitchPoint(0.89, 0).y + 26, 1.15);
drawBatter(now);
drawReviewBall(now);
drawScoreState();
drawFlash();
}
function tick(now) {
const dt = Math.min(34, now - state.lastFrame);
state.lastFrame = now;
if (state.mode === "bowling") {
state.ballProgress = clamp((now - state.deliveryStart) / Number(state.delivery.Speed), 0, 1.05);
if (state.charging) state.power = clamp((now - state.chargeStart) / 9, 0, 100);
noSwingIfNeeded(now);
} else if (state.mode === "ready") {
state.power = 0;
}
if (state.wicketShake > 0) state.wicketShake = Math.max(0, state.wicketShake - dt / 460);
updateHud();
drawScene(now);
requestAnimationFrame(tick);
}
function hideOverlayAndStart() {
if (state.mode === "gameover") state.target = Math.floor(rand(150, 221));
overlayTitle.textContent = "RunRate Lab";
overlaySubtitle.textContent = "Cricket Analytics Arcade";
overlayCopy.textContent = "Play a T20 chase and learn expected runs, wicket risk, variance, and decision quality from every shot.";
startBtn.textContent = "Start T20 Chase";
resetMatch();
}
function openPractice() {
hideOverlayAndStart();
state.target = 999;
updateHud();
coachCopy.textContent = "Practice mode. Ignore the target and test one change at a time.";
}
startBtn.addEventListener("click", hideOverlayAndStart);
practiceBtn.addEventListener("click", openPractice);
learnBtn.addEventListener("click", () => {
hideOverlayAndStart();
setMissionHidden(false);
});
restartBtn.addEventListener("click", () => {
state.target = Math.floor(rand(150, 221));
hideOverlayAndStart();
});
detailsBtn.addEventListener("click", () => {
state.detailsOpen = !state.detailsOpen;
updateCoach();
updateHud();
root.focus();
});
settingsBtn.addEventListener("click", () => {
settingsModal.classList.remove("hidden");
reduceFlashToggle.focus();
});
closeSettingsBtn.addEventListener("click", () => {
settingsModal.classList.add("hidden");
root.focus();
});
reduceFlashToggle.addEventListener("change", () => {
state.settings.reduceFlash = reduceFlashToggle.checked;
saveSettings();
applySettings();
});
colorSafeToggle.addEventListener("change", () => {
state.settings.colorSafe = colorSafeToggle.checked;
saveSettings();
applySettings();
});
tutorialToggle.addEventListener("change", () => {
state.settings.showTutorial = tutorialToggle.checked;
saveSettings();
applySettings();
});
nextBallBtn.addEventListener("click", nextBall);
prevMission.addEventListener("click", () => {
state.missionIndex = (state.missionIndex - 1 + missions.length) % missions.length;
updateMission();
});
nextMission.addEventListener("click", () => {
state.missionIndex = (state.missionIndex + 1) % missions.length;
updateMission();
});
hideMission.addEventListener("click", () => setMissionHidden(true));
missionChip.addEventListener("click", () => setMissionHidden(false));
root.addEventListener("pointerdown", (event) => {
event.preventDefault();
if (state.mode === "ready") beginDelivery();
});
root.addEventListener("pointerup", (event) => {
event.preventDefault();
releaseSwing();
});
root.addEventListener("pointercancel", releaseSwing);
window.addEventListener("keydown", (event) => {
if (event.repeat) return;
if (event.key === "1") state.selectedShot = "Defend";
if (event.key === "2") state.selectedShot = "Drive";
if (event.key === "3") state.selectedShot = "Cut / Pull";
if (event.key === "4") state.selectedShot = "Loft";
if (event.key.toLowerCase() === "a") cycleAim(-1);
if (event.key.toLowerCase() === "d") cycleAim(1);
if (event.key === "Escape") {
settingsModal.classList.add("hidden");
root.focus();
}
if (event.code === "Space") {
event.preventDefault();
if (state.mode === "ready") beginDelivery();
}
if (event.key === "Enter" && state.mode === "between") nextBall();
updateActiveControls();
updateCoach();
});
window.addEventListener("keyup", (event) => {
if (event.code === "Space") {
event.preventDefault();
releaseSwing();
}
});
window.addEventListener("resize", resize);
loadSettings();
renderControls();
updateMission();
restoreMissionVisibility();
applySettings();
chooseDelivery();
resize();
updateHud();
updateCoach();
requestAnimationFrame(tick);
})();
</script>
</body>
</html>
"""
def build_game_html(target_score: int) -> str:
"""Inject the Python model constants into the browser game."""
delivery_json = json.dumps(PITCH_MODEL.to_dict("records"))
shot_json = json.dumps(SHOT_MODEL.to_dict("records"))
aim_json = json.dumps(AIM_MODEL.to_dict("records"))
threshold_json = json.dumps(DECISION_THRESHOLDS)
return (
GAME_HTML.replace("__INITIAL_TARGET__", str(target_score))
.replace("__DELIVERIES_JSON__", delivery_json)
.replace("__SHOTS_JSON__", shot_json)
.replace("__AIMS_JSON__", aim_json)
.replace("__DECISION_THRESHOLDS__", threshold_json)
.replace("__TIMING_ASSIST_MULTIPLIER__", str(TIMING_ASSIST_MULTIPLIER))
.replace("__POWER_TOLERANCE__", str(POWER_TOLERANCE))
)
def render_arcade_game() -> None:
"""Embed the browser-native game loop in the Streamlit page."""
game_html = build_game_html(st.session_state.target_score)
encoded_game = base64.b64encode(game_html.encode("utf-8")).decode("ascii")
st.iframe(f"data:text/html;base64,{encoded_game}", height=690, width="stretch", tab_index=0)
def render_analytics_lab() -> None:
"""Let players experiment with the model behind each shot."""
st.subheader("Interactive Analytics Lab")
st.write(
"Change one input at a time to see how the model reacts. This is the same idea analysts use "
"when they isolate timing, power, pressure, and risk in a game situation."
)
control_col, chart_col = st.columns([0.95, 1.35], vertical_alignment="top")
with control_col:
delivery = st.selectbox("Delivery type", PITCH_MODEL["Delivery"].tolist(), index=1)
shot_intent = st.selectbox("Shot intent", SHOT_MODEL["Shot"].tolist(), index=1)
aim_lane = st.selectbox("Aim lane", AIM_MODEL["Aim"].tolist(), index=1)
timing_error = st.slider(
"Release timing error in milliseconds",
min_value=-220,
max_value=220,
value=0,
step=5,
help="Negative means early. Positive means late.",
)
power = st.slider("Shot power", min_value=0, max_value=100, value=70, step=1)
required_rate = st.slider("Required run rate", min_value=3.0, max_value=18.0, value=8.5, step=0.1)
risk_budget = st.slider("Wicket risk budget", min_value=5, max_value=50, value=20, step=1)
samples = st.slider("Simulation sample size", min_value=1000, max_value=10000, value=DEFAULT_SIMULATION_SAMPLES, step=1000)
probability_df, metrics = simulate_outcome_distribution(
delivery,
timing_error,
power,
required_rate,
samples,
shot_intent,
aim_lane,
)
best_outcome = probability_df.sort_values("Probability", ascending=False).iloc[0]
with chart_col:
metric_cols = st.columns(5)
metric_cols[0].metric("Contact Quality", f"{metrics['contact']:.0f}%")
metric_cols[1].metric("Expected Runs", f"{metrics['expected_runs']:.2f}")
metric_cols[2].metric("Wicket Risk", f"{metrics['wicket_risk']:.0f}%")
metric_cols[3].metric("Most Likely", str(best_outcome["Outcome"]))
metric_cols[4].metric("Decision", metrics["decision_quality"])
probability_fig = px.bar(
probability_df,
x="Outcome",
y="Probability",
color="Outcome",
color_discrete_map={
"0": "#94a3b8",
"1": "#64748b",
"2": "#22c55e",
"3": "#f59e0b",
"4": "#38bdf8",
"6": "#facc15",
"W": "#ef4444",
},
title="Monte Carlo Outcome Estimate",
)
probability_fig.update_layout(
height=330,
showlegend=False,
margin={"l": 20, "r": 20, "t": 50, "b": 20},
yaxis_title="Probability %",
)
st.plotly_chart(probability_fig, width="stretch")
interpretation = build_interpretation(delivery, shot_intent, aim_lane, timing_error, power, required_rate, risk_budget, metrics)
st.markdown("#### What The Model Is Teaching")
interp_cols = st.columns(4)
for column, (title, body) in zip(interp_cols, interpretation.items()):
with column:
st.markdown(f"**{title}**")
st.write(body)
timing_values = np.arange(-220, 221, 10)
sensitivity_rows = []
for timing in timing_values:
_, timing_metrics = simulate_outcome_distribution(
delivery,
int(timing),
power,
required_rate,
samples=2200,
shot_intent=shot_intent,
aim_lane=aim_lane,
)
sensitivity_rows.append(
{
"Timing Error": timing,
"Expected Runs": timing_metrics["expected_runs"],
"Wicket Risk": timing_metrics["wicket_risk"],
}
)
sensitivity_df = pd.DataFrame(sensitivity_rows)
sensitivity_fig = go.Figure()
sensitivity_fig.add_trace(
go.Scatter(
x=sensitivity_df["Timing Error"],
y=sensitivity_df["Expected Runs"],
mode="lines",
name="Expected Runs",
line={"color": "#22c55e", "width": 3},
)
)
sensitivity_fig.add_trace(
go.Scatter(
x=sensitivity_df["Timing Error"],
y=sensitivity_df["Wicket Risk"] / 10,
mode="lines",
name="Wicket Risk divided by 10",
line={"color": "#ef4444", "width": 3},
yaxis="y2",
)
)
sensitivity_fig.add_vline(x=timing_error, line_dash="dot", line_color="#facc15")
sensitivity_fig.update_layout(
title="Timing Sensitivity Curve",
height=330,
margin={"l": 20, "r": 20, "t": 50, "b": 25},
xaxis_title="Release Timing Error, ms",
yaxis={"title": "Expected Runs"},
yaxis2={"title": "Scaled Wicket Risk", "overlaying": "y", "side": "right", "showgrid": False},
)
frontier_rows = []
for test_power in range(0, 101, 5):
_, frontier_metrics = simulate_outcome_distribution(
delivery,
timing_error,
test_power,
required_rate,
samples=1800,
shot_intent=shot_intent,
aim_lane=aim_lane,
)
frontier_rows.append(
{
"Power": test_power,
"Expected Runs": frontier_metrics["expected_runs"],
"Wicket Risk": frontier_metrics["wicket_risk"],
"Inside Budget": frontier_metrics["wicket_risk"] <= risk_budget,
}
)
frontier_df = pd.DataFrame(frontier_rows)
frontier_fig = px.scatter(
frontier_df,
x="Wicket Risk",
y="Expected Runs",
color="Inside Budget",
size="Power",
hover_data=["Power"],
title="Risk-Reward Frontier",
color_discrete_map={True: "#22c55e", False: "#ef4444"},
)
frontier_fig.add_vline(x=risk_budget, line_dash="dot", line_color="#facc15", annotation_text="Risk budget")
frontier_fig.update_layout(height=330, margin={"l": 20, "r": 20, "t": 50, "b": 25})
curve_col, frontier_col = st.columns(2)
with curve_col:
st.plotly_chart(sensitivity_fig, width="stretch")
with frontier_col:
st.plotly_chart(frontier_fig, width="stretch")
st.markdown("#### Ten Possible Results From The Same Shot")
st.dataframe(
simulate_sample_table(delivery, timing_error, power, required_rate, shot_intent=shot_intent, aim_lane=aim_lane),
hide_index=True,
width="stretch",
)
def render_learning_path() -> None:
"""Render guided analytics modules."""
st.subheader("Learning Path")
modules = [
{
"title": "Expected Runs",
"goal": "Understand expected value.",
"mission": "Compare Defend and Drive against the same delivery in the game and lab.",
"lesson": "The scoreboard shows one result. Expected runs shows the long-run value of each decision.",
},
{
"title": "Wicket Risk",
"goal": "Learn risk-reward tradeoffs.",
"mission": "Complete an over using only shots the coach rates under 20% wicket risk.",
"lesson": "High scoring decisions are not always good if dismissal risk is too high.",
},
{
"title": "Variance And Luck",
"goal": "Separate process from outcome.",
"mission": "Identify one lucky result and one unlucky result from the coach panel or ball log.",
"lesson": "Analytics judges decisions by probabilities, not only scoreboard results.",
},
{
"title": "Timing Sensitivity",
"goal": "Learn how one input changes a model.",
"mission": "Replay the same shot intent and aim lane with early, good, and late release timing.",
"lesson": "Controlled experiments isolate cause and effect.",
},
{
"title": "Match Pressure",
"goal": "Learn context-aware decision-making.",
"mission": "Compare Drive and Loft at required run rates of 6, 10, and 14 in the lab.",
"lesson": "Optimal strategy changes when the chase context changes.",
},
]
for index, module in enumerate(modules, start=1):
with st.expander(f"{index}. {module['title']}", expanded=index == 1):
st.markdown(f"**Goal:** {module['goal']}")
st.markdown(f"**Mission:** {module['mission']}")
st.markdown(f"**Completion lesson:** {module['lesson']}")
def render_glossary() -> None:
"""Explain core analytics terms."""
st.subheader("Glossary")
terms = [
("xRuns", "Expected runs. The long-run run value of the shot before the random result appears."),
("Expected Value", "The average outcome you would expect if the same decision were repeated many times."),
("Variance", "The gap between what the model expects and what one random trial actually produces."),
("Monte Carlo Simulation", "A method that repeats the same scenario many times to estimate outcome probabilities."),
("Wicket Risk", "The probability that a shot leads to dismissal."),
("Decision Quality", "A label for whether the shot created enough value for the risk accepted."),
("Process vs Outcome", "The idea that a good decision can fail and a bad decision can succeed because of randomness."),
]
cols = st.columns(2)
for index, (term, definition) in enumerate(terms):
with cols[index % 2]:
st.markdown(f"**{term}**")
st.write(definition)
def render_how_to_use() -> None:
"""Offer a practical workflow for learners."""
st.subheader("How To Use This App")
st.write(
"Start with one over. After every ball, read the Analytics Coach panel before playing again. "
"Then recreate the same situation in the Analytics Lab, change one input, and compare the chart. "
"Finish by completing one Learning Path mission."
)
st.info(
"Best learning loop: play one ball, inspect decision quality, adjust timing or power in the lab, "
"then play again with a clearer hypothesis."
)
def render_page() -> None:
"""Render the full Streamlit app shell."""
initialize_session_state()
render_sidebar_model()
st.title(APP_TITLE)
st.caption(f"{APP_SUBTITLE} {SHORT_DESCRIPTION}")
render_arcade_game()
render_analytics_lab()
render_learning_path()
render_glossary()
render_how_to_use()
if __name__ == "__main__":
render_page()