You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
mirosociety/docs/plans/2026-03-17-mirosociety-impl...

44 KiB

MiroSociety — Implementation Plan

For Claude / Cursor: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a multi-agent society simulation engine where users define social rules and watch emergent behavior unfold.

Architecture: FastAPI backend with custom simulation engine (tension engine, reactive micro-rounds, tiered memory), Vue 3 + D3.js frontend with progressive streaming, SQLite state storage, OpenAI-compatible LLM for agent reasoning and narration.

Tech Stack: Python 3.11+ (FastAPI, Pydantic, aiosqlite, openai), Vue 3 (Composition API, Vite, D3.js, TailwindCSS), Docker


Task 1: Project Scaffold

Files:

  • Create: backend/pyproject.toml
  • Create: backend/app/__init__.py
  • Create: backend/app/main.py
  • Create: backend/app/config.py
  • Create: frontend/package.json
  • Create: frontend/vite.config.js
  • Create: frontend/tailwind.config.js
  • Create: frontend/postcss.config.js
  • Create: frontend/index.html
  • Create: frontend/src/main.js
  • Create: frontend/src/App.vue
  • Create: frontend/src/router/index.js
  • Create: .env.example
  • Create: docker-compose.yml
  • Create: Dockerfile
  • Create: .gitignore

Step 1: Create backend scaffold

backend/pyproject.toml:

[project]
name = "mirosociety"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.30.0",
    "openai>=1.0.0",
    "pydantic>=2.0.0",
    "pydantic-settings>=2.0.0",
    "aiosqlite>=0.20.0",
    "python-dotenv>=1.0.0",
    "sse-starlette>=2.0.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

backend/app/config.py:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    llm_api_key: str = ""
    llm_base_url: str = "https://api.openai.com/v1"
    llm_model: str = "gpt-4o-mini"
    database_dir: str = "data"
    host: str = "0.0.0.0"
    port: int = 8000
    max_concurrent_llm_calls: int = 10

    class Config:
        env_file = ".env"

settings = Settings()

