Skip to content

Latest commit

 

History

History
1920 lines (1607 loc) · 72.9 KB

File metadata and controls

1920 lines (1607 loc) · 72.9 KB

CivilizationSim — Complete AI Agent Build Guide

Version: 1.0
Target Platform: MacBook (Apple Silicon M1/M2/M3)
Primary Language: Python 3.11+
LLM Backend: DeepSeek V3 (via OpenRouter or direct API)
Renderer: Pygame 2.x (2D top-down)


OVERVIEW FOR THE AI AGENT

You are building a living civilization simulator that begins with two primitive humans (one male, one female) in a prehistoric world. Every character in this world is powered by an LLM brain that makes decisions. A second AI agent (the Crafting/World Agent) handles discovery, item creation, and emergent world logic. The simulation runs on an accelerated time scale so years of civilization progress happen in minutes.

READ THIS ENTIRE DOCUMENT BEFORE WRITING A SINGLE LINE OF CODE.


TECH STACK

Layer Technology Purpose
Language Python 3.11+ Core engine
Renderer Pygame 2.5 2D visual world
LLM DeepSeek V3 Flash via OpenRouter Character brains + Crafting agent
Database SQLite3 (built-in) World state, memory, history
HTTP httpx (async) LLM API calls
Config python-dotenv API keys
Data dataclasses + json World objects
UI Overlay Pygame + custom font rendering HUD, logs, panels

Install all dependencies first:

pip install pygame httpx python-dotenv

PROJECT STRUCTURE

civilization_sim/
├── main.py                  # Entry point, game loop
├── config.py                # Constants, settings, API config
├── .env                     # OPENROUTER_API_KEY=your_key_here
│
├── engine/
│   ├── __init__.py
│   ├── world.py             # World map, tiles, terrain
│   ├── time_engine.py       # Tick system, accelerated time
│   ├── physics.py           # Hunger, energy, temperature, weather
│   └── event_bus.py         # Global event system
│
├── entities/
│   ├── __init__.py
│   ├── character.py         # Human character class
│   ├── item.py              # All world items
│   ├── building.py          # Constructed structures
│   └── animal.py            # Wildlife
│
├── agents/
│   ├── __init__.py
│   ├── character_agent.py   # LLM brain for each character
│   ├── crafting_agent.py    # LLM agent that creates new items/recipes
│   └── memory.py            # Character memory manager
│
├── systems/
│   ├── __init__.py
│   ├── needs.py             # Hunger, thirst, sleep, love, safety
│   ├── crafting.py          # Item combination logic
│   ├── social.py            # Relationships, reproduction, family
│   ├── skills.py            # Skill progression system
│   └── knowledge.py         # Tech tree / discovered knowledge
│
├── renderer/
│   ├── __init__.py
│   ├── camera.py            # Scrollable camera
│   ├── tile_renderer.py     # World tiles
│   ├── entity_renderer.py   # Characters, animals, items
│   ├── hud.py               # Status panels, logs
│   └── sprites.py           # Sprite definitions (drawn with pygame.draw)
│
└── data/
    ├── world.db             # SQLite database (auto-created)
    ├── base_items.json      # Starter items (rock, wood, water, etc.)
    ├── base_recipes.json    # Starter recipes
    └── world_knowledge.json # Accumulated discoveries

PHASE 1 — FOUNDATION (Build This First)

Step 1.1 — config.py

# config.py
import os
from dotenv import load_dotenv

load_dotenv()

# === DISPLAY ===
SCREEN_WIDTH = 1280
SCREEN_HEIGHT = 800
TILE_SIZE = 32
FPS = 60
WINDOW_TITLE = "CivilizationSim"

# === WORLD ===
WORLD_WIDTH_TILES = 80    # tiles
WORLD_HEIGHT_TILES = 60   # tiles

# === TIME ===
# 1 real second = X simulated minutes
TIME_SCALE = 60            # 1 real second = 1 sim hour  
TICK_RATE = 10             # world logic ticks per second
SIM_DAY_IN_REAL_SECONDS = 24 * 60 / TIME_SCALE  # how long a sim day takes in real time

# === LLM ===
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
LLM_MODEL = "deepseek/deepseek-chat-v3-0324:free"  # or paid: deepseek/deepseek-chat
LLM_BASE_URL = "https://openrouter.ai/api/v1/chat/completions"
LLM_MAX_TOKENS = 300       # keep short — characters think in brief bursts
LLM_TIMEOUT = 10.0

# LLM call throttling — don't call LLM every tick
CHARACTER_THINK_INTERVAL_TICKS = 30   # think every 30 ticks (~3 seconds)
CRAFTING_AGENT_INTERVAL_TICKS = 100   # crafting agent checks every 10 seconds

# === NEEDS (max values) ===
MAX_HUNGER = 100.0
MAX_THIRST = 100.0
MAX_ENERGY = 100.0
MAX_WARMTH = 100.0
MAX_SOCIAL = 100.0
MAX_SAFETY = 100.0

# === NEED DECAY RATES (per tick) ===
HUNGER_DECAY = 0.05
THIRST_DECAY = 0.08
ENERGY_DECAY = 0.03
WARMTH_DECAY = 0.02   # increases when near fire/shelter
SOCIAL_DECAY = 0.01

# === COLORS (Palette) ===
COLOR_GRASS = (86, 125, 70)
COLOR_DIRT = (139, 115, 85)
COLOR_WATER = (64, 120, 180)
COLOR_STONE = (160, 160, 155)
COLOR_SAND = (210, 190, 140)
COLOR_TREE = (50, 90, 50)
COLOR_FIRE = (220, 100, 30)

COLOR_BG_PANEL = (20, 18, 25)
COLOR_TEXT = (230, 225, 210)
COLOR_TEXT_DIM = (140, 135, 120)
COLOR_ACCENT = (200, 140, 60)      # warm amber
COLOR_DANGER = (200, 60, 60)
COLOR_GOOD = (80, 180, 100)
COLOR_HIGHLIGHT = (240, 200, 80)

# === FONT ===
FONT_MONO = None   # set in main.py after pygame.init()
FONT_SIZE_SMALL = 11
FONT_SIZE_NORMAL = 14
FONT_SIZE_LARGE = 18

# === STARTING CONDITIONS ===
STARTING_CHARACTERS = [
    {"name": "Kael", "sex": "male",   "x": 38, "y": 28},
    {"name": "Lyra", "sex": "female", "x": 40, "y": 28},
]

Step 1.2 — engine/time_engine.py

# engine/time_engine.py
from config import TIME_SCALE, TICK_RATE

class TimeEngine:
    """
    Manages the simulation's accelerated time.
    
    Timeline: tick -> minute -> hour -> day -> season -> year
    """
    MINUTES_PER_HOUR = 60
    HOURS_PER_DAY = 24
    DAYS_PER_SEASON = 30
    SEASONS = ["Spring", "Summer", "Autumn", "Winter"]
    SEASONS_PER_YEAR = 4

    def __init__(self):
        self.total_ticks = 0
        self.sim_minute = 0
        self.sim_hour = 6        # start at dawn
        self.sim_day = 1
        self.sim_season_index = 0
        self.sim_year = 1
        self.ticks_per_minute = TICK_RATE / TIME_SCALE

    def tick(self):
        self.total_ticks += 1
        self.sim_minute += 1
        if self.sim_minute >= self.MINUTES_PER_HOUR:
            self.sim_minute = 0
            self.sim_hour += 1
        if self.sim_hour >= self.HOURS_PER_DAY:
            self.sim_hour = 0
            self.sim_day += 1
        if self.sim_day > self.DAYS_PER_SEASON:
            self.sim_day = 1
            self.sim_season_index = (self.sim_season_index + 1) % self.SEASONS_PER_YEAR
            if self.sim_season_index == 0:
                self.sim_year += 1

    @property
    def season(self):
        return self.SEASONS[self.sim_season_index]

    @property
    def is_night(self):
        return self.sim_hour < 6 or self.sim_hour >= 20

    @property
    def is_winter(self):
        return self.season == "Winter"

    def describe(self):
        return (f"Year {self.sim_year}, {self.season}, "
                f"Day {self.sim_day}, "
                f"{self.sim_hour:02d}:{self.sim_minute:02d}")

Step 1.3 — engine/world.py

# engine/world.py
import random
from dataclasses import dataclass, field
from typing import List, Optional
from config import WORLD_WIDTH_TILES, WORLD_HEIGHT_TILES

TILE_GRASS  = "grass"
TILE_DIRT   = "dirt"
TILE_WATER  = "water"
TILE_STONE  = "stone"
TILE_SAND   = "sand"
TILE_TREE   = "tree"

@dataclass
class Tile:
    type: str
    passable: bool = True
    resource: Optional[str] = None   # "wood", "stone", "berries", "water"
    resource_amount: int = 0

