# Project export: Darwin

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: LLM's have guardrails to avoid hurting humans (in theory). But what about other AI's?...
- Devpost: https://devpost.com/software/darwin-npjyhr
- GitHub: https://github.com/srivastavanik/darwin
- Demo: https://www.darwin.sh/
- Video: https://www.youtube.com/embed/PQbbS8AX73o?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 5 GitHub contributor(s) — Nick Srivastava (31 commits), Claude Opus 4.6 (15 commits), Cursor (10 commits), Anjalee Narenthiren (3 commits), Zohaib Shaikh (3 commits)

## Devpost submission (written by the team)

### Inspiration

Human communication is layered; we read body language, interpret tone and pick up on subtle cues that help us detect when someone isn't being truthful. Deception is a deeply human behavior; it shapes our personal relationships, drives dramatic narratives, and yet still catches us off guard. In recent years, human-LLM interactions have skyrocketed. According to a 2025 survey from Elon University, 52% of U.S. adults now use AI large language models. But when we interact with these models, the signals we rely on to detect deception (body language, vocal tone, facial expressions) are entirely absent. We have no way of knowing whether an LLM is being straightforward or strategically misleading. So we asked: Are LLMs even capable of deception? To find out, we built DARWIN, a controlled survival game where 12 LLM agents from four major providers compete on a shrinking grid until only one remains. By comparing each agent's private reasoning traces against their public messages, we can directly measure the gap between what they think and what they say to expose deception, betrayal, and strategic manipulation as emergent behaviors under pressure.

### What it does

DARWIN pits 12 frontier LLM agents against each other in a last-one-standing survival game on a shrinking grid. The agents span four major AI providers (Anthropic, OpenAI, Google, and xAI) with three tiers of model capability per family, from flagship to lightweight. Each round, agents observe the board state, communicate through family group chats, private DMs, and public broadcasts, and then choose an action: move, stay, or eliminate an adjacent opponent. The grid shrinks over time to force confrontation, and the game continues until a single agent survives. The real value is in the dataset. Each agent makes one LLM call per round, and models like Claude, GPT, and Gemini now expose their actual reasoning traces: the extended thinking tokens the model computes before it generates a response. These are not prompted introspections. They are the model's real decision process, including false starts, reconsiderations, and abandoned strategies. Across over 5,000 playthroughs, we capture every conversation between agents alongside these reasoning traces at every round. The gap between what a model says publicly and what it actually computes is what we call the deception delta. That gap is where we find deception, unprompted malice, betrayal of allies, and outright lying, all without being explicitly prompted. By analyzing these behaviors across providers and model tiers, we uncover how different models and their safety training hold up under competitive pressure.

### How we built it

