Skip to main content

Playtesting Methods

Validate Your Design Through Testing

Learn how to effectively test your games! Master feedback collection, player observation, analytics, A/B testing, and iterative design to create games players love! 📊🎮đŸ‘Ĩ

Understanding Playtesting

đŸ”Ŧ The Scientific Method Analogy

Think of playtesting like scientific research:

graph TD A["Playtesting Process"] --> B["Planning"] A --> C["Execution"] A --> D["Analysis"] A --> E["Iteration"] B --> F["Define Goals"] B --> G["Select Testers"] B --> H["Create Protocol"] C --> I["Observe"] C --> J["Record"] C --> K["Interview"] D --> L["Quantitative"] D --> M["Qualitative"] D --> N["Synthesis"] E --> O["Prioritize"] E --> P["Implement"] E --> Q["Validate"]
A circular feedback diagram in pentagon arrangement showing the five-step playtesting cycle. Five colored numbered nodes are arranged clockwise around a center, connected by dashed arrows that loop back to the start. The top node, numbered 1, is Build in blue. Clockwise next is node 2 Test in amber on the upper right, then node 3 Observe in teal on the lower right, node 4 Analyze in purple on the lower left, and node 5 Iterate in green on the upper left. Each node has a side callout panel listing three canonical artifacts: Build (Prototype, Vertical slice, Define goals); Test (Select testers, Create protocol, Run session); Observe (Heatmaps, Path tracking, Think-aloud); Analyze (Quantitative, Qualitative, Synthesis); Iterate (Prioritize, Implement, Validate). The center reads 'Tighten the loop' with a small iteration counter showing Iter 3, reinforcing the cyclical-improvement metaphor of the playtesting feedback process.
A circular five-step playtesting cycle — Build → Test → Observe → Analyze → Iterate → back to Build — shown as a frozen snapshot of the iteration loop driving the lesson's Playtesting Dashboard demo. Five colored numbered nodes are connected by dashed arrows that close into a loop, with side callouts listing the canonical artifacts produced at each step (Heatmaps and Path tracking under Observe; Quantitative, Qualitative, and Synthesis under Analyze; Prioritize, Implement, and Validate under Iterate — reused verbatim from the lesson's process mermaid). The interactive demo lets you start a session, choose a test type, and watch metrics populate live; this diagram shows the iterative feedback process that shapes every test plan and design revision without depending on JavaScript.

Interactive Playtesting Dashboard

A circular feedback loop with five steps: build, test, observe, analyze, iterate.
The playtesting feedback loop: build → test → observe → analyze → iterate. The interactive demo simulates running a session with feedback collection; this diagram shows the iteration cycle and the data collected at each step.

Simulate a playtesting session! Track player behavior, gather feedback, and analyze the data!

Playtest Session Controls:

Test Type:

Tester Profile:

Data Collection:

Survey Questions:

Session Info
Duration: 00:00
Testers: 0
Events: 0
Key Metrics
Completion: 0%
Drop-off: N/A
Avg Time: 0s
Feedback
Satisfaction: 0/5
Issues Found: 0
Suggestions: 0
Behavior
Clicks: 0
Errors: 0
Confusion: 0

Playtesting Implementation in Python

import json
import csv
import time
import random
from typing import Dict, List, Optional, Any, Tuple
from dataclasses import dataclass, asdict
from enum import Enum
from datetime import datetime
import statistics

class TestType(Enum):
    USABILITY = "usability"
    BALANCE = "balance"
    FUN = "fun"
    TUTORIAL = "tutorial"
    DIFFICULTY = "difficulty"
    AB_TEST = "ab_test"

@dataclass
class TesterProfile:
    """Playtester demographic information"""
    id: str
    age_group: str
    experience_level: str
    play_frequency: str
    genre_preferences: List[str]
    platform: str
    
@dataclass
class TestEvent:
    """Individual test event"""
    timestamp: float
    event_type: str
    tester_id: str
    data: Dict[str, Any]
    
@dataclass
class TestMetrics:
    """Collected test metrics"""
    completion_rate: float
    average_time: float
    error_count: int
    satisfaction_score: float
    task_success_rate: float
    engagement_score: float