backend/app/main.py:

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI(title="MiroSociety", version="0.1.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

@app.get("/health")
async def health():
    return {"status": "ok"}

Step 2: Create frontend scaffold

Standard Vue 3 + Vite + TailwindCSS setup with vue-router. Single App.vue with <router-view>. Routes: / (home), /simulation/:id (sim view — handles both generation and running), /report/:id (report), /gallery (community gallery).

Step 3: Create Docker and env files

docker-compose.yml with single service (port 8000). Multi-stage Dockerfile.

.env.example:

LLM_API_KEY=your-api-key
LLM_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o-mini

Step 4: Init git repo

cd /Users/yashgupta/Desktop/opensource/mirosociety
git init
git add .
git commit -m "feat: initial project scaffold"

Task 2: Data Models

Files:

  • Create: backend/app/models/__init__.py
  • Create: backend/app/models/world.py
  • Create: backend/app/models/agent.py
  • Create: backend/app/models/action.py
  • Create: backend/app/models/simulation.py

Step 1: Define world models

backend/app/models/world.py — Pydantic models:

  • Location(id, name, type: Literal["public", "commerce", "governance", "residential", "social"], description)
  • TimeConfig(total_days, rounds_per_day: int = 3, active_agents_per_round_min, active_agents_per_round_max)
  • WorldBlueprint(name, description, rules: list[str], locations: list[Location], resources: list[str], initial_tensions: list[str], time_config: TimeConfig)
  • WorldMetrics(stability: float, prosperity: float, trust: float, freedom: float, conflict: float) — all 0.0-1.0
  • Institution(name, purpose, founder_id, member_ids: list[int], rules: list[str], created_day: int)
  • Proposal(id, proposer_id, content, votes_for: list[int], votes_against: list[int], status: Literal["open", "passed", "rejected"], created_round: int)
  • WorldState(blueprint, metrics, institutions: list[Institution], proposals: list[Proposal], day, round_in_day, active_disputes: list[str], community_rules: list[str])

Step 2: Define agent models with tiered memory

backend/app/models/agent.py — Pydantic models:

  • Personality(honesty, ambition, empathy, confrontational, conformity) — all floats 0-1
  • AgentPersona(id, name, role, age, personality: Personality, background, goals: list[str], core_memory: list[str], working_memory: list[str], beliefs: list[str], relationships: dict[str, str], resources: dict[str, int], location, faction: str | None, emotional_state: str)

Note: relationships is dict[str, str] where key = agent_id as string, value = description of relationship. Using string keys for JSON compatibility.

Step 3: Define action models with internal thought

backend/app/models/action.py:

  • ActionType(Enum): SPEAK_PUBLIC, SPEAK_PRIVATE, TRADE, FORM_GROUP, PROPOSE_RULE, VOTE, PROTEST, COMPLY, DEFECT, BUILD, MOVE, OBSERVE, DO_NOTHING
  • AgentDecision(feel, want, fear, action: ActionType, args: dict, speech: str | None, internal_thought: str, belief_updates: list[str], memory_promotion: str | None)
  • ActionEntry(round, day, time_of_day, agent_id, agent_name, location, action_type: ActionType, action_args: dict, speech: str | None, internal_thought: str | None, targets: list[int], world_state_changes: dict, relationship_changes: dict)
  • ReactiveResponse(agent_id, reaction_type: Literal["respond", "whisper", "silent"], content: str | None, target_id: int | None)

Step 4: Define simulation models

backend/app/models/simulation.py:

  • SimulationStatus(Enum): GENERATING_WORLD, GENERATING_CITIZENS, RUNNING, PAUSED, COMPLETED, ERROR
  • SpeedMode(Enum): LIVE, FAST_FORWARD, JUMP
  • SimulationState(id, status, speed_mode, world_blueprint: WorldBlueprint | None, agents_generated: int, agents_total: int, current_day, current_round, total_rounds, action_count, created_at)
  • SSEEvent(type: Literal["world_ready", "citizen_generated", "round_complete", "narrative", "metrics_update", "injection_result", "simulation_complete", "error"], data: dict)
  • ForkRequest(source_simulation_id, fork_at_day, changes: str)

Step 5: Commit

git add backend/app/models/
git commit -m "feat: add core data models with tiered memory and reactive actions"

Task 3: Database Layer

Files:

  • Create: backend/app/db/__init__.py
  • Create: backend/app/db/store.py

Step 1: Build async SQLite store

Each simulation gets its own directory: data/simulations/{sim_id}/

  • simulation.db — all tables for this simulation
  • actions.jsonl — append-only action log (for fast streaming reads)

Tables in simulation.db:

  • meta(key TEXT PRIMARY KEY, value JSON) — simulation config, status, blueprint
  • agents(agent_id INT PRIMARY KEY, state JSON, updated_at TEXT)
  • world_state(round INT PRIMARY KEY, state JSON)
  • actions(id INTEGER PRIMARY KEY AUTOINCREMENT, round INT, day INT, entry JSON)
  • narratives(round INT PRIMARY KEY, day INT, time_of_day TEXT, text TEXT)
  • metrics_history(round INT PRIMARY KEY, metrics JSON)

Global database data/global.db:

  • simulations(id TEXT PRIMARY KEY, status TEXT, rules_text TEXT, world_name TEXT, agent_count INT, created_at TEXT, is_public BOOL, view_count INT, fork_count INT, forked_from TEXT)

Methods:

class SimulationStore:
    async def create(sim_id, rules_text) -> None
    async def get_meta(sim_id) -> dict
    async def set_meta(sim_id, key, value) -> None
    async def save_agent(sim_id, agent: AgentPersona) -> None
    async def save_agents_batch(sim_id, agents: list[AgentPersona]) -> None
    async def get_agent(sim_id, agent_id) -> AgentPersona
    async def get_all_agents(sim_id) -> list[AgentPersona]
    async def save_world_state(sim_id, round_num, state: WorldState) -> None
    async def get_world_state(sim_id, round_num: int | None = None) -> WorldState
    async def save_action(sim_id, entry: ActionEntry) -> None
    async def save_narrative(sim_id, round_num, day, time_of_day, text) -> None
    async def save_metrics(sim_id, round_num, metrics: WorldMetrics) -> None
    async def get_actions(sim_id, from_round, to_round) -> list[ActionEntry]
    async def get_narratives(sim_id, from_day, to_day) -> list[dict]
    async def get_metrics_history(sim_id) -> list[dict]
    async def list_simulations(public_only=False) -> list[dict]
    async def copy_state_at_round(source_sim_id, target_sim_id, round_num) -> None

Step 2: Commit

git add backend/app/db/
git commit -m "feat: add async SQLite storage with per-simulation databases"

Task 4: LLM Client

Files:

  • Create: backend/app/services/__init__.py
  • Create: backend/app/services/llm.py

Step 1: Build LLM client wrapper

class LLMClient:
    def __init__(self, api_key, base_url, model, max_concurrent):
        self.client = AsyncOpenAI(api_key=api_key, base_url=base_url)
        self.model = model
        self.semaphore = asyncio.Semaphore(max_concurrent)
        self.total_tokens = 0
        self.total_calls = 0

    async def generate(self, system: str, user: str, json_mode: bool = False, max_tokens: int = 1000) -> str:
        """Single LLM call with semaphore, retries (3x exponential backoff), JSON mode."""

    async def generate_batch(self, prompts: list[tuple[str, str]], json_mode: bool = False, max_tokens: int = 1000) -> list[str]:
        """Concurrent LLM calls via asyncio.gather, respecting semaphore."""

    async def generate_light(self, system: str, user: str, max_tokens: int = 100) -> str:
        """Lightweight call for reactive micro-rounds. Low max_tokens, no JSON mode."""

    def get_stats(self) -> dict:
        """Return total_tokens, total_calls, estimated_cost."""

JSON parsing: try json.loads(), if fails try to extract JSON from markdown fences, if fails try to repair truncated JSON (close open braces/brackets), if still fails log error and return a safe default.

Step 2: Commit

git add backend/app/services/llm.py
git commit -m "feat: add async LLM client with batching and lightweight mode"

Task 5: World Generator

Files:

  • Create: backend/app/services/world_generator.py

Step 1: Build world generator

class WorldGenerator:
    def __init__(self, llm: LLMClient): ...

    async def generate(self, rules_text: str, population: int = 25, duration_days: int = 365) -> WorldBlueprint:

System prompt:

You are a world-builder for a society simulation engine.

Given the user's rules for a society, design a small town that will be
interesting to simulate. Your job is to create CONFLICT POTENTIAL —
the world should have built-in tensions that make the rules interact
with human nature in surprising ways.

Generate a JSON world blueprint with:
- name: A evocative name for this society (2-3 words max)
- description: One sentence capturing the essence
- rules: The user's rules as a clean list (clarify ambiguities, add
  implied consequences)