class WorldMap:
    def __init__(self, width=WORLD_WIDTH_TILES, height=WORLD_HEIGHT_TILES, seed=None):
        self.width = width
        self.height = height
        self.seed = seed or random.randint(0, 99999)
        self.tiles: List[List[Tile]] = []
        self._generate()

    def _generate(self):
        random.seed(self.seed)
        self.tiles = []
        for y in range(self.height):
            row = []
            for x in range(self.width):
                tile = self._generate_tile(x, y)
                row.append(tile)
            self.tiles.append(row)

    def _generate_tile(self, x, y) -> Tile:
        # Simple noise-based generation
        cx, cy = self.width // 2, self.height // 2
        dist = ((x - cx)**2 + (y - cy)**2) ** 0.5

        # Water edges
        if dist > min(self.width, self.height) * 0.42:
            return Tile(TILE_WATER, passable=False)

        r = random.random()
        # River through center
        if abs(x - cx) < 3 and y > cy - 15:
            return Tile(TILE_WATER, passable=False, resource="water", resource_amount=999)

        if r < 0.18:
            return Tile(TILE_TREE, passable=False, resource="wood", resource_amount=random.randint(3,8))
        elif r < 0.22:
            return Tile(TILE_STONE, passable=True, resource="stone", resource_amount=random.randint(2,6))
        elif r < 0.26:
            return Tile(TILE_DIRT, passable=True, resource="berries" if random.random() < 0.3 else None,
                        resource_amount=random.randint(1,4))
        else:
            return Tile(TILE_GRASS, passable=True,
                        resource="berries" if random.random() < 0.05 else None,
                        resource_amount=random.randint(1,3))

    def get_tile(self, x, y) -> Optional[Tile]:
        if 0 <= x < self.width and 0 <= y < self.height:
            return self.tiles[y][x]
        return None

    def harvest_resource(self, x, y, amount=1) -> Optional[str]:
        tile = self.get_tile(x, y)
        if tile and tile.resource and tile.resource_amount > 0:
            tile.resource_amount -= amount
            resource = tile.resource
            if tile.resource_amount <= 0:
                tile.resource = None
                # Trees become stumps/dirt
                if tile.type == TILE_TREE:
                    tile.type = TILE_DIRT
                    tile.passable = True
            return resource
        return None

    def get_resources_near(self, x, y, radius=5) -> list:
        found = []
        for dy in range(-radius, radius+1):
            for dx in range(-radius, radius+1):
                nx, ny = x+dx, y+dy
                tile = self.get_tile(nx, ny)
                if tile and tile.resource:
                    found.append({"x": nx, "y": ny, "resource": tile.resource, "amount": tile.resource_amount})
        return found

Step 1.4 — entities/item.py

# entities/item.py
from dataclasses import dataclass, field
from typing import Optional, Dict, Any

@dataclass
class Item:
    id: str                      # unique slug: "cooked_meat", "stone_axe"
    name: str
    description: str
    category: str                # "food", "tool", "material", "fuel", "weapon", "clothing", "medicine"
    
    # Properties
    is_edible: bool = False
    nutrition: float = 0.0       # hunger restored
    hydration: float = 0.0       # thirst restored
    warmth_bonus: float = 0.0
    
    is_tool: bool = False
    tool_type: Optional[str] = None   # "axe", "knife", "hammer", "container"
    durability: int = 100
    
    is_fuel: bool = False
    burn_duration_ticks: int = 0
    
    is_wearable: bool = False
    warmth_rating: float = 0.0
    
    weight: float = 1.0
    stackable: bool = True
    quantity: int = 1
    
    # Discovery metadata
    discovered_by: Optional[str] = None
    discovered_on_day: int = 0
    
    # Custom properties (for crafting agent to add new props)
    extra: Dict[str, Any] = field(default_factory=dict)

    def to_dict(self):
        return {
            "id": self.id, "name": self.name, "description": self.description,
            "category": self.category, "is_edible": self.is_edible,
            "nutrition": self.nutrition, "is_tool": self.is_tool,
            "tool_type": self.tool_type, "is_fuel": self.is_fuel,
            "burn_duration_ticks": self.burn_duration_ticks,
            "is_wearable": self.is_wearable, "warmth_rating": self.warmth_rating,
            "weight": self.weight
        }

# ── Base Items Registry ──────────────────────────────────────────────────────
BASE_ITEMS = {
    "rock": Item("rock", "Rock", "A hard stone.", "material", weight=1.5, is_tool=True, tool_type="blunt"),
    "stick": Item("stick", "Stick", "A wooden branch.", "material", weight=0.5, is_fuel=True, burn_duration_ticks=50),
    "wood_log": Item("wood_log", "Wood Log", "A heavy log.", "material", is_fuel=True, burn_duration_ticks=200, weight=5.0),
    "raw_meat": Item("raw_meat", "Raw Meat", "Uncooked animal meat.", "food", is_edible=True, nutrition=20.0),
    "cooked_meat": Item("cooked_meat", "Cooked Meat", "Meat cooked over fire. Nutritious.", "food", is_edible=True, nutrition=60.0, warmth_bonus=5.0),
    "berries": Item("berries", "Wild Berries", "Sweet-tart wild berries.", "food", is_edible=True, nutrition=15.0, hydration=10.0),
    "water_skin": Item("water_skin", "Water (cupped)", "Water held in hands.", "food", is_edible=True, hydration=40.0),
    "fur": Item("fur", "Animal Fur", "Warm animal skin.", "material", is_wearable=True, warmth_rating=20.0),
    "bone": Item("bone", "Bone", "A hard animal bone.", "material"),
    "flint": Item("flint", "Flint", "Sharp-edged stone. Useful for cutting.", "material"),
    "grass_bundle": Item("grass_bundle", "Grass Bundle", "Dried grass, good for thatching.", "material", is_fuel=True, burn_duration_ticks=30),
    "mud": Item("mud", "Mud", "Wet earth. Can be shaped.", "material"),
    "clay": Item("clay", "Clay", "Moldable earth. Can be fired.", "material"),
}

# ── Recipe Registry ──────────────────────────────────────────────────────────
# Format: { "ingredient1+ingredient2": "output_item_id" }
BASE_RECIPES = {
    "raw_meat+fire": "cooked_meat",
    "stick+stick": "stick",    # rubbing sticks = fire (special case, handled separately)
    "rock+rock": "flint",
    "stick+flint": "crude_knife",
    "fur+grass_bundle": "basic_blanket",
    "mud+grass_bundle": "mud_brick",
    "wood_log+flint": "wood_plank",
}

# Fire-starting is a special action, not a recipe
FIRE_STARTER_COMBOS = [
    {"items": ["stick", "stick"], "method": "rubbing"},
    {"items": ["flint", "rock"], "method": "striking"},
]

Step 1.5 — entities/character.py

# entities/character.py
import random
from dataclasses import dataclass, field
from typing import List, Dict, Optional
from config import (MAX_HUNGER, MAX_THIRST, MAX_ENERGY, MAX_WARMTH, MAX_SOCIAL,
                    HUNGER_DECAY, THIRST_DECAY, ENERGY_DECAY, WARMTH_DECAY, SOCIAL_DECAY)

@dataclass
class Relationship:
    character_name: str
    bond_type: str          # "partner", "child", "parent", "friend", "rival"
    affection: float = 50.0  # 0-100

@dataclass
class Skill:
    name: str
    level: float = 0.0      # 0.0 - 100.0
    xp: float = 0.0