class PlaytestManager:
    """Comprehensive playtesting system"""
    
    def __init__(self) -> None:
        self.session_id: Optional[str] = None
        self.test_type: TestType = TestType.USABILITY
        self.testers: List[TesterProfile] = []
        self.events: List[TestEvent] = []
        self.start_time: Optional[float] = None
        self.metrics: TestMetrics = TestMetrics(0, 0, 0, 0, 0, 0)
        
        # Data collection
        self.heatmap_data: Dict[str, int] = {}
        self.path_data: Dict[str, List[Tuple[float, float, float]]] = {}
        self.input_sequences: List[Dict[str, Any]] = []
        self.feedback_responses: List[Dict[str, Any]] = []
        
        # Analytics
        self.funnel_analysis: Dict = {}
        self.retention_data: Dict = {}
        self.engagement_metrics: Dict = {}
        
    def start_session(self, test_type: TestType, testers: List[TesterProfile]) -> None:
        """Start a new playtest session"""
        self.session_id = f"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
        self.test_type = test_type
        self.testers = testers
        self.start_time = time.time()
        self.events = []
        
        print(f"Starting {test_type.value} test with {len(testers)} testers")
        
        # Log session start
        self.log_event("session_start", "system", {
            "test_type": test_type.value,
            "tester_count": len(testers)
        })
    
    def log_event(self, event_type: str, tester_id: str, data: Dict[str, Any]) -> None:
        """Log a test event"""
        event = TestEvent(
            timestamp=time.time() - self.start_time if self.start_time else 0,
            event_type=event_type,
            tester_id=tester_id,
            data=data
        )
        self.events.append(event)
    
    def track_position(self, tester_id: str, x: float, y: float) -> None:
        """Track tester position for heatmap"""
        grid_x = int(x / 20) * 20
        grid_y = int(y / 20) * 20
        key = f"{grid_x},{grid_y}"
        
        if key not in self.heatmap_data:
            self.heatmap_data[key] = 0
        self.heatmap_data[key] += 1
        
        # Track path
        if tester_id not in self.path_data:
            self.path_data[tester_id] = []
        self.path_data[tester_id].append((x, y, time.time()))
    
    def track_input(self, tester_id: str, input_type: str, data: Dict[str, Any]) -> None:
        """Track user input"""
        self.input_sequences.append({
            'tester_id': tester_id,
            'type': input_type,
            'data': data,
            'timestamp': time.time() - self.start_time
        })
        
        self.log_event(f"input_{input_type}", tester_id, data)
    
    def collect_feedback(self, tester_id: str, feedback_type: str, response: Any) -> None:
        """Collect tester feedback"""
        self.feedback_responses.append({
            'tester_id': tester_id,
            'type': feedback_type,
            'response': response,
            'timestamp': time.time() - self.start_time
        })
    
    def calculate_metrics(self) -> TestMetrics:
        """Calculate test metrics"""
        if not self.events:
            return self.metrics
        
        # Completion rate
        completions = [e for e in self.events if e.event_type == 'task_complete']
        starts = [e for e in self.events if e.event_type == 'task_start']
        completion_rate = len(completions) / max(len(starts), 1)
        
        # Average time
        completion_times = [e.timestamp for e in completions]
        avg_time = statistics.mean(completion_times) if completion_times else 0
        
        # Error count
        errors = [e for e in self.events if 'error' in e.event_type]
        error_count = len(errors)
        
        # Satisfaction score (from feedback)
        satisfaction_scores = [
            f['response'] for f in self.feedback_responses 
            if f['type'] == 'satisfaction' and isinstance(f['response'], (int, float))
        ]
        satisfaction = statistics.mean(satisfaction_scores) if satisfaction_scores else 0
        
        # Task success rate
        task_successes = [e for e in self.events if e.event_type == 'task_success']
        task_attempts = [e for e in self.events if e.event_type == 'task_attempt']
        success_rate = len(task_successes) / max(len(task_attempts), 1)
        
        # Engagement score (based on interactions)
        interactions = [e for e in self.events if 'interact' in e.event_type]
        engagement = min(len(interactions) / (len(self.testers) * 10), 1.0)
        
        self.metrics = TestMetrics(
            completion_rate=completion_rate,
            average_time=avg_time,
            error_count=error_count,
            satisfaction_score=satisfaction,
            task_success_rate=success_rate,
            engagement_score=engagement
        )
        
        return self.metrics
    
    def analyze_funnel(self, stages: List[str]) -> Dict:
        """Analyze conversion funnel"""
        funnel = {}
        
        for i, stage in enumerate(stages):
            stage_events = [e for e in self.events if e.data.get('stage') == stage]
            unique_testers = set(e.tester_id for e in stage_events)
            
            funnel[stage] = {
                'count': len(unique_testers),
                'percentage': len(unique_testers) / len(self.testers) * 100 if self.testers else 0
            }
            
            if i > 0:
                prev_stage = stages[i-1]
                if prev_stage in funnel:
                    conversion = funnel[stage]['count'] / max(funnel[prev_stage]['count'], 1) * 100
                    funnel[stage]['conversion_from_previous'] = conversion
        
        self.funnel_analysis = funnel
        return funnel
    
    def generate_heatmap(self) -> Dict[str, float]:
        """Generate heatmap visualization data"""
        max_intensity = max(self.heatmap_data.values()) if self.heatmap_data else 1
        
        normalized_heatmap = {
            key: value / max_intensity 
            for key, value in self.heatmap_data.items()
        }
        
        return normalized_heatmap
    
    def identify_pain_points(self) -> List[Dict[str, Any]]:
        """Identify areas where testers struggle"""
        pain_points = []
        
        # Find high error areas
        error_events = [e for e in self.events if 'error' in e.event_type]
        error_locations = {}
        
        for event in error_events:
            if 'x' in event.data and 'y' in event.data:
                loc = (event.data['x'], event.data['y'])
                if loc not in error_locations:
                    error_locations[loc] = 0
                error_locations[loc] += 1
        
        # Find areas with repeated attempts
        attempt_events = [e for e in self.events if e.event_type == 'task_attempt']
        repeated_attempts = {}
        
        for event in attempt_events:
            task = event.data.get('task')
            if task:
                if task not in repeated_attempts:
                    repeated_attempts[task] = []
                repeated_attempts[task].append(event.tester_id)
        
        # Compile pain points
        for task, testers in repeated_attempts.items():
            if len(testers) > len(set(testers)):  # Multiple attempts by same tester
                pain_points.append({
                    'type': 'repeated_attempts',
                    'task': task,
                    'severity': len(testers) / len(self.testers)
                })
        
        for loc, count in error_locations.items():
            if count > len(self.testers) * 0.3:  # 30% threshold
                pain_points.append({
                    'type': 'error_hotspot',
                    'location': loc,
                    'severity': count / len(self.testers)
                })
        
        return pain_points
    
    def export_data(self, format: str = 'json') -> str:
        """Export test data"""
        data = {
            'session_id': self.session_id,
            'test_type': self.test_type.value,
            'duration': time.time() - self.start_time if self.start_time else 0,
            'tester_count': len(self.testers),
            'metrics': asdict(self.calculate_metrics()),
            'events_count': len(self.events),
            'feedback_count': len(self.feedback_responses),
            'pain_points': self.identify_pain_points()
        }
        
        if format == 'json':
            return json.dumps(data, indent=2)
        elif format == 'csv':
            # Flatten for CSV
            rows = []
            for event in self.events:
                row = {
                    'timestamp': event.timestamp,
                    'event_type': event.event_type,
                    'tester_id': event.tester_id,
                    **event.data
                }
                rows.append(row)
            
            if rows:
                output = []
                keys = rows[0].keys()
                output.append(','.join(keys))
                for row in rows:
                    output.append(','.join(str(row.get(k, '')) for k in keys))
                return '\n'.join(output)
        
        return str(data)