- locations: 5-8 locations. Mix of:
  - 1-2 public gathering spaces
  - 1-2 commerce/trade locations
  - 1 governance/authority location
  - 1-2 residential/private spaces
  - 1 social/leisure space
  Each location should create different social dynamics.
- resources: 4-6 resource types that matter in this society. Always
  include "influence" and "knowledge". Others depend on the rules.
- initial_tensions: 2-4 inherent conflicts created by the rules.
  Think: who benefits from these rules? Who suffers? What loopholes exist?
  What happens when the rules conflict with basic human needs?
- time_config: { total_days: {duration}, rounds_per_day: 3,
  active_agents_per_round_min: 3, active_agents_per_round_max: {population * 0.4} }

Step 2: Commit

git add backend/app/services/world_generator.py
git commit -m "feat: add world generator with conflict-oriented prompting"

Task 6: Citizen Generator

Files:

  • Create: backend/app/services/citizen_generator.py

Step 1: Build citizen generator (streaming)

class CitizenGenerator:
    def __init__(self, llm: LLMClient): ...

    async def generate(self, blueprint: WorldBlueprint, count: int = 25,
                       on_citizen: Callable = None) -> list[AgentPersona]:
        """
        Generates citizens in two phases, streaming each one via on_citizen callback.

        Phase 1: Cast sheet (single LLM call)
        Generate a list of count citizens with: name, role, age, one-line personality
        hook, stance on each initial_tension (for/against/indifferent/exploiter).
        Ensure diversity: age range 18-75, mix of genders, roles that cover
        all locations, personality spread, at least 2 citizens per tension stance.

        Phase 2: Full personas (batched, 5 at a time)
        For each citizen, generate full persona. As each completes, call
        on_citizen(agent) to stream to frontend.
        """

    async def generate_relationships(self, blueprint: WorldBlueprint,
                                     agents: list[AgentPersona]) -> list[AgentPersona]:
        """
        Single LLM call: given all citizen summaries, generate initial
        relationship graph. Output: list of {agent_id, relationships: {target_id: description}}.
        Ensure: at least 2 relationships per citizen, mix of positive/negative/neutral,
        some family ties, some professional ties, some rivalries.
        """

Key prompt constraint for persona generation:

CRITICAL: Citizens must have DIVERSE stances on the society's rules.
For each initial tension, generate citizens across the spectrum:
- True believers who love the rules
- Pragmatists who comply but have reservations
- Quiet dissenters who obey but resent it
- Active resisters who will test boundaries
- Exploiters who find loopholes
- The indifferent who just want to be left alone

This diversity drives the simulation. A town of reasonable moderates is boring.
A town with extremists, idealists, cynics, and opportunists produces stories.

Step 2: Commit

git add backend/app/services/citizen_generator.py
git commit -m "feat: add streaming citizen generator with diversity constraints"

Task 7: Simulation Engine (Core)

Files:

  • Create: backend/app/services/engine.py
  • Create: backend/app/services/tension.py
  • Create: backend/app/services/resolver.py

Step 1: Build tension engine

backend/app/services/tension.py:

class TensionEngine:
    def __init__(self, llm: LLMClient): ...

    async def check_and_apply(self, world_state: WorldState, agents: list[AgentPersona],
                              metrics_history: list[WorldMetrics]) -> tuple[WorldState, list[AgentPersona], str | None]:
        """
        Check if society has reached boring equilibrium:
        - stability > 0.85 and conflict < 0.15 for 3+ consecutive rounds
        - OR no significant actions (proposals, protests, defections) for 5+ rounds
        - OR dominant faction has >60% of agents

        If triggered, apply ONE of:
        1. Internal pressure: modify 2-3 agent beliefs/emotions
        2. External event: generate environmental change via LLM
        3. Faction fracture: seed splinter ideology in dominant faction

        Returns: updated world_state, updated agents, event_description (or None)
        """

Step 2: Build action resolver

backend/app/services/resolver.py:

