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)
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.
| 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-dotenvcivilization_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
# 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},
]# 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}")# 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# 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"},
]# 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()# 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()}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}# 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)}"}# 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# 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','')}" }# 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))# 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)# 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)# 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))# 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()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
Once the base simulation runs, add these in order:
- Add
TILE_FARMtile type - Characters can
farm_plant→ seeds → 30-day growth → harvest - Crafting agent can generate new crop types
build_shelteraction places aBuildingobject on the map- Requires: 5x wood_log + 3x stone
- Shelter provides passive warmth and safety
- Characters sleep inside shelter
- Add animal entities (deer, rabbit, boar)
- Animals flee from characters
huntaction chases + kills → raw_meat + fur + bone- Animals reproduce over time
- 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
- Multiple tribes eventually meet
- Trade system: exchange items
- Conflict if territory overlap
- War decisions made by LLM with tribe context
- 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
- 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
-
Async handling: The
asyncio.get_event_loop().run_until_complete()pattern in main.py is intentional for simplicity. Once stable, migrate toasyncio.run()with a proper async game loop usingasyncio.gather()for parallel LLM calls. -
Rate limiting: DeepSeek Flash can handle ~30 req/sec but OpenRouter may throttle. The
CHARACTER_THINK_INTERVAL_TICKS = 30setting ensures each character only calls the LLM ~2x/second at 60fps. Increase this number if you hit rate limits. -
JSON parsing: Always wrap LLM JSON parsing in try/except and fall back to survival instinct logic. LLMs occasionally output malformed JSON.
-
Database: The SQLite database auto-creates on first run. The
data/directory must exist or be created by the code. -
Memory growth: Characters'
memory_logis capped at 20 entries. Don't remove this cap — uncapped memory sent to the LLM will inflate token costs. -
Coordinate system:
(x, y)refers to tile coordinates, not pixel coordinates. Always multiply byTILE_SIZEwhen rendering. -
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. -
Performance: If simulation slows down with many characters, reduce
FPSto 30 and increaseCHARACTER_THINK_INTERVAL_TICKSto 60. -
The
renderer/camera.pyfile: The Camera class is defined inline in main.py for simplicity. You can extract it torenderer/camera.pyand import it. -
First run checklist:
.envfile exists with validOPENROUTER_API_KEYdata/directory exists (or code creates it)pygamewindow opens and shows tiles- Two colored circles appear on the map
- Event log shows character decisions
- No unhandled exceptions in terminal
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
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+