class SurveyBuilder:
    """Build and manage playtest surveys"""
    
    def __init__(self) -> None:
        self.questions: List[Dict[str, Any]] = []
    
    def add_likert_scale(self, question: str, scale: int = 5) -> None:
        """Add Likert scale question"""
        self.questions.append({
            'type': 'likert',
            'question': question,
            'scale': scale,
            'options': list(range(1, scale + 1))
        })
    
    def add_multiple_choice(self, question: str, options: List[str]) -> None:
        """Add multiple choice question"""
        self.questions.append({
            'type': 'multiple_choice',
            'question': question,
            'options': options
        })
    
    def add_open_ended(self, question: str) -> None:
        """Add open-ended question"""
        self.questions.append({
            'type': 'open_ended',
            'question': question
        })
    
    def add_sus_scale(self) -> None:
        """Add System Usability Scale questions"""
        sus_questions = [
            "I think that I would like to use this game frequently",
            "I found the game unnecessarily complex",
            "I thought the game was easy to use",
            "I think that I would need support to be able to use this game",
            "I found the various functions in this game were well integrated",
            "I thought there was too much inconsistency in this game",
            "I would imagine that most people would learn to use this game very quickly",
            "I found the game very cumbersome to use",
            "I felt very confident using the game",
            "I needed to learn a lot of things before I could get going with this game"
        ]
        
        for q in sus_questions:
            self.add_likert_scale(q, 5)
    
    def calculate_sus_score(self, responses: List[int]) -> float:
        """Calculate SUS score from responses"""
        if len(responses) != 10:
            return 0
        
        score = 0
        for i, response in enumerate(responses):
            if i % 2 == 0:  # Odd questions (1, 3, 5, 7, 9)
                score += response - 1
            else:  # Even questions (2, 4, 6, 8, 10)
                score += 5 - response
        
        return score * 2.5  # Scale to 0-100