class ActionResolver:
    def resolve(self, decisions: list[tuple[AgentPersona, AgentDecision]],
                world_state: WorldState, all_agents: list[AgentPersona]) -> tuple[list[ActionEntry], WorldState, list[AgentPersona]]:
        """
        Process decisions in order of influence_weight:

        SPEAK_PUBLIC/PRIVATE: Always succeeds. Records speech.
        TRADE: Succeeds only if both parties have sufficient resources.
        FORM_GROUP: Creates institution or adds member. Updates faction.
        PROPOSE_RULE: Creates open Proposal in world state.
        VOTE: Adds vote to active Proposal. If quorum reached, resolve.
        PROTEST: Decreases stability, increases conflict. May trigger
                 reactive responses from authority figures.
        COMPLY: Increases stability. May decrease agent's emotional wellbeing.
        DEFECT: Roll detection check based on location privacy and nearby
                agents' observation scores. If detected, becomes public knowledge.
        BUILD: Requires resources. Creates new location or modifies existing.
        MOVE: Updates agent location.
        OBSERVE: Agent gains information about nearby agents' recent actions.
        DO_NOTHING: No effect but recorded (useful for narrator).

        Returns: resolved actions, updated world state, updated agents.
        """

    def _update_metrics(self, old_metrics: WorldMetrics, actions: list[ActionEntry],
                        world_state: WorldState) -> WorldMetrics:
        """
        Heuristic metric updates with exponential moving average (alpha=0.3):
        - stability: +0.02 per comply/vote, -0.05 per protest, -0.08 per defect
        - prosperity: +0.01 per trade, -0.02 per build (investment), track total resources
        - trust: +0.01 per positive relationship change, -0.02 per negative
        - freedom: inversely correlated with len(community_rules) and len(institutions)
        - conflict: +0.03 per protest/defect, -0.01 per comply, spikes on proposals
        All clamped to [0.0, 1.0].
        """

Step 3: Build main engine

backend/app/services/engine.py:

class SimulationEngine:
    def __init__(self, llm: LLMClient, store: SimulationStore,
                 tension: TensionEngine, resolver: ActionResolver): ...

    async def run(self, simulation_id: str, emit: Callable[[SSEEvent], None]):
        """
        Main loop. Runs until stopped, paused, or total_rounds reached.

        For each round:
        1. Load current world state and agents
        2. tension_engine.check_and_apply() — inject pressure if equilibrium
        3. select_active_agents() — pick subset
        4. Concurrent agent_decision() calls for all active agents
        5. Reactive micro-rounds for speech acts
        6. resolver.resolve() — apply all actions
        7. Update agent memories (working_memory window, core_memory promotions)
        8. Save state, actions, metrics to store
        9. If speed_mode == LIVE or round is significant:
              narrator.narrate_round() and save narrative
        10. emit SSEEvent with round data
        11. If speed_mode == JUMP and not significant, skip to step 1

        Significance score = number of (protests + defections + proposals + faction changes
        + relationship sign-flips + rule changes) in this round. Threshold = 1 for JUMP mode.
        """

    def select_active_agents(self, world_state: WorldState,
                             agents: list[AgentPersona], round_num: int) -> list[AgentPersona]:
        """
        Base count: random between config min and max.
        Boost for:
        - Open proposals: all agents with faction or opinion get +0.3 activation chance
        - Recent conflict (last 2 rounds): nearby agents get +0.5 activation chance
        - Injected event: all agents get +0.4 activation chance for 2 rounds
        - Emotional state "restless"/"angry"/"fearful": +0.3 activation chance
        Never exceed 60% of population.
        Selection: weighted random by (base_chance + boosts + activity_personality_score).
        """

    async def agent_decision(self, agent: AgentPersona, world_state: WorldState,
                             active_agents: list[AgentPersona]) -> AgentDecision:
        """
        Build prompt per design doc (feel/want/fear scaffold).
        Available actions filtered by location:
        - VOTE only if there's an open proposal
        - TRADE only if at commerce location or near another agent
        - PROPOSE_RULE only at governance location
        - BUILD only with sufficient resources

        Parse JSON response. Validate action type and args.
        Fallback to DO_NOTHING if parse fails.
        """

    async def reactive_micro_round(self, speech_actions: list[ActionEntry],
                                    agents: list[AgentPersona],
                                    world_state: WorldState) -> list[ReactiveResponse]:
        """
        For each SPEAK_PUBLIC action, identify witnesses (agents at same location).
        For each SPEAK_PRIVATE action, only the target witnesses.
        Batch lightweight LLM calls:
            "You are {name}. You just heard {speaker} say: '{content}'
             React: (a) respond publicly (b) whisper to someone (c) stay silent.
             Reply with just the letter and an optional one-sentence response."
        Parse responses. Return list of ReactiveResponse.
        """

    async def inject_event(self, simulation_id: str, event_text: str) -> str:
        """
        LLM call: "An event occurs in {world_name}: '{event_text}'.
        Describe: 1) What physically happens 2) Which agents are directly affected
        3) Any resource/location changes. Return JSON."
        Apply changes to world state.
        Add event to all affected agents' working memory.
        Return narrative description.
        """

    async def reflective_memory_pass(self, simulation_id: str):
        """
        Called every 30 simulated days.
        For each agent, LLM call:
        "You are {name}. Here is everything significant from the past 30 days:
        {working_memory_full_history}
        Your current core memories: {core_memory}
        Your current beliefs: {beliefs}
        Update: 1) core_memory (max 10 items) 2) beliefs 3) goals.
        Drop anything no longer relevant. Add anything now important."
        """

    # Pause/resume via asyncio.Event flag
    _pause_event: asyncio.Event

    async def pause(self, simulation_id): ...
    async def resume(self, simulation_id): ...
    async def set_speed(self, simulation_id, mode: SpeedMode): ...

Step 4: Commit

git add backend/app/services/engine.py backend/app/services/tension.py backend/app/services/resolver.py
git commit -m "feat: add simulation engine with tension engine and reactive micro-rounds"

Task 8: Narrator Service

Files:

  • Create: backend/app/services/narrator.py

