# EvoLab web build. GENERATED by web/build.py - do not edit this
# file, edit the real modules and run the builder again


# ===== ui/theme.py =====
# all the colours in one place, plus a few layout numbers.
# if something looks wrong colour-wise its probably in here

# panels and backgrounds, darkest to lightest
BG = (11, 15, 20)
PANEL = (16, 22, 29)
RAISED = (23, 31, 41)        # tiles inside a panel, buttons
RAISED_HOVER = (30, 41, 54)
PANEL_BORDER = (29, 39, 51)
BORDER_HOVER = (52, 69, 88)
METER_BG = (26, 35, 46)

# text
TEXT = (226, 232, 240)
TEXT_DIM = (128, 144, 168)
TEXT_FAINT = (78, 93, 113)

# the fun ones
ACCENT = (52, 211, 153)      # green, also the UI accent
ACCENT_DIM = (26, 92, 71)    # for rings that sit behind other stuff
PREDATOR = (248, 113, 113)   # red
PREDATOR_DIM = (110, 48, 48)
FOOD = (212, 175, 55)        # amber
FOOD_HI = (251, 211, 92)     # the eat pulse, brighter than the food itself
HEADING = (32, 140, 102)     # the little direction line
HEADING_PRED = (160, 60, 60)
GOLD = (250, 204, 21)        # replay bar
GRID_ALPHA = 14              # the faint grid on the plate

# layout
MARGIN = 12                  # gap round the outside of everything
GAP = 10                     # gap between stacked panels
PANEL_PAD = 12
HEADER_H = 52
KEYBAR_H = 26
SIDEBAR_W = 300


# ===== ui/fonts.py =====
# fonts, loaded once and passed round the UI.
# SysFont needs actual system fonts which the browser build does not have,
# so everything goes through load_font which falls back to the pygame one

import pygame


def load_font(names: str, size: int, bold: bool = False) -> pygame.font.Font:
    try:
        font = pygame.font.SysFont(names, size, bold=bold)
        if font is not None:
            return font
    except Exception:
        pass  # browser, or a machine with no fonts installed
    return pygame.font.Font(None, size)


MONO = "dejavusansmono,consolas,dejavusansmono,monospace"
SANS = "dejavusans,verdana,arial,sans-serif"


class Fonts:
    # one of each size/style the UI needs. if you add a new one put it here
    # instead of calling SysFont in the middle of the drawing code
    def __init__(self) -> None:
        self.logo = load_font(SANS, 19, bold=True)
        self.tagline = load_font(SANS, 11)
        self.title = load_font(SANS, 12, bold=True)  # panel headers
        self.body = load_font(MONO, 13)
        self.small = load_font(MONO, 11)
        self.tiny = load_font(MONO, 10)  # chart + trait labels
        self.button = load_font(SANS, 13, bold=True)
        self.metric = load_font(SANS, 22, bold=True)  # the big numbers
        self.keycap = load_font(MONO, 10, bold=True)


# ===== simulation/genome.py =====
# the genome. 8 traits, all floats 0..1, nothing else to it.
# higher isnt always better, every trait has a cost somewhere in ecosystem.py

import random
from dataclasses import dataclass

TRAIT_NAMES = (
    "speed",
    "size",
    "vision",
    "metabolism",
    "fertility",
    "lifespan",
    "aggression",
    "efficiency",
)

# where we draw the line between "prey" / "mixed" / "predator" in the UI.
# its only a label, the actual behaviour is a smooth 0..1 thing
PREY_MAX = 0.35
PREDATOR_MIN = 0.70


def role_of(aggression: float) -> str:
    if aggression >= PREDATOR_MIN:
        return "predator"
    if aggression >= PREY_MAX:
        return "mixed"
    return "prey"


@dataclass(slots=True)
class Genome:
    speed: float        # faster but burns more
    size: float         # bigger body, more upkeep
    vision: float       # how far it can see food + other creatures
    metabolism: float   # base burn rate
    fertility: float    # how fast it gets ready to breed
    lifespan: float     # max age
    aggression: float   # hunt vs forage split
    efficiency: float   # discount on all the energy costs


def random_genome() -> Genome:
    # just 8 random numbers, the founders are meant to be bad at everything
    return Genome(*(random.random() for _ in TRAIT_NAMES))


def crossover(a: Genome, b: Genome) -> Genome:
    # coin flip per trait, so a kid can get dads speed and mums eyes etc
    return Genome(
        **{n: random.choice((getattr(a, n), getattr(b, n))) for n in TRAIT_NAMES}
    )


def mutate(g: Genome, rate: float) -> Genome:
    # each trait gets a small gaussian kick with probability = rate.
    # 0.15 looked about right, bigger and everything just becomes noise
    if rate <= 0.0:
        return g
    vals = {}
    for n in TRAIT_NAMES:
        old = getattr(g, n)  # keep a copy around, handy when debugging
        v = old
        if random.random() < rate:
            v = max(0.0, min(1.0, v + random.gauss(0.0, 0.15)))
        vals[n] = v
    return Genome(**vals)


# --- traits -> actual pixels/numbers ----------------------------------

def body_radius_px(size: float) -> float:
    return 2.0 + size * 6.0  # 2..8 px


def move_speed_px(genome: Genome) -> float:
    return 10.0 + genome.speed * 60.0  # 10..70 px/s


# ===== simulation/food.py =====
# food. thats it. an amber dot you can eat

from dataclasses import dataclass


@dataclass(slots=True)
class Food:
    id: int
    x: float
    y: float
    energy: float


# ===== simulation/types.py =====
# a creature. mostly plain data, the actual thinking happens in ecosystem.py

from dataclasses import dataclass



@dataclass(slots=True)
class Organism:
    id: int
    x: float
    y: float
    heading: float  # radians, where its pointing
    genome: Genome
    energy: float
    age: float
    generation: int = 1  # founders are gen 1
    readiness: float = 0.0  # creeps up to 1.0 then it can breed
    target: Food | None = None  # the food its chasing rn
    last_sense: float = 0.0  # last time we did a food scan
    # predation stuff
    dead: bool = False  # gets flagged, cleaned up at the end of the tick
    prey_target: "Organism | None" = None
    last_hunt: float = 0.0
    danger: "Organism | None" = None  # something scarier nearby
    fleeing: bool = False  # set every tick while running away
    hunting: bool = False  # set every tick while chasing
    on_hunt: bool = False  # committed to hunting for a while
    role_until: float = 0.0  # ...until this time
    chase_started: float = 0.0  # so we can give up on hopeless chases
    # family tree. lineage = id of the founder this one came from, so the
    # whole family shares one number
    lineage: int = 0
    parent_a: int | None = None
    parent_b: int | None = None
    born_at: float = 0.0


@dataclass(slots=True)
class Event:
    # "something happened" messages for the drawing code, so the sim itself
    # stays headless and doesnt know about rings/pulses/log lines
    t: float
    kind: str  # 'birth' | 'ate' | 'starved' | 'aged' | 'eaten' | 'arrived'
    x: float
    y: float
    actor: int  # who it happened to
    other: int = 0  # who did it (the predator on an 'eaten', the parent on a 'birth')


# ===== simulation/spatial.py =====
# spatial hash grid. buckets the plate into cells so "whats near me"
# doesnt mean looping over every creature on screen.
# rebuilt from scratch every tick, its cheap enough (one dict insert each)

import math