class ABTestAnalyzer:
    """Analyze A/B test results"""
    
    def __init__(self) -> None:
        self.variants: Dict[str, List[float]] = {}
        
    def add_variant(self, name: str, data: List[float]) -> None:
        """Add variant data"""
        self.variants[name] = data
    
    def calculate_significance(self, variant_a: str, variant_b: str, 
                              confidence: float = 0.95) -> Dict[str, Any]:
        """Calculate statistical significance"""
        import scipy.stats as stats
        
        data_a = self.variants.get(variant_a, [])
        data_b = self.variants.get(variant_b, [])
        
        if not data_a or not data_b:
            return {'significant': False, 'message': 'Insufficient data'}
        
        # Perform t-test
        t_stat, p_value = stats.ttest_ind(data_a, data_b)
        
        # Calculate effect size (Cohen's d)
        mean_a = statistics.mean(data_a)
        mean_b = statistics.mean(data_b)
        std_a = statistics.stdev(data_a) if len(data_a) > 1 else 0
        std_b = statistics.stdev(data_b) if len(data_b) > 1 else 0
        pooled_std = ((std_a ** 2 + std_b ** 2) / 2) ** 0.5
        effect_size = (mean_a - mean_b) / pooled_std if pooled_std > 0 else 0
        
        return {
            'significant': p_value < (1 - confidence),
            'p_value': p_value,
            'effect_size': effect_size,
            'mean_a': mean_a,
            'mean_b': mean_b,
            'winner': variant_a if mean_a > mean_b else variant_b
        }

Best Practices

⚡ Playtesting Tips

Key Takeaways

Congratulations!

🎉 You've Completed Section 2: Game Polish & Feel!

You've mastered the art of game polish, from screen shake to playtesting! Your games will now feel professional and polished!

Section Summary:

Continue to the next section to learn about multiplayer and networking!

đŸ‹ī¸â€â™‚ī¸ Practice Exercise

đŸ‹ī¸â€â™‚ī¸ Exercise 1: Three Channels of Playtest Instrumentation — Heatmap, Path, and Event Log on One Session

Objective: Build a runnable pygame mini-test (~90 lines) that runs the three pillar instrumentation channels from this lesson side by side on a single play session: a spatial heatmap aggregated into a coarse grid, a temporal path preserving move order, and a semantic event log capturing meaningful tasks. The player navigates a 3-target task in sequence; all three channels record the same play through three different lenses, with overlays toggleable per channel so the orthogonality is visible. A simple pain_points() call surfaces loitering hotspots from the heatmap and repeated-attempt counts from the event log — the two-signal union the lesson’s identify_pain_points implements.