Step 1: Build narrator with continuity

class Narrator:
    def __init__(self, llm: LLMClient): ...

    async def narrate_round(self, world_state: WorldState,
                            actions: list[ActionEntry],
                            reactions: list[ReactiveResponse],
                            previous_narrative: str | None,
                            tension_event: str | None) -> str:
        """
        Prompt:
        "You are narrating events in {world_name}. Rules: {rules}.

        Previous scene (for continuity): {previous_narrative}

        Actions this round: {actions with speech, internal thoughts, reactions}
        {tension_event if any}

        Write 2-4 paragraphs as if writing a literary novel.
        - Use character names and dialogue (with quotation marks)
        - Show body language and emotion
        - When you know a character's internal thought, show it in italics
          as indirect narration (e.g. 'Elena nodded, though privately she
          doubted Marcus had the stomach for what was coming.')
        - If a tension event occurred, weave it in naturally
        - End with something unresolved — a glance, a question, a tension
        - Do NOT explain rules or editorialize. Just tell the story."
        """

    async def summarize_epoch(self, narratives: list[str], metrics_start: WorldMetrics,
                              metrics_end: WorldMetrics, day_start: int, day_end: int) -> str:
        """Compress 30 days of narratives into a 2-3 paragraph epoch summary."""

    async def generate_report(self, simulation_id: str, store: SimulationStore) -> dict:
        """
        Multi-stage pipeline:
        Stage 1: Generate epoch summaries (1 per 30 days)
        Stage 2: Feed all epochs + metrics to LLM for trend extraction
        Stage 3: Compose final report:
          {
            "title": "...",
            "summary": "one paragraph of what happened",
            "key_moments": [{"day": N, "title": "...", "description": "..."}],
            "surprise": "the most unexpected emergent behavior",
            "factions": [{"name": "...", "description": "...", "peak_members": N}],
            "metrics_history": [...],
            "total_actions": N,
            "total_days": N,
            "agent_count": N
          }
        """

Step 2: Commit

git add backend/app/services/narrator.py
git commit -m "feat: add narrator with literary style and multi-stage report pipeline"

Task 9: API Routes

Files:

  • Create: backend/app/api/__init__.py
  • Create: backend/app/api/simulate.py
  • Create: backend/app/api/agents.py
  • Create: backend/app/api/gallery.py
  • Create: backend/app/api/presets.py
  • Create: backend/app/presets.py
  • Modify: backend/app/main.py — register routers, create service instances on startup

Step 1: Unified simulate endpoint

# POST /api/simulate
# Request: { "rules": "...", "population": 25, "duration_days": 365 }
# Response: { "simulation_id": "..." }
# Kicks off the full pipeline in background: generate world → stream citizens → start simulation

# GET /api/simulation/{id}/stream
# SSE stream. Events:
#   { "type": "status", "data": { "status": "generating_world" } }
#   { "type": "world_ready", "data": WorldBlueprint }
#   { "type": "citizen_generated", "data": AgentPersona }
#   { "type": "round_complete", "data": { "day": N, "round": N, "actions": [...], "metrics": {...} } }
#   { "type": "narrative", "data": { "day": N, "time_of_day": "morning", "text": "..." } }
#   { "type": "fast_forward_summary", "data": { "from_day": N, "to_day": N, "summary": "..." } }
#   { "type": "simulation_complete", "data": { "total_days": N, "total_actions": N } }
#   { "type": "error", "data": { "message": "..." } }

# GET /api/simulation/{id}/state
# POST /api/simulation/{id}/inject  { "event": "..." }
# POST /api/simulation/{id}/fork    { "fork_at_day": N, "changes": "..." }
# POST /api/simulation/{id}/speed   { "mode": "live" | "fast_forward" | "jump" }
# POST /api/simulation/{id}/pause
# POST /api/simulation/{id}/resume
# POST /api/simulation/{id}/stop
# GET  /api/simulation/{id}/report

Step 2: Agent routes

# GET  /api/simulation/{sim_id}/agents
# GET  /api/simulation/{sim_id}/agent/{agent_id}
# POST /api/simulation/{sim_id}/agent/{agent_id}/interview
#   Request: { "question": "..." }
#   Response: { "response": "...", "emotional_state": "..." }

Interview prompt:

You are {name}, {role} in {world_name}. Current day: {day}.
Your personality: {personality}
Your core memories: {core_memory}
Your beliefs: {beliefs}
Your emotional state: {emotional_state}

Someone approaches and asks: "{question}"

Respond in character. Be authentic to your personality — if you're
guarded, be guarded. If you're passionate, be passionate. Reference
specific events from your memory. Keep it to 2-3 sentences.

Step 3: Gallery routes

# POST /api/simulation/{id}/publish    — mark simulation as public
# GET  /api/gallery                    — list public sims (sort: recent, views, forks)
# GET  /api/gallery/{id}               — public sim report (read-only)
# POST /api/gallery/{id}/fork          — fork public sim with modified rules

Step 4: Preset routes and data

# GET /api/presets