class Character:
    def __init__(self, name: str, sex: str, x: int, y: int, age_years: int = 18):
        # Identity
        self.name = name
        self.sex = sex           # "male" or "female"
        self.x = x
        self.y = y
        self.age_years = age_years
        self.age_days = 0        # days within current year
        self.is_alive = True
        self.is_pregnant = False
        self.pregnancy_days = 0
        self.pregnancy_days_total = 280  # simulated days

        # Needs (0=critical, 100=fully satisfied)
        self.hunger  = MAX_HUNGER * 0.7
        self.thirst  = MAX_THIRST * 0.7
        self.energy  = MAX_ENERGY * 0.8
        self.warmth  = MAX_WARMTH * 0.6
        self.social  = MAX_SOCIAL * 0.5

        # Inventory
        self.inventory: List[Dict] = []   # [{"item_id": str, "quantity": int}]
        self.equipped: Dict[str, str] = {}   # {"slot": "item_id"}
        self.max_carry_weight = 20.0

        # Skills
        self.skills: Dict[str, Skill] = {
            "foraging":     Skill("foraging"),
            "hunting":      Skill("hunting"),
            "crafting":     Skill("crafting"),
            "building":     Skill("building"),
            "farming":      Skill("farming"),
            "medicine":     Skill("medicine"),
            "cooking":      Skill("cooking"),
            "social":       Skill("social"),
            "fire_making":  Skill("fire_making"),
        }

        # Knowledge — what this character has personally discovered
        self.known_recipes: List[str] = []
        self.known_facts: List[str] = []   # e.g. "fire cooks meat", "water is near river"

        # Memory (last N decisions/events)
        self.memory_log: List[str] = []
        self.MAX_MEMORY = 20

        # Relationships
        self.relationships: List[Relationship] = []

        # Current state
        self.current_action: str = "idle"
        self.action_target: Optional[Dict] = None
        self.action_ticks_remaining: int = 0

        # Emotion / mood
        self.mood: str = "neutral"       # "happy", "sad", "afraid", "excited", "hungry", "tired"
        self.last_thought: str = ""

    # ── Needs management ────────────────────────────────────────────────────
    def decay_needs(self, is_night: bool, is_winter: bool, near_fire: bool):
        self.hunger = max(0, self.hunger - HUNGER_DECAY)
        self.thirst = max(0, self.thirst - THIRST_DECAY)
        
        # Energy decays faster at night if not sleeping
        energy_decay = ENERGY_DECAY * (1.5 if is_night and self.current_action != "sleep" else 1.0)
        self.energy = max(0, self.energy - energy_decay)
        
        warmth_decay = WARMTH_DECAY * (2.0 if is_winter else 1.0)
        if near_fire:
            self.warmth = min(MAX_WARMTH, self.warmth + 0.5)
        else:
            self.warmth = max(0, self.warmth - warmth_decay)
        
        self.social = max(0, self.social - SOCIAL_DECAY)

    @property
    def is_in_danger(self):
        return self.hunger < 20 or self.thirst < 15 or self.energy < 10 or self.warmth < 15

    @property
    def critical_need(self) -> Optional[str]:
        if self.thirst < 15: return "thirst"
        if self.hunger < 20: return "hunger"
        if self.energy < 10: return "energy"
        if self.warmth < 15: return "warmth"
        return None

    # ── Inventory ────────────────────────────────────────────────────────────
    def has_item(self, item_id: str, qty: int = 1) -> bool:
        for slot in self.inventory:
            if slot["item_id"] == item_id and slot["quantity"] >= qty:
                return True
        return False

    def add_item(self, item_id: str, quantity: int = 1):
        for slot in self.inventory:
            if slot["item_id"] == item_id:
                slot["quantity"] += quantity
                return
        self.inventory.append({"item_id": item_id, "quantity": quantity})

    def remove_item(self, item_id: str, quantity: int = 1) -> bool:
        for slot in self.inventory:
            if slot["item_id"] == item_id and slot["quantity"] >= quantity:
                slot["quantity"] -= quantity
                if slot["quantity"] == 0:
                    self.inventory.remove(slot)
                return True
        return False

    # ── Memory ───────────────────────────────────────────────────────────────
    def remember(self, event: str):
        self.memory_log.append(event)
        if len(self.memory_log) > self.MAX_MEMORY:
            self.memory_log.pop(0)

    # ── Skills ───────────────────────────────────────────────────────────────
    def gain_skill_xp(self, skill_name: str, xp: float):
        if skill_name in self.skills:
            skill = self.skills[skill_name]
            skill.xp += xp
            if skill.xp >= (skill.level + 1) * 10:
                skill.xp = 0
                skill.level = min(100, skill.level + 1)

    # ── State summary for LLM ─────────────────────────────────────────────────
    def get_state_summary(self, world_context: str = "") -> str:
        inv_summary = ", ".join([f"{s['item_id']}x{s['quantity']}" for s in self.inventory]) or "nothing"
        top_needs = []
        if self.hunger < 40:    top_needs.append(f"hungry({self.hunger:.0f})")
        if self.thirst < 40:    top_needs.append(f"thirsty({self.thirst:.0f})")
        if self.energy < 30:    top_needs.append(f"tired({self.energy:.0f})")
        if self.warmth < 30:    top_needs.append(f"cold({self.warmth:.0f})")
        if self.social < 30:    top_needs.append(f"lonely({self.social:.0f})")

        memory_str = " | ".join(self.memory_log[-5:]) if self.memory_log else "no memories yet"
        rel_str = ", ".join([f"{r.character_name}({r.bond_type})" for r in self.relationships]) or "none"

        skills_str = ", ".join([f"{k}:{int(v.level)}" for k,v in self.skills.items() if v.level > 0]) or "no skills yet"

        return f"""
Name: {self.name} | Sex: {self.sex} | Age: {self.age_years}y{self.age_days}d | Mood: {self.mood}
Needs: {', '.join(top_needs) if top_needs else 'all OK'}
Hunger:{self.hunger:.0f} Thirst:{self.thirst:.0f} Energy:{self.energy:.0f} Warmth:{self.warmth:.0f} Social:{self.social:.0f}
Inventory: {inv_summary}
Known recipes: {', '.join(self.known_recipes[:10]) or 'none'}
Skills: {skills_str}
Relationships: {rel_str}
Recent memory: {memory_str}
Current action: {self.current_action}
{f'World context: {world_context}' if world_context else ''}
""".strip()

PHASE 2 — THE AI AGENT BRAINS

Step 2.1 — agents/memory.py

# agents/memory.py
import sqlite3
import json
from config import WORLD_WIDTH_TILES

class WorldDatabase:
    """
    Persistent SQLite storage for world state, character history, and discoveries.
    """
    def __init__(self, db_path="data/world.db"):
        import os; os.makedirs("data", exist_ok=True)
        self.conn = sqlite3.connect(db_path)
        self._create_tables()

    def _create_tables(self):
        cur = self.conn.cursor()
        cur.executescript("""
            CREATE TABLE IF NOT EXISTS discoveries (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                item_id     TEXT NOT NULL,
                item_json   TEXT NOT NULL,
                recipe_key  TEXT,
                discovered_by TEXT,
                sim_day     INTEGER,
                sim_year    INTEGER,
                created_at  DATETIME DEFAULT CURRENT_TIMESTAMP
            );

            CREATE TABLE IF NOT EXISTS world_log (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                sim_day     INTEGER,
                sim_year    INTEGER,
                sim_hour    INTEGER,
                event_type  TEXT,
                description TEXT,
                character   TEXT,
                created_at  DATETIME DEFAULT CURRENT_TIMESTAMP
            );

            CREATE TABLE IF NOT EXISTS character_snapshots (
                id          INTEGER PRIMARY KEY AUTOINCREMENT,
                name        TEXT,
                snapshot_json TEXT,
                sim_year    INTEGER,
                sim_day     INTEGER,
                created_at  DATETIME DEFAULT CURRENT_TIMESTAMP
            );

            CREATE TABLE IF NOT EXISTS known_recipes (
                recipe_key  TEXT PRIMARY KEY,
                result_item TEXT,
                discovered_by TEXT,
                sim_year    INTEGER
            );
        """)
        self.conn.commit()

    def log_discovery(self, item, recipe_key, discovered_by, time_engine):
        cur = self.conn.cursor()
        cur.execute("""
            INSERT INTO discoveries (item_id, item_json, recipe_key, discovered_by, sim_day, sim_year)
            VALUES (?, ?, ?, ?, ?, ?)
        """, (item.id, json.dumps(item.to_dict()), recipe_key, discovered_by,
              time_engine.sim_day, time_engine.sim_year))
        self.conn.commit()

    def log_event(self, event_type, description, character, time_engine):
        cur = self.conn.cursor()
        cur.execute("""
            INSERT INTO world_log (sim_day, sim_year, sim_hour, event_type, description, character)
            VALUES (?, ?, ?, ?, ?, ?)
        """, (time_engine.sim_day, time_engine.sim_year, time_engine.sim_hour,
              event_type, description, character))
        self.conn.commit()

    def get_all_discoveries(self) -> list:
        cur = self.conn.cursor()
        cur.execute("SELECT item_json FROM discoveries ORDER BY id")
        return [json.loads(row[0]) for row in cur.fetchall()]

    def save_recipe(self, recipe_key, result_item, discovered_by, sim_year):
        cur = self.conn.cursor()
        cur.execute("""
            INSERT OR IGNORE INTO known_recipes (recipe_key, result_item, discovered_by, sim_year)
            VALUES (?, ?, ?, ?)
        """, (recipe_key, result_item, discovered_by, sim_year))
        self.conn.commit()

    def get_all_recipes(self) -> dict:
        cur = self.conn.cursor()
        cur.execute("SELECT recipe_key, result_item FROM known_recipes")
        return {row[0]: row[1] for row in cur.fetchall()}

Step 2.2 — agents/character_agent.py

This is the core LLM brain for every character.

# agents/character_agent.py
import httpx
import json
import asyncio
from config import OPENROUTER_API_KEY, LLM_MODEL, LLM_BASE_URL, LLM_MAX_TOKENS, LLM_TIMEOUT

SYSTEM_PROMPT = """You are the inner mind of a primitive human in a survival simulation.
You think with raw instinct and simple logic — no modern concepts.
You respond ONLY with a valid JSON object. No markdown, no explanation.

Your response must always be this exact JSON shape:
{
  "action": "<one of: gather_wood, gather_stone, gather_berries, drink_water, eat <item_id>, sleep, explore, make_fire, craft <item_id> from <ingredient1>+<ingredient2>, build_shelter, talk_to <character_name>, rest, hunt, farm_plant, farm_harvest, tend_to <character_name>, mate_with <character_name>>",
  "thought": "<1-2 sentences of raw instinct thinking>",
  "emotion": "<one of: content, happy, sad, afraid, angry, excited, tired, hungry, curious, loving>",
  "target_x": <integer or null>,
  "target_y": <integer or null>
}

Rules:
- Prioritize survival: thirst > hunger > warmth > energy > social
- Be realistic to the prehistoric era — no knowledge of metal, electricity, writing
- Learn from your memories — if you've seen fire cook meat, remember that
- Be drawn to your partner/family for social needs
- Explore when all needs are satisfied
- Use items you have in your inventory
"""