class SpatialGrid:
    def __init__(self, width: int, height: int, cell: int = 64) -> None:
        self.width = width
        self.height = height
        self.cell = cell
        # 64px cells felt right, smaller and there are too many buckets
        self.cols = max(1, math.ceil(width / cell))
        self.rows = max(1, math.ceil(height / cell))
        self.cells: dict[tuple[int, int], list] = {}

    def rebuild(self, items: list) -> None:
        self.cells.clear()
        for it in items:
            key = (int(it.x) // self.cell, int(it.y) // self.cell)
            bucket = self.cells.get(key)
            if bucket is None:
                self.cells[key] = [it]
            else:
                bucket.append(it)

    def nearest(self, x: float, y: float, radius: float, accept=None):
        # closest thing in radius, or None. this is what the sensing code
        # calls. accept= is a filter so we can ask for "nearest PREY" etc
        c = self.cell
        # world wraps around so everything has to be measured the short way
        half_w, half_h = self.width / 2, self.height / 2
        best = None
        best_d2 = radius * radius
        x0 = int((x - radius) // c)
        x1 = int((x + radius) // c)
        y0 = int((y - radius) // c)
        y1 = int((y + radius) // c)

        for cx in range(x0, x1 + 1):
            for cy in range(y0, y1 + 1):
                # % wraps the cell index so it works across the edges
                bucket = self.cells.get((cx % self.cols, cy % self.rows))
                if not bucket:
                    continue
                for it in bucket:
                    dx = it.x - x
                    if dx > half_w:
                        dx -= self.width
                    elif dx < -half_w:
                        dx += self.width
                    if dx * dx > best_d2:
                        continue  # already too far in x, skip the rest
                    dy = it.y - y
                    if dy > half_h:
                        dy -= self.height
                    elif dy < -half_h:
                        dy += self.height
                    d2 = dx * dx + dy * dy
                    # shrink best_d2 as we go so later cells get rejected sooner
                    if d2 <= best_d2 and (accept is None or accept(it)):
                        best_d2 = d2
                        best = it
        return best

    def within(self, x: float, y: float, radius: float) -> list:
        # everything in radius, not just the closest one. mating uses this
        c = self.cell
        half_w, half_h = self.width / 2, self.height / 2
        r2 = radius * radius
        x0 = int((x - radius) // c)
        x1 = int((x + radius) // c)
        y0 = int((y - radius) // c)
        y1 = int((y + radius) // c)

        out = []
        for cx in range(x0, x1 + 1):
            for cy in range(y0, y1 + 1):
                bucket = self.cells.get((cx % self.cols, cy % self.rows))
                if not bucket:
                    continue
                for it in bucket:
                    dx = it.x - x
                    if dx > half_w:
                        dx -= self.width
                    elif dx < -half_w:
                        dx += self.width
                    if dx * dx > r2:
                        continue
                    dy = it.y - y
                    if dy > half_h:
                        dy -= self.height
                    elif dy < -half_h:
                        dy += self.height
                    if dx * dx + dy * dy <= r2:
                        out.append(it)
        return out


# ===== simulation/ecosystem.py =====
# the whole world. no pygame in here at all, the UI drives this from outside.
# everything is a float, nothing is exact, thats kind of the point

import math
import random
from collections import deque
from dataclasses import dataclass
from typing import NamedTuple



# one row of the trends chart, taken once per simulated second
class Sample(NamedTuple):
    t: float
    speed: float
    size: float
    population: int
    aggression: float
    food: int
    energy: float  # as a fraction of max
    predators: int


# how hard the aggression dial yanks a newborn toward the target, per birth.
# 0.4 is strong, i tried 0.1 first and you basically couldnt see it move
PRESSURE_PULL = 0.4


@dataclass
class WorldConfig:
    width: int
    height: int
    organisms: int  # starting organism count
    wander_turn_rate: float  # how eagerly a creature changes direction (rad/s)

    # --- energy ---------------------------------------------------------
    max_energy: float = 100.0
    # drain per second = metabolism stuff * body upkeep * efficiency * aggression
    base_metabolism: float = 0.22
    metabolism_range: float = 0.40
    speed_cost: float = 0.9
    size_cost: float = 0.8
    efficiency_saving: float = 0.5

    # --- life -----------------------------------------------------------
    base_lifespan: float = 120.0  # seconds when the trait is 0
    lifespan_range: float = 480.0  # so max age is between 2 and 10 minutes

    # --- food -----------------------------------------------------------
    # food comes in little patches (meadows) instead of evenly sprinkled
    # dots. with even food, whoever was fastest won literally every race
    # and after 10 minutes the whole plate was at max speed, which meant
    # nobody could ever catch anybody. patches fixed it
    food_spawn_rate: float = 9.0  # items per second (the supply, not patches)
    food_supply_scale: float = 1.0  # the FOOD dial. famine < 1.0 < boom
    food_patch_size: int = 10  # items per meadow
    food_patch_radius: float = 55.0
    max_food: int = 320  # hard cap or the plate turns into a carpet
    food_energy: float = 12.0  # small meals, lots of them
    initial_food_fraction: float = 0.35  # some at t=0 so it doesnt start dead

    # --- senses ---------------------------------------------------------
    vision_base: float = 30.0
    vision_range: float = 120.0  # so 30..150px depending on the trait
    sense_interval: float = 0.25  # dont rescan every frame, too slow
    hungry_level: float = 0.80  # under 80% energy it goes looking
    steer_rate: float = 6.0  # rad/s. big creatures turn slower

    # --- reproduction ---------------------------------------------------
    mate_radius: float = 30.0
    mate_energy_gate: float = 50.0
    mate_cost: float = 18.0  # each parent pays this
    baby_energy: float = 30.0
    readiness_base: float = 0.10
    readiness_fertility: float = 0.45
    # hunters breed slower, otherwise they just end up with the whole plate
    repro_aggression_penalty: float = 0.8
    # tried 1.5 here once, plate went extinct in 4 minutes. keep it at 0.8
    max_population: int = 400  # safety valve
    mutation_rate: float = 0.05  # the MUTATION slider writes into this
    # --- immigration ----------------------------------------------------
    # if everything dies we trickle in random strangers so the sim is never
    # just an empty plate. barely ever fires now that breeding works
    min_population: int = 28
    migration_rate: float = 0.15

    # --- predation ------------------------------------------------------
    # aggression is a 0..1 thing: hungry + a dice roll under aggression =
    # go hunting, otherwise go eat plants. important bit: the cost is on
    # the CHASE, not on carrying the gene. the early version taxed the
    # trait and nobody could ever evolve into a hunter, the middle ground
    # was just too expensive
    predation_drain: float = 0.3
    hunt_drain_cost: float = 1.2  # per second while actually chasing
    prey_energy_gain: float = 45.0  # a catch is worth about 4 plants
    # the AGGRESSION dial. 0.0 = leave evolution alone. +1 drags newborns
    # toward hunter and seeds the immigrants mean, -1 pushes back to grazers.
    # its a bias not a cheat, selection still gets the last word
    aggression_pressure: float = 0.0
    # hunters are bad at plants (wrong kind of gut) which is roughly what
    # keeps a grazer majority around. without it everything drifts to hunter
    forage_penalty: float = 0.2
    capture_bonus: float = 6.0
    # stick with a decision for a couple of seconds. without this they flip
    # between hunting and eating every single frame and you cant see what
    # any of them is doing
    role_span: float = 2.5
    # Aggression must exceed a sensed organism's by at least this margin
    # for it to count as prey (and to trigger fleeing) — so similar
    # neighbours ignore each other instead of flinching constantly.
    hunt_margin: float = 0.1
    # how much faster than someone you have to be to bother chasing them.
    # small on purpose, chases here are endurance chases, you win by still
    # being there when they run out of puff
    catch_margin: float = 1.05
    # under 25% energy you cant sprint at all. thats the window a hunt
    # lives in, otherwise everything just outruns everything forever
    sprint_energy_floor: float = 0.25
    chase_timeout: float = 10.0  # give up after this long, its not happening
    # you only panic if it is close AND faster than you. if it cant catch
    # you then running away just burns energy for nothing
    danger_range_frac: float = 0.45  # flight zone = 45% of your vision
    flee_strength: float = 2.6
    flee_speed_boost: float = 1.35
    flee_drain_cost: float = 0.8
    # predators need way more energy stored before they breed, so their
    # numbers lag behind the prey instead of all turning up at once
    predator_mate_gate_mult: float = 2.0


class Ecosystem:
    # all the state lives on this thing. it has no idea it is being drawn,
    # it just gets told "advance by dt" over and over by main.py

    def __init__(self, config: WorldConfig) -> None:
        self.config = config
        self.organisms: list[Organism] = []
        self.food: list[Food] = []
        self.time = 0.0  # simulation time in seconds
        self.deaths: dict[str, int] = {"starvation": 0, "age": 0, "eaten": 0}
        self.births = 0  # lifetime count of births
        self.migrants = 0  # lifetime count of immigrants
        # Per-second observations for the trends chart.
        self.history: list[Sample] = []
        self._history_acc = 0.0
        self.grid = SpatialGrid(config.width, config.height)
        # second grid just for food. scanning all 300 plants for every
        # creature was a disaster once the population got going
        self.food_grid = SpatialGrid(config.width, config.height)
        self._next_id = 1
        self._migration_acc = 0.0
        self._food_acc = 0.0
        self._food_ids: set[int] = set()
        self.eaten = 0  # how many plants have been eaten, all time
        # best generation so far. cheap to track here and the HUD wants it
        # every frame, no point looping the population for it
        self.max_generation = 1
        # recent happenings for the drawing code (pulses, the log panel).
        # the UI empties this with take_events(). maxlen so a 3 hour run
        # left open on a laptop doesnt eat all the memory
        self.events: deque[Event] = deque(maxlen=256)
        # start with a pure prey population. predators are supposed to
        # turn up on their own later once aggressive mutations spread
        for _ in range(config.organisms):
            self._spawn(founder=True)
        # and put some food down so we dont open on an empty plate
        while len(self.food) < config.max_food * config.initial_food_fraction:
            self._spawn_patch()
        self.food_grid.rebuild(self.food)

    # --- events -----------------------------------------------------------

    def _emit(self, kind: str, x: float, y: float, actor: int, other: int = 0) -> None:
        self.events.append(Event(self.time, kind, x, y, actor, other))

    def take_events(self) -> list[Event]:
        # give me everything since last time and forget it
        if not self.events:
            return []
        out = list(self.events)
        self.events.clear()
        return out

    # --- population -----------------------------------------------------

    def _spawn(self, founder: bool = False) -> None:
        # one new creature: the starting 90 at t=0, or an immigrant later.
        # they come in at whatever aggression the dial is set to (0.0 by
        # default) so this is where you can see the AGGRESSION dial bite
        # when a crash gets reseeded
        c = self.config
        genome = random_genome()
        genome.aggression = max(0.0, c.aggression_pressure)
        o = Organism(
            id=self._next_id,
            x=random.random() * c.width,
            y=random.random() * c.height,
            heading=random.random() * 2 * math.pi,
            genome=genome,
            energy=random.uniform(0.4, 1.0) * c.max_energy,
            age=0.0,
            born_at=self.time,
        )
        # everyone starts their own family line. kids inherit it later
        o.lineage = o.id
        self._next_id += 1
        self.organisms.append(o)
        if not founder:
            self.migrants += 1
            self._emit("arrived", o.x, o.y, o.id)

    def _kill(self, o: Organism, cause: str, by: int = 0) -> None:
        # just flag it, the body gets swept at the end of the tick.
        # do NOT remove from the list in here: a predator can eat someone
        # who hasnt had their turn yet this frame and that crashes the loop
        if not o.dead:
            o.dead = True
            self.deaths[cause] = self.deaths.get(cause, 0) + 1
            kind = {"starvation": "starved", "age": "aged"}.get(cause, cause)
            self._emit(kind, o.x, o.y, o.id, by)

    # --- food -------------------------------------------------------------

    def _spawn_food(self, x: float | None = None, y: float | None = None) -> None:
        c = self.config
        f = Food(
            id=self._next_id,  # shares the id space with organisms — ids are unique
            x=random.random() * c.width if x is None else x,
            y=random.random() * c.height if y is None else y,
            energy=c.food_energy,
        )
        self._next_id += 1
        self.food.append(f)
        self._food_ids.add(f.id)

    def _spawn_patch(self) -> None:
        # drop a clump of food somewhere. sqrt on the random keeps it from
        # all bunching up in the middle (found that trick online)
        c = self.config
        cx = random.random() * c.width
        cy = random.random() * c.height
        r = c.food_patch_radius
        for _ in range(c.food_patch_size):
            if len(self.food) >= c.max_food:
                return
            dist = math.sqrt(random.random()) * r
            angle = random.random() * 2 * math.pi
            self._spawn_food(
                (cx + math.cos(angle) * dist) % c.width,
                (cy + math.sin(angle) * dist) % c.height,
            )

    def _eat_food(self, f: Food, o: Organism) -> None:
        self.food.remove(f)
        self._food_ids.discard(f.id)
        self.eaten += 1
        self._emit("ate", f.x, f.y, o.id)

    def _update_food(self, dt: float) -> None:
        c = self.config
        if len(self.food) >= c.max_food:
            self._food_acc = 0.0
            return
        self._food_acc += dt * c.food_spawn_rate * c.food_supply_scale / c.food_patch_size
        while self._food_acc >= 1.0 and len(self.food) < c.max_food:
            self._food_acc -= 1.0
            self._spawn_patch()

    # --- sensing -----------------------------------------------------------

    def _vision_range(self, o: Organism) -> float:
        c = self.config
        return c.vision_base + o.genome.vision * c.vision_range

    def _sense_food(self, o: Organism) -> None:
        # look for the closest plant in vision. the grid is one tick behind
        # so something eaten earlier this frame can still be sitting in a
        # bucket, hence the _food_ids check or they chase ghosts
        o.target = self.food_grid.nearest(
            o.x, o.y, self._vision_range(o), accept=self._is_live_food
        )

    def _is_live_food(self, f: Food) -> bool:
        # tiny helper so nearest() can filter, still not sure this is
        # cheaper than checking after the fact but it works
        return f.id in self._food_ids

    def _hunts(self, o: Organism, p: Organism) -> bool:
        # is p lunch? needs to be alive and clearly less aggressive than me.
        # the 0.1 margin stops similar creatures from flinching at each other
        return (
            not p.dead
            and p is not o
            and o.genome.aggression - p.genome.aggression >= self.config.hunt_margin
        )

    def _scan_prey(self, o: Organism) -> None:
        # nearest neighbour i could actually run down. the speed test is on
        # cruising speed not sprint speed, because i can chase someone of
        # similar speed until they get tired
        o.prey_target = self.grid.nearest(
            o.x,
            o.y,
            self._vision_range(o),
            accept=lambda p: self._hunts(o, p) and self._outruns(o, p),
        )

    def _scan_danger(self, o: Organism) -> None:
        # is anything scary close by. it has to be more aggressive than me
        # AND fast enough to catch me, otherwise panicking is just burning
        # energy for nothing (that bug cost me a whole evening once)
        c = self.config
        zone = self._vision_range(o) * c.danger_range_frac
        o.danger = self.grid.nearest(
            o.x,
            o.y,
            zone,
            accept=lambda p: self._threatens(p, o) and self._outruns(p, o),
        )

    def _threatens(self, p: Organism, o: Organism) -> bool:
        # does p look dangerous to o
        return (
            not p.dead
            and p is not o
            and p.genome.aggression - o.genome.aggression >= self.config.hunt_margin
        )

    def _flee(self, o: Organism, danger: Organism, dt: float) -> None:
        # turn away from the scary thing. creatures with some aggression in
        # them barely flinch, pure prey bolt. everything here is measured
        # the short way round because the world wraps
        c = self.config
        dx = o.x - danger.x
        if dx > c.width / 2:
            dx -= c.width
        elif dx < -c.width / 2:
            dx += c.width
        dy = o.y - danger.y
        if dy > c.height / 2:
            dy -= c.height
        elif dy < -c.height / 2:
            dy += c.height
        desired = math.atan2(dy, dx)  # bearing toward the danger
        away = desired + math.pi
        diff = math.atan2(math.sin(away - o.heading), math.cos(away - o.heading))
        turn = c.flee_strength * (1.0 - 0.5 * o.genome.size) * (1.0 - 0.7 * o.genome.aggression)
        o.heading += max(-turn * dt, min(turn * dt, diff))

    def _turn(self, o: Organism, dt: float) -> None:
        # what is this one doing this tick. order of priority:
        #   1. run away from something that can actually catch me
        #   2. if not hungry, wander about
        #   3. if hungry, hunt or graze depending on the aggression roll
        # well fed creatures just potter around, thats what gives prey
        # enough peace to breed between the raids
        c = self.config

        # running away beats everything else
        if o.danger is not None and not o.danger.dead:
            o.fleeing = True
            self._flee(o, o.danger, dt)
            return

        hungry = o.energy < c.hungry_level * c.max_energy
        if not hungry:
            o.heading += (random.random() - 0.5) * 2 * c.wander_turn_rate * dt
            return

        # roll for a role and KEEP it for a few seconds. if we rerolled
        # every tick you just see them twitch between the two
        if self.time >= o.role_until:
            o.on_hunt = random.random() < o.genome.aggression
            o.role_until = self.time + c.role_span

        if o.on_hunt:
            if o.prey_target is None or o.prey_target.dead:
                self._scan_prey(o)
                o.last_hunt = self.time
                o.chase_started = self.time
            elif self.time - o.last_hunt >= c.sense_interval:
                # have another look round for something better. the timer
                # only resets if we actually swap target, so chasing the
                # same hopeless one forever is not possible
                held = o.prey_target
                self._scan_prey(o)
                o.last_hunt = self.time
                if o.prey_target is not held:
                    o.chase_started = self.time
            if o.prey_target is not None:
                if self.time - o.chase_started >= c.chase_timeout:
                    # this one isnt happening: too fast, or too nimble.
                    # stop paying for it and go eat a plant instead
                    o.prey_target = None
                else:
                    # steer_toward reads o.target, so temporarily point the
                    # food target at the prey and put it back after. bit of
                    # a hack but it saves duplicating the steering maths
                    saved = o.target
                    o.target = o.prey_target  # type: ignore[assignment]
                    self._steer_toward(o, dt)
                    o.target = saved
                    o.hunting = True
                    return
            # nothing to chase. graze for the rest of this span instead of
            # jogging around burning energy on nothing
            o.on_hunt = False
            o.role_until = self.time + c.role_span

        # Foraging for plant food.
        if self.time - o.last_sense >= c.sense_interval:
            if o.target is None or o.target.id not in self._food_ids:
                self._sense_food(o)
                o.last_sense = self.time
        if o.target is not None:
            self._steer_toward(o, dt)
        else:
            o.heading += (random.random() - 0.5) * 2 * c.wander_turn_rate * dt

    def _can_sprint(self, o: Organism) -> bool:
        # sprinting needs energy in the tank
        return o.energy > self.config.max_energy * self.config.sprint_energy_floor

    def _outruns(self, o: Organism, p: Organism) -> bool:
        # am i fast enough to run them down
        return move_speed_px(o.genome) >= move_speed_px(p.genome) * self.config.catch_margin

    def _try_catch(self, o: Organism) -> None:
        # if the prey is close enough AND not currently outrunning me, its
        # dinner. note this checks whether it is sprinting RIGHT NOW, which
        # is the whole endurance chase thing: a fresh one gets away, a
        # knackered one doesnt, so speed is worth evolving on both sides
        c = self.config
        prey = o.prey_target
        if prey is None or prey.dead:
            return
        dx = prey.x - o.x
        if dx > c.width / 2:
            dx -= c.width
        elif dx < -c.width / 2:
            dx += c.width
        dy = prey.y - o.y
        if dy > c.height / 2:
            dy -= c.height
        elif dy < -c.height / 2:
            dy += c.height
        reach = body_radius_px(o.genome.size) + body_radius_px(prey.genome.size) + c.capture_bonus
        if dx * dx + dy * dy > reach * reach:
            return

        prey_speed = move_speed_px(prey.genome)
        if prey.fleeing and self._can_sprint(prey):
            prey_speed *= c.flee_speed_boost
        if move_speed_px(o.genome) < prey_speed * c.catch_margin:
            return  # outrun: the prey gets away

        o.energy = min(c.max_energy, o.energy + c.prey_energy_gain)
        self._kill(prey, "eaten", by=o.id)
        o.prey_target = None

    def _try_eat(self, o: Organism) -> None:
        # plants. eating them is just touching them
        c = self.config
        f = o.target
        if f is None or f.id not in self._food_ids:
            return
        dx = f.x - o.x
        if dx > c.width / 2:
            dx -= c.width
        elif dx < -c.width / 2:
            dx += c.width
        dy = f.y - o.y
        if dy > c.height / 2:
            dy -= c.height
        elif dy < -c.height / 2:
            dy += c.height
        reach = body_radius_px(o.genome.size) + 3  # +3 so it isnt pixel perfect
        if dx * dx + dy * dy <= reach * reach:
            # aggressive guts are worse at plants, see forage_penalty
            gain = f.energy * (1.0 - c.forage_penalty * o.genome.aggression)
            o.energy = min(c.max_energy, o.energy + gain)
            self._eat_food(f, o)
            o.target = None

    # --- behaviour ------------------------------------------------------

    def update(self, dt: float) -> None:
        # advance the world by dt seconds. this is the whole simulation
        self.time += dt
        c = self.config
        # put the food into the grid once per tick, sensing uses it
        self.food_grid.rebuild(self.food)

        for o in list(self.organisms):
            if o.dead:
                continue  # someone ate it earlier in this very loop

            o.age += dt
            repro = (c.readiness_base + c.readiness_fertility * o.genome.fertility)
            repro *= 1.0 - c.repro_aggression_penalty * o.genome.aggression
            o.readiness += repro * dt

            # check for danger every so often then decide what to do
            if o.danger is None or o.danger.dead:
                if self.time - o.last_sense >= c.sense_interval:
                    self._scan_danger(o)
            self._turn(o, dt)

            # actual movement
            speed = move_speed_px(o.genome)
            if o.fleeing and self._can_sprint(o):
                speed *= c.flee_speed_boost
            o.x += math.cos(o.heading) * speed * dt
            o.y += math.sin(o.heading) * speed * dt

            # wrap round the edges, no walls in this world
            self._wrap(o)

            drain = self._drain(o.genome)
            if o.fleeing:
                drain += c.flee_drain_cost
            if o.hunting:
                drain += c.hunt_drain_cost
            o.energy = max(0.0, o.energy - drain * dt)
            o.fleeing = False  # reset both, they are per tick flags
            o.hunting = False
            if o.energy <= 0.0:
                self._kill(o, "starvation")
                continue
            if o.age >= self._max_age(o.genome):
                self._kill(o, "age")
                continue

            if o.on_hunt and o.prey_target is not None:
                self._try_catch(o)  # in reach? then its a kill
            else:
                self._try_eat(o)

        # clear out the dead (see _kill, we only flag them during the loop)
        self.organisms = [o for o in self.organisms if not o.dead]

        self._update_food(dt)
        self.grid.rebuild(self.organisms)
        self._mate()
        self._migrate(dt)

        # the chart gets one row per simulated second
        self._history_acc += dt
        if self._history_acc >= 1.0:
            self._history_acc -= 1.0
            pop = self.organisms
            n = len(pop)
            total_energy = 0.0  # not used any more, left it in for now
            predators = 0
            for o in pop:
                if o.genome.aggression >= PREDATOR_MIN:
                    predators += 1
            self.history.append(
                Sample(
                    t=self.time,
                    speed=self.trait_average("speed"),
                    size=self.trait_average("size"),
                    population=n,
                    aggression=self.trait_average("aggression"),
                    food=len(self.food),
                    energy=(sum(o.energy for o in pop) / n / c.max_energy) if n else 0.0,
                    predators=predators,
                )
            )
            if len(self.history) > 600:
                del self.history[0]

    def _steer_toward(self, o: Organism, dt: float) -> None:
        # turn toward whatever o.target is, at a limited rate. big guys are
        # sluggish so size isnt a free upgrade
        c = self.config
        t = o.target
        dx = t.x - o.x
        if dx > c.width / 2:
            dx -= c.width
        elif dx < -c.width / 2:
            dx += c.width
        dy = t.y - o.y
        if dy > c.height / 2:
            dy -= c.height
        elif dy < -c.height / 2:
            dy += c.height

        desired = math.atan2(dy, dx)
        diff = math.atan2(math.sin(desired - o.heading), math.cos(desired - o.heading))
        turn = c.steer_rate * (1.0 - 0.5 * o.genome.size)
        o.heading += max(-turn * dt, min(turn * dt, diff))

    # --- reproduction -----------------------------------------------------

    def _mate(self) -> None:
        # breeding. both parents have to be ready + have the energy, both
        # pay, and the baby pops out between them with a shuffled genome
        c = self.config
        for o in list(self.organisms):
            if len(self.organisms) >= c.max_population:
                break
            # Carnivorous organisms must stock far more energy to breed, so
            # predator numbers lag prey availability instead of flooding.
            carnivory = o.genome.aggression
            gate = c.mate_energy_gate * (1.0 + (c.predator_mate_gate_mult - 1.0) * carnivory)
            if o.readiness < 1.0 or o.energy < gate:
                continue
            partner = self._find_partner(o)
            if partner is None:
                continue

            o.energy -= c.mate_cost
            partner.energy -= c.mate_cost
            o.readiness = 0.0
            partner.readiness = 0.0

            # midpoint, measured the short way so a couple either side of
            # the edge still has the baby between them
            dx = partner.x - o.x
            if dx > c.width / 2:
                dx -= c.width
            elif dx < -c.width / 2:
                dx += c.width
            dy = partner.y - o.y
            if dy > c.height / 2:
                dy -= c.height
            elif dy < -c.height / 2:
                dy += c.height

            genome = mutate(crossover(o.genome, partner.genome), c.mutation_rate)
            self._bias_newborn(genome)
            baby = Organism(
                id=self._next_id,
                x=o.x + dx / 2,
                y=o.y + dy / 2,
                heading=random.random() * 2 * math.pi,
                genome=genome,
                energy=c.baby_energy,
                age=0.0,
                generation=max(o.generation, partner.generation) + 1,
                # The line comes from the first parent; both parent links
                # are kept so a single creature's own descendants can be
                # traced later.
                lineage=o.lineage,
                parent_a=o.id,
                parent_b=partner.id,
                born_at=self.time,
            )
            self._next_id += 1
            self._wrap(baby)
            self.organisms.append(baby)
            self.births += 1
            self._emit("birth", baby.x, baby.y, baby.id, o.id)
            if baby.generation > self.max_generation:
                self.max_generation = baby.generation

    def _bias_newborn(self, genome: Genome) -> None:
        # the AGGRESSION dial does its work here, on every new baby.
        # at +1 each one lands 40% of the way toward full hunter, at -1
        # 40% toward grazer, and it only touches the aggression trait.
        # deliberately NOT a clamp - if the plate cant feed hunters you
        # just get a starving experiment, which is more interesting
        pressure = self.config.aggression_pressure
        if not pressure:
            return
        target = 1.0 if pressure > 0 else 0.0
        genome.aggression += (target - genome.aggression) * abs(pressure) * PRESSURE_PULL

    def _find_partner(self, o: Organism) -> Organism | None:
        # first neighbour that is also ready and can afford it
        c = self.config
        for p in self.grid.within(o.x, o.y, c.mate_radius):
            if p is not o and p.readiness >= 1.0 and p.energy >= c.mate_energy_gate:
                return p
        return None

    # --- trait costs -----------------------------------------------------

    def _drain(self, g: Genome) -> float:
        # energy per second. this formula is the whole game really, every
        # trait shows up in here and that is what makes them tradeoffs
        c = self.config
        metabolism = c.base_metabolism + g.metabolism * c.metabolism_range
        upkeep = 1.0 + g.speed * c.speed_cost + g.size * c.size_cost
        saving = 1.0 - g.efficiency * c.efficiency_saving
        aggression = 1.0 + g.aggression * c.predation_drain
        return metabolism * upkeep * saving * aggression

    def _max_age(self, g: Genome) -> float:
        c = self.config
        return c.base_lifespan + g.lifespan * c.lifespan_range

    # --- observations ----------------------------------------------------

    def trait_average(self, trait: str) -> float:
        # population mean of one trait. used by the chart + the inspector
        pop = self.organisms
        if not pop:
            return 0.0
        return sum(getattr(o.genome, trait) for o in pop) / len(pop)

    def role_counts(self) -> tuple[int, int, int]:
        # (prey, mixed, predators) for the little diet bar in the sidebar
        prey = mixed = pred = 0
        for o in self.organisms:
            a = o.genome.aggression
            if a >= PREDATOR_MIN:
                pred += 1
            elif a >= PREY_MAX:
                mixed += 1
            else:
                prey += 1
        return prey, mixed, pred

    def mean_energy(self) -> float:
        # average energy as a fraction of full
        pop = self.organisms
        if not pop:
            return 0.0
        return sum(o.energy for o in pop) / len(pop) / self.config.max_energy

    def lineage_members(self, lineage: int) -> set[int]:
        # everyone alive from the same founder. this is what gets ringed
        # on the plate when you click a creature
        return {o.id for o in self.organisms if o.lineage == lineage}

    def descendants_of(self, oid: int) -> set[int]:
        # walk parent links down from oid. works even if oid is long dead
        # because the kids still point at it (the line outlives the body!)
        children: dict[int, list[int]] = {}
        for o in self.organisms:
            if o.parent_a is not None:
                children.setdefault(o.parent_a, []).append(o.id)
            if o.parent_b is not None:
                children.setdefault(o.parent_b, []).append(o.id)
        found: set[int] = set()
        stack = [oid]
        while stack:
            for cid in children.get(stack.pop(), ()):
                if cid not in found:
                    found.add(cid)
                    stack.append(cid)
        return found

    def organism_at(self, x: float, y: float, tolerance: float = 0.0) -> Organism | None:
        # what did i just click on. tolerance makes small creatures easier
        # to hit (they are like 4px wide)
        best: Organism | None = None
        best_d = None
        for o in self.organisms:
            r = body_radius_px(o.genome.size) + tolerance
            dx = o.x - x
            dy = o.y - y
            d2 = dx * dx + dy * dy
            if d2 <= r * r and (best_d is None or d2 < best_d):
                best = o
                best_d = d2
        return best

    # --- helpers ----------------------------------------------------------

    def _migrate(self, dt: float) -> None:
        c = self.config
        if len(self.organisms) >= c.min_population:
            self._migration_acc = 0.0
            return
        self._migration_acc += dt * c.migration_rate
        while self._migration_acc >= 1.0 and len(self.organisms) < c.min_population:
            self._migration_acc -= 1.0
            self._spawn()

    def _wrap(self, o: Organism) -> None:
        # walk off the right edge, come back on the left
        c = self.config
        if o.x < 0:
            o.x += c.width
        elif o.x >= c.width:
            o.x -= c.width
        if o.y < 0:
            o.y += c.height
        elif o.y >= c.height:
            o.y -= c.height


# ===== rendering/orbs.py =====
# drawing a creature as a little glowing dot.
# this lives on its own because the plate AND the inspector both need to
# draw the exact same looking orb

import pygame


RADIUS_STEPS = list(range(2, 9))
ENERGY_LEVELS = 8   # brightness buckets. 8 looked smooth enough
AGGRESSION_BUCKETS = 8
GLOW = 2  # px of halo round the body

# what a creature looks like when its basically out of energy
_STARVED_PREY = (30, 90, 62)
_STARVED_PRED = (94, 38, 38)


def lerp(a: tuple[int, int, int], b: tuple[int, int, int], t: float) -> tuple[int, int, int]:
    # blend two colours. t=0 gives a, t=1 gives b
    return tuple(round(av + (bv - av) * t) for av, bv in zip(a, b))


def _orb_color(species: str, level: int) -> tuple[int, int, int]:
    # dim for a starving one, full colour for a full one
    t = level / (ENERGY_LEVELS - 1) if ENERGY_LEVELS > 1 else 1.0
    if species == "pred":
        return lerp(_STARVED_PRED, PREDATOR, t)
    return lerp(_STARVED_PREY, ACCENT, t)


def orb_color(aggression: float, level: int) -> tuple[int, int, int]:
    # green -> amber -> red depending on how murdery it is.
    # amber in the middle reads better than a straight green-red blend
    t = min(1.0, max(0.0, aggression))
    green = _orb_color("prey", level)
    red = _orb_color("pred", level)
    warm = lerp(green, red, 0.45)
    if t < 0.5:
        return lerp(green, warm, t * 2.0)
    return lerp(warm, red, (t - 0.5) * 2.0)


def energy_level(energy: float, max_energy: float) -> int:
    return min(
        ENERGY_LEVELS - 1,
        max(0, int(energy / max_energy * ENERGY_LEVELS)),
    )


def aggression_bucket(aggression: float) -> int:
    return min(AGGRESSION_BUCKETS - 1, max(0, int(aggression * AGGRESSION_BUCKETS)))


def make_orb(color: tuple[int, int, int], radius: int, glow: int = GLOW) -> pygame.Surface:
    # paint one dot: body, then a bright core inside, then a soft ring
    # outside it so it doesnt look like a hard circle
    side = (radius + glow) * 2 + 2
    surf = pygame.Surface((side, side), pygame.SRCALPHA)
    c = side // 2
    for rr in range(radius + glow, radius, -1):
        a = int(255 * (radius + glow - rr + 1) / (glow + 1))
        pygame.draw.circle(surf, (*color, a), (c, c), rr)
    pygame.draw.circle(surf, (*color, 255), (c, c), radius)
    core = lerp(color, (255, 255, 255), 0.35)
    pygame.draw.circle(surf, (*core, 220), (c, c), max(1, int(radius * 0.7)))
    inner = lerp(color, (255, 255, 255), 0.65)
    pygame.draw.circle(surf, (*inner, 160), (c, c), max(1, int(radius * 0.4)))
    return surf


# ===== ui/widgets.py =====
# the UI widgets. pygame has no buttons or sliders so these are all drawn
# from scratch out of rectangles and circles.
# every widget owns its own rect and knows how to draw itself. draw() takes
# the mouse position so we can do hover effects without each widget asking
# pygame for it separately

import pygame



def draw_panel(surface: pygame.Surface, rect: pygame.Rect, radius: int = 8) -> None:
    # a panel with a thin border. borders make the biggest difference to
    # how "finished" this looks, i use this everywhere
    pygame.draw.rect(surface, PANEL, rect, border_radius=radius)
    pygame.draw.rect(surface, PANEL_BORDER, rect, width=1, border_radius=radius)


def draw_tile(surface: pygame.Surface, rect: pygame.Rect, hover: bool = False,
              color: tuple[int, int, int] = RAISED) -> None:
    pygame.draw.rect(surface, RAISED_HOVER if hover else color, rect, border_radius=6)


class Label:
    # a bit of text. rerendering every frame for 10 labels was a waste so
    # it only re-renders when the string actually changes

    def __init__(
        self,
        pos: tuple[int, int],
        text: str,
        font: pygame.font.Font,
        color: tuple[int, int, int] = TEXT,
        anchor: str = "midleft",
    ) -> None:
        self.pos = pos
        self.font = font
        self.color = color
        self.anchor = anchor  # any of pygames rect anchors, "midleft" etc
        self.text = text
        self.surface = font.render(text, True, color)

    def set_text(self, text: str) -> None:
        if text != self.text:
            self.text = text
            self.surface = self.font.render(text, True, self.color)

    def draw(self, surface: pygame.Surface) -> None:
        surface.blit(self.surface, self.surface.get_rect(**{self.anchor: self.pos}))


class Button:
    # clicky label. active=True paints it filled in (used for Pause/Replay)

    def __init__(
        self,
        rect: pygame.Rect | tuple[int, int, int, int],
        label: str,
        font: pygame.font.Font,
        active: bool = False,
        accent: tuple[int, int, int] = ACCENT,
    ) -> None:
        self.rect = pygame.Rect(rect)
        self.label = label
        self.font = font
        self.active = active
        self.accent = accent
        self._pressed = False

    def handle(self, event: pygame.event.Event) -> bool:
        # returns True on the click, this style of "press then release on
        # the same rect" is what everyone expects from a button
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            self._pressed = self.rect.collidepoint(event.pos)
        elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
            clicked = self._pressed and self.rect.collidepoint(event.pos)
            self._pressed = False
            return clicked
        return False

    def draw(self, surface: pygame.Surface, mouse: tuple[int, int] = (0, 0)) -> None:
        hovered = self.rect.collidepoint(mouse)
        fill = self.accent if self.active else (
            RAISED_HOVER if hovered else RAISED
        )
        border = self.accent if (self.active or hovered) else PANEL_BORDER
        text_color = BG if self.active else TEXT
        pygame.draw.rect(surface, fill, self.rect, border_radius=6)
        pygame.draw.rect(surface, border, self.rect, width=1, border_radius=6)
        text = self.font.render(self.label, True, text_color)
        surface.blit(text, text.get_rect(center=self.rect.center))


class Slider:
    # horizontal slider. the value snaps to `step`

    def __init__(
        self,
        rect: pygame.Rect | tuple[int, int, int, int],
        font: pygame.font.Font,
        min_value: float,
        max_value: float,
        value: float,
        step: float = 0.25,
        accent: tuple[int, int, int] = ACCENT,
        bipolar: bool = False,
    ) -> None:
        self.rect = pygame.Rect(rect)
        self.font = font
        self.min = min_value
        self.max = max_value
        self.step = step
        self.accent = accent
        # bipolar sliders (-1..+1) fill out from the middle instead of the
        # left, otherwise "0" looks half full which is just wrong
        self.bipolar = bipolar
        self._value = value
        self._drag = False

    @property
    def value(self) -> float:
        return self._value

    @value.setter
    def value(self, v: float) -> None:
        v = max(self.min, min(self.max, v))
        self._value = round(v / self.step) * self.step

    @property
    def dragging(self) -> bool:
        # while the user is holding the knob (replay uses this to stop
        # auto-advancing under the cursor)
        return self._drag

    @property
    def track(self) -> pygame.Rect:
        # the line is 4px high but the thing you can click is 12px, nobody
        # can hit a 4px target
        return self.rect.inflate(0, 12)

    def _set_from_x(self, x: int) -> None:
        t = max(0.0, min(1.0, (x - self.rect.left) / max(1, self.rect.width)))
        self._value = round((self.min + t * (self.max - self.min)) / self.step) * self.step

    def _knob_x(self) -> int:
        t = (self._value - self.min) / (self.max - self.min)
        return self.rect.left + round(t * self.rect.width)

    def handle(self, event: pygame.event.Event) -> None:
        if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
            self._drag = self.track.collidepoint(event.pos)
            if self._drag:
                self._set_from_x(event.pos[0])
        elif event.type == pygame.MOUSEMOTION and self._drag:
            self._set_from_x(event.pos[0])
        elif event.type == pygame.MOUSEBUTTONUP and event.button == 1:
            self._drag = False

    def draw(self, surface: pygame.Surface, mouse: tuple[int, int] = (0, 0)) -> None:
        y = self.rect.centery
        hovered = self.track.collidepoint(mouse) or self._drag
        pygame.draw.line(surface, METER_BG, (self.rect.left, y), (self.rect.right, y), 4)
        knob = self._knob_x()
        origin = self._origin_x()
        if knob != origin:
            lo, hi = min(knob, origin), max(knob, origin)
            pygame.draw.line(surface, self.accent, (lo, y), (hi, y), 4)
        if hovered:
            pygame.draw.circle(surface, ACCENT_DIM, (knob, y), 9)
        pygame.draw.circle(surface, self.accent, (knob, y), 6)
        pygame.draw.circle(surface, BG, (knob, y), 2)

    def _origin_x(self) -> int:
        # where the coloured part starts from
        return (self.rect.left + self.rect.width // 2) if self.bipolar else self.rect.left


class Meter:
    # label, bar, value. used for energy/age/readiness in the inspector

    def __init__(self, font: pygame.font.Font, label_w: int = 34, value_w: int = 62) -> None:
        self.font = font
        self.label_w = label_w
        self.value_w = value_w

    def draw(self, surface: pygame.Surface, rect: pygame.Rect, label: str,
             fraction: float, text: str,
             color: tuple[int, int, int] = ACCENT) -> None:
        surface.blit(self.font.render(label, True, TEXT_DIM), (rect.left, rect.top))
        bar = pygame.Rect(
            rect.left + self.label_w, rect.top + 1,
            max(8, rect.width - self.label_w - self.value_w), 8,
        )
        pygame.draw.rect(surface, METER_BG, bar, border_radius=4)
        frac = max(0.0, min(1.0, fraction))
        if frac > 0.0:
            filled = pygame.Rect(bar.left, bar.top, max(2, round(bar.width * frac)), bar.height)
            pygame.draw.rect(surface, color, filled, border_radius=4)
        surface.blit(
            self.font.render(text, True, TEXT),
            (bar.right + 6, rect.top),
        )


def stat_tile(
    surface: pygame.Surface,
    rect: pygame.Rect,
    value: str,
    caption: str,
    fonts,
    color: tuple[int, int, int] = TEXT,
) -> None:
    # a big number with a small caption under it. the sidebar is made of
    # these
    draw_tile(surface, rect)
    big = fonts.metric.render(value, True, color)
    surface.blit(big, big.get_rect(midtop=(rect.centerx, rect.top + 4)))
    cap = fonts.small.render(caption, True, TEXT_FAINT)
    surface.blit(cap, cap.get_rect(midbottom=(rect.centerx, rect.bottom - 4)))


def keycap(surface: pygame.Surface, pos: tuple[int, int], key: str, fonts,
           text: str) -> int:
    # draws a little keyboard key in a box next to some text, like
    # [SPACE] pause. returns how wide it ended up so the caller can lay the
    # next one out after it
    rendered = fonts.keycap.render(key, True, TEXT_DIM)
    box = pygame.Rect(pos[0], pos[1] - 7, rendered.get_width() + 8, 14)
    pygame.draw.rect(surface, RAISED, box, border_radius=3)
    pygame.draw.rect(surface, PANEL_BORDER, box, width=1, border_radius=3)
    surface.blit(rendered, rendered.get_rect(center=box.center))
    hint = fonts.small.render(text, True, TEXT_FAINT)
    surface.blit(hint, hint.get_rect(midleft=(box.right + 5, pos[1])))
    return box.width + 5 + hint.get_width()


# ===== ui/charts.py =====
# the trends chart in the sidebar. population, food and mean aggression on
# one plot because you want to read them against each other.
# each line gets its own scale so this is about SHAPE not absolute numbers,
# the legend at the bottom prints the actual values

import pygame


PAD = 6
GRID_LINES = 3
WINDOW = 300  # seconds shown. the sim keeps 600 but 5 min fits better

# the soft block under the population line. making a new surface every
# frame was hammering the gc so we keep one around and wipe it instead
_shade: pygame.Surface | None = None


def _shade_for(size: tuple[int, int]) -> pygame.Surface:
    # global, i know. it works and its one surface
    global _shade
    if _shade is None or _shade.get_size() != size:
        _shade = pygame.Surface(size, pygame.SRCALPHA)
    _shade.fill((0, 0, 0, 0))
    return _shade


def draw_trends(
    surface: pygame.Surface,
    rect: pygame.Rect,
    hist: list[Sample],
    fonts,
    config,
) -> None:
    # draw the whole thing, chart + legend, into rect
    legend_h = fonts.small.get_height() + 2
    plot = pygame.Rect(rect.left, rect.top, rect.width, rect.height - legend_h)
    pygame.draw.rect(surface, BG, plot, border_radius=4)

    if len(hist) < 2:
        hint = fonts.small.render("collecting data…", True, TEXT_FAINT)
        surface.blit(hint, hint.get_rect(center=plot.center))
    else:
        window = hist[-WINDOW:]
        n = len(window)

        # grid lines. since every line is scaled differently these are the
        # only way to judge how big a swing actually is
        for i in range(1, GRID_LINES + 1):
            y = plot.top + round(plot.height * i / (GRID_LINES + 1))
            pygame.draw.line(surface, PANEL_BORDER, (plot.left, y), (plot.right, y), 1)

        def plot_line(values: list[float], scale: float,
                      color: tuple[int, int, int], fill: bool = False) -> None:
            # one line. scale is whatever that series tops out at
            if scale <= 0:
                return
            pts = [
                (
                    plot.left + round(i * (plot.width - 1) / max(1, n - 1)),
                    plot.bottom - 1 - round(min(1.0, values[i] / scale) * (plot.height - 2)),
                )
                for i in range(n)
            ]
            if fill:
                # translucent block from the line down to the bottom
                shade = _shade_for(plot.size)
                poly = [(p[0] - plot.left, p[1] - plot.top) for p in pts]
                poly += [(poly[-1][0], plot.height), (poly[0][0], plot.height)]
                pygame.draw.polygon(shade, (*color, 34), poly)
                surface.blit(shade, plot.topleft)
            pygame.draw.lines(surface, color, False, pts, 1)

        plot_line([s.population for s in window],
                  float(config.max_population) * 0.65, ACCENT, fill=True)
        plot_line([s.food for s in window], float(config.max_food) * 0.65, FOOD)
        plot_line([s.aggression for s in window], 1.0, PREDATOR)

    # legend along the bottom: dot, name, current value
    y = rect.bottom - legend_h // 2
    x = rect.left + 2
    latest = hist[-1] if hist else None
    for label, value, color in (
        ("pop", str(latest.population) if latest else "-", ACCENT),
        ("food", str(latest.food) if latest else "-", FOOD),
        ("agg", f"{latest.aggression * 100:.0f}%" if latest else "-", PREDATOR),
    ):
        pygame.draw.circle(surface, color, (x + 3, y - 1), 3)
        text = fonts.small.render(f"{label} {value}", True, TEXT_DIM)
        surface.blit(text, text.get_rect(midleft=(x + 10, y)))
        x += 10 + text.get_width() + 12


# ===== ui/inspector.py =====
# the SELECTED panel. everything we know about the creature you clicked.
# this is my favourite bit of the whole UI: the bars show the genome and
# there is a little tick on each one showing where the POPULATION average
# is, so you can tell at a glance if this one is fast for its time or not

from typing import NamedTuple

import pygame


# Short display names for the genome traits.
TRAIT_LABELS = {
    "speed": "spd",
    "size": "size",
    "vision": "vis",
    "metabolism": "meta",
    "fertility": "fert",
    "lifespan": "life",
    "aggression": "agg",
    "efficiency": "eff",
}

CHIP = 34  # the portrait box
BAR_W = 54
BAR_H = 5


class Descent(NamedTuple):
    # family info for the selected one
    line: int          # how many of its line are alive
    descendants: int   # how many creatures came from this specific one
    population: int    # so we can do the %


class Inspector:
    def __init__(self, fonts) -> None:
        self.fonts = fonts
        # the little portrait orb, cached (it only changes when the
        # creature changes size/colour/brightness)
        self.meter = Meter(fonts.small, label_w=30, value_w=66)
        self._chip: pygame.Surface | None = None
        self._chip_key: tuple | None = None

    # --- public API -------------------------------------------------------

    def draw(self, surface: pygame.Surface, rect: pygame.Rect, o: Organism | None,
             config, averages: dict[str, float], descent: Descent | None) -> None:
        draw_panel(surface, rect)
        fonts = self.fonts
        x = rect.left + PANEL_PAD
        y = rect.top + PANEL_PAD
        surface.blit(fonts.title.render("SELECTED", True, TEXT_DIM), (x, y))
        y += fonts.title.get_height() + 6

        if o is None:
            self._draw_empty(surface, rect, y)
            return

        y = self._draw_identity(surface, x, y, o, config)
        y = self._draw_meters(surface, rect, y, o, config)
        self._draw_traits(surface, rect, y, o, averages)
        self._draw_descent(surface, rect, o, descent)

    # --- sections ---------------------------------------------------------

    def _draw_empty(self, surface: pygame.Surface, rect: pygame.Rect, y: int) -> None:
        # nothing selected, just tell them what to do
        fonts = self.fonts
        lines = (
            ("click a creature", TEXT_DIM),
            ("its genome, energy and", TEXT_FAINT),
            ("family line land here.", TEXT_FAINT),
        )
        for text, color in lines:
            rendered = fonts.small.render(text, True, color)
            surface.blit(rendered, (rect.left + PANEL_PAD, y))
            y += fonts.small.get_height() + 1

    def _draw_identity(self, surface: pygame.Surface, x: int, y: int,
                       o: Organism, config) -> int:
        # portrait + "#412 gen 7" + the role and family line
        chip = pygame.Rect(x, y, CHIP, CHIP)
        pygame.draw.rect(surface, BG, chip, border_radius=6)
        pygame.draw.rect(surface, PANEL_BORDER, chip, width=1, border_radius=6)
        orb = self._portrait(o, config)
        surface.blit(orb, orb.get_rect(center=chip.center))

        fonts = self.fonts
        tx = chip.right + 10
        role = role_of(o.genome.aggression)
        role_color = {
            "predator": PREDATOR,
            "mixed": FOOD,
            "prey": ACCENT,
        }[role]
        surface.blit(fonts.body.render(f"#{o.id}   gen {o.generation}", True, TEXT),
                     (tx, y + 2))
        pygame.draw.circle(surface, role_color, (tx + 4, y + fonts.body.get_height() + 9), 3)
        tag = fonts.small.render(f"{role}   line #{o.lineage}", True, role_color)
        surface.blit(tag, (tx + 12, y + fonts.body.get_height() + 2))
        return y + CHIP + 8

    def _portrait(self, o: Organism, config) -> pygame.Surface:
        # draw the creature exactly like the plate does, so the dot in the
        # panel is the same dot you clicked on
        radius = max(3, round(body_radius_px(o.genome.size)))
        level = energy_level(o.energy, config.max_energy)
        agg = aggression_bucket(o.genome.aggression)
        key = (agg, radius, level)
        if key != self._chip_key:
            self._chip = make_orb(orb_color(agg / 7.0, level), radius, glow=3)
            self._chip_key = key
        return self._chip

    def _draw_meters(self, surface: pygame.Surface, rect: pygame.Rect, y: int,
                     o: Organism, config) -> int:
        fonts = self.fonts
        row = fonts.small.get_height() + 4
        left = rect.left + PANEL_PAD
        width = rect.width - 2 * PANEL_PAD
        life = config.base_lifespan + o.genome.lifespan * config.lifespan_range

        self.meter.draw(surface, pygame.Rect(left, y, width, row), "energy",
                        o.energy / config.max_energy,
                        f"{o.energy:3.0f}/{config.max_energy:.0f}")
        y += row
        age_frac = o.age / life if life > 0 else 0.0
        # Age is a countdown, so tint it as it runs out.
        age_color = ACCENT if age_frac < 0.75 else FOOD
        self.meter.draw(surface, pygame.Rect(left, y, width, row), "age",
                        age_frac, f"{o.age:3.0f}/{life:3.0f}s", age_color)
        y += row
        self.meter.draw(surface, pygame.Rect(left, y, width, row), "ready",
                        o.readiness, "yes" if o.readiness >= 1.0 else f"{o.readiness * 100:2.0f}%",
                        PREDATOR if o.readiness >= 1.0 else ACCENT_DIM)
        return y + row + 4

    def _draw_traits(self, surface: pygame.Surface, rect: pygame.Rect, y: int,
                     o: Organism, averages: dict[str, float]) -> None:
        # 8 traits, 2 columns of 4. averages[] is the population mean so we
        # can mark it on each bar
        fonts = self.fonts
        row = fonts.tiny.get_height() + 6
        col_w = (rect.width - 2 * PANEL_PAD) // 2
        names = list(TRAIT_NAMES)
        for i, name in enumerate(names):
            col, line = divmod(i, 4)
            left = rect.left + PANEL_PAD + col * col_w
            top = y + line * row
            self._trait(surface, left, top, name, getattr(o.genome, name), averages.get(name, 0.0))

    def _trait(self, surface: pygame.Surface, x: int, y: int, name: str,
               value: float, average: float) -> None:
        fonts = self.fonts
        surface.blit(fonts.tiny.render(TRAIT_LABELS[name], True, TEXT_FAINT), (x, y))
        bar = pygame.Rect(x + 30, y + 2, BAR_W, BAR_H)
        pygame.draw.rect(surface, METER_BG, bar, border_radius=2)
        fill = round(BAR_W * max(0.0, min(1.0, value)))
        if fill:
            pygame.draw.rect(surface, ACCENT,
                             pygame.Rect(bar.left, bar.top, fill, BAR_H), border_radius=2)
        # the population average tick. this is the useful bit
        tick = bar.left + round(BAR_W * max(0.0, min(1.0, average)))
        pygame.draw.line(surface, TEXT_DIM, (tick, bar.top - 3), (tick, bar.bottom + 1), 1)
        surface.blit(fonts.tiny.render(f"{value * 100:3.0f}", True, TEXT),
                     (bar.right + 4, y))

    def _draw_descent(self, surface: pygame.Surface, rect: pygame.Rect,
                      o: Organism, descent: Descent | None) -> None:
        if descent is None:
            return
        fonts = self.fonts
        y = rect.bottom - PANEL_PAD - fonts.small.get_height() * 2 - 2
        share = descent.line / descent.population * 100 if descent.population else 0.0
        line = fonts.small.render(
            f"line #{o.lineage}: {descent.line} alive ({share:.0f}% of plate)",
            True, TEXT_DIM,
        )
        kids = fonts.small.render(
            f"descendants of #{o.id}: {descent.descendants}", True, TEXT_DIM,
        )
        surface.blit(line, (rect.left + PANEL_PAD, y))
        surface.blit(kids, (rect.left + PANEL_PAD, y + fonts.small.get_height() + 2))


# ===== ui/hud.py =====
# the HUD. everything on screen that isnt the actual plate.
# it does the layout, draws the panels and routes the mouse to the right
# widget. when a button gets pressed it calls back into main.py, it doesnt
# touch the world itself (apart from reading it to draw the numbers)
#
#   +----------------- header: logo + the dials + buttons -------------+
#   +--- legend bar ---- clock -- fps ---------------------------------+
#   |  +-------- plate --------+  +--- sidebar: census / trends / ----+
#   |  |                        |  |     recent / selected ----------+|
#
import random  # not used any more, was for the old sparkline noise
from collections import deque
from typing import Callable, NamedTuple

import pygame


HEIGHT = 860  # window height the layout is designed for
WIDTH = 1280

TILE_ROWS = 2
TILE_COLS = 3


class Replay(NamedTuple):
    # where we are in the recording while replaying

    index: int
    total: int


class Hud:
    def __init__(self, size: tuple[int, int], fonts,
                 on_toggle_pause: Callable[[], None],
                 on_toggle_replay: Callable[[], None],
                 on_seek: Callable[[int], None]) -> None:
        self.fonts = fonts
        self.size = size
        self._on_toggle_pause = on_toggle_pause
        self._on_toggle_replay = on_toggle_replay
        self._on_seek = on_seek
        self._build_layout(size)

        # --- header controls ------------------------------------------
        f = fonts
        self.pause_btn = Button((0, 0, 78, 30), "Pause", f.button)
        self.replay_btn = Button((0, 0, 78, 30), "Replay", f.button, accent=GOLD)
        self.speed_slider = Slider((0, 0, 150, 16), f.small, 0.25, 20.0, 1.0)
        self.mut_slider = Slider((0, 0, 110, 16), f.small, 0.001, 0.20, 0.05,
                                 step=0.001, accent=FOOD)
        # The two environment dials: the world's food supply and the
        # selection regime the lab imposes on aggression.
        self.food_slider = Slider((0, 0, 110, 16), f.small, 0.1, 2.5, 1.0,
                                  step=0.05, accent=FOOD)
        self.pressure_slider = Slider((0, 0, 110, 16), f.small, -1.0, 1.0, 0.0,
                                      step=0.05, accent=PREDATOR, bipolar=True)
        # Time-machine scrub bar (only drawn while replaying).
        self.scrub = Slider((0, 0, 400, 14), f.small, 0, 239, 0, step=1, accent=GOLD)

        self.clock = Label((0, 0), "T+ 0:00", f.small, TEXT_DIM, anchor="midright")
        self.fps = Label((0, 0), "60 fps", f.small, TEXT_FAINT, anchor="midright")
        self.state = Label((0, 0), "", f.title, GOLD, anchor="midleft")
        self.replay_pos = Label((0, 0), "", f.small, TEXT, anchor="midright")
        self._place_header_controls()
        self._place_readouts()

        # --- the RECENT log ---------------------------------------------
        # (text, colour) pairs. kills go in the second they happen because
        # theyre the interesting bit, but deaths get summed up once per
        # second - a starvation crash would otherwise flood the whole panel
        self.log: deque[tuple[str, tuple[int, int, int]]] = deque(maxlen=40)
        self._pending: dict[str, int] = {}
        self._log_second = -1
        self._last_generation = 1

        # these get recomputed once a second, not every frame. looping the
        # population 60x a second for the trait averages was silly
        self._stat_second = -1
        self._averages: dict[str, float] = {}
        self._descent: Descent | None = None

        self.meter = Meter(f.small, label_w=44, value_w=0)  # value_w 0 = no numbers

    # --- layout -----------------------------------------------------------

    def _build_layout(self, size: tuple[int, int]) -> None:
        # work out where everything goes. panel heights come from the font
        # metrics so nothing overlaps if the fonts change
        w, h = size
        m = MARGIN
        self.header = pygame.Rect(m, m, w - 2 * m, HEADER_H)
        side_x = w - m - SIDEBAR_W
        self.sidebar = pygame.Rect(side_x, self.header.bottom + 8,
                                   SIDEBAR_W, h - self.header.bottom - 8 - m)
        plate_w = side_x - 2 * m
        self.keybar = pygame.Rect(m, self.header.bottom + 8, plate_w, KEYBAR_H)
        self.plate = pygame.Rect(m, self.keybar.bottom + 4, plate_w,
                                 h - self.keybar.bottom - 4 - m)

        # the sidebar, top to bottom
        f = self.fonts
        pad = PANEL_PAD
        tile_h = f.metric.get_height() + f.small.get_height() + 6
        census_h = (2 * pad + f.title.get_height() + 6
                    + TILE_ROWS * tile_h + (TILE_ROWS - 1) * 6
                    + 8 + f.small.get_height() + 12)
        trends_h = 2 * pad + f.title.get_height() + 6 + 100 + f.small.get_height() + 2
        log_h = 2 * pad + f.title.get_height() + 6 + 6 * (f.small.get_height() + 2)

        y = self.sidebar.top
        self.census_panel = pygame.Rect(self.sidebar.left, y, self.sidebar.width, census_h)
        y = self.census_panel.bottom + GAP
        self.trends_panel = pygame.Rect(self.sidebar.left, y, self.sidebar.width, trends_h)
        y = self.trends_panel.bottom + GAP
        self.log_panel = pygame.Rect(self.sidebar.left, y, self.sidebar.width, log_h)
        y = self.log_panel.bottom + GAP
        self.inspector_panel = pygame.Rect(self.sidebar.left, y, self.sidebar.width,
                                           self.sidebar.bottom - y)
        self.inspector = Inspector(self.fonts)

    def _place_header_controls(self) -> None:
        # lay the toolbar out from the right edge backwards. two groups:
        # the world dials (food, aggression) then a divider then the sim
        # controls (speed, mutation) then the buttons. the divider is there
        # so it reads as two different kinds of control
        f = self.fonts
        cy = self.header.centery
        slider_y = cy - 6
        value_gap, group_gap = 8, 18
        value_w = 46
        divider_gap = 13

        def group_width(slider: Slider) -> int:
            return slider.rect.width + value_gap + value_w

        widths = [group_width(s) for s in
                  (self.food_slider, self.pressure_slider, self.speed_slider,
                   self.mut_slider)]
        total = (sum(widths) + group_gap * 3 + 2 * divider_gap
                 + 78 + 8 + 78)
        x = self.header.right - 16 - total

        self.env_labels = []
        for label_text, slider, value_text, color in (
            ("FOOD", self.food_slider, "1.00×", FOOD),
            ("AGGRESSION", self.pressure_slider, "0.00", PREDATOR),
        ):
            self.env_labels.append(
                (Label((x, cy - 14), label_text, f.tiny, TEXT_FAINT), slider,
                 Label((x + slider.rect.width + value_gap, cy), value_text, f.small, color))
            )
            slider.rect.topleft = (x, slider_y)
            x += group_width(slider) + group_gap

        self.divider_x = x - group_gap + divider_gap // 2
        x += 2 * divider_gap - group_gap

        self.speed_label = Label((x, cy - 14), "SPEED", f.tiny, TEXT_FAINT)
        self.speed_slider.rect.topleft = (x, slider_y)
        self.speed_value = Label((x + self.speed_slider.rect.width + value_gap, cy),
                                 "1.0x", f.small, TEXT)
        x += group_width(self.speed_slider) + group_gap

        self.mut_label = Label((x, cy - 14), "MUTATION", f.tiny, TEXT_FAINT)
        self.mut_slider.rect.topleft = (x, slider_y)
        self.mut_value = Label((x + self.mut_slider.rect.width + value_gap, cy),
                               "5.0%", f.small, TEXT)
        x += group_width(self.mut_slider) + group_gap

        self.pause_btn.rect.topleft = (x, cy - 15)
        self.replay_btn.rect.topleft = (self.pause_btn.rect.right + 8, cy - 15)

    def _place_readouts(self) -> None:
        # the clock/fps in the legend bar and the replay scrub bar
        self.clock.pos = (self.keybar.right - 8 - 74, self.keybar.centery)
        self.fps.pos = (self.keybar.right - 8, self.keybar.centery)
        self.state.pos = (self.plate.left + 12, self.plate.top + 14)

        # The scrub bar sits over the foot of the plate; its geometry is
        # fixed by the layout, not by whether replay is currently on.
        bar = pygame.Rect(self.plate.left, self.plate.bottom - 46, self.plate.width, 40)
        pos_w = 78
        self.scrub.rect = pygame.Rect(bar.left + 92, bar.centery - 7,
                                      bar.width - 92 - pos_w - 16, 14)

    # --- properties -------------------------------------------------------

    @property
    def speed(self) -> float:
        return self.speed_slider.value

    @property
    def mutation(self) -> float:
        return self.mut_slider.value

    @property
    def food_scale(self) -> float:
        return self.food_slider.value

    @property
    def pressure(self) -> float:
        return self.pressure_slider.value

    # how long the recording is, so the scrub bar covers exactly it
    def set_replay_span(self, total: int) -> None:
        self.scrub.max = max(1, total - 1)
        self.scrub.value = 0

    def widgets(self) -> tuple:
        return (self.pause_btn, self.replay_btn, self.speed_slider, self.mut_slider,
                self.food_slider, self.pressure_slider, self.scrub)

    def chrome_at(self, pos: tuple[int, int], replaying: bool) -> bool:
        # is this point on the UI rather than the plate. otherwise clicking
        # a panel would also try to select whatever creature is underneath
        if self.header.collidepoint(pos) or self.keybar.collidepoint(pos):
            return True
        if self.sidebar.collidepoint(pos):
            return True
        if replaying and self.scrub.track.collidepoint(pos):
            return True
        return any(w.rect.collidepoint(pos) for w in self.widgets())

    # --- input ------------------------------------------------------------

    def handle(self, event: pygame.event.Event, replaying: bool) -> None:
        if self.pause_btn.handle(event):
            self._on_toggle_pause()
        if self.replay_btn.handle(event):
            self._on_toggle_replay()
        self.speed_slider.handle(event)
        self.mut_slider.handle(event)
        self.food_slider.handle(event)
        self.pressure_slider.handle(event)
        if replaying:
            self.scrub.handle(event)
            if self.scrub.dragging:
                self._on_seek(int(self.scrub.value))

    # --- event log --------------------------------------------------------

    def consume(self, events: list[Event], world_time: float,
                max_generation: int) -> None:
        for e in events:
            if e.kind == "eaten":
                self.log.append((f"#{e.actor} eaten by #{e.other}", PREDATOR))
            elif e.kind in ("starved", "aged", "arrived"):
                self._pending[e.kind] = self._pending.get(e.kind, 0) + 1

        second = int(world_time)
        if second != self._log_second:
            self._log_second = second
            parts = [f"{n} {kind}" for kind, n in self._pending.items() if n]
            self._pending.clear()
            if parts:
                stamp = f"{second // 60}:{second % 60:02d}"
                self.log.append((f"{stamp}  " + " · ".join(parts), TEXT_FAINT))
        if max_generation > self._last_generation:
            self._last_generation = max_generation
            self.log.append((f"generation {max_generation} reached", ACCENT))

    # --- per-second population views --------------------------------------

    def _refresh(self, world: Ecosystem, selected: Organism | None) -> None:
        # once a second: population averages + the selected ones family
        second = int(world.time)
        if second == self._stat_second:
            return
        self._stat_second = second
        self._averages = {name: world.trait_average(name) for name in TRAIT_NAMES}
        if selected is None:
            self._descent = None
            return
        self._descent = Descent(
            line=len(world.lineage_members(selected.lineage)),
            descendants=len(world.descendants_of(selected.id)),
            population=len(world.organisms),
        )

    # --- drawing ----------------------------------------------------------

    def draw(self, screen: pygame.Surface, world: Ecosystem, selected: Organism | None,
             kin: frozenset[int], paused: bool, replay: Replay | None,
             fps: float, mouse: tuple[int, int]) -> None:
        self._refresh(world, selected)
        self._draw_header(screen, paused, replay is not None, mouse)
        self._draw_keybar(screen, world, paused, replay, fps)
        self._draw_census(screen, world)
        self._draw_trends(screen, world)
        self._draw_log(screen)
        self.inspector.draw(screen, self.inspector_panel, selected, world.config,
                            self._averages, self._descent)
        if replay is not None:
            self._draw_scrub(screen, replay, mouse)
        else:
            self._draw_hints(screen)

    def _draw_header(self, screen: pygame.Surface, paused: bool,
                     replaying: bool, mouse: tuple[int, int]) -> None:
        draw_panel(screen, self.header)
        f = self.fonts
        x = self.header.left + 16
        logo = f.logo.render("EVOLAB", True, ACCENT)
        screen.blit(logo, (x, self.header.top + 8))
        tag = f.tagline.render("artificial life, running live", True, TEXT_FAINT)
        screen.blit(tag, (x, self.header.top + 8 + logo.get_height() + 1))

        self.pause_btn.label = "Resume" if paused else "Pause"
        self.pause_btn.active = paused
        self.pause_btn.draw(screen, mouse)
        self.replay_btn.label = "Live" if replaying else "Replay"
        self.replay_btn.active = replaying
        self.replay_btn.draw(screen, mouse)

        # Environment dials, then the hairline, then the sim controls.
        for (label, slider, value), text in zip(
            self.env_labels,
            (f"{self.food_scale:.2f}×",
             f"{self.pressure:+.2f}" if self.pressure else "0.00"),
        ):
            label.draw(screen)
            slider.draw(screen, mouse)
            if value.text != text:
                value.set_text(text)
            value.draw(screen)
        pygame.draw.line(
            screen, PANEL_BORDER,
            (self.divider_x, self.header.top + 12),
            (self.divider_x, self.header.bottom - 12), 1,
        )

        self.speed_label.draw(screen)
        self.speed_slider.draw(screen, mouse)
        self.speed_value.set_text(f"{self.speed_slider.value:.2f}x")
        self.speed_value.draw(screen)
        self.mut_label.draw(screen)
        self.mut_slider.draw(screen, mouse)
        self.mut_value.set_text(f"{self.mut_slider.value * 100:.1f}%")
        self.mut_value.draw(screen)

    def _draw_keybar(self, screen: pygame.Surface, world: Ecosystem, paused: bool,
                     replay: Replay | None, fps: float) -> None:
        # the strip under the header: what all the colours mean + clock + fps
        draw_panel(screen, self.keybar, radius=6)
        f = self.fonts
        x = self.keybar.left + 10
        y = self.keybar.centery
        items = (
            (ACCENT, "prey — forages plants", False),
            (PREDATOR, "predator — hunts prey", False),
            (FOOD, "food", False),
            (TEXT, "ready to mate", True),
            (ACCENT_DIM, "family line", True),
        )
        for color, text, hollow in items:
            pygame.draw.circle(screen, color, (x + 3, y), 4 if hollow else 3,
                               1 if hollow else 0)
            label = f.small.render(text, True, TEXT_FAINT)
            screen.blit(label, label.get_rect(midleft=(x + 10, y)))
            x += 10 + label.get_width() + 14

        self.fps.set_text(f"{fps:.0f} fps")
        self.fps.draw(screen)
        seconds = int(world.time)
        self.clock.set_text(f"T+ {seconds // 60}:{seconds % 60:02d}")
        self.clock.draw(screen)

        self.state.set_text(
            "REPLAYING" if replay is not None else ("PAUSED" if paused else "")
        )
        if self.state.text:
            self.state.draw(screen)

    def _draw_census(self, screen: pygame.Surface, world: Ecosystem) -> None:
        # population / food / generation / births / starved / eaten tiles
        draw_panel(screen, self.census_panel)
        f = self.fonts
        pad = PANEL_PAD
        x0 = self.census_panel.left + pad
        y = self.census_panel.top + pad
        screen.blit(f.title.render("ECOSYSTEM", True, TEXT_DIM), (x0, y))
        y += f.title.get_height() + 6

        deaths = world.deaths
        tiles = (
            (str(len(world.organisms)), "population", ACCENT),
            (str(len(world.food)), "food", FOOD),
            (str(world.max_generation), "generation", TEXT),
            (str(world.births), "births", TEXT),
            (str(deaths["starvation"]), "starved", TEXT_FAINT),
            (str(deaths["eaten"]), "eaten", PREDATOR),
        )
        tile_h = f.metric.get_height() + f.small.get_height() + 6
        gap = 6
        tile_w = (self.census_panel.width - 2 * pad - (TILE_COLS - 1) * gap) // TILE_COLS
        for i, (value, caption, color) in enumerate(tiles):
            row, col = divmod(i, TILE_COLS)
            rect = pygame.Rect(x0 + col * (tile_w + gap), y + row * (tile_h + gap),
                               tile_w, tile_h)
            stat_tile(screen, rect, value, caption, f, color)
        y += TILE_ROWS * tile_h + (TILE_ROWS - 1) * gap + 10

        # the diet bar. prey green, mixed amber, predators red
        prey, mixed, pred = world.role_counts()
        total = max(1, prey + mixed + pred)
        bar = pygame.Rect(x0, y + f.small.get_height() + 2,
                          self.census_panel.width - 2 * pad, 8)
        pygame.draw.rect(screen, METER_BG, bar, border_radius=4)
        x = bar.left
        for count, color in ((prey, ACCENT), (mixed, FOOD), (pred, PREDATOR)):
            width = round(bar.width * count / total)
            if width:
                pygame.draw.rect(screen, color, (x, bar.top, width, bar.height))
            x += width
        screen.blit(
            f.small.render(
                f"{prey} prey · {mixed} mixed · {pred} predators", True, TEXT_DIM),
            (x0, y),
        )

    def _draw_trends(self, screen: pygame.Surface, world: Ecosystem) -> None:
        draw_panel(screen, self.trends_panel)
        f = self.fonts
        pad = PANEL_PAD
        x0 = self.trends_panel.left + pad
        y = self.trends_panel.top + pad
        screen.blit(f.title.render("TRENDS  ·  last 5 min", True, TEXT_DIM), (x0, y))
        y += f.title.get_height() + 6
        draw_trends(
            screen,
            pygame.Rect(x0, y, self.trends_panel.width - 2 * pad,
                        self.trends_panel.bottom - pad - y),
            world.history, f, world.config,
        )

    def _draw_log(self, screen: pygame.Surface) -> None:
        # newest line at the top, however many fit in the panel
        draw_panel(screen, self.log_panel)
        f = self.fonts
        pad = PANEL_PAD
        x0 = self.log_panel.left + pad
        y = self.log_panel.top + pad
        screen.blit(f.title.render("RECENT", True, TEXT_DIM), (x0, y))
        y += f.title.get_height() + 6
        line_h = f.small.get_height() + 2
        room = max(0, (self.log_panel.bottom - pad - y) // line_h)
        for text, color in list(self.log)[-room:][::-1]:
            screen.blit(f.small.render(text, True, color), (x0, y))
            y += line_h

    def _draw_scrub(self, screen: pygame.Surface, replay: Replay,
                    mouse: tuple[int, int]) -> None:
        # the replay bar. it sits over the bottom of the plate like a video
        # player timeline. TODO clicking the plate under it is blocked while
        # replaying, which is fine for now
        bar = pygame.Rect(self.plate.left, self.plate.bottom - 46, self.plate.width, 40)
        pygame.draw.rect(screen, PANEL, bar, border_radius=6)
        pygame.draw.rect(screen, GOLD, bar, width=1, border_radius=6)

        f = self.fonts
        pygame.draw.circle(screen, GOLD, (bar.left + 18, bar.centery), 4)
        tag = f.title.render("REPLAY", True, GOLD)
        screen.blit(tag, tag.get_rect(midleft=(bar.left + 28, bar.centery)))

        self.scrub.draw(screen, mouse)
        self.replay_pos.pos = (bar.right - 12, bar.centery)
        self.replay_pos.set_text(f"{replay.index + 1} / {replay.total}")
        self.replay_pos.draw(screen)

    def _draw_hints(self, screen: pygame.Surface) -> None:
        # little key hints in the corner of the plate
        f = self.fonts
        hints = (("SPACE", "pause"), ("R", "replay"), ("click", "inspect a creature"))
        width = sum(
            f.keycap.size(key)[0] + 8 + 5 + f.small.size(text)[0] + 16
            for key, text in hints
        )
        panel = pygame.Rect(self.plate.left + 8,
                            self.plate.bottom - 8 - f.small.get_height() - 14,
                            width + 4, f.small.get_height() + 14)
        pygame.draw.rect(screen, PANEL, panel, border_radius=6)
        pygame.draw.rect(screen, PANEL_BORDER, panel, width=1, border_radius=6)
        x = panel.left + 8
        for key, text in hints:
            x += keycap(screen, (x, panel.centery), key, f, text) + 16


# ===== rendering/renderer.py =====
# everything that gets painted on the plate.
# this is a "dumb" view: it reads the world and draws it, it never changes
# anything. the plate has its own surface and main.py blits it into place,
# that way the sim has no clue where it is on screen

import math

import pygame


GRID_SPACING = 64
MAX_TICK = 14   # px, at full speed
TRAIL_LEN = 10  # how many old positions make the tail

# the rings you get when something happens. (colour, how long it lives,
# how big it grows). ate = a meal, eaten = a kill, arrived = an immigrant
PULSES = {
    "ate": (FOOD_HI, 0.35, 26.0),
    "eaten": (PREDATOR, 0.55, 40.0),
    "arrived": (TEXT, 0.6, 34.0),
}


class Renderer:
    def __init__(self, size: tuple[int, int]) -> None:
        self.surface = pygame.Surface(size)
        self.font = load_font(MONO, 10)
        w, h = size
        self.background = self._build_background(w, h)
        # pre-render every possible orb up front so drawing a creature
        # later is one blit. 8 x 7 x 8 combinations, only takes a sec
        self.atlas: dict[tuple[int, int, int], pygame.Surface] = {
            (agg, radius, lvl): make_orb(orb_color(agg / (AGGRESSION_BUCKETS - 1), lvl), radius)
            for agg in range(AGGRESSION_BUCKETS)
            for radius in RADIUS_STEPS
            for lvl in range(ENERGY_LEVELS)
        }
        self.food_sprite = make_orb(FOOD, 3, glow=1)
        # tails, keyed by creature id. this is pure decoration, the sim
        # doesnt know they exist
        self.trails: dict[int, list[tuple[float, float]]] = {}
        # (x, y, time left, total life, colour, how far it grows)
        self.pulses: list[tuple[float, float, float, float, tuple[int, int, int], float]] = []

    def _build_background(self, w: int, h: int) -> pygame.Surface:
        # done once at startup then blitted every frame, way cheaper
        bg = pygame.Surface((w, h))
        bg.fill(BG)

        # the vignette: dark round the edges fading to clear in the middle
        # so the empty space looks like depth and not just black nothing
        side = 128
        glow = pygame.Surface((side, side), pygame.SRCALPHA)
        cc = side // 2
        for rr in range(cc, 0, -4):
            a = int(210 * (rr / cc))
            pygame.draw.circle(glow, (0, 0, 0, a), (cc, cc), rr)
        bg.blit(pygame.transform.smoothscale(glow, (w, h)), (0, 0))

        grid = pygame.Surface((w, h), pygame.SRCALPHA)
        for x in range(0, w + 1, GRID_SPACING):
            pygame.draw.line(grid, (*TEXT_DIM, GRID_ALPHA), (x, 0), (x, h))
        for y in range(0, h + 1, GRID_SPACING):
            pygame.draw.line(grid, (*TEXT_DIM, GRID_ALPHA), (0, y), (w, y))
        bg.blit(grid, (0, 0))
        return bg

    def consume(self, events: list[Event]) -> None:
        # main.py hands us this frames events, we turn them into rings
        for e in events:
            look = PULSES.get(e.kind)
            if look is not None:
                color, life, grow = look
                self.pulses.append((e.x, e.y, life, life, color, grow))

    def _age_pulses(self, dt: float) -> None:
        if not self.pulses:
            return
        kept = []
        for x, y, left, life, color, grow in self.pulses:
            left -= dt
            if left > 0.0:
                kept.append((x, y, left, life, color, grow))
        self.pulses = kept

    def _draw_pulses(self) -> None:
        # expanding ring that fades out. t goes 1 -> 0 over its life
        for x, y, left, life, color, grow in self.pulses:
            t = left / life
            radius = round((1.0 - t) * grow) + 3
            pygame.draw.circle(
                self.surface, (*color, int(190 * t)), (int(x), int(y)),
                radius, max(1, int(1 + t * 2)),
            )

    def _update_trails(self, world: Ecosystem) -> None:
        # add this frame position to everyones tail, forget the dead.
        # if someone wrapped round the edge we clear their tail instead of
        # drawing a line straight across the whole screen (that looked
        # terrible, took me a while to work out what was causing it)
        half_w = world.config.width / 2
        half_h = world.config.height / 2
        seen = set()
        for o in world.organisms:
            seen.add(o.id)
            trail = self.trails.get(o.id)
            if trail is None:
                self.trails[o.id] = [(o.x, o.y)]
                continue
            lx, ly = trail[-1]
            if abs(o.x - lx) > half_w or abs(o.y - ly) > half_h:
                trail.clear()
            trail.append((o.x, o.y))
            if len(trail) > TRAIL_LEN:
                del trail[0]
        for oid in [k for k in self.trails if k not in seen]:
            del self.trails[oid]

    def _draw_trails(self, world: Ecosystem) -> None:
        # a short tail so you can read where things are going. tinted by
        # aggression so a hunt is a red streak chasing a green one.
        # TWO lines per creature not one per segment - the old way spent
        # most of the frame budget in here and tanked it to 55fps
        by_id = {o.id: o for o in world.organisms}
        n_segments = 0  # left over from when i was profiling this
        for oid, pts in self.trails.items():
            o = by_id.get(oid)
            if o is None or len(pts) < 2:
                continue
            color = lerp(HEADING, HEADING_PRED, o.genome.aggression)
            if len(pts) > 4:
                pygame.draw.lines(self.surface, lerp(color, BG, 0.6), False, pts, 1)
                pygame.draw.lines(self.surface, lerp(color, BG, 0.2), False, pts[-4:], 1)
            else:
                pygame.draw.lines(self.surface, lerp(color, BG, 0.4), False, pts, 1)
            n_segments += len(pts)

    def render(self, world: Ecosystem, selected: Organism | None = None,
               kin: frozenset[int] = frozenset(), snapshot: dict | None = None,
               dt: float = 1 / 60) -> None:
        # draw the world. if we get a snapshot we draw that instead (thats
        # the time machine). snapshots have no tails or pulses, theyre only
        # a copy of positions anyway
        self.surface.blit(self.background, (0, 0))

        food = world.food if snapshot is None else snapshot["food"]
        organisms = world.organisms if snapshot is None else snapshot["organisms"]
        live = snapshot is None

        # food goes UNDER the creatures
        fs = self.food_sprite.get_width() // 2
        for f in food:
            self.surface.blit(self.food_sprite, (int(f.x) - fs, int(f.y) - fs))

        if live:
            self._update_trails(world)
            self._draw_trails(world)
            self._age_pulses(dt)
            self._draw_pulses()

        # family rings first so they sit behind the herd
        if selected is not None and kin:
            self._draw_kin(organisms, kin, selected.id)

        selected_pos: tuple[float, float] | None = None
        for o in organisms:
            self._draw_organism(o, world.config.max_energy)
            if selected is not None and o.id == selected.id:
                selected_pos = (o.x, o.y)

        if selected is not None and selected_pos is not None:
            self._draw_reticle(selected_pos, body_radius_px(selected.genome.size), selected.id)

    def _draw_organism(self, o: Organism, max_energy: float) -> None:
        radius = body_radius_px(o.genome.size)
        tick = 4 + o.genome.speed * MAX_TICK  # 4..18 px
        aggression = o.genome.aggression
        tick_color = lerp(HEADING, HEADING_PRED, aggression)

        # the little line showing which way its facing. longer = faster
        pygame.draw.line(
            self.surface,
            tick_color,
            (o.x, o.y),
            (o.x + math.cos(o.heading) * tick, o.y + math.sin(o.heading) * tick),
            1,
        )
        orb = self.atlas[
            (aggression_bucket(aggression), round(radius),
             energy_level(o.energy, max_energy))
        ]
        self.surface.blit(orb, (int(o.x) - orb.get_width() // 2,
                                int(o.y) - orb.get_height() // 2))

        # thin ring = ready to breed
        if o.readiness >= 1.0:
            ring = lerp(ACCENT, PREDATOR, aggression)
            pygame.draw.circle(self.surface, ring, (int(o.x), int(o.y)),
                               round(radius) + 2, 1)

    def _draw_kin(self, organisms, kin: frozenset[int], selected_id: int) -> None:
        # dim ring on every living member of the selected creatures family
        # line. you can sit and watch a family take over the plate
        for o in organisms:
            if o.id in kin and o.id != selected_id:
                pygame.draw.circle(
                    self.surface, ACCENT_DIM, (int(o.x), int(o.y)),
                    round(body_radius_px(o.genome.size)) + 3, 1,
                )

    def _draw_reticle(self, pos: tuple[float, float], radius: float, oid: int) -> None:
        # brackets round whatever you clicked + its id so you can find it
        # again after it wanders off
        x, y = int(pos[0]), int(pos[1])
        r = round(radius) + 7
        pygame.draw.circle(self.surface, (255, 255, 255), (x, y), r, 1)
        arm = 5
        for sx, sy in ((-1, -1), (1, -1), (-1, 1), (1, 1)):
            cx, cy = x + sx * r, y + sy * r
            pygame.draw.line(self.surface, (255, 255, 255), (cx, cy), (cx - sx * arm, cy), 2)
            pygame.draw.line(self.surface, (255, 255, 255), (cx, cy), (cx, cy - sy * arm), 2)
        tag = self.font.render(f"#{oid}", True, BG)
        box = tag.get_rect(midbottom=(x, y - r - 2)).inflate(6, 3)
        pygame.draw.rect(self.surface, (255, 255, 255), box, border_radius=3)
        self.surface.blit(tag, tag.get_rect(center=box.center))

    def present(self, screen: pygame.Surface, rect: pygame.Rect) -> None:
        # stick the finished plate into the hole the layout left for it
        screen.blit(self.surface, rect.topleft)
        pygame.draw.rect(screen, PANEL_BORDER, rect, width=1, border_radius=6)


# ===== main.py =====
# EvoLab. run it with:  python main.py
#
# controls: SPACE pause, R replay the last minute, ESC out of replay,
# click a creature to inspect it, and the header sliders do speed /
# mutation / food / aggression
#
# this file is just the shell: it owns the clock, the window, the
# selection and the time machine. the world lives in simulation/, the
# drawing in rendering/ and ui/, and none of them know about each other

import argparse
import asyncio
import math  # left over from an old idea, not used
import sys
from collections import deque

import pygame


# the sim always runs in 1/60s chunks. the SPEED slider changes how many
# chunks per second, never the size of a chunk, so 0.25x and 20x behave
# exactly the same, just faster or slower
STEP = 1 / 60
# cap the catch up. drag the window about at 20x and come back and it
# would otherwise try to simulate a week in one frame
MAX_STEPS_PER_FRAME = 32

# time machine: how often we save a frame and how many we keep
SNAP_INTERVAL = 0.25
SNAP_MAX = 240      # roughly a minute
REPLAY_STEP = 0.10  # sim seconds per saved frame while playing back

# True in the browser (pyscript = wasm). we cant block on clock.tick
# there, the page drives the frames so we await instead
IN_BROWSER = sys.platform == "emscripten"


async def main() -> int:
    parser = argparse.ArgumentParser(description="EvoLab — artificial ecosystem simulator")
    parser.add_argument(
        "--frames", type=int, default=0,
        help="quit after N rendered frames (0 = run until closed)",
    )
    parser.add_argument(
        "--shot", default="", metavar="PATH",
        help="save the last rendered frame to PATH (headless verification)",
    )
    parser.add_argument(
        "--seed", type=int, default=0, help="seed the world's randomness (0 = any)",
    )
    args = parser.parse_args()
    if args.shot and not args.frames:
        args.frames = 240  # give the world a few seconds before the shot
    if args.seed:
        import random

        random.seed(args.seed)

    pygame.init()
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    pygame.display.set_caption("EvoLab")
    fonts = Fonts()
    clock = pygame.time.Clock()

    # --- state ----------------------------------------------------------
    paused = False
    selected = None  # the Organism currently picked
    kin: frozenset[int] = frozenset()
    _kin_key = None  # (selected id, births) the cached kin set was built for
    accumulator = 0.0
    frame = 0

    # --- the time machine -----------------------------------------------
    snapshots: deque = deque(maxlen=SNAP_MAX)
    _snap_acc = 0.0
    replaying = False
    replay_idx = 0
    _manual_seek = False  # user grabbed the scrubber; stop auto-advance
    _replay_acc = 0.0

    def toggle_pause() -> None:
        nonlocal paused
        paused = not paused

    def toggle_replay() -> None:
        # in and out of the time machine
        nonlocal replaying, replay_idx, _replay_acc, _manual_seek, paused
        if not snapshots:
            return
        if not replaying:
            # going into replay pauses the live world
            replaying = True
            paused = True
            replay_idx = 0
            _replay_acc = 0.0
            _manual_seek = False
            hud.set_replay_span(len(snapshots))
        else:
            replaying = False
            replay_idx = 0

    # jump the replay to a frame
    def seek(index: int) -> None:
        nonlocal replay_idx, _manual_seek
        if not snapshots:
            return
        _manual_seek = True
        replay_idx = min(len(snapshots) - 1, max(0, index))

    def pick(x: int, y: int) -> None:
        # click the plate = select whatever is under the cursor.
        # clicking empty space clears it
        nonlocal selected
        selected = world.organism_at(x, y, tolerance=3)

    hud = Hud((WIDTH, HEIGHT), fonts, toggle_pause, toggle_replay, seek)

    # the world is exactly the size of the plate the layout left for it
    world = Ecosystem(
        WorldConfig(
            width=hud.plate.width,
            height=hud.plate.height,
            organisms=90,
            wander_turn_rate=1.6,
        )
    )
    renderer = Renderer(hud.plate.size)

    running = True
    while running:
        # dt in seconds, clamped to 0.25 so a stall (window drag, laptop
        # waking up) cant teleport the world forwards
        if IN_BROWSER:
            await asyncio.sleep(1 / 60)
            dt = min(clock.tick(0) / 1000.0, 0.25)
        else:
            dt = min(clock.tick(60) / 1000.0, 0.25)
        mouse = pygame.mouse.get_pos()

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
                continue
            if event.type == pygame.KEYDOWN:
                if event.key == pygame.K_SPACE:
                    toggle_pause()
                elif event.key == pygame.K_r:
                    toggle_replay()
                elif event.key == pygame.K_ESCAPE and replaying:
                    toggle_replay()
                elif event.key == pygame.K_LEFT and replaying:
                    seek(replay_idx - 1)
                    hud.scrub.value = float(replay_idx)
                elif event.key == pygame.K_RIGHT and replaying:
                    seek(replay_idx + 1)
                    hud.scrub.value = float(replay_idx)
            hud.handle(event, replaying)
            # Clicking the plate (not the chrome) picks a creature.
            if (event.type == pygame.MOUSEBUTTONDOWN and event.button == 1
                    and hud.plate.collidepoint(event.pos)
                    and not hud.chrome_at(event.pos, replaying)):
                pick(event.pos[0] - hud.plate.left, event.pos[1] - hud.plate.top)

        world.config.mutation_rate = hud.mutation
        world.config.food_supply_scale = hud.food_scale
        world.config.aggression_pressure = hud.pressure
        events = world.take_events()
        screen.fill(BG)

        if replaying:
            # move the playhead and draw a stored frame instead of the world
            if _manual_seek:
                replay_idx = min(len(snapshots) - 1, max(0, int(hud.scrub.value)))
            else:
                _replay_acc += dt * hud.speed
                while _replay_acc >= REPLAY_STEP:
                    _replay_acc -= REPLAY_STEP
                    replay_idx += 1
                    if replay_idx >= len(snapshots):
                        replay_idx = 0  # loop it
                hud.scrub.value = float(replay_idx)
            renderer.render(world, snapshot=snapshots[replay_idx], dt=dt)
        else:
            if not paused:
                # the accumulator: add the real time (times the speed
                # slider) and run whole 1/60 steps off the total
                accumulator += dt * hud.speed
                steps = 0
                while accumulator >= STEP and steps < MAX_STEPS_PER_FRAME:
                    world.update(STEP)
                    accumulator -= STEP
                    steps += 1
                if steps == MAX_STEPS_PER_FRAME:
                    accumulator = 0.0  # we are behind, bin the backlog

                # save a frame for the time machine every 0.25 sim seconds
                _snap_acc += dt * hud.speed
                while _snap_acc >= SNAP_INTERVAL:
                    _snap_acc -= SNAP_INTERVAL
                    snapshots.append(capture_snapshot(world))

            # if the creature we had selected died, forget it
            if selected is not None and selected.dead:
                selected = None
            # recompute the family rings only when something could have
            # changed (a birth or a new creature clicked). doing it every
            # frame meant walking the whole population 60x a second
            key = (selected.id, world.births, len(world.organisms)) if selected else None
            if key != _kin_key:
                _kin_key = key
                kin = world.lineage_members(selected.lineage) if selected else frozenset()
            renderer.consume(events)
            renderer.render(world, selected=selected, kin=kin, dt=dt)

        hud.consume(events, world.time, world.max_generation)
        renderer.present(screen, hud.plate)
        hud.draw(screen, world, selected, kin, paused,
                 Replay(replay_idx, len(snapshots)) if (replaying and snapshots) else None,
                 clock.get_fps(), mouse)
        pygame.display.flip()

        frame += 1
        if args.frames and frame >= args.frames:
            running = False

    if args.shot:
        # dev flag, saves the last frame so i can look at it without a
        # monitor attached
        pygame.image.save(screen, args.shot)
    pygame.quit()
    return 0


# --- web entry ------------------------------------------------------------
# pyscript runs this in an async context so we start main() by hand:
# as a task if there is already a loop running, otherwise asyncio.run
try:
    asyncio.get_running_loop()
    asyncio.create_task(main())
except RuntimeError:
    asyncio.run(main())

try:  # just hides the loading text once the game is actually running
    from pyscript import window as _win

    _status = _win.document.getElementById("status")
    if _status:
        _status.style.display = "none"
except Exception:
    pass