PRESETS = [
    {
        "id": "no-lies",
        "name": "Nobody Can Lie",
        "rules": "Lying is physically impossible. Every person can only speak what they believe to be true. Emotions are visible on everyone's face and cannot be hidden.",
        "teaser": "They invented silence clubs by month 3.",
        "population": 25,
        "duration_days": 365
    },
    {
        "id": "full-communism",
        "name": "Full Communism",
        "rules": "No private property exists. All resources are communal. No one can own more than they need. A community council allocates resources based on need.",
        "teaser": "It worked. Then it didn't.",
        "population": 25,
        "duration_days": 365
    },
    {
        "id": "everyone-armed",
        "name": "Everyone Is Armed",
        "rules": "Every citizen carries a weapon at all times. Violence is always a legal option for resolving disputes. There are no police — only self-defense.",
        "teaser": "Politeness became a survival skill.",
        "population": 25,
        "duration_days": 365
    },
    {
        "id": "total-surveillance",
        "name": "Total Surveillance",
        "rules": "Everyone can see what everyone else is doing at all times. There are no secrets, no private spaces, no hidden actions. All conversations are public.",
        "teaser": "The most powerful person was the one who stopped watching.",
        "population": 25,
        "duration_days": 365
    },
    {
        "id": "direct-democracy",
        "name": "Direct Democracy",
        "rules": "Every decision that affects more than one person must be voted on by the entire community. No representatives, no delegation. Every citizen has equal vote.",
        "teaser": "They voted 47 times on day 1. By day 30, nobody showed up.",
        "population": 25,
        "duration_days": 365
    },
    {
        "id": "no-digital",
        "name": "No Phones, No Internet",
        "rules": "All communication is face-to-face only. No long-distance messaging, no written notes left behind, no technology for communication. You must be physically present to interact.",
        "teaser": "Rumors moved slower but hit harder.",
        "population": 25,
        "duration_days": 365
    },
    {
        "id": "meritocracy",
        "name": "Absolute Meritocracy",
        "rules": "Social status, housing, food quality, and privileges are strictly determined by measurable productive output. Nothing else matters — not age, relationships, or character.",
        "teaser": "The artists starved. Then they revolted.",
        "population": 25,
        "duration_days": 365
    },
    {
        "id": "anonymity",
        "name": "Total Anonymity",
        "rules": "No one has a persistent name or identity. Every day, physical appearances change randomly. You cannot recognize anyone. Reputation does not exist.",
        "teaser": "Trust became the scarcest resource.",
        "population": 25,
        "duration_days": 365
    }
]

Step 5: Commit

git add backend/app/api/ backend/app/presets.py
git commit -m "feat: add all API routes with SSE streaming and gallery"

Task 10: Frontend — Landing Page

Files:

  • Create: frontend/src/views/HomeView.vue
  • Create: frontend/src/components/RuleEditor.vue
  • Create: frontend/src/components/PresetCard.vue
  • Create: frontend/src/api/client.js
  • Create: frontend/src/styles/main.css

Step 1: API client

Axios-based client + EventSource wrapper for SSE. Methods mirror all backend endpoints. Base URL from import.meta.env.VITE_API_URL with fallback to http://localhost:8000.

Step 2: HomeView

Dark theme (slate-950 bg). Layout:

  • Top: "MiroSociety" title + one-line description
  • Center: Large textarea (placeholder: "Define the rules of your society...") with example text that cycles
  • Below textarea: Population dropdown (10/15/20/25/30/50), Duration dropdown (30/90/180/365 days)
  • "Simulate" button (emerald-500, full width under controls)
  • Below: "Popular experiments" section header
  • Grid of PresetCards (2 columns on mobile, 4 on desktop)

On submit: POST /api/simulate → navigate to /simulation/{id}

Step 3: PresetCard

Clickable card (slate-900 bg, hover: slate-800, border-l-2 emerald-500 on hover):

  • Name in bold (slate-100)
  • Teaser in italic (slate-400)
  • Click fills textarea with the preset's rules

Step 4: Styling

TailwindCSS dark theme. Fonts:

  • UI: Inter (via Google Fonts or bundled)
  • Narrative text: Georgia (system serif) Color palette: slate-950/900/800 backgrounds, slate-100/300/400 text, emerald-500 accent, red-500 for conflict, amber-500 for warnings.

Step 5: Commit

git add frontend/src/
git commit -m "feat: add landing page with rule editor and preset cards"

Task 11: Frontend — Simulation View (Main Screen)

Files:

  • Create: frontend/src/views/SimulationView.vue
  • Create: frontend/src/components/SocialGraph.vue
  • Create: frontend/src/components/NarrativeFeed.vue
  • Create: frontend/src/components/WorldMetrics.vue
  • Create: frontend/src/components/TimelineScrubber.vue
  • Create: frontend/src/components/SpeedControl.vue
  • Create: frontend/src/components/InjectModal.vue
  • Create: frontend/src/components/CitizenReveal.vue

Step 1: SimulationView — progressive generation flow

This single view handles BOTH world generation and simulation running.

Phase 1 (generation): Shows world name appearing, citizens revealing one by one (CitizenReveal component), progress text. All driven by SSE events.

Phase 2 (simulation): Seamlessly transitions when first round_complete event arrives. Three-panel layout appears:

  • Left (30%): SocialGraph + WorldMetrics stacked
  • Right (70%): NarrativeFeed
  • Bottom bar: TimelineScrubber + SpeedControl
  • Top bar: world name, "Day N", speed indicator, [Pause] [Inject] [Fork] buttons