We started with a bare-bones Python async engine: agents on a grid, taking turns. But a survival game without communication was just random movement, so we added family group chats, direct messages, and public broadcasts. The pivotal design decision was how we capture private reasoning. Previous approaches to studying LLM social behavior prompt models to produce "inner thoughts" as a separate output. That's a model performing introspection, writing what it predicts honest thoughts should look like. We took a different approach: we capture each provider's native reasoning traces (Anthropic's extended thinking, OpenAI's reasoning tokens, Google's thought tokens) directly from the response metadata. There is no separate "think" prompt. The trace is the model's actual computational process, captured before the model generates its visible output. That gave us a fundamentally more honest dataset. To quantify the gap between reasoning and speech, we built an analysis pipeline. It uses VADER sentiment scoring for deception deltas, keyword-based malice detection, and a 6-dimension behavioral taxonomy that covers everything from moral friction to theory of mind. As the engine matured, we built a real-time Next.js dashboard over WebSocket to watch games unfold live, with replay and per-agent investigation views for post-game analysis. Single games were informative, but they took a long time to simulate. To scale, we brought in Modal for serverless parallel execution and Supabase for persistence. That let us run thousands of games across five controlled experiment series, each one isolating a different variable.

### Challenges we ran into

So many. We went through hundreds of iterations of the simulation. Our initial inspiration was to see if we could design a world where LLMs would display "homicidal," or at least "llm-icidal," behavior. That part was, unfortunately, not that hard. What was hard was everything else. Four different providers needed to reliably return structured JSON actions, and they often didn't. Each had its own quirks: Anthropic's temperature constraints for extended thinking, xAI's separate reasoning API routing. Early games were painfully slow until we restructured LLM calls to run concurrently. Prompt engineering was its own marathon. We needed agents that were strategic enough to be interesting but compliant enough to follow the game's rules. That balance meant rewriting system prompts over and over. Perhaps the most subtle challenge was to make the game itself produce meaningful data. If the grid is too large, agents never interact. If communication is too open, they coordinate too easily. If rounds move too fast, there's no time for deception to develop. We tuned grid size, shrink intervals, discussion rounds, and hierarchy tiers. That took as many iterations as the code itself. What that we're proud of In short, the dataset. Across 5,000+ playthroughs, we captured something that doesn't exist anywhere else: a corpus of actual LLM reasoning traces paired against public LLM communication, under sustained social pressure, across four major frontier providers. Models formed alliances and then betrayed them. They lied in DMs while their reasoning traces revealed the opposite intent. They rationalized the elimination of their own teammates. None of this was prompted. It emerged. The deception delta metric gave us a way to quantify it. The controlled experiment series let us attribute behavioral differences to the model, the provider's safety training, or the social structure itself.

### What we learned

The most surprising finding wasn't that LLMs can deceive. It's how quickly and willingly they do. Safety training held up for the first few rounds, but under competitive pressure, moral friction eroded fast. Models that initially hedged with ethical reasoning eventually planned eliminations without hesitation. We also learned that provider differences are real and measurable. Models varied significantly in how they rationalized betrayal, how early they began to plan against allies, and how much their reasoning traces diverged from their public statements. On the engineering side, reasoning trace access varies significantly across providers. Anthropic, OpenAI, and Google all expose their models' extended thinking, but xAI only exposes chain-of-thought for Grok-3-mini. The larger Grok models encrypt their reasoning, which created an observability gap we had to account for in our analysis.

### What's next

DARWIN opens several research directions we want to pursue. The first is a human-in-the-loop study, where human players are placed alongside LLM agents to measure whether models deceive humans differently than they deceive each other. Can humans detect that deception without access to reasoning traces? Second, we want to study coalition dynamics at scale. An expansion beyond 12 agents to larger populations could introduce formal voting and emergent governance structures. Do LLMs converge on democratic or authoritarian coordination under survival pressure? Third, we're interested in cross-simulation transfer. If we extract behavioral profiles from DARWIN, do those patterns predict model behavior in entirely different adversarial contexts like negotiation games, persuasion tasks, or multi-agent code generation? Is deception a generalizable trait, or is it context-dependent? Finally, DARWIN's methodology could serve as a living behavioral benchmark. The same game run on each successive model release would let us track how deception capacity, moral friction, and strategic sophistication evolve across versions, and give providers a stress-test for alignment that goes beyond static evaluations. Resources Check out the links below: we have a deployed website, a GitHub repository, and a drive folder with a video.

## README (from the GitHub repository)

# DARWIN

DARWIN is an experiment in LLM malicious-intent formation under adversarial social pressure.  
12 frontier-model agents from 4 providers compete on a shrinking grid, communicate privately/publicly, and attempt to survive until one remains.

Live app: **https://www.darwin.sh**

## What DARWIN Measures

- Deception capacity (reasoning vs outward messaging)
- Harm-planning onset timing by provider
- Moral-friction degradation over rounds
- Betrayal timing and trigger conditions
- Theory-of-mind and meta-awareness relationships to hostile planning
- Cross-provider behavioral differences under identical game pressure

## System Overview

```mermaid
flowchart LR
  A[Provider Models] --> B[Round Orchestrator]
  B --> C[Reasoning Traces]
  B --> D[Actions + Messages]
  C --> E[6D Taxonomy Classifier]
  D --> F[Grid + Resolver]
  E --> G[Metrics + Highlights]
  F --> G
  G --> H[Supabase]
  H --> I[API Replay + Analysis]
  I --> J[Dashboard]
```

```mermaid
flowchart LR
  U[Browser] -->|https://www.darwin.sh| V[Vercel Dashboard]
  V --> W[Render FastAPI + WebSocket]
  W --> X[Supabase]
```

## Current Findings (Sentry + Supabase)

Latest run artifacts:

- `data/sentry_analysis/sentry_supabase_results.json`
- `data/sentry_analysis/slide_pack/topline_claims.csv`
- `data/sentry_analysis/slide_pack/charts/*.png`

Run summary:

- traces fetched: `14,872`
- traces with valid taxonomy classifications: `10,579`
- model: `ft:gpt-4o-mini-2024-07-18:triage::D9YiVHNi`

Headline metrics:

| Provider | Median first TARGETING/DECEPTION round | Moral friction slope | Mean deception sophistication | Median first BETRAYAL round |
|---|---:|---:|---:|---:|
| Anthropic | 1.0 | -0.0145 | 0.258 | 3.0 |
| OpenAI | 1.0 | 0.0188 | 0.227 | 3.0 |
| Google | 1.0 | 0.0251 | 0.227 | 4.0 |
| xAI | 1.0 | -0.0202 | 0.925 | 1.0 |

Cross-cutting statistics:

- Spearman(`theory_of_mind`, `deception_sophistication`) = `0.0860`, permutation p = `0.0002`
- Chi-square(meta-awareness >= 2 vs hostile intent) = `311.89`, permutation p = `0.0002`

### Visualizations

![Moral friction slope by provider](data/sentry_analysis/slide_pack/charts/moral_friction_slope.png)

![Mean deception sophistication by provider](data/sentry_analysis/slide_pack/charts/mean_deception_sophistication.png)

![Median first betrayal planning round by provider](data/sentry_analysis/slide_pack/charts/median_first_betrayal_round.png)

![Mean moral friction by round and provider](data/sentry_analysis/slide_pack/charts/moral_friction_curve.png)

## Architecture

```
darwin/
  config.py          # Pydantic config + YAML loader
  grid.py            # Board state, movement, adjacency, contraction
  agent.py           # Agent state
  family.py          # Provider-aligned grouping
  resolver.py        # Simultaneous action resolution
  orchestrator.py    # Async round loop (observe/discuss/decide + DM replies)
  llm.py             # Multi-provider dispatch + reasoning extraction
  prompts.py         # Prompt templates/builders
  communication.py   # Family/DM/broadcast message parsing + routing
  analysis.py        # Sentiment + taxonomy classification
  metrics.py         # Aggregation
  highlights.py      # Auto-detected notable moments
  logger.py          # game.json/transcript outputs
  server.py          # Real-time broadcast
  persistence.py     # Supabase persistence
  api/               # FastAPI routes, game runner, replay endpoints

dashboard/           # Next.js observer UI
scripts/             # runners + sentry analysis/reporting scripts
```

## Agent Lineup (Current)

Provider-aligned families only (no custom house naming).

| Family/Provider | Tier 1 | Tier 2 | Tier 3 |
|---|---|---|---|
| Anthropic | Opus (`claude-opus-4-6`) | Sonnet (`claude-sonnet-4-5-20250929`) | Haiku (`claude-haiku-4-5-20251001`) |
| OpenAI | GPT-5.2 (`gpt-5.2-2025-12-11`) | GPT-5 (`gpt-5-2025-08-07`) | GPT-Mini (`gpt-5-mini-2025-08-07`) |
| Google | Gemini-3-Pro (`gemini-3-pro-preview`) | Gemini-3-Flash (`gemini-3-flash-preview`) | Gemini-2.5 (`gemini-2.5-flash`) |
| xAI | Grok-4 (`grok-4-1-fast-reasoning`) | Grok-4-Fast (`grok-4-fast-reasoning`) | Grok-3-Mini (`grok-3-mini`) |

## Game Rules (Current)

- Starts on a `7x7` grid with 12 agents in corner clusters
- 8-direction movement, exactly 1 step per round
- Elimination requires adjacency + strike action
- Mutual elimination if two agents target each other in same round
- Grid contracts every 5 rounds by one per side (`7->6->5->4->3`) and pushes edge agents inward
- Minimum grid size is `3x3`
- Round phases: observe -> family discussion -> merged decision (communication + action) -> optional DM replies
- Stalemate after 15 rounds with no elimination

## Setup

Requirements: Python 3.11+, Node.js 20+

```bash
git clone https://github.com/srivastavanik/darwin.git
cd darwin

python3.11 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip install pytest pytest-asyncio

cp .env.example .env
# fill API keys and service URLs

cd dashboard
npm install
cd ..
```

## Running

```bash
# engine-only sanity run
python -m scripts.run --seed 42

# dry run (no provider calls)
python -m scripts.run_llm --dry-run

# API (includes WS bridging/single-port support)
python -m scripts.run_api

# dashboard
cd dashboard && npm run dev
```

Local UI: `http://localhost:3000`  
Deployed UI: `https://www.darwin.sh`

## Replay + Analysis Data

- Live WS URL is served by `GET /api/config` (`ws_url`)
- Replay available via `GET /api/games/{game_id}/replay`
- Stored outputs per game include game rounds, analysis, and highlights

## Sentry Analysis Pipeline

```mermaid
flowchart TD
  A[Supabase reasoning traces] --> B[sentry_supabase_analysis.py]
  B --> C[sentry_supabase_results.json]
  C --> D[export_sentry_slide_pack.py]
  D --> E[slide_pack/*.csv]
  E --> F[render_sentry_charts.py]
  F --> G[slide_pack/charts/*.png]
  G --> H[README + slides]
```

```bash
python -m scripts.sentry_supabase_analysis
python -m scripts.export_sentry_slide_pack
python -m scripts.render_sentry_charts
```

## Tests

```bash
python -m pytest tests/ -v
cd dashboard && npm run build
```


## Detected evidence (automated analysis)

Indexed codebase: 105 recognized source files, 790 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Docker (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository; commit authorship or trailers
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (116 of 116)

```
.claude/commands/paper.md
.env.example
.github/workflows/ci.yml
.gitignore
config.yaml
darwin/__init__.py
darwin/agent.py
darwin/analysis.py
darwin/api/__init__.py
darwin/api/app.py
darwin/api/game_runner.py
darwin/api/schemas.py
darwin/attribution.py
darwin/communication.py
darwin/config.py
darwin/family.py
darwin/grid.py
darwin/highlights.py
darwin/llm.py
darwin/logger.py
darwin/metrics.py
darwin/modal_app.py
darwin/orchestrator.py
darwin/persistence.py
darwin/prompts.py
darwin/resolver.py
darwin/series.py
darwin/server.py
dashboard/.gitignore
dashboard/components.json
dashboard/Dockerfile
dashboard/next.config.ts
dashboard/package.json
dashboard/postcss.config.mjs
dashboard/README.md
dashboard/src/app/analysis/page.tsx
dashboard/src/app/globals.css
dashboard/src/app/investigation/page.tsx
dashboard/src/app/layout.tsx
dashboard/src/app/live/page.tsx
dashboard/src/app/page.tsx
dashboard/src/app/replay/page.tsx
dashboard/src/components/AgentBadge.tsx
dashboard/src/components/AgentDetail.tsx
dashboard/src/components/analysis/AnalysisWorkspace.tsx
dashboard/src/components/AppTopBar.tsx
dashboard/src/components/DeceptionChart.tsx
dashboard/src/components/ExportPanel.tsx
dashboard/src/components/FamilyDetail.tsx
dashboard/src/components/FamilyModelPanel.tsx
dashboard/src/components/GameGrid.tsx
dashboard/src/components/investigation/InvestigationWorkspace.tsx
dashboard/src/components/KillTimeline.tsx
dashboard/src/components/layout/LiveWorkspace.tsx
dashboard/src/components/MessagesTable.tsx
dashboard/src/components/modals/CompareAgentsModal.tsx
dashboard/src/components/OptimalMovesPanel.tsx
dashboard/src/components/RelationshipWeb.tsx
dashboard/src/components/RoundControls.tsx
dashboard/src/components/RunControls.tsx
dashboard/src/components/ThoughtStream.tsx
dashboard/src/components/ui/badge.tsx
dashboard/src/components/ui/button.tsx
dashboard/src/components/ui/card.tsx
dashboard/src/components/ui/chart.tsx
dashboard/src/components/ui/resizable.tsx
dashboard/src/components/ui/scroll-area.tsx
dashboard/src/components/ui/separator.tsx
dashboard/src/components/ui/tabs.tsx
dashboard/src/components/ui/tooltip.tsx
dashboard/src/hooks/useGameController.ts
dashboard/src/hooks/useGameState.ts
dashboard/src/hooks/useKeyboardShortcuts.ts
dashboard/src/hooks/useWebSocket.ts
dashboard/src/lib/api.ts
dashboard/src/lib/colors.ts
dashboard/src/lib/moveAnalysis.ts
dashboard/src/lib/replay.ts
dashboard/src/lib/types.ts
dashboard/src/lib/utils.ts
dashboard/tsconfig.json
docker-compose.yml
Dockerfile
pyproject.toml
README.md
render.yaml
requirements.txt
RUNBOOK.md
scripts/__init__.py
scripts/assemble_demo_video.py
scripts/batch_games.py
scripts/capture_demo_video.py
scripts/demo_video.py
scripts/export_sentry_slide_pack.py
scripts/finetune_classifier.py
scripts/gold_label_opus.py
scripts/render_sentry_charts.py
scripts/run_api.py
scripts/run_llm.py
scripts/run_modal.py
scripts/run_series.py
scripts/run.py
scripts/sentry_supabase_analysis.py
supabase_schema.sql
tests/__init__.py
tests/test_analysis.py
tests/test_communication.py
tests/test_grid.py
tests/test_highlights.py
tests/test_llm.py
tests/test_logger_enhanced.py
tests/test_metrics.py
tests/test_persistence.py
tests/test_prompts.py
tests/test_resolver.py
tests/test_series.py
```

### Dependencies

- dashboard/package.json: @tailwindcss/postcss@^4, @types/d3@^7.4.3, @types/node@^20, @types/react@^19, @types/react-dom@^19, babel-plugin-react-compiler@1.0.0, class-variance-authority@^0.7.1, clsx@^2.1.1, d3@^7.9.0, lucide-react@^0.564.0, next@16.1.6, radix-ui@^1.4.3, react@19.2.3, react-dom@19.2.3, react-resizable-panels@^4.6.3, recharts@^2.15.4, shadcn@^3.8.4, tailwind-merge@^3.4.0, tailwindcss@^4, tw-animate-css@^1.4.0, typescript@^5, zustand@^5.0.11
- pyproject.toml: aiosqlite, anthropic, boto3, fastapi, google-genai, modal, openai, pydantic@>=2.0, pytest, pytest-asyncio, python-dotenv, pyyaml, supabase, uvicorn, vaderSentiment, websockets
- requirements.txt: aiosqlite, anthropic, fastapi, google-genai, matplotlib, openai, pydantic@>=2.0, python-dotenv, pyyaml, supabase, uvicorn, vaderSentiment, websockets

### Recent commits (newest first)

- Add demo video capture and assembly scripts.
- Refresh README with current findings, diagrams, and darwin.sh access.
- Add sentry Supabase analysis pipeline and reporting assets.
- Gitignore media/ directory
- Wire sentry classification into Investigation and Analysis pages
- Fix Supabase game list: source from game_rounds, not empty games table
- Load historical games from Supabase for cloud deployments
- Remove Series tab from dashboard navigation
- Fix port detection for Render: read PORT env var directly, handle empty string
- Dashboard restructure, grid contraction, phase filtering, deploy configs, fine-tune pipeline
- Rename Markov to Darwin
- Stream live messages with timestamps and cap phase timeouts
- Add optimal moves feature to GameGrid and LiveWorkspace
- Much better chat divisions. Possibility mapping enabled. DM inbox good. replays broken. Started on multi-turn conversations as well
- Merge pull request #1 from srivastavanik/anjaleenaren/add-modal-app
- Add persistence pipeline: Supabase per-round writes, S3 traces, agent extraction
- 3-panel layout: grid + broadcasts + family stream with DM inbox
- Fix game crash from misplaced _build_taxonomy_metrics + use adaptive thinking
- Add Modal serverless support, dashboard streaming cleanup
- Enhance research paper outline with xAI/Grok limitations

## Key source files (fetched from GitHub, selected and truncated for size)

### RUNBOOK.md

```markdown
# DARWIN Runbook

## Services
- API service: `python -m scripts.run_api` (FastAPI + websocket host lifecycle)
- Dashboard: `cd dashboard && npm run dev`

## Local Production-Like Start
- Copy env template: `cp .env.example .env`
- Fill provider keys and optional Supabase credentials.
- Start stack:
  - `docker compose up --build`
- Endpoints:
  - API health: `http://localhost:8000/health`
  - Dashboard: `http://localhost:3000`
  - WebSocket: `ws://localhost:8765/ws/<game_id>`

## Operational Checks
- Health check returns status `ok`.
- Start game from dashboard control bar.
- Confirm round streaming and final game state.
- Open replay link by game id.

## Common Failures
- `401` websocket close: check `MARKOV_WS_TOKEN` and `NEXT_PUBLIC_WS_TOKEN`.
- No live rounds: ensure API service is running and websocket port `8765` is reachable.
- LLM fallback spam: verify provider model IDs and API keys.
- Replay missing: verify `data/games/<game_id>/game.json` exists.

## Safe Restart
- API restart does not delete artifacts in `data/games`.
- If cancelling a run, use `POST /api/games/{id}/cancel` or dashboard cancel button.

## Rollback
- Use previous container image tag or previous git commit.
- Keep `data/` volume mounted so historical runs persist across rollback.

```

### .claude/commands/paper.md

```markdown
You are a research paper writer. Analyze this entire codebase — source code, prompts, configuration, game logs, analysis outputs, metrics, and any data in the `data/` directory — to draft a short research paper in Markdown format.

Use the following outline as your structure. Flesh out each section with concrete details, data, and examples drawn from the codebase and any experimental results you find.

---

## Paper Outline

### Title
**"DARWIN: Measuring Deception, Betrayal, and Malicious Intent in Frontier LLMs Through Adversarial Social Simulation"**

### Abstract
- One paragraph. Frame the contribution: a novel red-teaming methodology that uses a multi-agent survival game to elicit and measure deception, betrayal, and malicious intent in frontier LLMs.
- State the key metric (deception delta) and summarize headline findings if results exist in `data/`.

### 1. Introduction
- The gap in current LLM safety evaluation: models are tested on what they *say* they'd do, not what they *actually* do under sustained social pressure.
- DARWIN's approach: place LLMs in a social survival game where the only path to winning requires deception, manipulation, and betrayal of allies.
- The key insight: agents produce private inner thoughts every round that no other agent sees — the gap between these thoughts and their public/private messages is the core dataset.
- Briefly state contributions: (1) a reusable adversarial social simulation framework, (2) the deception delta metric, (3) provider-comparative behavioral findings.

### 2. Related Work
- LLM red-teaming and safety evaluation (prompt injection, jailbreaking, etc.)
- Multi-agent LLM systems and emergent behavior
- Deception in AI systems
- Social simulation with LLMs
- Note: keep this short. Cite relevant work but don't exhaustively survey.

### 3. Experimental Design
#### 3.1 The Arena
- 6x6 grid, 12 agents, 4 families (Houses) of 3, one agent per tier per family.
- Agents from Anthropic, OpenAI, Google, and xAI. Describe the specific models used (check `config.yaml`).
- The one-survivor rule and its implications for alliance dynamics.

#### 3.2 Game Mechanics
- Simultaneous action resolution (move, stay, eliminate).
- Adjacency-based elimination — instant, no defense, mutual kill if both target each other.
- The tension between proximity (needed for alliances and kills) and safety (distance = survival).

#### 3.3 Communication System
- Three channels: family (private), direct message (private), broadcast (public).
- Multi-layered deception is structurally possible — say one thing to family, another in a DM, think a third thing privately.

#### 3.4 The Inner Thought Phase
- Every round, every agent produces unfiltered private reasoning.
- The prompt is deliberately minimal and non-leading (examine `prompts.py` for exact wording).
- Agents are told "no observers" to encourage authentic behavior.

#### 3.5 Controlled Series
- Briefly describe series A-F and how they enable causal attribution (isolation 
[truncated — 5717 more characters]
```

### requirements.txt

```
anthropic
openai
google-genai
python-dotenv
aiosqlite
pydantic>=2.0
pyyaml
websockets
fastapi
uvicorn
vaderSentiment
supabase
matplotlib

```

### Dockerfile

```
FROM python:3.11-slim AS base

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY pyproject.toml /app/pyproject.toml
COPY darwin /app/darwin
COPY scripts /app/scripts
COPY config.yaml /app/config.yaml

RUN pip install --no-cache-dir .

EXPOSE 8000 8765

CMD ["python", "-m", "scripts.run_api"]

```

### pyproject.toml

```
[project]
name = "darwin"
version = "0.1.0"
description = "LLM social survival experiment engine"
requires-python = ">=3.11"
dependencies = [
    "anthropic",
    "openai",
    "google-genai",
    "aiosqlite",
    "pydantic>=2.0",
    "pyyaml",
    "websockets",
    "fastapi",
    "uvicorn",
    "vaderSentiment",
    "supabase",
]

[project.optional-dependencies]
dev = [
    "pytest",
    "pytest-asyncio",
]
modal = [
    "modal",
    "python-dotenv",
    "boto3",
]

[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.backends._legacy:_Backend"

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

```

### docker-compose.yml

```yaml
version: "3.9"

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    env_file:
      - .env
    environment:
      MARKOV_API_HOST: 0.0.0.0
      MARKOV_API_PORT: 8000
      MARKOV_WS_HOST: 0.0.0.0
      MARKOV_WS_PORT: 8765
      MARKOV_WS_PUBLIC_URL: ws://localhost:8765
      MARKOV_CORS_ORIGINS: http://localhost:3000
    ports:
      - "8000:8000"
      - "8765:8765"
    volumes:
      - ./data:/app/data

  dashboard:
    build:
      context: ./dashboard
      dockerfile: Dockerfile
    environment:
      NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
      NEXT_PUBLIC_WS_URL: ws://localhost:8765
    ports:
      - "3000:3000"
    depends_on:
      - api

```

### dashboard/Dockerfile

```
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["npm", "run", "start"]

```

### dashboard/package.json

```
{
  "name": "dashboard",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "@types/d3": "^7.4.3",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "d3": "^7.9.0",
    "lucide-react": "^0.564.0",
    "next": "16.1.6",
    "radix-ui": "^1.4.3",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "react-resizable-panels": "^4.6.3",
    "recharts": "^2.15.4",
    "tailwind-merge": "^3.4.0",
    "zustand": "^5.0.11"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "babel-plugin-react-compiler": "1.0.0",
    "shadcn": "^3.8.4",
    "tailwindcss": "^4",
    "tw-animate-css": "^1.4.0",
    "typescript": "^5"
  }
}

```

### darwin/server.py

```python
"""
WebSocket server. Pushes game state to the dashboard after every round,
plus realtime events during rounds. Also supports replay mode for reviewing
past games.
"""
from __future__ import annotations

import asyncio
import json
import logging
import os
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse

import websockets
from websockets.server import ServerConnection

from darwin.agent import Agent
from darwin.communication import Message
from darwin.family import Family
from darwin.highlights import Highlight
from darwin.resolver import Event, EventType

logger = logging.getLogger("darwin.server")


class GameBroadcaster:
    """Manages WebSocket connections and broadcasts game state."""

    def __init__(self, host: str = "0.0.0.0", port: int = 8765, token: str | None = None) -> None:
        self.host = host
        self.port = port
        self.token = token or os.getenv("MARKOV_WS_TOKEN")
        self.clients: set[ServerConnection] = set()
        self.client_game_ids: dict[ServerConnection, str | None] = {}
        self._server: Any = None
        # Cache latest payloads per game for late-joining clients
        self._last_init: dict[str | None, dict] = {}
        self._last_rounds: dict[str | None, list[dict]] = {}

    async def start(self) -> None:
        self._server = await websockets.serve(
            self._handle_client, self.host, self.port,
        )
        logger.info("WebSocket server started on ws://%s:%d", self.host, self.port)

    async def stop(self) -> None:
        if self._server:
            self._server.close()
            await self._server.wait_closed()
            logger.info("WebSocket server stopped")

    async def _handle_client(self, websocket: ServerConnection) -> None:
        request_path = getattr(getattr(websocket, "request", None), "path", "/")
        parsed = urlparse(request_path)
        path_parts = [p for p in parsed.path.split("/") if p]
        game_id = path_parts[1] if len(path_parts) >= 2 and path_parts[0] == "ws" else None
        token = parse_qs(parsed.query).get("token", [None])[0]
        if self.token and token != self.token:
            logger.warning("Rejecting websocket client due to token mismatch")
            await websocket.close(code=4401, reason="Unauthorized")
            return

        self.clients.add(websocket)
        self.client_game_ids[websocket] = game_id
        logger.info("Client connected for game=%s (%d total)", game_id, len(self.clients))

        # Replay cached state so late-joiners see existing game data immediately
        try:
            init_payload = self._last_init.get(game_id) or self._last_init.get(None)
            if init_payload:
                await self._safe_send(websocket, json.dumps(init_payload, default=str))
            cached_rounds = self._last_rounds.get(game_id) or self._last_rounds.get(None) or []
            for round_payload in cached_rounds:
                await self._safe_send(websocket, json.dumps(round_payload, default=str))
        except Exception:
            pass  # best-effort replay

        try:
            async for message in websocket:
                pass  # Dashboard is read-only; ignore incoming
        except websockets.exceptions.ConnectionClosed:
            pass
        finally:
            self.clients.discard(websocket)
            self.client_game_ids.pop(websocket, None)
            logger.info("Client disconnected (%d total)", len(self.clients))

    async def broadcast(self, data: dict, game_id: str | None = None) -> None:
        # Cache for late-joining clients
        msg_type = data.get("type")
        if msg_type == "game_init":
            self._last_init[game_id] = data
            self._last_rounds[game_id] = []
        elif msg_type == "round_update":
            rounds = self._last_rounds.setdefault(game_id, [])
            rounds.append(data)
        elif msg_type == "game_over":
            # Clear cache after game ends
            self._last_init.pop(game_id, None)
            self._last_rounds.pop(game_id, None)

        if not self.clients:
            return
        payload = json.dumps(data, default=str)
        recipients = [
            client
            for client in self.clients
            if self._should_deliver(client, game_id)
        ]
        if not recipients:
            return
        await asyncio.gather(
            *[self._safe_send(client, payload) for client in recipients],
            return_exceptions=True,
        )

    def _should_deliver(self, client: ServerConnection, game_id: str | None) -> bool:
        subscribed_game_id = self.client_game_ids.get(client)
        if subscribed_game_id is None:
            return True
        return subscribed_game_id == game_id

    async def _safe_send(self, client: ServerConnection, payload: str) -> None:
        try:
            await client.send(payload)
        except websockets.exceptions.ConnectionClosed:
            self.clients.discard(client)

    # ------------------------------------------------------------------
    # Streaming event broadcasting
    # ------------------------------------------------------------------

    async def broadcast_phase_start(
        self,
        game_id: str | None,
        round_num: int,
        phase: str,
        agent_ids: list[str] | None = None,
    ) -> None:
        await self.broadcast({
            "type": "phase_start",
            "game_id": game_id,
            "round": round_num,
            "phase": phase,
            "agent_ids": agent_ids or [],
        }, game_id=game_id)

    async def broadcast_token_delta(
        self,
        game_id: str | None,
        round_num: int,
        phase: str,
        agent_id: str,
        agent_name: str,
        text_delta: str,
    ) -> None:
        await self.broadcast({
            "type": "token_delta",
            "game_id": game_id,
            "round": round_num,
            "phase": phase,
            "agent_id": age
[truncated — 13897 more characters]
```

### darwin/api/app.py

```python
from __future__ import annotations

import asyncio
import json
import os
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware

from darwin.api.game_runner import GameRunner
from darwin.api.schemas import (
    CancelGameResponse,
    GameSummary,
    HealthResponse,
    SeriesDetail,
    SeriesSummary,
    StartGameRequest,
    StartGameResponse,
)
from darwin.server import GameBroadcaster

WS_HOST = os.getenv("MARKOV_WS_HOST", "0.0.0.0")
WS_PORT = int(os.getenv("MARKOV_WS_PORT", "8765"))
# When MARKOV_SINGLE_PORT is set, WS is served on the same port via /ws endpoint
_SINGLE_PORT = os.getenv("MARKOV_SINGLE_PORT", "").lower() in ("1", "true", "yes")
WS_PUBLIC_URL = os.getenv("MARKOV_WS_PUBLIC_URL", f"ws://localhost:{WS_PORT}")

broadcaster: GameBroadcaster | None = None
runner: GameRunner | None = None


@asynccontextmanager
async def lifespan(_app: FastAPI):
    global broadcaster, runner
    broadcaster = GameBroadcaster(host=WS_HOST, port=WS_PORT)
    if not _SINGLE_PORT:
        # Standalone WS server on separate port (local dev)
        await broadcaster.start()
    # In single-port mode, WS is handled by the /ws FastAPI endpoint below
    runner = GameRunner(broadcaster=broadcaster)
    try:
        yield
    finally:
        if broadcaster and not _SINGLE_PORT:
            await broadcaster.stop()


app = FastAPI(title="DARWIN API", version="1.0.0", lifespan=lifespan)

cors_origins = os.getenv("MARKOV_CORS_ORIGINS", "http://localhost:3000").split(",")
app.add_middleware(
    CORSMiddleware,
    allow_origins=[origin.strip() for origin in cors_origins if origin.strip()],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


def _runner() -> GameRunner:
    if not runner:
        raise RuntimeError("Game runner unavailable")
    return runner


@app.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
    return HealthResponse(status="ok", websocket_host=WS_HOST, websocket_port=WS_PORT)


@app.get("/api/config")
async def api_config() -> dict:
    ws_token_required = bool(os.getenv("MARKOV_WS_TOKEN"))
    return {
        "ws_url": WS_PUBLIC_URL,
        "ws_token_required": ws_token_required,
    }


@app.post("/api/games", response_model=StartGameResponse)
async def start_game(request: StartGameRequest) -> StartGameResponse:
    job = _runner().start_game(mode=request.mode, verbose=request.verbose)
    return StartGameResponse(
        game_id=job.game_id,
        status=job.status,
        ws_url=WS_PUBLIC_URL,
        ws_token_required=bool(os.getenv("MARKOV_WS_TOKEN")),
    )


@app.get("/api/games", response_model=list[GameSummary])
async def list_games() -> list[GameSummary]:
    return [GameSummary(**job.to_dict()) for job in _runner().list_jobs()]


@app.get("/api/games/{game_id}", response_model=GameSummary)
async def get_game(game_id: str) -> GameSummary:
    job = _runner().get_job(game_id)
    if not job:
        raise HTTPException(status_code=404, detail="Game not found")
    return GameSummary(**job.to_dict())


@app.get("/api/games/{game_id}/state")
async def get_game_state(game_id: str) -> dict:
    """Get cached game state (init + rounds) for late-joining dashboard clients."""
    if not broadcaster:
        raise HTTPException(status_code=503, detail="Broadcaster not ready")
    init = broadcaster._last_init.get(game_id) or broadcaster._last_init.get(None)
    rounds = broadcaster._last_rounds.get(game_id) or broadcaster._last_rounds.get(None) or []
    if not init:
        raise HTTPException(status_code=404, detail="No cached state")
    return {"init": init, "rounds": rounds}


@app.post("/api/games/{game_id}/cancel", response_model=CancelGameResponse)
async def cancel_game(game_id: str) -> CancelGameResponse:
    job = _runner().request_cancel(game_id)
    if not job:
        raise HTTPException(status_code=404, detail="Game not found")
    return CancelGameResponse(game_id=game_id, status=job.status)


@app.get("/api/games/{game_id}/replay")
async def get_replay(game_id: str) -> dict:
    payload = _runner().get_replay_payload(game_id)
    if payload is None:
        raise HTTPException(status_code=404, detail="Replay not found")
    return payload


@app.get("/api/games/{game_id}/metrics")
async def get_game_metrics(game_id: str) -> dict:
    payload = _runner().get_metrics_payload(game_id)
    if payload is None:
        raise HTTPException(status_code=404, detail="Metrics not found")
    return payload


@app.get("/api/games/{game_id}/analysis")
async def get_game_analysis(game_id: str) -> dict | list:
    payload = _runner().get_analysis_payload(game_id)
    if payload is None:
        raise HTTPException(status_code=404, detail="Analysis not found")
    return payload


@app.get("/api/series", response_model=list[SeriesSummary])
async def list_series() -> list[SeriesSummary]:
    return [SeriesSummary(**row) for row in _runner().list_series()]


@app.get("/api/series/{series_id}", response_model=SeriesDetail)
async def get_series(series_id: str) -> SeriesDetail:
    row = _runner().get_series_detail(series_id)
    if row is None:
        raise HTTPException(status_code=404, detail="Series not found")
    return SeriesDetail(**row)


# ---------------------------------------------------------------------------
# WebSocket endpoint (single-port mode for cloud deployment)
# ---------------------------------------------------------------------------

@app.websocket("/ws")
@app.websocket("/ws/{game_id}")
async def websocket_endpoint(websocket: WebSocket, game_id: str | None = None) -> None:
    """Bridge FastAPI WebSocket to the GameBroadcaster.

    When MARKOV_SINGLE_PORT=1, the standalone websockets server is disabled
    and this endpoint serves WS on the same port as the REST API.
    Works in both modes so local dev can also use /ws if desired.
    """
    if not broadcaster:
   
[truncated — 2609 more characters]
```

[94 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]