async def get_character_decision(character, world_context: str, nearby_chars: list) -> dict:
    """Ask the LLM what this character should do next."""
    
    nearby_str = ""
    if nearby_chars:
        nearby_str = "Nearby people: " + ", ".join([
            f"{c.name}({c.sex}, {c.current_action})" for c in nearby_chars
        ])

    user_prompt = f"{character.get_state_summary(world_context)}\n{nearby_str}"

    try:
        async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client:
            response = await client.post(
                LLM_BASE_URL,
                headers={
                    "Authorization": f"Bearer {OPENROUTER_API_KEY}",
                    "Content-Type": "application/json",
                    "HTTP-Referer": "https://civilizationsim.local",
                    "X-Title": "CivilizationSim"
                },
                json={
                    "model": LLM_MODEL,
                    "max_tokens": LLM_MAX_TOKENS,
                    "messages": [
                        {"role": "system", "content": SYSTEM_PROMPT},
                        {"role": "user",   "content": user_prompt}
                    ]
                }
            )
            data = response.json()
            text = data["choices"][0]["message"]["content"]
            # Strip possible markdown fences
            text = text.replace("```json", "").replace("```", "").strip()
            return json.loads(text)
    except Exception as e:
        # Fallback decision based on critical need
        fallback_actions = {
            "thirst":  {"action": "drink_water", "thought": "Need water.", "emotion": "hungry", "target_x": None, "target_y": None},
            "hunger":  {"action": "gather_berries", "thought": "Need food.", "emotion": "hungry", "target_x": None, "target_y": None},
            "energy":  {"action": "sleep", "thought": "Must rest.", "emotion": "tired", "target_x": None, "target_y": None},
            "warmth":  {"action": "make_fire", "thought": "Too cold.", "emotion": "afraid", "target_x": None, "target_y": None},
        }
        need = character.critical_need
        if need and need in fallback_actions:
            return fallback_actions[need]
        return {"action": "explore", "thought": "Looking around.", "emotion": "curious", "target_x": None, "target_y": None}

Step 2.3 — agents/crafting_agent.py

# agents/crafting_agent.py
import httpx
import json
import asyncio
from config import OPENROUTER_API_KEY, LLM_MODEL, LLM_BASE_URL, LLM_TIMEOUT

CRAFTING_SYSTEM_PROMPT = """You are a prehistoric technology logic engine for a civilization simulator.
Your job: given two items that a human is trying to combine, decide what new item results.

Respond ONLY with valid JSON. No markdown, no explanation.

JSON shape:
{
  "success": true or false,
  "result_item_id": "snake_case_item_name",
  "result_item_name": "Human Readable Name",
  "result_description": "One sentence description.",
  "category": "food | tool | material | fuel | clothing | medicine | weapon | building_material",
  "is_edible": false,
  "nutrition": 0,
  "hydration": 0,
  "is_tool": false,
  "tool_type": null,
  "is_fuel": false,
  "burn_duration_ticks": 0,
  "is_wearable": false,
  "warmth_rating": 0,
  "weight": 1.0,
  "fail_reason": null
}

Rules you must follow:
- Only accept combinations that make PHYSICAL sense in prehistoric times
- No electricity, no metal smelting, no written language — not discovered yet
- Fire + raw food = cooked version
- Stone + Stone (struck) = flint or sparks
- Stick + Flint = crude cutting tool
- Wood + Mud = reinforced material
- Animal hide + plant fibers = crude clothing
- Clay + Fire = pottery (if pottery not yet discovered)
- If the combination makes no sense: success=false, give a fail_reason
- Be creative but grounded in real prehistoric logic
- If both items are the same and rubbing makes sense (stick+stick), produce sparks/fire starter
"""

async def evaluate_crafting_combination(item1_id: str, item2_id: str, 
                                         known_items: list, character_name: str,
                                         context: str = "") -> dict:
    """
    Ask the crafting agent what results from combining two items.
    Returns a dict with result item definition.
    """
    known_str = ", ".join(known_items[:30])
    prompt = f"""
Character '{character_name}' is trying to combine: [{item1_id}] and [{item2_id}]
Already known items in this world: {known_str}
Additional context: {context or 'none'}

What does combining these produce?
"""
    try:
        async with httpx.AsyncClient(timeout=15.0) as client:
            response = await client.post(
                LLM_BASE_URL,
                headers={
                    "Authorization": f"Bearer {OPENROUTER_API_KEY}",
                    "Content-Type": "application/json",
                    "HTTP-Referer": "https://civilizationsim.local",
                    "X-Title": "CivilizationSim-Crafting"
                },
                json={
                    "model": LLM_MODEL,
                    "max_tokens": 400,
                    "messages": [
                        {"role": "system", "content": CRAFTING_SYSTEM_PROMPT},
                        {"role": "user",   "content": prompt}
                    ]
                }
            )
            data = response.json()
            text = data["choices"][0]["message"]["content"]
            text = text.replace("```json", "").replace("```", "").strip()
            return json.loads(text)
    except Exception as e:
        return {"success": False, "fail_reason": f"Agent error: {str(e)}"}

PHASE 3 — SYSTEMS

Step 3.1 — systems/needs.py

# systems/needs.py
from entities.item import BASE_ITEMS
from config import MAX_HUNGER, MAX_THIRST, MAX_ENERGY, MAX_WARMTH, MAX_SOCIAL

def process_eat(character, item_id: str) -> str:
    """Character eats an item. Returns result message."""
    item_def = BASE_ITEMS.get(item_id)
    if not item_def or not item_def.is_edible:
        return f"{character.name} tries to eat {item_id} but can't."
    if not character.has_item(item_id):
        return f"{character.name} has no {item_id} to eat."
    
    character.remove_item(item_id)
    character.hunger  = min(MAX_HUNGER, character.hunger + item_def.nutrition)
    character.thirst  = min(MAX_THIRST, character.thirst + item_def.hydration)
    character.warmth  = min(MAX_WARMTH, character.warmth + item_def.warmth_bonus)
    character.gain_skill_xp("cooking", 1.0)
    return f"{character.name} eats {item_def.name}. Hunger: {character.hunger:.0f}"

def process_drink(character, world_map) -> str:
    """Character drinks water from nearest water source."""
    tile = world_map.get_tile(character.x, character.y)
    # Check adjacent tiles for water
    for dx, dy in [(0,0),(1,0),(-1,0),(0,1),(0,-1)]:
        t = world_map.get_tile(character.x+dx, character.y+dy)
        if t and t.type == "water":
            character.thirst = min(MAX_THIRST, character.thirst + 50)
            return f"{character.name} drinks from the water. Thirst: {character.thirst:.0f}"
    return f"{character.name} looks for water but finds none nearby."

def process_sleep(character) -> str:
    character.energy = min(MAX_ENERGY, character.energy + 2.0)
    return f"{character.name} sleeps. Energy: {character.energy:.0f}"

def check_survival(character, time_engine, db) -> bool:
    """Returns False if character dies."""
    if character.hunger <= 0:
        character.is_alive = False
        db.log_event("death", f"{character.name} died of starvation on {time_engine.describe()}",
                     character.name, time_engine)
        return False
    if character.thirst <= 0:
        character.is_alive = False
        db.log_event("death", f"{character.name} died of thirst on {time_engine.describe()}",
                     character.name, time_engine)
        return False
    # Age-related death
    if character.age_years > 60 and character.energy < 5:
        character.is_alive = False
        db.log_event("death", f"{character.name} died of old age at {character.age_years}.",
                     character.name, time_engine)
        return False
    return True

Step 3.2 — systems/crafting.py

# systems/crafting.py
import asyncio
from entities.item import Item, BASE_ITEMS, BASE_RECIPES
from agents.crafting_agent import evaluate_crafting_combination

# Runtime item registry (starts with base, grows with discoveries)
ITEM_REGISTRY = dict(BASE_ITEMS)
RECIPE_REGISTRY = dict(BASE_RECIPES)

def make_recipe_key(item1_id: str, item2_id: str) -> str:
    """Canonical recipe key (order-independent)."""
    return "+".join(sorted([item1_id, item2_id]))

def try_craft(character, item1_id: str, item2_id: str, world_context: str = "") -> dict:
    """
    Attempt to combine two items. 
    Returns: {"success": bool, "result_item_id": str, "message": str, "new_discovery": bool}
    """
    recipe_key = make_recipe_key(item1_id, item2_id)
    
    # Check known recipes first
    if recipe_key in RECIPE_REGISTRY:
        result_id = RECIPE_REGISTRY[recipe_key]
        if result_id in ITEM_REGISTRY:
            result_item = ITEM_REGISTRY[result_id]
            if character.has_item(item1_id) and character.has_item(item2_id):
                character.remove_item(item1_id)
                character.remove_item(item2_id)
                character.add_item(result_id)
                character.gain_skill_xp("crafting", 3.0)
                if recipe_key not in character.known_recipes:
                    character.known_recipes.append(recipe_key)
                return {
                    "success": True,
                    "result_item_id": result_id,
                    "message": f"{character.name} crafts {result_item.name}!",
                    "new_discovery": False
                }
    
    # Unknown recipe — ask crafting agent (async, wrapped)
    return {"success": False, "message": "Unknown recipe. Will ask crafting agent.", 
            "needs_agent": True, "recipe_key": recipe_key}