Step 2: SocialGraph (D3.js force-directed)

  • Nodes = agents. Radius proportional to influence resource. Color by faction (null = slate-500, factions get distinct colors from a palette).
  • Edges = relationships. Green solid = positive, red dashed = tension, width = strength.
  • On each round_complete SSE event: update node positions, add/remove/recolor edges with transitions.
  • Click node → open AgentProfile sidebar.
  • Tooltip on hover: name + role + emotional state.
  • Node pulse animation when agent takes an action this round.

Step 3: NarrativeFeed

  • Scrollable container (right 70% of screen).
  • Each entry: day header (sticky) + time-of-day subheader + narrative text.
  • Agent names in narrative are <span class="agent-link"> — clickable, opens profile.
  • Narrative text uses Georgia font, slightly larger than UI text (1.1rem).
  • Auto-scroll when live. Manual scroll locks auto-scroll (show "Jump to latest" button).
  • Fade-in animation for new entries.
  • During fast-forward: show compact summaries instead of full narratives.

Step 4: WorldMetrics

5 horizontal progress bars (h-2, rounded):

  • Stability (emerald), Prosperity (blue), Trust (violet), Freedom (amber), Conflict (red)
  • Label + percentage on right
  • Smooth CSS transition on width change (300ms ease)

Step 5: TimelineScrubber

  • Horizontal range input from day 1 to current day.
  • Significant event markers: small dots above the slider at days with high significance.
  • Dragging loads historical narratives and agent states from the API.
  • Current day indicator when live.

Step 6: SpeedControl

Three buttons: [Live] [Fast] [Jump] — toggles speed mode via POST /api/simulation/{id}/speed. Visual indicator: Live = green dot pulsing, Fast = yellow arrow, Jump = lightning bolt.

Step 7: InjectModal

Trigger: "Inject" button in top bar. Modal (centered, slate-900 bg, backdrop blur):

  • Textarea: "What happens next?"
  • Submit → POST /api/simulation/{id}/inject → close modal, result appears in narrative feed.

Step 8: CitizenReveal

Used during generation phase. Animated card for each citizen as they're generated:

  • Name + role appear with typewriter effect
  • Personality bars animate from 0 to value
  • One-line teaser from background
  • Cards arrange in a flowing grid

Step 9: Commit

git add frontend/src/views/SimulationView.vue frontend/src/components/
git commit -m "feat: add simulation view with graph, narrative feed, and controls"

Task 12: Frontend — Agent Profile & Interview

Files:

  • Create: frontend/src/components/AgentProfile.vue
  • Create: frontend/src/components/InterviewChat.vue

Step 1: AgentProfile

Slide-in sidebar from right (w-96, slate-900 bg, border-l slate-800):

  • Header: Name, role, age, emotional state (with emoji-free indicator — colored dot)
  • Quote: italicized line from background
  • Section "Personality": 5 horizontal bars with labels
  • Section "Believes": bulleted list of current beliefs
  • Section "Relationships": list of (name → description), clickable names
  • Section "Remembers": core_memory items as a timeline
  • Section "Recent": last 10 actions as compact entries (Day N: action description)
  • Button: "Interview {name}" → expands InterviewChat

Updates live via SSE when simulation is running.

Step 2: InterviewChat

Embedded below profile content when activated:

  • Chat bubbles: user (right, emerald bg) and agent (left, slate-800 bg)
  • Input field at bottom
  • Submit → POST /api/simulation/{sim_id}/agent/{id}/interview
  • Response appears as agent bubble with typing indicator
  • Conversation history persisted in component state (lost on close)

Step 3: Commit

git add frontend/src/components/AgentProfile.vue frontend/src/components/InterviewChat.vue
git commit -m "feat: add agent profile sidebar with interview chat"

Files:

  • Create: frontend/src/views/ReportView.vue
  • Create: frontend/src/views/GalleryView.vue
  • Create: frontend/src/components/MetricSparkline.vue
  • Create: frontend/src/components/KeyMoment.vue
  • Create: frontend/src/components/GalleryCard.vue

Step 1: ReportView

Centered content column (max-w-3xl mx-auto), designed to look good as a standalone shareable page:

  • Header: world name + rules as subtitle + stats (N citizens, N days, N events)
  • "What Happened": summary paragraph in Georgia serif
  • "Key Moments": vertical timeline (line on left, dots at each moment, day number + title + description)
  • "Metrics Over Time": 5 MetricSparklines in a row
  • "The Surprise": highlighted section (slate-800 bg, border-l-4 emerald-500) with the unexpected emergent behavior
  • "Factions": cards for each faction with name, description, peak members
  • Footer buttons: [Run Again] [Fork With Changes] [Share Link] [Publish to Gallery]
  • Share: copies URL to clipboard. URL is /report/{id} which works for public sims.

If viewing a fork: show split-screen comparison with original (side by side key moments + divergent metrics).

Step 2: MetricSparkline

SVG component (200x40px). Line chart with filled area under curve (gradient from emerald-500/20 to transparent). No axes, no labels — just the shape. Metric name + final value below.

Step 3: GalleryView