Instructions:

  1. Create a Session class with three orthogonal data structures: heatmap = defaultdict(int) keyed by (cell_x, cell_y) grid coords (40-pixel buckets) holding visit counts, path = [] as a list of (x, y, t) tuples preserving temporal order, and events = [] as a list of (t, event_type, data) semantic records. None of the three is reducible to the others — the heatmap answers WHERE, the path answers HOW, the event log answers WHAT and WHEN.
  2. Per-frame tick(x, y) updates two channels: increment heatmap[(x // 40, y // 40)] every frame (spatial aggregation) and append (x, y, t) to path roughly every 10 frames (temporal sample, kept sparse enough to avoid memory blowup over a long session).
  3. Per-action log(event_type, data) appends to events only on meaningful actions: session_start, task_complete, session_complete. Events are semantic, not per-frame — logging every frame would collapse the channel into a path-clone and lose the meaning-distinguishing property.
  4. Build a 3-target task: three pygame.Rect targets at (700, 50), (50, 400), (370, 210). Player (20×20 green rect, WASD/arrows at 220 px/s, clamp_ip to screen) must visit them in sequence. On player.colliderect(targets[current_target]), log task_complete and advance current_target. The active target is highlighted yellow; completed targets dim green; future targets dark gray.
  5. Implement pain_points(threshold=20): two-signal union. Signal A (spatial): cells in heatmap with count > threshold are loitering hotspots (the player got stuck somewhere). Signal B (semantic): count 'task_attempt' events — repeated attempts on the same task signal “the design fails to communicate the goal.” Either signal alone misses failure modes the other catches; the union is the discipline.
  6. Wire three overlay toggles: H toggles the heatmap (semi-transparent red squares scaled by visit-count intensity), P toggles the path (cyan polyline through all (x, y) samples in order), E toggles the event log (small marks rendered at top of screen). R resets the session. Three overlays, three orthogonal lenses on the same play.
  7. Render a HUD: controls; task progress (current_target/3 + active target name); per-channel sample counts (len(heatmap) cells, len(path) samples, len(events) events); pain-point summary (len(hotspots) loitering hotspots above threshold). Compare counts: a 60-second session typically logs ~3600 heatmap ticks (60 fps), ~360 path samples (10x sparse), and ~5 events (only the meaningful ones) — three orders of magnitude apart, by design.
💡 Hint

The three channels intentionally sample at different rates because they answer questions of different granularity. The heatmap is per-frame because spatial coverage is densest when sampled at simulation rate — sparse sampling would miss short visits to small regions. The path is sub-sampled (every ~10 frames) because the temporal lens cares about the shape of the trajectory, not its sub-pixel precision — storing every frame produces a list that’s 10x larger with no analytical gain. The event log is sparse because semantic events are intrinsically rare — most frames are not interesting, and logging every frame would collapse the channel into a path-clone. For pain-points, the threshold (20 visits per cell at 60 fps means > 0.33 seconds of stationary loitering) is the knob: lower thresholds catch slight pauses (false positives); higher thresholds only catch true stuck-states (false negatives). Same instrumentation discipline as chat-47 M2 level_design’s validate() — design-time validation catches structural failure, runtime instrumentation catches behavioral failure; together they cover both axes. For overlay rendering, draw heatmap first (background), then path (mid-layer), then targets and player (foreground) — the same back-to-front Painter’s algorithm as chat-47 M1 platformer_parallax’s layered rendering.

✅ Example Solution
import pygame
from collections import defaultdict

class Session:
    """Three orthogonal instrumentation channels: spatial / temporal / semantic."""
    def __init__(self):
        self.heatmap = defaultdict(int)   # (cx, cy) -> count           [SPATIAL]
        self.path = []                    # list of (x, y, t) tuples    [TEMPORAL]
        self.events = []                  # list of (t, type, data)     [SEMANTIC]
        self.start = pygame.time.get_ticks() / 1000.0
        self._frame = 0

    def tick(self, x, y):
        # spatial: per-frame grid aggregation in 40px buckets
        self.heatmap[(x // 40, y // 40)] += 1
        # temporal: 10x sub-sampled trajectory preserving order
        self._frame += 1
        if self._frame % 10 == 0:
            self.path.append((x, y, pygame.time.get_ticks() / 1000.0 - self.start))

    def log(self, event_type, data):
        # semantic: only meaningful actions
        self.events.append((pygame.time.get_ticks() / 1000.0 - self.start, event_type, data))

    def pain_points(self, threshold=20):
        # two-signal union: spatial loitering hotspots + behavioral repeated attempts
        hotspots = [(c, n) for c, n in self.heatmap.items() if n > threshold]
        repeated = sum(1 for e in self.events if e[1] == 'task_attempt')
        return hotspots, repeated

pygame.init()
SCR = pygame.display.set_mode((800, 480))
clock, font = pygame.time.Clock(), pygame.font.Font(None, 16)
session = Session()
session.log('session_start', {})
player = pygame.Rect(50, 50, 20, 20)
targets = [pygame.Rect(700, 50, 60, 60),
           pygame.Rect(50, 400, 60, 60),
           pygame.Rect(370, 210, 60, 60)]
names = ['T1: top-right', 'T2: bottom-left', 'T3: center']
current = 0
overlays = {'heatmap': True, 'path': True, 'events': True}

run = True
while run:
    dt = clock.tick(60) / 1000.0
    for ev in pygame.event.get():
        if ev.type == pygame.QUIT: run = False
        elif ev.type == pygame.KEYDOWN:
            if ev.key == pygame.K_h: overlays['heatmap'] = not overlays['heatmap']
            if ev.key == pygame.K_p: overlays['path']    = not overlays['path']
            if ev.key == pygame.K_e: overlays['events']  = not overlays['events']
            if ev.key == pygame.K_r:
                session = Session(); session.log('session_start', {}); current = 0
    keys = pygame.key.get_pressed()
    if keys[pygame.K_LEFT]  or keys[pygame.K_a]: player.x -= int(220 * dt)
    if keys[pygame.K_RIGHT] or keys[pygame.K_d]: player.x += int(220 * dt)
    if keys[pygame.K_UP]    or keys[pygame.K_w]: player.y -= int(220 * dt)
    if keys[pygame.K_DOWN]  or keys[pygame.K_s]: player.y += int(220 * dt)
    player.clamp_ip(SCR.get_rect())

    session.tick(player.centerx, player.centery)
    if current < 3 and player.colliderect(targets[current]):
        session.log('task_complete', {'task': names[current]})
        current += 1
        if current == 3:
            session.log('session_complete', {'duration': session.events[-1][0]})

    SCR.fill((20, 20, 28))
    if overlays['heatmap'] and session.heatmap:
        mx = max(session.heatmap.values())
        for (cx, cy), n in session.heatmap.items():
            a = int(min(255, n / mx * 200))
            s = pygame.Surface((40, 40), pygame.SRCALPHA); s.fill((220, 60, 60, a))
            SCR.blit(s, (cx * 40, cy * 40))
    if overlays['path'] and len(session.path) > 1:
        pygame.draw.lines(SCR, (80, 200, 240), False, [(p[0], p[1]) for p in session.path], 2)
    for i, t in enumerate(targets):
        c = (220, 220, 80) if i == current else (40, 120, 40) if i < current else (60, 80, 60)
        pygame.draw.rect(SCR, c, t, 3)
        SCR.blit(font.render(names[i], True, (220, 220, 220)), (t.x, t.y - 16))
    if overlays['events']:
        for j, e in enumerate(session.events[-15:]):
            pygame.draw.circle(SCR, (255, 220, 80), (15 + j * 16, 460), 4)
    pygame.draw.rect(SCR, (80, 220, 100), player)

    hotspots, repeated = session.pain_points(threshold=20)
    active = names[current] if current < 3 else 'DONE'
    lines = [
        "WASD/arrows = move | H/P/E = toggle heatmap/path/events | R = reset",
        f"target progress: {current}/3   active: {active}",
        f"channel A (spatial heatmap): {len(session.heatmap)} cells visited",
        f"channel B (temporal path):   {len(session.path)} samples",
        f"channel C (semantic events): {len(session.events)} events",
        f"pain-points: {len(hotspots)} loitering hotspots (>20 visits)   repeated_attempts: {repeated}",
    ]
    for i, ln in enumerate(lines):
        SCR.blit(font.render(ln, True, (240, 240, 240)), (10, 8 + i * 18))
    pygame.display.flip()
pygame.quit()

đŸŽ¯ Quick Quiz

Question 1: The lesson’s PlaytestManager keeps three separate data structures: heatmap_data (a dict keyed by f"{grid_x},{grid_y}" with count values), path_data (a per-tester list of (x, y, time) tuples), and events (a list of TestEvent records with timestamp, event_type, tester_id, data). Why three structures instead of one unified store?

Question 2: The lesson’s identify_pain_points walks two orthogonal signals: spatial error clusters (error_locations keyed by (x, y) with threshold count > len(testers) * 0.3) AND behavioral repeated-attempts (testers attempting the same task multiple times, detected via len(testers) > len(set(testers))). Why both signals instead of just one?

Question 3: The lesson’s ABTestAnalyzer.calculate_significance returns BOTH a p_value (from scipy.stats.ttest_ind) AND an effect_size (Cohen’s d, computed as (mean_a - mean_b) / pooled_std). Why are both required, instead of just checking p_value < 0.05?