async def try_craft_with_agent(character, item1_id: str, item2_id: str, 
                                known_item_ids: list, db, time_engine) -> dict:
    """Full crafting attempt including LLM evaluation."""
    recipe_key = make_recipe_key(item1_id, item2_id)
    
    if recipe_key in RECIPE_REGISTRY:
        # Already known — process normally
        return try_craft(character, item1_id, item2_id)
    
    if not character.has_item(item1_id) or not character.has_item(item2_id):
        return {"success": False, "message": f"{character.name} doesn't have both items."}

    result = await evaluate_crafting_combination(
        item1_id, item2_id, known_item_ids, character.name
    )
    
    if result.get("success"):
        # Register new item and recipe
        new_item = Item(
            id=result["result_item_id"],
            name=result["result_item_name"],
            description=result["result_description"],
            category=result.get("category", "material"),
            is_edible=result.get("is_edible", False),
            nutrition=result.get("nutrition", 0),
            hydration=result.get("hydration", 0),
            is_tool=result.get("is_tool", False),
            tool_type=result.get("tool_type"),
            is_fuel=result.get("is_fuel", False),
            burn_duration_ticks=result.get("burn_duration_ticks", 0),
            is_wearable=result.get("is_wearable", False),
            warmth_rating=result.get("warmth_rating", 0),
            weight=result.get("weight", 1.0),
            discovered_by=character.name,
            discovered_on_day=time_engine.sim_day
        )
        ITEM_REGISTRY[new_item.id] = new_item
        RECIPE_REGISTRY[recipe_key] = new_item.id
        
        character.remove_item(item1_id)
        character.remove_item(item2_id)
        character.add_item(new_item.id)
        character.known_recipes.append(recipe_key)
        character.gain_skill_xp("crafting", 10.0)
        
        db.log_discovery(new_item, recipe_key, character.name, time_engine)
        db.save_recipe(recipe_key, new_item.id, character.name, time_engine.sim_year)
        
        return {
            "success": True,
            "result_item_id": new_item.id,
            "message": f"🔥 NEW DISCOVERY! {character.name} creates {new_item.name}!",
            "new_discovery": True,
            "item": new_item
        }
    else:
        return {"success": False, "message": f"{character.name} tries combining {item1_id}+{item2_id} — nothing works. {result.get('fail_reason','')}" }

Step 3.3 — systems/social.py

# systems/social.py
import random
from entities.character import Character, Relationship
from config import MAX_SOCIAL

def process_talk(char_a: Character, char_b: Character, time_engine, db) -> str:
    """Two characters interact socially."""
    if not char_b or not char_b.is_alive:
        return f"{char_a.name} looks for {char_b.name if char_b else 'someone'} but they're gone."
    
    # Boost social need
    char_a.social = min(MAX_SOCIAL, char_a.social + 15)
    char_b.social = min(MAX_SOCIAL, char_b.social + 15)
    char_a.gain_skill_xp("social", 1.0)

    # Update relationship
    _update_relationship(char_a, char_b, 2.0)
    _update_relationship(char_b, char_a, 2.0)

    # Knowledge sharing
    shared = []
    for recipe in char_a.known_recipes:
        if recipe not in char_b.known_recipes:
            char_b.known_recipes.append(recipe)
            shared.append(recipe)
    for fact in char_a.known_facts:
        if fact not in char_b.known_facts:
            char_b.known_facts.append(fact)

    msg = f"{char_a.name} and {char_b.name} spend time together."
    if shared:
        msg += f" {char_a.name} teaches {char_b.name}: {shared[0]}."
    return msg

def try_reproduce(char_a: Character, char_b: Character, time_engine, db) -> dict:
    """Attempt reproduction between two partners."""
    if char_a.sex == char_b.sex:
        return {"success": False, "message": "Same sex — no reproduction."}
    
    female = char_a if char_a.sex == "female" else char_b
    male   = char_b if char_a.sex == "female" else char_a
    
    if female.is_pregnant:
        return {"success": False, "message": f"{female.name} is already pregnant."}
    
    # Check age and health
    if female.age_years < 14 or female.age_years > 45:
        return {"success": False, "message": "Not the right age for reproduction."}
    
    if female.hunger < 30 or female.energy < 30:
        return {"success": False, "message": "Too weak to reproduce right now."}
    
    # Success chance
    rel = _get_relationship(female, male.name)
    affection = rel.affection if rel else 50.0
    chance = 0.3 * (affection / 100)
    
    if random.random() < chance:
        female.is_pregnant = True
        female.pregnancy_days = 0
        _update_relationship(char_a, char_b, 10.0)
        _update_relationship(char_b, char_a, 10.0)
        
        # Set partner relationship if not already
        if not _get_relationship(female, male.name):
            female.relationships.append(Relationship(male.name, "partner", 70.0))
            male.relationships.append(Relationship(female.name, "partner", 70.0))
        
        db.log_event("reproduction", f"{female.name} becomes pregnant (father: {male.name})",
                     female.name, time_engine)
        return {"success": True, "message": f"{female.name} is now pregnant!"}
    
    return {"success": False, "message": f"{char_a.name} and {char_b.name} are intimate but no pregnancy."}

def process_pregnancy(female: Character, all_characters: list, time_engine, db) -> dict:
    """Advance pregnancy. Returns new character if birth occurs."""
    if not female.is_pregnant:
        return {}
    
    female.pregnancy_days += 1
    # Needs decay faster during pregnancy
    female.hunger = max(0, female.hunger - 0.05)
    
    if female.pregnancy_days >= female.pregnancy_days_total:
        female.is_pregnant = False
        female.pregnancy_days = 0
        
        # Name the child
        baby_names_m = ["Rok", "Tor", "Brak", "Zan", "Dak", "Kem", "Vol"]
        baby_names_f = ["Ara", "Mira", "Sela", "Duna", "Kira", "Ona", "Vea"]
        sex = random.choice(["male", "female"])
        name = random.choice(baby_names_m if sex == "male" else baby_names_f)
        
        baby = Character(name=name, sex=sex, x=female.x, y=female.y, age_years=0)
        baby.hunger = 80
        baby.thirst = 80
        baby.energy = 90
        
        # Relationship
        female.relationships.append(Relationship(baby.name, "child", 90.0))
        
        db.log_event("birth", f"{female.name} gives birth to {baby.name} ({sex})",
                     female.name, time_engine)
        return {"birth": True, "baby": baby, "message": f"👶 {female.name} gives birth to {baby.name}!"}
    
    return {}

def _get_relationship(char: Character, other_name: str):
    for r in char.relationships:
        if r.character_name == other_name:
            return r
    return None

def _update_relationship(char: Character, other: Character, delta: float):
    rel = _get_relationship(char, other.name)
    if rel:
        rel.affection = min(100, max(0, rel.affection + delta))
    else:
        char.relationships.append(Relationship(other.name, "acquaintance", 50.0 + delta))

PHASE 4 — RENDERER

Step 4.1 — renderer/tile_renderer.py

# renderer/tile_renderer.py
import pygame
from config import TILE_SIZE, COLOR_GRASS, COLOR_WATER, COLOR_DIRT, COLOR_STONE, COLOR_SAND

TILE_COLORS = {
    "grass": COLOR_GRASS,
    "water": COLOR_WATER,
    "dirt":  COLOR_DIRT,
    "stone": COLOR_STONE,
    "sand":  COLOR_SAND,
    "tree":  (40, 80, 40),
}

RESOURCE_DOTS = {
    "berries": (180, 60, 80),
    "wood":    (100, 60, 30),
    "stone":   (160, 160, 140),
    "water":   (80, 150, 220),
}