Grid of GalleryCards. Top: sort selector (Recent / Most Viewed / Most Forked). Each card:

  • World name (bold)
  • Rules (truncated, slate-400)
  • Teaser from report summary (1 line)
  • Stats: N days, N agents, N views, N forks
  • Click → navigate to read-only ReportView

Step 4: Commit

git add frontend/src/views/ReportView.vue frontend/src/views/GalleryView.vue frontend/src/components/
git commit -m "feat: add report view, gallery, and sparkline components"

Task 14: Fork System

Files:

  • Create: backend/app/services/forker.py
  • Modify: backend/app/api/simulate.py — add fork endpoint logic

Step 1: Build fork service

class SimulationForker:
    def __init__(self, llm: LLMClient, store: SimulationStore): ...

    async def fork(self, source_sim_id: str, fork_at_day: int, changes: str) -> str:
        """
        1. Create new simulation entry (forked_from = source_sim_id)
        2. Copy all state from source up to fork_at_day's last round
        3. LLM interprets the change request:
           "The user wants to change: '{changes}'
            Current agents: {agent_summaries}
            Current world state: {state_summary}
            Describe the specific modifications to make:
            - Agent changes: [{agent_id, field, new_value}]
            - World state changes: [{field, new_value}]
            - Removed agents: [agent_ids]
            - Added agents: [{persona}]"
        4. Apply modifications to the copied state
        5. Return new simulation_id (caller starts engine from the fork point)
        """

Step 2: Commit

git add backend/app/services/forker.py
git commit -m "feat: add simulation fork system"

Task 15: Integration & Docker

Files:

  • Modify: backend/app/main.py — wire all services, lifespan handler
  • Modify: frontend/src/router/index.js — all routes
  • Create: Dockerfile
  • Modify: docker-compose.yml
  • Create: root package.json — convenience scripts

Step 1: Backend integration

main.py lifespan handler:

  • On startup: create data directories, init global.db, instantiate LLMClient + SimulationStore + all services
  • Store service instances in app.state
  • Register routers: simulate, agents, gallery, presets

Step 2: Frontend routing

routes: [
  { path: '/', component: HomeView },
  { path: '/simulation/:id', component: SimulationView },
  { path: '/report/:id', component: ReportView },
  { path: '/gallery', component: GalleryView },
]

Step 3: Docker

Multi-stage Dockerfile:

FROM node:20-slim AS frontend
WORKDIR /app/frontend
COPY frontend/ .
RUN npm ci && npm run build

FROM python:3.12-slim
WORKDIR /app
COPY backend/ backend/
COPY --from=frontend /app/frontend/dist backend/static
RUN pip install -e backend/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Backend serves static/ as static files mount and serves index.html for all non-API routes (SPA fallback).

Step 4: Root package.json convenience scripts

{
  "scripts": {
    "dev": "concurrently \"npm run backend\" \"npm run frontend\"",
    "backend": "cd backend && uvicorn app.main:app --reload --port 8000",
    "frontend": "cd frontend && npm run dev",
    "setup": "cd backend && pip install -e . && cd ../frontend && npm install"
  }
}

Step 5: Commit

git add .
git commit -m "feat: full integration with Docker and convenience scripts"

Task 16: Polish & First Demo

Step 1: Run full flow end-to-end

npm run setup
npm run dev
# Open localhost:5173
# Click "Nobody Can Lie" preset
# Click Simulate
# Watch generation → simulation → narrative
# Test inject, speed controls, agent profile, interview
# Let it run to completion
# Check report

Step 2: Fix issues found during demo

Expected areas needing tuning:

  • Agent decision prompt may need iteration (actions too timid, or too chaotic)
  • Narrator may need style adjustments (too verbose, not enough dialogue)
  • Tension engine thresholds may need calibration
  • Reactive micro-round parsing may need fallback handling

Step 3: Run all 8 presets, save best narrative excerpts

Use the best moments as example content on the landing page (below the preset cards, as a "Recent highlights" section showing actual simulation output).

Step 4: Commit

git add .
git commit -m "feat: polish and demo-tested with all 8 presets"

Execution Order Summary

Task What Dependencies Est. Time
1 Project scaffold None 30 min
2 Data models Task 1 45 min
3 Database layer Task 2 1 hr
4 LLM client Task 1 30 min
5 World generator Tasks 2, 4 45 min
6 Citizen generator Tasks 2, 4 1 hr
7 Simulation engine + tension + resolver Tasks 2, 3, 4 3 hr
8 Narrator Tasks 2, 4 1 hr
9 API routes + presets Tasks 3-8 1.5 hr
10 Frontend: Landing Task 1 1 hr
11 Frontend: Simulation view Task 10 3 hr
12 Frontend: Agent profile + interview Task 11 1 hr
13 Frontend: Report + Gallery Task 11 1.5 hr
14 Fork system Tasks 3, 4, 7 1 hr
15 Integration & Docker Tasks 9-14 1 hr
16 Polish & demo Task 15 2 hr

Total estimated: ~19-20 hours

Parallelizable:

  • Tasks 3 + 4 (independent backend infra)
  • Tasks 5 + 6 (both need 2 + 4, independent of each other)
  • Tasks 10-13 (all frontend, only need API contract from Task 9)
  • Task 14 (fork system, independent of frontend)

Critical path: 1 → 2 → 4 → 7 → 9 → 15 → 16