def render_tiles(surface, world_map, camera_x, camera_y):
    screen_w, screen_h = surface.get_size()
    start_tx = max(0, camera_x // TILE_SIZE)
    start_ty = max(0, camera_y // TILE_SIZE)
    end_tx   = min(world_map.width,  start_tx + screen_w // TILE_SIZE + 2)
    end_ty   = min(world_map.height, start_ty + screen_h // TILE_SIZE + 2)

    for ty in range(start_ty, end_ty):
        for tx in range(start_tx, end_tx):
            tile = world_map.get_tile(tx, ty)
            if not tile:
                continue
            sx = tx * TILE_SIZE - camera_x
            sy = ty * TILE_SIZE - camera_y
            color = TILE_COLORS.get(tile.type, (80, 80, 80))
            pygame.draw.rect(surface, color, (sx, sy, TILE_SIZE-1, TILE_SIZE-1))
            
            # Resource indicator dot
            if tile.resource and tile.resource_amount > 0:
                dot_color = RESOURCE_DOTS.get(tile.resource, (255,255,0))
                pygame.draw.circle(surface, dot_color,
                                   (sx + TILE_SIZE//2, sy + TILE_SIZE//2), 3)

Step 4.2 — renderer/entity_renderer.py

# renderer/entity_renderer.py
import pygame
from config import TILE_SIZE, COLOR_ACCENT, COLOR_TEXT

CHAR_COLORS = {
    "male":   (100, 150, 220),
    "female": (220, 130, 160),
}
CHILD_COLOR = (180, 220, 140)

def render_characters(surface, characters, camera_x, camera_y, selected_char, font):
    for char in characters:
        if not char.is_alive:
            continue
        sx = char.x * TILE_SIZE - camera_x + TILE_SIZE // 2
        sy = char.y * TILE_SIZE - camera_y + TILE_SIZE // 2
        
        if not (0 <= sx <= surface.get_width() and 0 <= sy <= surface.get_height()):
            continue
        
        # Body
        color = CHILD_COLOR if char.age_years < 12 else CHAR_COLORS.get(char.sex, (200,200,200))
        radius = 6 if char.age_years >= 12 else 4
        pygame.draw.circle(surface, color, (sx, sy), radius)
        
        # Selection ring
        if selected_char and selected_char.name == char.name:
            pygame.draw.circle(surface, COLOR_ACCENT, (sx, sy), radius + 3, 2)
        
        # Name label
        lbl = font.render(char.name, True, COLOR_TEXT)
        surface.blit(lbl, (sx - lbl.get_width()//2, sy - radius - 14))
        
        # Thought bubble (last thought)
        if char.last_thought:
            thought_lbl = font.render(f"💭 {char.last_thought[:30]}", True, (200, 200, 160))
            surface.blit(thought_lbl, (sx - thought_lbl.get_width()//2, sy - radius - 28))

def render_fires(surface, fires, camera_x, camera_y):
    """Render active fire objects."""
    for fire in fires:
        sx = fire["x"] * TILE_SIZE - camera_x + TILE_SIZE // 2
        sy = fire["y"] * TILE_SIZE - camera_y + TILE_SIZE // 2
        # Animated glow
        pygame.draw.circle(surface, (220, 80, 20), (sx, sy), 8)
        pygame.draw.circle(surface, (240, 160, 40), (sx, sy), 5)
        pygame.draw.circle(surface, (255, 220, 120), (sx, sy), 2)

Step 4.3 — renderer/hud.py

# renderer/hud.py
import pygame
from config import (SCREEN_WIDTH, SCREEN_HEIGHT, COLOR_BG_PANEL, COLOR_TEXT,
                    COLOR_TEXT_DIM, COLOR_ACCENT, COLOR_DANGER, COLOR_GOOD,
                    MAX_HUNGER, MAX_THIRST, MAX_ENERGY, MAX_WARMTH, MAX_SOCIAL)

PANEL_W = 280
EVENT_LOG_H = 180
MAX_LOG_LINES = 12

event_log = []

def add_event(message: str):
    event_log.append(message)
    if len(event_log) > MAX_LOG_LINES * 3:
        event_log.pop(0)

def _bar(surface, x, y, w, h, value, max_val, color, font, label):
    bg_rect = pygame.Rect(x, y, w, h)
    pygame.draw.rect(surface, (40, 38, 45), bg_rect, border_radius=3)
    fill_w = int((value / max_val) * w)
    if fill_w > 0:
        fill_color = COLOR_DANGER if value < max_val * 0.2 else color
        pygame.draw.rect(surface, fill_color, pygame.Rect(x, y, fill_w, h), border_radius=3)
    lbl = font.render(f"{label}: {value:.0f}", True, COLOR_TEXT)
    surface.blit(lbl, (x, y - 13))

def render_character_panel(surface, character, font_small, font_normal):
    if not character:
        return
    panel_x = SCREEN_WIDTH - PANEL_W - 8
    panel_y = 8
    panel_h = SCREEN_HEIGHT // 2 - 20
    
    # Background
    panel_surf = pygame.Surface((PANEL_W, panel_h), pygame.SRCALPHA)
    panel_surf.fill((*COLOR_BG_PANEL, 210))
    surface.blit(panel_surf, (panel_x, panel_y))
    pygame.draw.rect(surface, COLOR_ACCENT, (panel_x, panel_y, PANEL_W, panel_h), 1, border_radius=4)
    
    x, y = panel_x + 10, panel_y + 10
    
    # Name
    name_lbl = font_normal.render(f"{character.name} ({character.sex}, {character.age_years}y)", True, COLOR_ACCENT)
    surface.blit(name_lbl, (x, y)); y += 22
    
    # Status
    action_lbl = font_small.render(f"Action: {character.current_action}", True, COLOR_TEXT_DIM)
    surface.blit(action_lbl, (x, y)); y += 16
    mood_lbl = font_small.render(f"Mood: {character.mood}", True, COLOR_TEXT_DIM)
    surface.blit(mood_lbl, (x, y)); y += 20
    
    # Need bars
    bar_w = PANEL_W - 20
    bar_h = 8
    bars = [
        ("Hunger",  character.hunger,  MAX_HUNGER,  (180, 140, 60)),
        ("Thirst",  character.thirst,  MAX_THIRST,  (60, 140, 200)),
        ("Energy",  character.energy,  MAX_ENERGY,  (140, 100, 200)),
        ("Warmth",  character.warmth,  MAX_WARMTH,  (200, 100, 60)),
        ("Social",  character.social,  MAX_SOCIAL,  (100, 180, 140)),
    ]
    for label, val, max_val, color in bars:
        _bar(surface, x, y + 13, bar_w, bar_h, val, max_val, color, font_small, label)
        y += 28
    
    y += 6
    # Inventory
    inv_lbl = font_small.render("Inventory:", True, COLOR_TEXT)
    surface.blit(inv_lbl, (x, y)); y += 14
    for slot in character.inventory[:8]:
        item_lbl = font_small.render(f"  {slot['item_id']} x{slot['quantity']}", True, COLOR_TEXT_DIM)
        surface.blit(item_lbl, (x, y)); y += 13
    
    y += 4
    # Skills
    skill_lbl = font_small.render("Skills:", True, COLOR_TEXT)
    surface.blit(skill_lbl, (x, y)); y += 14
    for sk_name, sk in character.skills.items():
        if sk.level > 0:
            s = font_small.render(f"  {sk_name}: {sk.level:.0f}", True, COLOR_TEXT_DIM)
            surface.blit(s, (x, y)); y += 13

def render_time_bar(surface, time_engine, font_normal, font_small):
    """Top bar showing time, year, season."""
    bar_h = 30
    pygame.draw.rect(surface, (*COLOR_BG_PANEL, 220), (0, 0, SCREEN_WIDTH - PANEL_W - 16, bar_h))
    t_str = time_engine.describe()
    t_lbl = font_normal.render(t_str, True, COLOR_ACCENT)
    surface.blit(t_lbl, (10, 7))
    
    phase = "🌙 Night" if time_engine.is_night else "☀️ Day"
    phase_lbl = font_small.render(phase, True, COLOR_TEXT_DIM)
    surface.blit(phase_lbl, (t_lbl.get_width() + 30, 9))

def render_event_log(surface, font_small):
    """Bottom event log panel."""
    log_x, log_y = 8, SCREEN_HEIGHT - EVENT_LOG_H - 8
    log_w = SCREEN_WIDTH - PANEL_W - 24
    log_surf = pygame.Surface((log_w, EVENT_LOG_H), pygame.SRCALPHA)
    log_surf.fill((*COLOR_BG_PANEL, 180))
    surface.blit(log_surf, (log_x, log_y))
    pygame.draw.rect(surface, (60, 58, 65), (log_x, log_y, log_w, EVENT_LOG_H), 1, border_radius=3)
    
    visible = event_log[-(MAX_LOG_LINES):]
    for i, line in enumerate(visible):
        alpha = int(200 * ((i+1) / len(visible))) if visible else 200
        color = COLOR_TEXT if i == len(visible)-1 else COLOR_TEXT_DIM
        lbl = font_small.render(line[:100], True, color)
        surface.blit(lbl, (log_x + 8, log_y + 8 + i * 14))

def render_discovery_popup(surface, message, font_normal, tick):
    """Flash a discovery popup for ~3 seconds."""
    if tick <= 0:
        return
    alpha = min(255, tick * 8)
    popup_w, popup_h = 600, 60
    px = (SCREEN_WIDTH - popup_w) // 2
    py = SCREEN_HEIGHT // 2 - 80
    pop_surf = pygame.Surface((popup_w, popup_h), pygame.SRCALPHA)
    pop_surf.fill((50, 40, 20, alpha))
    lbl = font_normal.render(message, True, COLOR_HIGHLIGHT)
    pop_surf.blit(lbl, ((popup_w - lbl.get_width())//2, (popup_h - lbl.get_height())//2))
    surface.blit(pop_surf, (px, py))

PHASE 5 — MAIN GAME LOOP

Step 5.1 — main.py

# main.py
import pygame
import asyncio
import sys
import random
from config import *
from engine.world import WorldMap
from engine.time_engine import TimeEngine
from entities.character import Character
from agents.character_agent import get_character_decision
from agents.memory import WorldDatabase
from systems import needs as needs_sys
from systems import crafting as crafting_sys
from systems import social as social_sys
from renderer import tile_renderer, entity_renderer
from renderer import hud
from renderer.camera import Camera

# ── Camera ─────────────────────────────────────────────────────────────────
class Camera:
    def __init__(self):
        self.x = 0; self.y = 0
        self.speed = 4
    def move(self, dx, dy):
        self.x = max(0, min(self.x + dx * self.speed, WORLD_WIDTH_TILES * TILE_SIZE - SCREEN_WIDTH))
        self.y = max(0, min(self.y + dy * TILE_SIZE, WORLD_HEIGHT_TILES * TILE_SIZE - SCREEN_HEIGHT))
    def center_on(self, char):
        self.x = max(0, char.x * TILE_SIZE - SCREEN_WIDTH // 2)
        self.y = max(0, char.y * TILE_SIZE - SCREEN_HEIGHT // 2)

# ── World fires (active fire objects) ──────────────────────────────────────
fires = []   # [{"x": int, "y": int, "fuel_ticks": int}]
popup_message = ""
popup_ticks = 0

def get_world_context(char, world_map, time_engine):
    nearby_resources = world_map.get_resources_near(char.x, char.y, radius=6)
    res_str = ", ".join([f"{r['resource']} at ({r['x']},{r['y']})" for r in nearby_resources[:5]])
    near_fire = any(abs(f["x"]-char.x) <= 3 and abs(f["y"]-char.y) <= 3 for f in fires)
    return (f"Location:({char.x},{char.y}). Nearby resources: {res_str or 'none'}. "
            f"Time:{time_engine.describe()}. Near fire: {near_fire}. "
            f"Season:{time_engine.season}. Night:{time_engine.is_night}.")

async def execute_action(char, decision, world_map, all_chars, time_engine, db):
    """Execute the character's LLM decision."""
    global fires, popup_message, popup_ticks
    action = decision.get("action", "explore")
    char.current_action = action
    char.last_thought = decision.get("thought", "")[:40]
    char.mood = decision.get("emotion", "neutral")
    
    msg = ""
    
    if action.startswith("eat "):
        item_id = action.split(" ", 1)[1].strip()
        msg = needs_sys.process_eat(char, item_id)

    elif action == "drink_water":
        msg = needs_sys.process_drink(char, world_map)

    elif action == "sleep":
        msg = needs_sys.process_sleep(char)

    elif action == "gather_wood":
        tile = world_map.get_tile(char.x, char.y)
        for dx,dy in [(0,0),(1,0),(-1,0),(0,1),(0,-1),(1,1),(-1,-1)]:
            t = world_map.get_tile(char.x+dx, char.y+dy)
            if t and t.resource == "wood":
                wood = world_map.harvest_resource(char.x+dx, char.y+dy)
                if wood:
                    char.add_item("wood_log")
                    char.add_item("stick", random.randint(1,2))
                    char.gain_skill_xp("foraging", 2.0)
                    msg = f"{char.name} chops wood. Gets wood_log + sticks."
                    break
        msg = msg or f"{char.name} looks for wood but finds none close."

    elif action == "gather_stone":
        for dx,dy in [(0,0),(1,0),(-1,0),(0,1),(0,-1)]:
            t = world_map.get_tile(char.x+dx, char.y+dy)
            if t and t.resource == "stone":
                world_map.harvest_resource(char.x+dx, char.y+dy)
                char.add_item("rock")
                char.gain_skill_xp("foraging", 1.0)
                msg = f"{char.name} collects rocks."
                break
        msg = msg or f"{char.name} searches for stone."

    elif action == "gather_berries":
        for dx,dy in [(0,0),(1,0),(-1,0),(0,1),(0,-1),(0,-2),(0,2)]:
            t = world_map.get_tile(char.x+dx, char.y+dy)
            if t and t.resource == "berries":
                world_map.harvest_resource(char.x+dx, char.y+dy)
                char.add_item("berries", random.randint(2,5))
                char.gain_skill_xp("foraging", 1.0)
                msg = f"{char.name} picks berries."
                break
        msg = msg or f"{char.name} searches for berries."

    elif action == "make_fire":
        if char.has_item("stick") and (char.has_item("stick") or char.has_item("flint")):
            fire = {"x": char.x, "y": char.y, "fuel_ticks": 500}
            fires.append(fire)
            char.remove_item("stick", 2)
            char.gain_skill_xp("fire_making", 5.0)
            msg = f"🔥 {char.name} starts a fire!"
            char.known_facts.append("fire cooks meat and provides warmth")
            db.log_event("discovery", f"{char.name} makes fire!", char.name, time_engine)
        else:
            msg = f"{char.name} tries to make fire but lacks materials (needs 2 sticks or flint)."

    elif action.startswith("craft "):
        parts = action.replace("craft ", "").split(" from ")
        if len(parts) == 2:
            target = parts[0].strip()
            ingredients = [i.strip() for i in parts[1].split("+")]
            if len(ingredients) >= 2:
                result = await crafting_sys.try_craft_with_agent(
                    char, ingredients[0], ingredients[1],
                    list(crafting_sys.ITEM_REGISTRY.keys()), db, time_engine
                )
                msg = result["message"]
                if result.get("new_discovery"):
                    popup_message = msg
                    popup_ticks = 180
        msg = msg or f"{char.name} attempts to craft something."

    elif action.startswith("talk_to "):
        target_name = action.split("talk_to ", 1)[1].strip()
        target_char = next((c for c in all_chars if c.name == target_name and c.is_alive), None)
        if target_char:
            msg = social_sys.process_talk(char, target_char, time_engine, db)
        else:
            msg = f"{char.name} looks for {target_name} to talk but doesn't find them."

    elif action.startswith("mate_with "):
        target_name = action.split("mate_with ", 1)[1].strip()
        target_char = next((c for c in all_chars if c.name == target_name and c.is_alive), None)
        if target_char:
            result = social_sys.try_reproduce(char, target_char, time_engine, db)
            msg = result["message"]
        else:
            msg = f"{char.name} is looking for {target_name}."

    elif action == "explore":
        # Move randomly
        dx, dy = random.choice([(1,0),(-1,0),(0,1),(0,-1),(1,1),(-1,1),(1,-1),(-1,-1)])
        nx, ny = char.x + dx, char.y + dy
        tile = world_map.get_tile(nx, ny)
        if tile and tile.passable:
            char.x, char.y = nx, ny
        msg = f"{char.name} explores."

    elif action == "rest":
        char.energy = min(MAX_ENERGY, char.energy + 1.0)
        msg = f"{char.name} rests."

    else:
        # Movement toward target
        if decision.get("target_x") is not None:
            tx, ty = decision["target_x"], decision["target_y"]
            if tx != char.x:
                step_x = 1 if tx > char.x else -1
                if world_map.get_tile(char.x + step_x, char.y) and world_map.get_tile(char.x + step_x, char.y).passable:
                    char.x += step_x
            elif ty != char.y:
                step_y = 1 if ty > char.y else -1
                if world_map.get_tile(char.x, char.y + step_y) and world_map.get_tile(char.x, char.y + step_y).passable:
                    char.y += step_y
        msg = f"{char.name} acts: {action}"

    char.remember(msg)
    if msg:
        hud.add_event(f"[{time_engine.describe()}] {msg}")

def main():
    global popup_message, popup_ticks

    pygame.init()
    screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
    pygame.display.set_caption(WINDOW_TITLE)
    clock = pygame.time.Clock()

    # Fonts
    font_small  = pygame.font.SysFont("monospace", FONT_SIZE_SMALL)
    font_normal = pygame.font.SysFont("monospace", FONT_SIZE_NORMAL)

    # Initialize world
    world_map   = WorldMap()
    time_engine = TimeEngine()
    db          = WorldDatabase()
    camera      = Camera()

    # Spawn starting characters
    characters = []
    for spec in STARTING_CHARACTERS:
        char = Character(spec["name"], spec["sex"], spec["x"], spec["y"])
        char.add_item("rock", 2)
        char.add_item("stick", 3)
        char.add_item("berries", 4)
        characters.append(char)
    
    selected_char = characters[0]
    camera.center_on(selected_char)

    # Timing
    tick_count = 0
    char_think_counter = {c.name: 0 for c in characters}

    hud.add_event("=== CivilizationSim Started ===")
    hud.add_event(f"World seed: {world_map.seed}")
    hud.add_event(f"Characters: {', '.join(c.name for c in characters)}")

    running = True
    while running:
        dt = clock.tick(FPS)

        # ── Events ──────────────────────────────────────────────────────────
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_ESCAPE:
                    running = False
                elif event.key == pygame.K_TAB:
                    # Cycle selected character
                    idx = characters.index(selected_char) if selected_char in characters else 0
                    selected_char = characters[(idx+1) % len(characters)]
                    camera.center_on(selected_char)
                elif event.key == pygame.K_f:
                    # Follow selected
                    camera.center_on(selected_char)
                elif event.key == pygame.K_PLUS or event.key == pygame.K_EQUALS:
                    pass  # Speed up — implement if needed
            elif event.type == pygame.MOUSEBUTTONDOWN:
                mx, my = event.pos
                # Click to select character
                for char in characters:
                    sx = char.x * TILE_SIZE - camera.x + TILE_SIZE//2
                    sy = char.y * TILE_SIZE - camera.y + TILE_SIZE//2
                    if abs(mx-sx) < 10 and abs(my-sy) < 10:
                        selected_char = char

        # ── Camera panning ───────────────────────────────────────────────────
        keys = pygame.key.get_pressed()
        cam_dx = (keys[pygame.K_RIGHT] or keys[pygame.K_d]) - (keys[pygame.K_LEFT] or keys[pygame.K_a])
        cam_dy = (keys[pygame.K_DOWN]  or keys[pygame.K_s]) - (keys[pygame.K_UP]   or keys[pygame.K_w])
        camera.x = max(0, min(camera.x + cam_dx * 4, WORLD_WIDTH_TILES*TILE_SIZE - SCREEN_WIDTH))
        camera.y = max(0, min(camera.y + cam_dy * 4, WORLD_HEIGHT_TILES*TILE_SIZE - SCREEN_HEIGHT))

        # ── Simulation tick ──────────────────────────────────────────────────
        tick_count += 1
        time_engine.tick()

        # Process fires
        for fire in fires[:]:
            fire["fuel_ticks"] -= 1
            if fire["fuel_ticks"] <= 0:
                fires.remove(fire)

        # Process each character
        for char in characters[:]:
            if not char.is_alive:
                continue
            
            # Age (one day per TICK_RATE ticks)
            if tick_count % TICK_RATE == 0:
                char.age_days += 1
                if char.age_days >= 365:
                    char.age_days = 0
                    char.age_years += 1

            # Near fire?
            near_fire = any(abs(f["x"]-char.x) <= 3 and abs(f["y"]-char.y) <= 3 for f in fires)
            char.decay_needs(time_engine.is_night, time_engine.is_winter, near_fire)

            # Survival check
            if not needs_sys.check_survival(char, time_engine, db):
                hud.add_event(f"💀 {char.name} has died.")
                characters.remove(char)
                if selected_char == char:
                    selected_char = characters[0] if characters else None
                continue

            # Pregnancy
            if char.is_pregnant:
                result = social_sys.process_pregnancy(char, characters, time_engine, db)
                if result.get("birth"):
                    new_char = result["baby"]
                    characters.append(new_char)
                    char_think_counter[new_char.name] = 0
                    hud.add_event(result["message"])
                    popup_message = result["message"]
                    popup_ticks = 200

            # LLM thinking — throttled
            char_think_counter[char.name] = char_think_counter.get(char.name, 0) + 1
            if char_think_counter[char.name] >= CHARACTER_THINK_INTERVAL_TICKS:
                char_think_counter[char.name] = 0
                nearby_chars = [c for c in characters if c != char and
                                abs(c.x-char.x) <= 8 and abs(c.y-char.y) <= 8 and c.is_alive]
                world_ctx = get_world_context(char, world_map, time_engine)
                
                # Run async decision in sync loop
                decision = asyncio.get_event_loop().run_until_complete(
                    get_character_decision(char, world_ctx, nearby_chars)
                )
                asyncio.get_event_loop().run_until_complete(
                    execute_action(char, decision, world_map, characters, time_engine, db)
                )

        # ── Render ───────────────────────────────────────────────────────────
        screen.fill((10, 10, 15))
        
        tile_renderer.render_tiles(screen, world_map, camera.x, camera.y)
        entity_renderer.render_fires(screen, fires, camera.x, camera.y)
        entity_renderer.render_characters(screen, characters, camera.x, camera.y, selected_char, font_small)
        
        hud.render_time_bar(screen, time_engine, font_normal, font_small)
        hud.render_character_panel(screen, selected_char, font_small, font_normal)
        hud.render_event_log(screen, font_small)
        
        if popup_ticks > 0:
            hud.render_discovery_popup(screen, popup_message, font_normal, popup_ticks)
            popup_ticks -= 1
        
        # Controls hint
        hint = font_small.render("TAB=next char | WASD=pan | F=follow | ESC=quit", True, (80,78,70))
        screen.blit(hint, (10, SCREEN_HEIGHT - 20))

        pygame.display.flip()

    db.conn.close()
    pygame.quit()
    sys.exit()

if __name__ == "__main__":
    main()

PHASE 6 — BUILD ORDER FOR THE AI AGENT

Follow this exact sequence. Do not skip steps.

1. Create project folder: civilization_sim/
2. Create all __init__.py files
3. Create .env with OPENROUTER_API_KEY
4. pip install pygame httpx python-dotenv
5. Build config.py
6. Build engine/time_engine.py
7. Build engine/world.py
8. Build entities/item.py
9. Build entities/character.py
10. Build agents/memory.py
11. Build agents/character_agent.py
12. Build agents/crafting_agent.py
13. Build systems/needs.py
14. Build systems/crafting.py
15. Build systems/social.py
16. Build renderer/tile_renderer.py
17. Build renderer/entity_renderer.py
18. Build renderer/hud.py
19. Build main.py (assembles everything)
20. Run: python main.py
21. Debug any import errors
22. Verify both characters appear and move
23. Verify LLM calls work (check event log for decisions)
24. Verify crafting agent triggers on unknown combos

PHASE 7 — EXPANSION ROADMAP (Post-MVP)

Once the base simulation runs, add these in order:

Expansion 1 — Agriculture

  • Add TILE_FARM tile type
  • Characters can farm_plant → seeds → 30-day growth → harvest
  • Crafting agent can generate new crop types

Expansion 2 — Construction

  • build_shelter action places a Building object on the map
  • Requires: 5x wood_log + 3x stone
  • Shelter provides passive warmth and safety
  • Characters sleep inside shelter

Expansion 3 — Animal Wildlife

  • Add animal entities (deer, rabbit, boar)
  • Animals flee from characters
  • hunt action chases + kills → raw_meat + fur + bone
  • Animals reproduce over time

Expansion 4 — Tribes & Language

  • Characters who spend 5+ years together form a tribe
  • Tribe has a name (LLM-generated)
  • Tribe shares knowledge pool (all known recipes accessible)
  • Language emergences: common words for fire, food, water, love

Expansion 5 — Trade & War

  • Multiple tribes eventually meet
  • Trade system: exchange items
  • Conflict if territory overlap
  • War decisions made by LLM with tribe context

Expansion 6 — Technology Tree

  • Track which era the civilization is in
  • Era gates unlock new recipes: Stone Age → Bronze Age → Iron Age
  • Crafting agent checks era before allowing recipes
  • Era advances trigger major popup announcements

Expansion 7 — God Mode Panel

  • Overlay panel with buttons to:
    • Drop items at cursor
    • Trigger rain / drought / wildfire
    • Fast-forward time x10
    • Spawn new character
    • Inject an idea into a character's memory

CRITICAL IMPLEMENTATION NOTES FOR THE AI AGENT

  1. Async handling: The asyncio.get_event_loop().run_until_complete() pattern in main.py is intentional for simplicity. Once stable, migrate to asyncio.run() with a proper async game loop using asyncio.gather() for parallel LLM calls.

  2. Rate limiting: DeepSeek Flash can handle ~30 req/sec but OpenRouter may throttle. The CHARACTER_THINK_INTERVAL_TICKS = 30 setting ensures each character only calls the LLM ~2x/second at 60fps. Increase this number if you hit rate limits.

  3. JSON parsing: Always wrap LLM JSON parsing in try/except and fall back to survival instinct logic. LLMs occasionally output malformed JSON.

  4. Database: The SQLite database auto-creates on first run. The data/ directory must exist or be created by the code.

  5. Memory growth: Characters' memory_log is capped at 20 entries. Don't remove this cap — uncapped memory sent to the LLM will inflate token costs.

  6. Coordinate system: (x, y) refers to tile coordinates, not pixel coordinates. Always multiply by TILE_SIZE when rendering.

  7. Crafting agent cost: Each unknown crafting combo costs one LLM call. Once registered in RECIPE_REGISTRY, it costs nothing. The crafting system is self-learning.

  8. Performance: If simulation slows down with many characters, reduce FPS to 30 and increase CHARACTER_THINK_INTERVAL_TICKS to 60.

  9. The renderer/camera.py file: The Camera class is defined inline in main.py for simplicity. You can extract it to renderer/camera.py and import it.

  10. First run checklist:

    • .env file exists with valid OPENROUTER_API_KEY
    • data/ directory exists (or code creates it)
    • pygame window opens and shows tiles
    • Two colored circles appear on the map
    • Event log shows character decisions
    • No unhandled exceptions in terminal

ENVIRONMENT VARIABLES

Create a .env file in the project root:

OPENROUTER_API_KEY=sk-or-v1-your-key-here

Get your key at: https://openrouter.ai/keys


QUICK TEST (Run This First)

Before building the full system, test LLM connectivity:

# test_llm.py
import asyncio
import httpx
import os
from dotenv import load_dotenv
load_dotenv()

async def test():
    async with httpx.AsyncClient(timeout=10) as client:
        r = await client.post(
            "https://openrouter.ai/api/v1/chat/completions",
            headers={"Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY')}",
                     "Content-Type": "application/json"},
            json={
                "model": "deepseek/deepseek-chat-v3-0324:free",
                "max_tokens": 100,
                "messages": [{"role": "user", "content": "Say hello in JSON: {\"message\": \"...\"}"}]
            }
        )
        print(r.json()["choices"][0]["message"]["content"])

asyncio.run(test())

If this returns valid JSON, your setup is complete. Build the sim.


End of CivilizationSim Build Guide v1.0
Built for DeepSeek V3 Flash on OpenRouter | MacBook Apple Silicon | Python 3.11+